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