mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
533 lines
22 KiB
Swift
533 lines
22 KiB
Swift
import SwiftUI
|
|
import Foundation
|
|
import OpenClawKit
|
|
import os
|
|
import UIKit
|
|
import BackgroundTasks
|
|
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)
|
|
self.backgroundWakeLogger.info(
|
|
"Scheduled background wake refresh reason=\(reason, privacy: .public) delaySeconds=\(max(60, delay), privacy: .public)")
|
|
} catch {
|
|
self.backgroundWakeLogger.error(
|
|
"Failed scheduling background wake refresh reason=\(reason, privacy: .public) error=\(error.localizedDescription, 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":
|
|
// 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
|
|
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
|
|
// Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
|
|
// produce a normal Swift error backtrace.
|
|
let reason = exception.reason ?? "(no reason)"
|
|
NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
|
|
for line in exception.callStackSymbols {
|
|
NSLog(" %@", line)
|
|
}
|
|
}
|
|
}
|
|
}
|