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

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky.
- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky.
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.

View File

@@ -3,3 +3,7 @@ parent_config: ../../.swiftlint.yml
included:
- Sources
- ../shared/ClawdisNodeKit/Sources
type_body_length:
warning: 900
error: 1300

View File

@@ -1,10 +1,14 @@
// Shared iOS signing defaults for local development + CI.
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
#include? "../.local-signing.xcconfig"
#include? "../LocalSigning.xcconfig"
CODE_SIGN_STYLE = Automatic
CODE_SIGN_IDENTITY = Apple Development

View File

@@ -6,6 +6,8 @@ OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =

View File

@@ -4,14 +4,14 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClaw Share</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDisplayName</key>
<string>OpenClaw Share</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
@@ -28,6 +28,8 @@
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>10</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>

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

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.16</string>
<string>2026.2.18</string>
<key>CFBundleVersion</key>
<string>20260216</string>
<string>20260218</string>
<key>WKCompanionAppBundleIdentifier</key>
<string>ai.openclaw.ios</string>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>
<true/>
</dict>

View File

@@ -15,15 +15,15 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.16</string>
<string>2026.2.18</string>
<key>CFBundleVersion</key>
<string>20260216</string>
<string>20260218</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>WKAppBundleIdentifier</key>
<string>ai.openclaw.ios.watchkitapp</string>
<string>$(OPENCLAW_WATCH_APP_BUNDLE_ID)</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.watchkit</string>

View File

@@ -70,9 +70,9 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
replyHandler(["ok": false])
return
}
replyHandler(["ok": true])
Task { @MainActor in
self.store.consume(message: incoming, transport: "sendMessage")
replyHandler(["ok": true])
}
}

View File

@@ -75,6 +75,7 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
@@ -98,6 +99,7 @@ targets:
UIApplicationSupportsMultipleScenes: false
UIBackgroundModes:
- audio
- remote-notification
NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network.
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true
@@ -140,6 +142,19 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
info:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "2026.2.18"
CFBundleVersion: "20260218"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
NSExtensionAttributes:
NSExtensionActivationRule:
NSExtensionActivationSupportsText: true
NSExtensionActivationSupportsWebURLWithMaxCount: 1
NSExtensionActivationSupportsImageWithMaxCount: 10
NSExtensionActivationSupportsMovieWithMaxCount: 1
OpenClawWatchApp:
type: application.watchapp2
@@ -154,14 +169,14 @@ targets:
Release: Config/Signing.xcconfig
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.watchkitapp
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
info:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.2.16"
CFBundleVersion: "20260216"
WKCompanionAppBundleIdentifier: ai.openclaw.ios
CFBundleShortVersionString: "2026.2.18"
CFBundleVersion: "20260218"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
OpenClawWatchExtension:
@@ -178,16 +193,16 @@ targets:
Release: Config/Signing.xcconfig
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.watchkitapp.extension
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
info:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.2.16"
CFBundleVersion: "20260216"
CFBundleShortVersionString: "2026.2.18"
CFBundleVersion: "20260218"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: ai.openclaw.ios.watchkitapp
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
NSExtensionPointIdentifier: com.apple.watchkit
OpenClawTests: