diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index fe086049a8f..0d74308a8b7 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -64,6 +64,10 @@ audio remote-notification + BGTaskSchedulerPermittedIdentifiers + + ai.openclaw.ios.bgrefresh + UILaunchScreen UISupportedInterfaceOrientations diff --git a/apps/ios/Sources/Location/SignificantLocationMonitor.swift b/apps/ios/Sources/Location/SignificantLocationMonitor.swift index f12a157dc69..1b8d5ca2a0d 100644 --- a/apps/ios/Sources/Location/SignificantLocationMonitor.swift +++ b/apps/ios/Sources/Location/SignificantLocationMonitor.swift @@ -10,7 +10,8 @@ enum SignificantLocationMonitor { static func startIfNeeded( locationService: any LocationServicing, locationMode: OpenClawLocationMode, - gateway: GatewayNodeSession + gateway: GatewayNodeSession, + beforeSend: (@MainActor @Sendable () async -> Void)? = nil ) { guard locationMode == .always else { return } let status = locationService.authorizationStatus() @@ -31,6 +32,9 @@ enum SignificantLocationMonitor { let json = String(data: data, encoding: .utf8) else { return } Task { @MainActor in + if let beforeSend { + await beforeSend() + } await gateway.sendEvent(event: "location.update", payloadJSON: json) } } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index ef2f375296b..d9206c41efd 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -42,6 +42,7 @@ private final class NotificationInvokeLatch: @unchecked Sendable { final class NodeAppModel { private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") + private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") enum CameraHUDKind { case photo case recording @@ -103,6 +104,11 @@ final class NodeAppModel { private var backgroundTalkKeptActive = false private var backgroundedAt: Date? private var reconnectAfterBackgroundArmed = false + private var backgroundGraceTaskID: UIBackgroundTaskIdentifier = .invalid + @ObservationIgnored private var backgroundGraceTaskTimer: Task? + private var backgroundReconnectSuppressed = false + private var backgroundReconnectLeaseUntil: Date? + private var lastSignificantLocationWakeAt: Date? private var gatewayConnected = false private var operatorConnected = false @@ -271,6 +277,7 @@ final class NodeAppModel { self.stopGatewayHealthMonitor() self.backgroundedAt = Date() self.reconnectAfterBackgroundArmed = true + self.beginBackgroundConnectionGracePeriod() // Release voice wake mic in background. self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled @@ -278,6 +285,8 @@ final class NodeAppModel { self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive) case .active, .inactive: self.isBackgrounded = false + self.endBackgroundConnectionGracePeriod(reason: "scene_foreground") + self.clearBackgroundReconnectSuppression(reason: "scene_foreground") if self.operatorConnected { self.startGatewayHealthMonitor() } @@ -329,9 +338,98 @@ final class NodeAppModel { } @unknown default: self.isBackgrounded = false + self.endBackgroundConnectionGracePeriod(reason: "scene_unknown") + self.clearBackgroundReconnectSuppression(reason: "scene_unknown") } } + private func beginBackgroundConnectionGracePeriod(seconds: TimeInterval = 25) { + self.grantBackgroundReconnectLease(seconds: seconds, reason: "scene_background_grace") + self.endBackgroundConnectionGracePeriod(reason: "restart") + let taskID = UIApplication.shared.beginBackgroundTask(withName: "gateway-background-grace") { [weak self] in + Task { @MainActor in + self?.suppressBackgroundReconnect( + reason: "background_grace_expired", + disconnectIfNeeded: true) + self?.endBackgroundConnectionGracePeriod(reason: "expired") + } + } + guard taskID != .invalid else { + self.pushWakeLogger.info("Background grace unavailable: beginBackgroundTask returned invalid") + return + } + self.backgroundGraceTaskID = taskID + self.pushWakeLogger.info("Background grace started seconds=\(seconds, privacy: .public)") + self.backgroundGraceTaskTimer = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(max(1, seconds) * 1_000_000_000)) + await MainActor.run { + self.suppressBackgroundReconnect(reason: "background_grace_timer", disconnectIfNeeded: true) + self.endBackgroundConnectionGracePeriod(reason: "timer") + } + } + } + + private func endBackgroundConnectionGracePeriod(reason: String) { + self.backgroundGraceTaskTimer?.cancel() + self.backgroundGraceTaskTimer = nil + guard self.backgroundGraceTaskID != .invalid else { return } + UIApplication.shared.endBackgroundTask(self.backgroundGraceTaskID) + self.backgroundGraceTaskID = .invalid + self.pushWakeLogger.info("Background grace ended reason=\(reason, privacy: .public)") + } + + private func grantBackgroundReconnectLease(seconds: TimeInterval, reason: String) { + guard self.isBackgrounded else { return } + let leaseSeconds = max(5, seconds) + let leaseUntil = Date().addingTimeInterval(leaseSeconds) + if let existing = self.backgroundReconnectLeaseUntil, existing > leaseUntil { + // Keep the longer lease if one is already active. + } else { + self.backgroundReconnectLeaseUntil = leaseUntil + } + let wasSuppressed = self.backgroundReconnectSuppressed + self.backgroundReconnectSuppressed = false + self.pushWakeLogger.info( + "Background reconnect lease reason=\(reason, privacy: .public) seconds=\(leaseSeconds, privacy: .public) wasSuppressed=\(wasSuppressed, privacy: .public)") + } + + private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) { + guard self.isBackgrounded else { return } + let hadLease = self.backgroundReconnectLeaseUntil != nil + let changed = hadLease || !self.backgroundReconnectSuppressed + self.backgroundReconnectLeaseUntil = nil + self.backgroundReconnectSuppressed = true + guard changed else { return } + self.pushWakeLogger.info( + "Background reconnect suppressed reason=\(reason, privacy: .public) disconnect=\(disconnectIfNeeded, privacy: .public)") + guard disconnectIfNeeded else { return } + Task { [weak self] in + guard let self else { return } + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + await MainActor.run { + self.operatorConnected = false + self.gatewayConnected = false + self.talkMode.updateGatewayConnected(false) + if self.isBackgrounded { + self.gatewayStatusText = "Background idle" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.showLocalCanvasOnDisconnect() + } + } + } + } + + private func clearBackgroundReconnectSuppression(reason: String) { + let changed = self.backgroundReconnectSuppressed || self.backgroundReconnectLeaseUntil != nil + self.backgroundReconnectSuppressed = false + self.backgroundReconnectLeaseUntil = nil + guard changed else { return } + self.pushWakeLogger.info("Background reconnect cleared reason=\(reason, privacy: .public)") + } + func setVoiceWakeEnabled(_ enabled: Bool) { self.voiceWake.setEnabled(enabled) if enabled { @@ -568,7 +666,7 @@ final class NodeAppModel { } catch { if let gatewayError = error as? GatewayResponseError { let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") { + if lower.contains("unauthorized role") || lower.contains("missing scope") { await self.setGatewayHealthMonitorDisabled(true) return true } @@ -601,7 +699,7 @@ final class NodeAppModel { } catch { if let gatewayError = error as? GatewayResponseError { let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") { + if lower.contains("unauthorized role") || lower.contains("missing scope") { await self.setGatewayHealthMonitorDisabled(true) return } @@ -1725,6 +1823,23 @@ private extension NodeAppModel { self.apnsLastRegisteredTokenHex = nil } + func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { + guard self.isBackgrounded else { return } + guard !self.backgroundReconnectSuppressed else { return } + guard let leaseUntil = self.backgroundReconnectLeaseUntil else { + self.suppressBackgroundReconnect(reason: "\(source):no_lease", disconnectIfNeeded: true) + return + } + if Date() >= leaseUntil { + self.suppressBackgroundReconnect(reason: "\(source):lease_expired", disconnectIfNeeded: true) + } + } + + func shouldPauseReconnectLoopInBackground(source: String) -> Bool { + self.refreshBackgroundReconnectSuppressionIfNeeded(source: source) + return self.isBackgrounded && self.backgroundReconnectSuppressed + } + func startOperatorGatewayLoop( url: URL, stableID: String, @@ -1747,6 +1862,7 @@ private extension NodeAppModel { try? await Task.sleep(nanoseconds: 1_000_000_000) continue } + if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } if await self.isOperatorConnected() { try? await Task.sleep(nanoseconds: 1_000_000_000) continue @@ -1834,6 +1950,7 @@ private extension NodeAppModel { try? await Task.sleep(nanoseconds: 1_000_000_000) continue } + if self.shouldPauseReconnectLoopInBackground(source: "node_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } if await self.isGatewayConnected() { try? await Task.sleep(nanoseconds: 1_000_000_000) continue @@ -1883,7 +2000,15 @@ private extension NodeAppModel { } await self.showA2UIOnConnectIfNeeded() await self.onNodeGatewayConnected() - await MainActor.run { SignificantLocationMonitor.startIfNeeded(locationService: self.locationService, locationMode: self.locationMode(), gateway: self.nodeGateway) } + await MainActor.run { + SignificantLocationMonitor.startIfNeeded( + locationService: self.locationService, + locationMode: self.locationMode(), + gateway: self.nodeGateway, + beforeSend: { [weak self] in + await self?.handleSignificantLocationWakeIfNeeded() + }) + } }, onDisconnected: { [weak self] reason in guard let self else { return } @@ -2135,12 +2260,59 @@ extension NodeAppModel { } func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool { + let wakeId = Self.makePushWakeAttemptID() guard Self.isSilentPushPayload(userInfo) else { - self.pushWakeLogger.info("Ignored APNs payload: not silent push") + self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push") return false } - self.pushWakeLogger.info("Silent push received; attempting reconnect if needed") - return await self.reconnectGatewaySessionsForSilentPushIfNeeded() + let pushKind = Self.openclawPushKind(userInfo) + self.pushWakeLogger.info( + "Silent push received wakeId=\(wakeId, privacy: .public) kind=\(pushKind, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.pushWakeLogger.info( + "Silent push outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + return result.applied + } + + func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool { + let wakeId = Self.makePushWakeAttemptID() + self.pushWakeLogger.info( + "Background refresh wake received wakeId=\(wakeId, privacy: .public) trigger=\(trigger, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.pushWakeLogger.info( + "Background refresh wake outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + return result.applied + } + + func handleSignificantLocationWakeIfNeeded() async { + let wakeId = Self.makePushWakeAttemptID() + let now = Date() + let throttleWindowSeconds: TimeInterval = 180 + + if await self.isGatewayConnected() { + self.locationWakeLogger.info( + "Location wake no-op wakeId=\(wakeId, privacy: .public): already connected") + return + } + if let last = self.lastSignificantLocationWakeAt, + now.timeIntervalSince(last) < throttleWindowSeconds + { + self.locationWakeLogger.info( + "Location wake throttled wakeId=\(wakeId, privacy: .public) elapsedSec=\(now.timeIntervalSince(last), privacy: .public)") + return + } + self.lastSignificantLocationWakeAt = now + + self.locationWakeLogger.info( + "Location wake begin wakeId=\(wakeId, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.locationWakeLogger.info( + "Location wake trigger wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + + guard result.applied else { return } + let connected = await self.waitForGatewayConnection(timeoutMs: 5000, pollMs: 250) + self.locationWakeLogger.info( + "Location wake post-check wakeId=\(wakeId, privacy: .public) connected=\(connected, privacy: .public)") } func updateAPNsDeviceToken(_ tokenData: Data) { @@ -2210,28 +2382,83 @@ extension NodeAppModel { return false } - private func reconnectGatewaySessionsForSilentPushIfNeeded() async -> Bool { - guard self.isBackgrounded else { - self.pushWakeLogger.info("Wake no-op: app not backgrounded") - return false + private static func makePushWakeAttemptID() -> String { + let raw = UUID().uuidString.replacingOccurrences(of: "-", with: "") + return String(raw.prefix(8)) + } + + private static func openclawPushKind(_ userInfo: [AnyHashable: Any]) -> String { + if let payload = userInfo["openclaw"] as? [String: Any], + let kind = payload["kind"] as? String + { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } } - guard self.gatewayAutoReconnectEnabled else { - self.pushWakeLogger.info("Wake no-op: auto reconnect disabled") - return false + if let payload = userInfo["openclaw"] as? [AnyHashable: Any], + let kind = payload["kind"] as? String + { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } } - guard self.activeGatewayConnectConfig != nil else { - self.pushWakeLogger.info("Wake no-op: no active gateway config") - return false + return "unknown" + } + + private struct SilentPushWakeAttemptResult { + var applied: Bool + var reason: String + var durationMs: Int + } + + private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool { + let clampedTimeoutMs = max(0, timeoutMs) + let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000 + let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0) + while Date() < deadline { + if await self.isGatewayConnected() { + return true + } + try? await Task.sleep(nanoseconds: pollIntervalNs) + } + return await self.isGatewayConnected() + } + + private func reconnectGatewaySessionsForSilentPushIfNeeded( + wakeId: String + ) async -> SilentPushWakeAttemptResult { + let startedAt = Date() + let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in + let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) + return SilentPushWakeAttemptResult( + applied: applied, + reason: reason, + durationMs: max(0, durationMs)) } + guard self.isBackgrounded else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded") + return makeResult(false, "not_backgrounded") + } + guard self.gatewayAutoReconnectEnabled else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled") + return makeResult(false, "auto_reconnect_disabled") + } + guard let cfg = self.activeGatewayConnectConfig else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config") + return makeResult(false, "no_active_gateway_config") + } + + self.pushWakeLogger.info( + "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") + self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") await self.operatorGateway.disconnect() await self.nodeGateway.disconnect() self.operatorConnected = false self.gatewayConnected = false self.gatewayStatusText = "Reconnecting…" self.talkMode.updateGatewayConnected(false) - self.pushWakeLogger.info("Wake reconnect trigger applied") - return true + self.applyGatewayConnectConfig(cfg) + self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") + return makeResult(true, "reconnect_triggered") } } diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 091c1b90fdf..ade0cadad3b 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -2,9 +2,13 @@ import SwiftUI import Foundation import os import UIKit +import BackgroundTasks final class OpenClawAppDelegate: NSObject, UIApplicationDelegate { 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? private var pendingAPNsDeviceToken: Data? weak var appModel: NodeAppModel? { didSet { @@ -21,6 +25,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + self.registerBackgroundWakeRefreshTask() application.registerForRemoteNotifications() return true } @@ -49,14 +54,70 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate { 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)") + } + } } @main @@ -89,6 +150,7 @@ struct OpenClawApp: App { .onChange(of: self.scenePhase) { _, newValue in self.appModel.setScenePhase(newValue) self.gatewayController.setScenePhase(newValue) + self.appDelegate.scenePhaseChanged(newValue) } } }