| import SwiftUI |
| import Foundation |
| import OpenClawKit |
| import os |
| import UIKit |
| import BackgroundTasks |
| @preconcurrency import UserNotifications |
|
|
| 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 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) |
| } |
| } |
| } |
| } |
| } |
|
|
| func application( |
| _ application: UIApplication, |
| didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil |
| ) -> Bool |
| { |
| self.registerBackgroundWakeRefreshTask() |
| UNUserNotificationCenter.current().delegate = self |
| application.registerForRemoteNotifications() |
| return true |
| } |
|
|
| func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { |
| if let appModel = self.appModel { |
| Task { @MainActor in |
| appModel.updateAPNsDeviceToken(deviceToken) |
| } |
| return |
| } |
|
|
| self.pendingAPNsDeviceToken = deviceToken |
| } |
|
|
| func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { |
| self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") |
| } |
|
|
| func application( |
| _ application: UIApplication, |
| didReceiveRemoteNotification userInfo: [AnyHashable: Any], |
| fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) |
| { |
| self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)") |
| Task { @MainActor in |
| guard let appModel = self.appModel else { |
| self.logger.info("APNs wake skipped: appModel unavailable") |
| self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model") |
| completionHandler(.noData) |
| return |
| } |
| let handled = await appModel.handleSilentPushWake(userInfo) |
| self.logger.info("APNs wake handled=\(handled, privacy: .public)") |
| if !handled { |
| self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_not_applied") |
| } |
| completionHandler(handled ? .newData : .noData) |
| } |
| } |
|
|
| func scenePhaseChanged(_ phase: ScenePhase) { |
| if phase == .background { |
| self.scheduleBackgroundWakeRefresh(afterSeconds: 120, reason: "scene_background") |
| } |
| } |
|
|
| private func registerBackgroundWakeRefreshTask() { |
| BGTaskScheduler.shared.register( |
| forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier, |
| using: nil |
| ) { [weak self] task in |
| guard let refreshTask = task as? BGAppRefreshTask else { |
| task.setTaskCompleted(success: false) |
| return |
| } |
| self?.handleBackgroundWakeRefresh(task: refreshTask) |
| } |
| } |
|
|
| private func scheduleBackgroundWakeRefresh(afterSeconds delay: TimeInterval, reason: String) { |
| let request = BGAppRefreshTaskRequest(identifier: Self.wakeRefreshTaskIdentifier) |
| request.earliestBeginDate = Date().addingTimeInterval(max(60, delay)) |
| do { |
| try BGTaskScheduler.shared.submit(request) |
| let scheduledLogMessage = |
| "Scheduled background wake refresh reason=\(reason) " |
| + "delaySeconds=\(max(60, delay))" |
| self.backgroundWakeLogger.info( |
| "\(scheduledLogMessage, privacy: .public)" |
| ) |
| } catch { |
| let failedLogMessage = |
| "Failed scheduling background wake refresh reason=\(reason) " |
| + "error=\(error.localizedDescription)" |
| self.backgroundWakeLogger.error( |
| "\(failedLogMessage, privacy: .public)" |
| ) |
| } |
| } |
|
|
| private func handleBackgroundWakeRefresh(task: BGAppRefreshTask) { |
| self.scheduleBackgroundWakeRefresh(afterSeconds: 15 * 60, reason: "reschedule") |
| self.backgroundWakeTask?.cancel() |
|
|
| let wakeTask = Task { @MainActor [weak self] in |
| guard let self, let appModel = self.appModel else { return false } |
| return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh") |
| } |
| self.backgroundWakeTask = wakeTask |
| task.expirationHandler = { |
| wakeTask.cancel() |
| } |
| Task { |
| let applied = await wakeTask.value |
| task.setTaskCompleted(success: applied) |
| self.backgroundWakeLogger.info( |
| "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: |
| break |
| } |
|
|
| guard response.actionIdentifier.hasPrefix(WatchPromptNotificationBridge.actionIdentifierPrefix) else { |
| return nil |
| } |
| let indexString = String( |
| response.actionIdentifier.dropFirst(WatchPromptNotificationBridge.actionIdentifierPrefix.count)) |
| guard let actionIndex = Int(indexString), actionIndex >= 0 else { |
| return nil |
| } |
| let actionIdKey = WatchPromptNotificationBridge.actionIDKey(index: actionIndex) |
| let actionLabelKey = WatchPromptNotificationBridge.actionLabelKey(index: actionIndex) |
| let actionId = (userInfo[actionIdKey] as? String)? |
| .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" |
| guard !actionId.isEmpty else { |
| return nil |
| } |
| let actionLabel = userInfo[actionLabelKey] as? String |
| return PendingWatchPromptAction( |
| promptId: promptId, |
| actionId: actionId, |
| actionLabel: actionLabel, |
| sessionKey: sessionKey) |
| } |
|
|
| 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 actionIdentifierPrefix = "openclaw.watch.action." |
| static let actionIDKeyPrefix = "openclaw.watch.action.id." |
| static let actionLabelKeyPrefix = "openclaw.watch.action.label." |
| 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 displayedActions = Array(normalizedActions.prefix(4)) |
|
|
| let center = UNUserNotificationCenter.current() |
| var categoryIdentifier = "" |
| if !displayedActions.isEmpty { |
| let categoryID = "\(self.categoryPrefix)\(invokeID)" |
| let category = UNNotificationCategory( |
| identifier: categoryID, |
| actions: self.categoryActions(displayedActions), |
| 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 |
| } |
| for (index, action) in displayedActions.enumerated() { |
| userInfo[self.actionIDKey(index: index)] = action.id |
| userInfo[self.actionLabelKey(index: index)] = action.label |
| if index == 0 { |
| userInfo[self.actionPrimaryIDKey] = action.id |
| userInfo[self.actionPrimaryLabelKey] = action.label |
| } else if index == 1 { |
| userInfo[self.actionSecondaryIDKey] = action.id |
| userInfo[self.actionSecondaryLabelKey] = action.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) |
| } |
|
|
| static func actionIDKey(index: Int) -> String { |
| "\(self.actionIDKeyPrefix)\(index)" |
| } |
|
|
| static func actionLabelKey(index: Int) -> String { |
| "\(self.actionLabelKeyPrefix)\(index)" |
| } |
|
|
| private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] { |
| actions.enumerated().map { index, action in |
| let identifier: String |
| switch index { |
| case 0: |
| identifier = self.actionPrimaryIdentifier |
| case 1: |
| identifier = self.actionSecondaryIdentifier |
| default: |
| identifier = "\(self.actionIdentifierPrefix)\(index)" |
| } |
| return UNNotificationAction( |
| identifier: identifier, |
| title: action.label, |
| options: self.notificationActionOptions(style: action.style)) |
| } |
| } |
|
|
| private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { |
| switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { |
| case "destructive": |
| return [.destructive] |
| case "foreground": |
| |
| 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) |
| if self.isAuthorizationStatusAllowed(updatedStatus) { |
| |
| |
| await MainActor.run { |
| UIApplication.shared.registerForRemoteNotifications() |
| } |
| } |
| 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 |
| ThrowingContinuationSupport.resumeVoid(continuation, error: error) |
| } |
| } |
| } |
| } |
|
|
| 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 |
| struct OpenClawApp: App { |
| @State private var appModel: NodeAppModel |
| @State private var gatewayController: GatewayConnectionController |
| @UIApplicationDelegateAdaptor(OpenClawAppDelegate.self) private var appDelegate |
| @Environment(\.scenePhase) private var scenePhase |
|
|
| init() { |
| Self.installUncaughtExceptionLogger() |
| GatewaySettingsStore.bootstrapPersistence() |
| let appModel = NodeAppModel() |
| _appModel = State(initialValue: appModel) |
| _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) |
| } |
|
|
| var body: some Scene { |
| WindowGroup { |
| RootCanvas() |
| .environment(self.appModel) |
| .environment(self.appModel.voiceWake) |
| .environment(self.gatewayController) |
| .task { |
| self.appDelegate.appModel = self.appModel |
| } |
| .onOpenURL { url in |
| Task { await self.appModel.handleDeepLink(url: url) } |
| } |
| .onChange(of: self.scenePhase) { _, newValue in |
| self.appModel.setScenePhase(newValue) |
| self.gatewayController.setScenePhase(newValue) |
| self.appDelegate.scenePhaseChanged(newValue) |
| } |
| } |
| } |
| } |
|
|
| extension OpenClawApp { |
| private static func installUncaughtExceptionLogger() { |
| NSLog("OpenClaw: installing uncaught exception handler") |
| NSSetUncaughtExceptionHandler { exception in |
| |
| |
| let reason = exception.reason ?? "(no reason)" |
| NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason) |
| for line in exception.callStackSymbols { |
| NSLog(" %@", line) |
| } |
| } |
| } |
| } |
|
|