diff --git a/CHANGELOG.md b/CHANGELOG.md index 391291777ac..39c54a169d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/ios/.swiftlint.yml b/apps/ios/.swiftlint.yml index fc8509c8385..23db4515968 100644 --- a/apps/ios/.swiftlint.yml +++ b/apps/ios/.swiftlint.yml @@ -3,3 +3,7 @@ parent_config: ../../.swiftlint.yml included: - Sources - ../shared/ClawdisNodeKit/Sources + +type_body_length: + warning: 900 + error: 1300 diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig index 3f834bdb9b7..e0afd46aa7e 100644 --- a/apps/ios/Config/Signing.xcconfig +++ b/apps/ios/Config/Signing.xcconfig @@ -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 diff --git a/apps/ios/LocalSigning.xcconfig.example b/apps/ios/LocalSigning.xcconfig.example index 65492131080..bfa610fb350 100644 --- a/apps/ios/LocalSigning.xcconfig.example +++ b/apps/ios/LocalSigning.xcconfig.example @@ -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 = diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 0715406665c..aa4ef744434 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -4,14 +4,14 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw Share CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 - CFBundleDisplayName - OpenClaw Share CFBundleName $(PRODUCT_NAME) CFBundlePackageType @@ -28,6 +28,8 @@ NSExtensionActivationSupportsImageWithMaxCount 10 + NSExtensionActivationSupportsMovieWithMaxCount + 1 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 327a4315460..4d1fec46257 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -62,6 +62,7 @@ UIBackgroundModes audio + remote-notification UILaunchScreen diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 6efe148d45d..4d57e28bc6c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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 diff --git a/apps/ios/Sources/OpenClaw.entitlements b/apps/ios/Sources/OpenClaw.entitlements new file mode 100644 index 00000000000..a2663ce930b --- /dev/null +++ b/apps/ios/Sources/OpenClaw.entitlements @@ -0,0 +1,9 @@ + + + + + aps-environment + development + + + diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index d180e1fc4d9..4c5f3874ce8 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -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) } } diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index 7fcab097cad..2c1d88f30a7 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.16 + 2026.2.18 CFBundleVersion - 20260216 + 20260218 WKCompanionAppBundleIdentifier - ai.openclaw.ios + $(OPENCLAW_APP_BUNDLE_ID) WKWatchKitApp diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index a145333bd1c..e401494d225 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,15 +15,15 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.2.16 + 2026.2.18 CFBundleVersion - 20260216 + 20260218 NSExtension NSExtensionAttributes WKAppBundleIdentifier - ai.openclaw.ios.watchkitapp + $(OPENCLAW_WATCH_APP_BUNDLE_ID) NSExtensionPointIdentifier com.apple.watchkit diff --git a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift index 9a128049c3c..fd0d84cc55c 100644 --- a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift @@ -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]) } } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index b3dce3028c9..cd543c59430 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -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: