Skip to content

Commit bdba90a

Browse files
steipetengutman
andauthored
feat: add authenticated iOS background presence beacon (#73330)
* feat: add iOS background presence beacon Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> * fix: keep iOS background reconnects ahead of beacon throttle * build: refresh gateway protocol swift models * fix: emit swift protocol string enums --------- Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
1 parent d525d64 commit bdba90a

27 files changed

Lines changed: 1082 additions & 76 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman.
910
- Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong.
1011
- Security/networking: add opt-in operator-managed outbound proxy routing (proxy.enabled + proxy.proxyUrl/OPENCLAW_PROXY_URL) with strict http:// forward-proxy validation, loopback-only Gateway bypass, and cleanup of proxy env/dispatcher state on exit. (#70044) Thanks @jesse-merhi and @joshavant.
1112

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 98 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ final class NodeAppModel {
200200
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
201201

202202
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
203+
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
204+
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
203205

204206
var cameraHUDText: String?
205207
var cameraHUDKind: CameraHUDKind?
@@ -3142,32 +3144,39 @@ extension NodeAppModel {
31423144
return handled
31433145
}
31443146

3145-
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
3147+
let result = await self.performBackgroundAliveBeaconIfNeeded(
3148+
wakeId: wakeId,
3149+
trigger: .silentPush)
31463150
let outcomeMessage =
31473151
"Silent push outcome wakeId=\(wakeId) "
31483152
+ "applied=\(result.applied) "
3153+
+ "handled=\(result.handled) "
31493154
+ "reason=\(result.reason) "
31503155
+ "durationMs=\(result.durationMs)"
31513156
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
3152-
return result.applied
3157+
return result.handled
31533158
}
31543159

31553160
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
31563161
let wakeId = Self.makePushWakeAttemptID()
3162+
let normalizedTrigger = BackgroundAliveBeacon.normalizeTrigger(trigger)
31573163
let receivedMessage =
31583164
"Background refresh wake received wakeId=\(wakeId) "
3159-
+ "trigger=\(trigger) "
3165+
+ "trigger=\(normalizedTrigger.rawValue) "
31603166
+ "backgrounded=\(self.isBackgrounded) "
31613167
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
31623168
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
3163-
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
3169+
let result = await self.performBackgroundAliveBeaconIfNeeded(
3170+
wakeId: wakeId,
3171+
trigger: normalizedTrigger)
31643172
let outcomeMessage =
31653173
"Background refresh wake outcome wakeId=\(wakeId) "
31663174
+ "applied=\(result.applied) "
3175+
+ "handled=\(result.handled) "
31673176
+ "reason=\(result.reason) "
31683177
+ "durationMs=\(result.durationMs)"
31693178
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
3170-
return result.applied
3179+
return result.handled
31713180
}
31723181

31733182
func handleSignificantLocationWakeIfNeeded() async {
@@ -3196,10 +3205,13 @@ extension NodeAppModel {
31963205
+ "backgrounded=\(self.isBackgrounded) "
31973206
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
31983207
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
3199-
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
3208+
let result = await self.performBackgroundAliveBeaconIfNeeded(
3209+
wakeId: wakeId,
3210+
trigger: .significantLocation)
32003211
let triggerMessage =
32013212
"Location wake trigger wakeId=\(wakeId) "
32023213
+ "applied=\(result.applied) "
3214+
+ "handled=\(result.handled) "
32033215
+ "reason=\(result.reason) "
32043216
+ "durationMs=\(result.durationMs)"
32053217
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
@@ -3621,8 +3633,9 @@ extension NodeAppModel {
36213633
return gatewayError.message.lowercased().contains("allow-always is unavailable")
36223634
}
36233635

3624-
private struct SilentPushWakeAttemptResult {
3636+
private struct BackgroundAliveWakeAttemptResult {
36253637
var applied: Bool
3638+
var handled: Bool
36263639
var reason: String
36273640
var durationMs: Int
36283641
}
@@ -3797,43 +3810,100 @@ extension NodeAppModel {
37973810
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
37983811
}
37993812

3800-
private func reconnectGatewaySessionsForSilentPushIfNeeded(
3801-
wakeId: String) async -> SilentPushWakeAttemptResult
3813+
private func performBackgroundAliveBeaconIfNeeded(
3814+
wakeId: String,
3815+
trigger: BackgroundAliveBeacon.Trigger) async -> BackgroundAliveWakeAttemptResult
38023816
{
38033817
let startedAt = Date()
3804-
let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in
3818+
let makeResult: (Bool, Bool, String) -> BackgroundAliveWakeAttemptResult = { applied, handled, reason in
38053819
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
3806-
return SilentPushWakeAttemptResult(
3820+
return BackgroundAliveWakeAttemptResult(
38073821
applied: applied,
3822+
handled: handled,
38083823
reason: reason,
38093824
durationMs: max(0, durationMs))
38103825
}
38113826

38123827
guard self.isBackgrounded else {
38133828
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded")
3814-
return makeResult(false, "not_backgrounded")
3829+
return makeResult(false, false, "not_backgrounded")
38153830
}
38163831
guard self.gatewayAutoReconnectEnabled else {
38173832
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled")
3818-
return makeResult(false, "auto_reconnect_disabled")
3833+
return makeResult(false, false, "auto_reconnect_disabled")
38193834
}
3820-
guard let cfg = self.activeGatewayConnectConfig else {
3821-
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config")
3822-
return makeResult(false, "no_active_gateway_config")
3835+
let now = Date()
3836+
let gatewayConnected = await self.isGatewayConnected()
3837+
3838+
var appliedReconnect = false
3839+
if !gatewayConnected {
3840+
guard let cfg = self.activeGatewayConnectConfig else {
3841+
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config")
3842+
return makeResult(false, false, "no_active_gateway_config")
3843+
}
3844+
self.pushWakeLogger.info(
3845+
"Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)")
3846+
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
3847+
await self.operatorGateway.disconnect()
3848+
await self.nodeGateway.disconnect()
3849+
self.operatorConnected = false
3850+
self.gatewayConnected = false
3851+
self.gatewayStatusText = "Reconnecting…"
3852+
self.talkMode.updateGatewayConnected(false)
3853+
self.applyGatewayConnectConfig(cfg)
3854+
appliedReconnect = true
3855+
self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)")
3856+
3857+
let connected = await self.waitForGatewayConnection(timeoutMs: 12000, pollMs: 250)
3858+
guard connected else {
3859+
return makeResult(appliedReconnect, false, "connect_timeout")
3860+
}
3861+
} else if BackgroundAliveBeacon.shouldSkipRecentSuccess(
3862+
isGatewayConnected: true,
3863+
now: now,
3864+
lastSuccessAtMs: UserDefaults.standard.object(forKey: Self.backgroundAliveLastSuccessAtMsKey) as? Double)
3865+
{
3866+
return makeResult(false, true, "recent_success")
38233867
}
38243868

3825-
self.pushWakeLogger.info(
3826-
"Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)")
3827-
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
3828-
await self.operatorGateway.disconnect()
3829-
await self.nodeGateway.disconnect()
3830-
self.operatorConnected = false
3831-
self.gatewayConnected = false
3832-
self.gatewayStatusText = "Reconnecting…"
3833-
self.talkMode.updateGatewayConnected(false)
3834-
self.applyGatewayConnectConfig(cfg)
3835-
self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)")
3836-
return makeResult(true, "reconnect_triggered")
3869+
let beacon = await self.publishBackgroundAliveBeacon(trigger: trigger)
3870+
if beacon.handled {
3871+
let successAtMs = Date().timeIntervalSince1970 * 1000
3872+
UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey)
3873+
UserDefaults.standard.set(trigger.rawValue, forKey: Self.backgroundAliveLastTriggerKey)
3874+
return makeResult(appliedReconnect, true, beacon.reason)
3875+
}
3876+
return makeResult(appliedReconnect, false, beacon.reason)
3877+
}
3878+
3879+
private func publishBackgroundAliveBeacon(
3880+
trigger: BackgroundAliveBeacon.Trigger) async -> (handled: Bool, reason: String)
3881+
{
3882+
do {
3883+
let pushTransport = await self.pushRegistrationManager.usesRelayTransport ? "relay" : "direct"
3884+
let displayName = NodeDisplayName.resolve(
3885+
existing: UserDefaults.standard.string(forKey: "node.displayName"),
3886+
deviceName: UIDevice.current.name,
3887+
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
3888+
let payload = BackgroundAliveBeacon.makePayload(
3889+
trigger: trigger,
3890+
displayName: displayName,
3891+
pushTransport: pushTransport)
3892+
let paramsJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload)
3893+
let response = try await self.nodeGateway.request(
3894+
method: "node.event",
3895+
paramsJSON: paramsJSON,
3896+
timeoutSeconds: 8)
3897+
guard let decoded = BackgroundAliveBeacon.decodeResponse(response) else {
3898+
return (false, "invalid_response")
3899+
}
3900+
if decoded.handled == true {
3901+
return (true, decoded.reason ?? "beacon_persisted")
3902+
}
3903+
return (false, decoded.reason ?? "unsupported")
3904+
} catch {
3905+
return (false, "beacon_failed")
3906+
}
38373907
}
38383908
}
38393909

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import Foundation
2+
import UIKit
3+
4+
enum BackgroundAliveBeacon {
5+
static let eventName = "node.presence.alive"
6+
static let minSuccessIntervalSeconds: TimeInterval = 10 * 60
7+
8+
enum Trigger: String, CaseIterable, Codable {
9+
case background
10+
case silentPush = "silent_push"
11+
case bgAppRefresh = "bg_app_refresh"
12+
case significantLocation = "significant_location"
13+
case manual
14+
case connect
15+
}
16+
17+
struct Payload: Encodable {
18+
var trigger: String
19+
var sentAtMs: Int64
20+
var displayName: String
21+
var version: String
22+
var platform: String
23+
var deviceFamily: String
24+
var modelIdentifier: String
25+
var pushTransport: String?
26+
}
27+
28+
struct NodeEventRequestPayload: Codable {
29+
var event: String = BackgroundAliveBeacon.eventName
30+
var payloadJSON: String
31+
}
32+
33+
struct NodeEventResponsePayload: Decodable {
34+
var ok: Bool?
35+
var event: String?
36+
var handled: Bool?
37+
var reason: String?
38+
}
39+
40+
static func normalizeTrigger(_ raw: String) -> Trigger {
41+
let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
42+
return Trigger(rawValue: normalized) ?? .background
43+
}
44+
45+
static func shouldSkipRecentSuccess(
46+
isGatewayConnected: Bool,
47+
now: Date,
48+
lastSuccessAtMs: Double?,
49+
minInterval: TimeInterval = Self.minSuccessIntervalSeconds) -> Bool
50+
{
51+
guard isGatewayConnected else { return false }
52+
guard let lastSuccessAtMs, lastSuccessAtMs > 0 else { return false }
53+
let elapsed = now.timeIntervalSince1970 - (lastSuccessAtMs / 1000.0)
54+
return elapsed >= 0 && elapsed < minInterval
55+
}
56+
57+
@MainActor
58+
static func makePayload(trigger: Trigger, displayName: String, pushTransport: String?) -> Payload {
59+
Payload(
60+
trigger: trigger.rawValue,
61+
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
62+
displayName: displayName,
63+
version: DeviceInfoHelper.appVersion(),
64+
platform: DeviceInfoHelper.platformString(),
65+
deviceFamily: DeviceInfoHelper.deviceFamily(),
66+
modelIdentifier: DeviceInfoHelper.modelIdentifier(),
67+
pushTransport: pushTransport)
68+
}
69+
70+
static func makeNodeEventRequestPayloadJSON(
71+
payload: Payload,
72+
encoder: JSONEncoder = JSONEncoder()) throws -> String
73+
{
74+
let payloadData = try encoder.encode(payload)
75+
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
76+
throw EncodingError.invalidValue(payload, EncodingError.Context(
77+
codingPath: [],
78+
debugDescription: "Failed to encode background alive payload as UTF-8"))
79+
}
80+
let requestData = try encoder.encode(NodeEventRequestPayload(payloadJSON: payloadJSON))
81+
guard let requestJSON = String(data: requestData, encoding: .utf8) else {
82+
throw EncodingError.invalidValue(payload, EncodingError.Context(
83+
codingPath: [],
84+
debugDescription: "Failed to encode node.event payload as UTF-8"))
85+
}
86+
return requestJSON
87+
}
88+
89+
static func decodeResponse(_ data: Data) -> NodeEventResponsePayload? {
90+
try? JSONDecoder().decode(NodeEventResponsePayload.self, from: data)
91+
}
92+
}

