iOS: add APNs registration and notification signing config (#20308)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 614180020e
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-18 19:37:03 +00:00
committed by GitHub
parent 99d099aa84
commit c2d12b7e31
13 changed files with 160 additions and 18 deletions

View File

@@ -62,6 +62,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
</array>
<key>UILaunchScreen</key>
<dict/>

View File

@@ -127,6 +127,8 @@ final class NodeAppModel {
private var operatorConnected = false
private var shareDeliveryChannel: String?
private var shareDeliveryTo: String?
private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String?
var gatewaySession: GatewayNodeSession { self.nodeGateway }
var operatorSession: GatewayNodeSession { self.operatorGateway }
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
@@ -164,6 +166,7 @@ final class NodeAppModel {
self.motionService = motionService
self.watchMessagingService = watchMessagingService
self.talkMode = talkMode
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
GatewayDiagnostics.bootstrap()
self.voiceWake.configure { [weak self] cmd in
@@ -409,6 +412,14 @@ final class NodeAppModel {
}
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static var apnsEnvironment: String {
#if DEBUG
"sandbox"
#else
"production"
#endif
}
private static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1702,6 +1713,7 @@ private extension NodeAppModel {
self.gatewayDefaultAgentId = nil
self.gatewayAgents = []
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
self.apnsLastRegisteredTokenHex = nil
}
func startOperatorGatewayLoop(
@@ -2109,7 +2121,55 @@ extension NodeAppModel {
}
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected() async {}
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
}
func updateAPNsDeviceToken(_ tokenData: Data) {
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.apnsDeviceTokenHex = trimmed
UserDefaults.standard.set(trimmed, forKey: Self.apnsDeviceTokenUserDefaultsKey)
Task { [weak self] in
await self?.registerAPNsTokenIfNeeded()
}
}
private func registerAPNsTokenIfNeeded() async {
guard self.gatewayConnected else { return }
guard let token = self.apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
else {
return
}
if token == self.apnsLastRegisteredTokenHex {
return
}
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!topic.isEmpty
else {
return
}
struct PushRegistrationPayload: Codable {
var token: String
var topic: String
var environment: String
}
let payload = PushRegistrationPayload(
token: token,
topic: topic,
environment: Self.apnsEnvironment)
do {
let json = try Self.encodePayload(payload)
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json)
self.apnsLastRegisteredTokenHex = token
} catch {
// Best-effort only.
}
}
}
#if DEBUG

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View File

@@ -1,10 +1,51 @@
import SwiftUI
import Foundation
import os
import UIKit
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate {
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
private var pendingAPNsDeviceToken: Data?
weak var appModel: NodeAppModel? {
didSet {
guard let model = self.appModel, let token = self.pendingAPNsDeviceToken else { return }
self.pendingAPNsDeviceToken = nil
Task { @MainActor in
model.updateAPNsDeviceToken(token)
}
}
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
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)")
}
}
@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() {
@@ -21,6 +62,9 @@ struct OpenClawApp: App {
.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) }
}