Skip to content

Commit c2d12b7

Browse files
authored
iOS: add APNs registration and notification signing config (#20308)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 6141800 Co-authored-by: mbelinky <[email protected]> Co-authored-by: mbelinky <[email protected]> Reviewed-by: @mbelinky
1 parent 99d099a commit c2d12b7

13 files changed

Lines changed: 160 additions & 18 deletions

File tree

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/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky.
910
- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky.
1011
- 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.
1112
- 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.

apps/ios/.swiftlint.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ parent_config: ../../.swiftlint.yml
33
included:
44
- Sources
55
- ../shared/ClawdisNodeKit/Sources
6+
7+
type_body_length:
8+
warning: 900
9+
error: 1300

apps/ios/Config/Signing.xcconfig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Shared iOS signing defaults for local development + CI.
22
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
33
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
4+
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
5+
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
6+
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
47

58
// Local contributors can override this by running scripts/ios-configure-signing.sh.
69
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
710
#include? "../.local-signing.xcconfig"
11+
#include? "../LocalSigning.xcconfig"
812

913
CODE_SIGN_STYLE = Automatic
1014
CODE_SIGN_IDENTITY = Apple Development

apps/ios/LocalSigning.xcconfig.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL
66

77
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano
88
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share
9+
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp
10+
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp.extension
911

1012
// Leave empty with automatic signing.
1113
OPENCLAW_APP_PROFILE =

apps/ios/ShareExtension/Info.plist

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
<dict>
55
<key>CFBundleDevelopmentRegion</key>
66
<string>$(DEVELOPMENT_LANGUAGE)</string>
7+
<key>CFBundleDisplayName</key>
8+
<string>OpenClaw Share</string>
79
<key>CFBundleExecutable</key>
810
<string>$(EXECUTABLE_NAME)</string>
911
<key>CFBundleIdentifier</key>
1012
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
1113
<key>CFBundleInfoDictionaryVersion</key>
1214
<string>6.0</string>
13-
<key>CFBundleDisplayName</key>
14-
<string>OpenClaw Share</string>
1515
<key>CFBundleName</key>
1616
<string>$(PRODUCT_NAME)</string>
1717
<key>CFBundlePackageType</key>
@@ -28,6 +28,8 @@
2828
<dict>
2929
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
3030
<integer>10</integer>
31+
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
32+
<integer>1</integer>
3133
<key>NSExtensionActivationSupportsText</key>
3234
<true/>
3335
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>

apps/ios/Sources/Info.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
<key>UIBackgroundModes</key>
6363
<array>
6464
<string>audio</string>
65+
<string>remote-notification</string>
6566
</array>
6667
<key>UILaunchScreen</key>
6768
<dict/>

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ final class NodeAppModel {
127127
private var operatorConnected = false
128128
private var shareDeliveryChannel: String?
129129
private var shareDeliveryTo: String?
130+
private var apnsDeviceTokenHex: String?
131+
private var apnsLastRegisteredTokenHex: String?
130132
var gatewaySession: GatewayNodeSession { self.nodeGateway }
131133
var operatorSession: GatewayNodeSession { self.operatorGateway }
132134
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
@@ -164,6 +166,7 @@ final class NodeAppModel {
164166
self.motionService = motionService
165167
self.watchMessagingService = watchMessagingService
166168
self.talkMode = talkMode
169+
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
167170
GatewayDiagnostics.bootstrap()
168171

169172
self.voiceWake.configure { [weak self] cmd in
@@ -409,6 +412,14 @@ final class NodeAppModel {
409412
}
410413

411414
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
415+
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
416+
private static var apnsEnvironment: String {
417+
#if DEBUG
418+
"sandbox"
419+
#else
420+
"production"
421+
#endif
422+
}
412423

413424
private static func color(fromHex raw: String?) -> Color? {
414425
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1702,6 +1713,7 @@ private extension NodeAppModel {
17021713
self.gatewayDefaultAgentId = nil
17031714
self.gatewayAgents = []
17041715
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
1716+
self.apnsLastRegisteredTokenHex = nil
17051717
}
17061718

17071719
func startOperatorGatewayLoop(
@@ -2109,7 +2121,55 @@ extension NodeAppModel {
21092121
}
21102122

21112123
/// Back-compat hook retained for older gateway-connect flows.
2112-
func onNodeGatewayConnected() async {}
2124+
func onNodeGatewayConnected() async {
2125+
await self.registerAPNsTokenIfNeeded()
2126+
}
2127+
2128+
func updateAPNsDeviceToken(_ tokenData: Data) {
2129+
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
2130+
let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines)
2131+
guard !trimmed.isEmpty else { return }
2132+
self.apnsDeviceTokenHex = trimmed
2133+
UserDefaults.standard.set(trimmed, forKey: Self.apnsDeviceTokenUserDefaultsKey)
2134+
Task { [weak self] in
2135+
await self?.registerAPNsTokenIfNeeded()
2136+
}
2137+
}
2138+
2139+
private func registerAPNsTokenIfNeeded() async {
2140+
guard self.gatewayConnected else { return }
2141+
guard let token = self.apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines),
2142+
!token.isEmpty
2143+
else {
2144+
return
2145+
}
2146+
if token == self.apnsLastRegisteredTokenHex {
2147+
return
2148+
}
2149+
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
2150+
!topic.isEmpty
2151+
else {
2152+
return
2153+
}
2154+
2155+
struct PushRegistrationPayload: Codable {
2156+
var token: String
2157+
var topic: String
2158+
var environment: String
2159+
}
2160+
2161+
let payload = PushRegistrationPayload(
2162+
token: token,
2163+
topic: topic,
2164+
environment: Self.apnsEnvironment)
2165+
do {
2166+
let json = try Self.encodePayload(payload)
2167+
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json)
2168+
self.apnsLastRegisteredTokenHex = token
2169+
} catch {
2170+
// Best-effort only.
2171+
}
2172+
}
21132173
}
21142174

21152175
#if DEBUG
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>aps-environment</key>
6+
<string>development</string>
7+
</dict>
8+
</plist>
9+

apps/ios/Sources/OpenClawApp.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,51 @@
11
import SwiftUI
22
import Foundation
3+
import os
4+
import UIKit
5+
6+
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate {
7+
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
8+
private var pendingAPNsDeviceToken: Data?
9+
weak var appModel: NodeAppModel? {
10+
didSet {
11+
guard let model = self.appModel, let token = self.pendingAPNsDeviceToken else { return }
12+
self.pendingAPNsDeviceToken = nil
13+
Task { @MainActor in
14+
model.updateAPNsDeviceToken(token)
15+
}
16+
}
17+
}
18+
19+
func application(
20+
_ application: UIApplication,
21+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
22+
) -> Bool
23+
{
24+
application.registerForRemoteNotifications()
25+
return true
26+
}
27+
28+
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
29+
if let appModel = self.appModel {
30+
Task { @MainActor in
31+
appModel.updateAPNsDeviceToken(deviceToken)
32+
}
33+
return
34+
}
35+
36+
self.pendingAPNsDeviceToken = deviceToken
37+
}
38+
39+
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
40+
self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)")
41+
}
42+
}
343

444
@main
545
struct OpenClawApp: App {
646
@State private var appModel: NodeAppModel
747
@State private var gatewayController: GatewayConnectionController
48+
@UIApplicationDelegateAdaptor(OpenClawAppDelegate.self) private var appDelegate
849
@Environment(\.scenePhase) private var scenePhase
950

1051
init() {
@@ -21,6 +62,9 @@ struct OpenClawApp: App {
2162
.environment(self.appModel)
2263
.environment(self.appModel.voiceWake)
2364
.environment(self.gatewayController)
65+
.task {
66+
self.appDelegate.appModel = self.appModel
67+
}
2468
.onOpenURL { url in
2569
Task { await self.appModel.handleDeepLink(url: url) }
2670
}

apps/ios/WatchApp/Info.plist

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717
<key>CFBundlePackageType</key>
1818
<string>APPL</string>
1919
<key>CFBundleShortVersionString</key>
20-
<string>2026.2.16</string>
20+
<string>2026.2.18</string>
2121
<key>CFBundleVersion</key>
22-
<string>20260216</string>
22+
<string>20260218</string>
2323
<key>WKCompanionAppBundleIdentifier</key>
24-
<string>ai.openclaw.ios</string>
24+
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
2525
<key>WKWatchKitApp</key>
2626
<true/>
2727
</dict>

0 commit comments

Comments
 (0)