apps/ios/SwiftSources.input.xcfilelist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Sources/Onboarding/OnboardingWizardView.swift
4242
Sources/Onboarding/QRScannerView.swift
4343
Sources/OpenClawApp.swift
4444
Sources/Push/ExecApprovalNotificationBridge.swift
45+
Sources/Push/BackgroundAliveBeacon.swift
4546
Sources/Push/PushBuildConfig.swift
4647
Sources/Push/PushRegistrationManager.swift
4748
Sources/Push/PushRelayClient.swift
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import Foundation
2+
import Testing
3+
@testable import OpenClaw
4+
5+
struct BackgroundAliveBeaconTests {
6+
@Test func `normalize trigger accepts closed reasons`() {
7+
#expect(BackgroundAliveBeacon.normalizeTrigger("silent_push") == .silentPush)
8+
#expect(BackgroundAliveBeacon.normalizeTrigger(" bg_app_refresh ") == .bgAppRefresh)
9+
#expect(BackgroundAliveBeacon.normalizeTrigger("SIGNIFICANT_LOCATION") == .significantLocation)
10+
}
11+
12+
@Test func `normalize trigger falls back to background`() {
13+
#expect(BackgroundAliveBeacon.normalizeTrigger("watch_prompt_action") == .background)
14+
#expect(BackgroundAliveBeacon.normalizeTrigger("") == .background)
15+
}
16+
17+
@Test func `recent success throttle uses milliseconds`() {
18+
let now = Date(timeIntervalSince1970: 1000)
19+
20+
#expect(BackgroundAliveBeacon.shouldSkipRecentSuccess(
21+
isGatewayConnected: true,
22+
now: now,
23+
lastSuccessAtMs: 999_500,
24+
minInterval: 10))
25+
#expect(!BackgroundAliveBeacon.shouldSkipRecentSuccess(
26+
isGatewayConnected: true,
27+
now: now,
28+
lastSuccessAtMs: 980_000,
29+
minInterval: 10))
30+
}
31+
32+
@Test func `recent success throttle does not suppress disconnected wakes`() {
33+
let now = Date(timeIntervalSince1970: 1000)
34+
35+
#expect(!BackgroundAliveBeacon.shouldSkipRecentSuccess(
36+
isGatewayConnected: false,
37+
now: now,
38+
lastSuccessAtMs: 999_500,
39+
minInterval: 10))
40+
}
41+
42+
@Test func `make node event payload wraps presence payload JSON`() throws {
43+
let payload = BackgroundAliveBeacon.Payload(
44+
trigger: BackgroundAliveBeacon.Trigger.silentPush.rawValue,
45+
sentAtMs: 123,
46+
displayName: "Peter's iPhone",
47+
version: "2026.4.28",
48+
platform: "iOS 18.4.0",
49+
deviceFamily: "iPhone",
50+
modelIdentifier: "iPhone17,1",
51+
pushTransport: "relay")
52+
let requestJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload)
53+
let requestData = try #require(requestJSON.data(using: .utf8))
54+
let request = try JSONDecoder().decode(
55+
BackgroundAliveBeacon.NodeEventRequestPayload.self,
56+
from: requestData)
57+
58+
#expect(request.event == "node.presence.alive")
59+
let payloadData = try #require(request.payloadJSON.data(using: .utf8))
60+
let decodedPayload = try #require(JSONSerialization.jsonObject(with: payloadData) as? [String: Any])
61+
let sentAtMs = try #require(decodedPayload["sentAtMs"] as? Int)
62+
#expect(decodedPayload["trigger"] as? String == "silent_push")
63+
#expect(sentAtMs == 123)
64+
#expect(decodedPayload["pushTransport"] as? String == "relay")
65+
}
66+
67+
@Test func `old gateway ack does not count as handled`() throws {
68+
let data = try #require(#"{"ok":true}"#.data(using: .utf8))
69+
let response = try #require(BackgroundAliveBeacon.decodeResponse(data))
70+
71+
#expect(response.ok == true)
72+
#expect(response.handled == nil)
73+
}
74+
}

0 commit comments

Comments
 (0)