mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -3,3 +3,7 @@ parent_config: ../../.swiftlint.yml
|
||||
included:
|
||||
- Sources
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
|
||||
type_body_length:
|
||||
warning: 900
|
||||
error: 1300
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
|
||||
@@ -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
|
||||
|
||||
9
apps/ios/Sources/OpenClaw.entitlements
Normal file
9
apps/ios/Sources/OpenClaw.entitlements
Normal 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>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user