feat(push): add iOS APNs relay gateway (#43369)
* feat(push): add ios apns relay gateway * fix(shared): avoid oslog string concatenation # Conflicts: # apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift * fix(push): harden relay validation and invalidation * fix(push): persist app attest state before relay registration * fix(push): harden relay invalidation and url handling * feat(push): use scoped relay send grants * feat(push): configure ios relay through gateway config * feat(push): bind relay registration to gateway identity * fix(push): tighten ios relay trust flow * fix(push): bound APNs registration fields (#43369) (thanks @ngutman)
This commit is contained in:
@@ -62,12 +62,18 @@ Release behavior:
|
||||
|
||||
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
|
||||
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
|
||||
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
|
||||
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- Root `package.json.version` is the only version source for iOS.
|
||||
- A root version like `2026.3.11-beta.1` becomes:
|
||||
- `CFBundleShortVersionString = 2026.3.11`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.3.11`
|
||||
|
||||
Required env for beta builds:
|
||||
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Archive without upload:
|
||||
|
||||
```bash
|
||||
@@ -91,9 +97,43 @@ pnpm ios:beta -- --build-number 7
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- Debug builds register as APNs sandbox; Release builds use production.
|
||||
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
|
||||
|
||||
## APNs Expectations For Official Builds
|
||||
|
||||
- Official/TestFlight builds register with the external push relay before they publish `push.apns.register` to the gateway.
|
||||
- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, relay origin metadata, and installation metadata instead of the raw APNs token.
|
||||
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||
- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration.
|
||||
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||
|
||||
## Official Build Relay Trust Model
|
||||
|
||||
- `iOS -> gateway`
|
||||
- The app must pair with the gateway and establish both node and operator sessions.
|
||||
- The operator session is used to fetch `gateway.identity.get`.
|
||||
- `iOS -> relay`
|
||||
- The app registers with the relay over HTTPS using App Attest plus the app receipt.
|
||||
- The relay requires the official production/TestFlight distribution path, which is why local
|
||||
Xcode/dev installs cannot use the hosted relay.
|
||||
- `gateway delegation`
|
||||
- The app includes the gateway identity in relay registration.
|
||||
- The relay returns a relay handle and registration-scoped send grant delegated to that gateway.
|
||||
- `gateway -> relay`
|
||||
- The gateway signs relay send requests with its own device identity.
|
||||
- The relay verifies both the delegated send grant and the gateway signature before it sends to
|
||||
APNs.
|
||||
- `relay -> APNs`
|
||||
- Production APNs credentials and raw official-build APNs tokens stay in the relay deployment,
|
||||
not on the gateway.
|
||||
|
||||
This exists to keep the hosted relay limited to genuine OpenClaw official builds and to ensure a
|
||||
gateway can only send pushes for iOS devices that paired with that gateway.
|
||||
|
||||
## What Works Now (Concrete)
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>OpenClawPushAPNsEnvironment</key>
|
||||
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||
<key>OpenClawPushDistribution</key>
|
||||
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
|
||||
<key>OpenClawPushRelayBaseURL</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
|
||||
<key>OpenClawPushTransport</key>
|
||||
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -12,6 +12,12 @@ import UserNotifications
|
||||
private struct NotificationCallError: Error, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private struct GatewayRelayIdentityResponse: Decodable {
|
||||
let deviceId: String
|
||||
let publicKey: String
|
||||
}
|
||||
|
||||
// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -140,6 +146,7 @@ final class NodeAppModel {
|
||||
private var shareDeliveryTo: String?
|
||||
private var apnsDeviceTokenHex: String?
|
||||
private var apnsLastRegisteredTokenHex: String?
|
||||
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||
var gatewaySession: GatewayNodeSession { self.nodeGateway }
|
||||
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
@@ -528,13 +535,6 @@ final class NodeAppModel {
|
||||
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
||||
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
||||
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
||||
private static var apnsEnvironment: String {
|
||||
#if DEBUG
|
||||
"sandbox"
|
||||
#else
|
||||
"production"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func refreshBrandingFromGateway() async {
|
||||
do {
|
||||
@@ -1189,7 +1189,15 @@ final class NodeAppModel {
|
||||
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
}
|
||||
|
||||
return await self.notificationAuthorizationStatus()
|
||||
let updatedStatus = await self.notificationAuthorizationStatus()
|
||||
if Self.isNotificationAuthorizationAllowed(updatedStatus) {
|
||||
// Refresh APNs registration immediately after the first permission grant so the
|
||||
// gateway can receive a push registration without requiring an app relaunch.
|
||||
await MainActor.run {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
return updatedStatus
|
||||
}
|
||||
|
||||
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
@@ -1204,6 +1212,17 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isNotificationAuthorizationAllowed(
|
||||
_ status: NotificationAuthorizationStatus
|
||||
) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
true
|
||||
case .denied, .notDetermined:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private func runNotificationCall<T: Sendable>(
|
||||
timeoutSeconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
@@ -1834,6 +1853,7 @@ private extension NodeAppModel {
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.refreshAgentsFromGateway()
|
||||
await self.refreshShareRouteFromGateway()
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.startVoiceWakeSync()
|
||||
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
|
||||
await MainActor.run { self.startGatewayHealthMonitor() }
|
||||
@@ -2479,7 +2499,8 @@ extension NodeAppModel {
|
||||
else {
|
||||
return
|
||||
}
|
||||
if token == self.apnsLastRegisteredTokenHex {
|
||||
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||
if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
|
||||
return
|
||||
}
|
||||
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
@@ -2488,25 +2509,40 @@ extension NodeAppModel {
|
||||
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)
|
||||
let gatewayIdentity: PushRelayGatewayIdentity?
|
||||
if usesRelayTransport {
|
||||
guard self.operatorConnected else { return }
|
||||
gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
|
||||
} else {
|
||||
gatewayIdentity = nil
|
||||
}
|
||||
let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload(
|
||||
apnsTokenHex: token,
|
||||
topic: topic,
|
||||
gatewayIdentity: gatewayIdentity)
|
||||
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON)
|
||||
self.apnsLastRegisteredTokenHex = token
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
self.pushWakeLogger.error(
|
||||
"APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
|
||||
let response = try await self.operatorGateway.request(
|
||||
method: "gateway.identity.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 8)
|
||||
let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
|
||||
let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !deviceId.isEmpty, !publicKey.isEmpty else {
|
||||
throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields")
|
||||
}
|
||||
return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey)
|
||||
}
|
||||
|
||||
private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
|
||||
guard let apsAny = userInfo["aps"] else { return false }
|
||||
if let aps = apsAny as? [AnyHashable: Any] {
|
||||
|
||||
@@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge {
|
||||
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
if !granted { return false }
|
||||
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
||||
if self.isAuthorizationStatusAllowed(updatedStatus) {
|
||||
// Refresh APNs registration immediately after the first permission grant so the
|
||||
// gateway can receive a push registration without requiring an app relaunch.
|
||||
await MainActor.run {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
return self.isAuthorizationStatusAllowed(updatedStatus)
|
||||
case .denied:
|
||||
return false
|
||||
|
||||
75
apps/ios/Sources/Push/PushBuildConfig.swift
Normal file
75
apps/ios/Sources/Push/PushBuildConfig.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
enum PushTransportMode: String {
|
||||
case direct
|
||||
case relay
|
||||
}
|
||||
|
||||
enum PushDistributionMode: String {
|
||||
case local
|
||||
case official
|
||||
}
|
||||
|
||||
enum PushAPNsEnvironment: String {
|
||||
case sandbox
|
||||
case production
|
||||
}
|
||||
|
||||
struct PushBuildConfig {
|
||||
let transport: PushTransportMode
|
||||
let distribution: PushDistributionMode
|
||||
let relayBaseURL: URL?
|
||||
let apnsEnvironment: PushAPNsEnvironment
|
||||
|
||||
static let current = PushBuildConfig()
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
self.transport = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushTransport",
|
||||
fallback: .direct)
|
||||
self.distribution = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushDistribution",
|
||||
fallback: .local)
|
||||
self.apnsEnvironment = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushAPNsEnvironment",
|
||||
fallback: Self.defaultAPNsEnvironment)
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
}
|
||||
|
||||
var usesRelay: Bool {
|
||||
self.transport == .relay
|
||||
}
|
||||
|
||||
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let components = URLComponents(string: trimmed),
|
||||
components.scheme?.lowercased() == "https",
|
||||
let host = components.host,
|
||||
!host.isEmpty,
|
||||
components.user == nil,
|
||||
components.password == nil,
|
||||
components.query == nil,
|
||||
components.fragment == nil
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return components.url
|
||||
}
|
||||
|
||||
private static func readEnum<T: RawRepresentable>(
|
||||
bundle: Bundle,
|
||||
key: String,
|
||||
fallback: T)
|
||||
-> T where T.RawValue == String {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return T(rawValue: trimmed) ?? fallback
|
||||
}
|
||||
|
||||
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
|
||||
}
|
||||
169
apps/ios/Sources/Push/PushRegistrationManager.swift
Normal file
169
apps/ios/Sources/Push/PushRegistrationManager.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
private struct DirectGatewayPushRegistrationPayload: Encodable {
|
||||
var transport: String = PushTransportMode.direct.rawValue
|
||||
var token: String
|
||||
var topic: String
|
||||
var environment: String
|
||||
}
|
||||
|
||||
private struct RelayGatewayPushRegistrationPayload: Encodable {
|
||||
var transport: String = PushTransportMode.relay.rawValue
|
||||
var relayHandle: String
|
||||
var sendGrant: String
|
||||
var gatewayDeviceId: String
|
||||
var installationId: String
|
||||
var topic: String
|
||||
var environment: String
|
||||
var distribution: String
|
||||
var tokenDebugSuffix: String?
|
||||
}
|
||||
|
||||
struct PushRelayGatewayIdentity: Codable {
|
||||
var deviceId: String
|
||||
var publicKey: String
|
||||
}
|
||||
|
||||
actor PushRegistrationManager {
|
||||
private let buildConfig: PushBuildConfig
|
||||
private let relayClient: PushRelayClient?
|
||||
|
||||
var usesRelayTransport: Bool {
|
||||
self.buildConfig.transport == .relay
|
||||
}
|
||||
|
||||
init(buildConfig: PushBuildConfig = .current) {
|
||||
self.buildConfig = buildConfig
|
||||
self.relayClient = buildConfig.relayBaseURL.map { PushRelayClient(baseURL: $0) }
|
||||
}
|
||||
|
||||
func makeGatewayRegistrationPayload(
|
||||
apnsTokenHex: String,
|
||||
topic: String,
|
||||
gatewayIdentity: PushRelayGatewayIdentity?)
|
||||
async throws -> String {
|
||||
switch self.buildConfig.transport {
|
||||
case .direct:
|
||||
return try Self.encodePayload(
|
||||
DirectGatewayPushRegistrationPayload(
|
||||
token: apnsTokenHex,
|
||||
topic: topic,
|
||||
environment: self.buildConfig.apnsEnvironment.rawValue))
|
||||
case .relay:
|
||||
guard let gatewayIdentity else {
|
||||
throw PushRelayError.relayMisconfigured("Missing gateway identity for relay registration")
|
||||
}
|
||||
return try await self.makeRelayPayload(
|
||||
apnsTokenHex: apnsTokenHex,
|
||||
topic: topic,
|
||||
gatewayIdentity: gatewayIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRelayPayload(
|
||||
apnsTokenHex: String,
|
||||
topic: String,
|
||||
gatewayIdentity: PushRelayGatewayIdentity)
|
||||
async throws -> String {
|
||||
guard self.buildConfig.distribution == .official else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushDistribution=official")
|
||||
}
|
||||
guard self.buildConfig.apnsEnvironment == .production else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushAPNsEnvironment=production")
|
||||
}
|
||||
guard let relayClient = self.relayClient else {
|
||||
throw PushRelayError.relayBaseURLMissing
|
||||
}
|
||||
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleId.isEmpty
|
||||
else {
|
||||
throw PushRelayError.relayMisconfigured("Missing bundle identifier for relay registration")
|
||||
}
|
||||
guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!installationId.isEmpty
|
||||
else {
|
||||
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
|
||||
}
|
||||
|
||||
let tokenHashHex = Self.sha256Hex(apnsTokenHex)
|
||||
let relayOrigin = relayClient.normalizedBaseURLString
|
||||
if let stored = PushRelayRegistrationStore.loadRegistrationState(),
|
||||
stored.installationId == installationId,
|
||||
stored.gatewayDeviceId == gatewayIdentity.deviceId,
|
||||
stored.relayOrigin == relayOrigin,
|
||||
stored.lastAPNsTokenHashHex == tokenHashHex,
|
||||
!Self.isExpired(stored.relayHandleExpiresAtMs)
|
||||
{
|
||||
return try Self.encodePayload(
|
||||
RelayGatewayPushRegistrationPayload(
|
||||
relayHandle: stored.relayHandle,
|
||||
sendGrant: stored.sendGrant,
|
||||
gatewayDeviceId: gatewayIdentity.deviceId,
|
||||
installationId: installationId,
|
||||
topic: topic,
|
||||
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
distribution: self.buildConfig.distribution.rawValue,
|
||||
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
let response = try await relayClient.register(
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
appVersion: DeviceInfoHelper.appVersion(),
|
||||
environment: self.buildConfig.apnsEnvironment,
|
||||
distribution: self.buildConfig.distribution,
|
||||
apnsTokenHex: apnsTokenHex,
|
||||
gatewayIdentity: gatewayIdentity)
|
||||
let registrationState = PushRelayRegistrationStore.RegistrationState(
|
||||
relayHandle: response.relayHandle,
|
||||
sendGrant: response.sendGrant,
|
||||
relayOrigin: relayOrigin,
|
||||
gatewayDeviceId: gatewayIdentity.deviceId,
|
||||
relayHandleExpiresAtMs: response.expiresAtMs,
|
||||
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
|
||||
lastAPNsTokenHashHex: tokenHashHex,
|
||||
installationId: installationId,
|
||||
lastTransport: self.buildConfig.transport.rawValue)
|
||||
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
|
||||
return try Self.encodePayload(
|
||||
RelayGatewayPushRegistrationPayload(
|
||||
relayHandle: response.relayHandle,
|
||||
sendGrant: response.sendGrant,
|
||||
gatewayDeviceId: gatewayIdentity.deviceId,
|
||||
installationId: installationId,
|
||||
topic: topic,
|
||||
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
distribution: self.buildConfig.distribution.rawValue,
|
||||
tokenDebugSuffix: registrationState.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
private static func isExpired(_ expiresAtMs: Int64?) -> Bool {
|
||||
guard let expiresAtMs else { return true }
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
// Refresh shortly before expiry so reconnect-path republishes a live handle.
|
||||
return expiresAtMs <= nowMs + 60_000
|
||||
}
|
||||
|
||||
private static func sha256Hex(_ value: String) -> String {
|
||||
let digest = SHA256.hash(data: Data(value.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func normalizeTokenSuffix(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func encodePayload(_ payload: some Encodable) throws -> String {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw PushRelayError.relayMisconfigured("Failed to encode push registration payload as UTF-8")
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
349
apps/ios/Sources/Push/PushRelayClient.swift
Normal file
349
apps/ios/Sources/Push/PushRelayClient.swift
Normal file
@@ -0,0 +1,349 @@
|
||||
import CryptoKit
|
||||
import DeviceCheck
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
enum PushRelayError: LocalizedError {
|
||||
case relayBaseURLMissing
|
||||
case relayMisconfigured(String)
|
||||
case invalidResponse(String)
|
||||
case requestFailed(status: Int, message: String)
|
||||
case unsupportedAppAttest
|
||||
case missingReceipt
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .relayBaseURLMissing:
|
||||
"Push relay base URL missing"
|
||||
case let .relayMisconfigured(message):
|
||||
message
|
||||
case let .invalidResponse(message):
|
||||
message
|
||||
case let .requestFailed(status, message):
|
||||
"Push relay request failed (\(status)): \(message)"
|
||||
case .unsupportedAppAttest:
|
||||
"App Attest unavailable on this device"
|
||||
case .missingReceipt:
|
||||
"App Store receipt missing after refresh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PushRelayChallengeResponse: Decodable {
|
||||
var challengeId: String
|
||||
var challenge: String
|
||||
var expiresAtMs: Int64
|
||||
}
|
||||
|
||||
private struct PushRelayRegisterSignedPayload: Encodable {
|
||||
var challengeId: String
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var environment: String
|
||||
var distribution: String
|
||||
var gateway: PushRelayGatewayIdentity
|
||||
var appVersion: String
|
||||
var apnsToken: String
|
||||
}
|
||||
|
||||
private struct PushRelayAppAttestPayload: Encodable {
|
||||
var keyId: String
|
||||
var attestationObject: String?
|
||||
var assertion: String
|
||||
var clientDataHash: String
|
||||
var signedPayloadBase64: String
|
||||
}
|
||||
|
||||
private struct PushRelayReceiptPayload: Encodable {
|
||||
var base64: String
|
||||
}
|
||||
|
||||
private struct PushRelayRegisterRequest: Encodable {
|
||||
var challengeId: String
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var environment: String
|
||||
var distribution: String
|
||||
var gateway: PushRelayGatewayIdentity
|
||||
var appVersion: String
|
||||
var apnsToken: String
|
||||
var appAttest: PushRelayAppAttestPayload
|
||||
var receipt: PushRelayReceiptPayload
|
||||
}
|
||||
|
||||
struct PushRelayRegisterResponse: Decodable {
|
||||
var relayHandle: String
|
||||
var sendGrant: String
|
||||
var expiresAtMs: Int64?
|
||||
var tokenSuffix: String?
|
||||
var status: String
|
||||
}
|
||||
|
||||
private struct RelayErrorResponse: Decodable {
|
||||
var error: String?
|
||||
var message: String?
|
||||
var reason: String?
|
||||
}
|
||||
|
||||
private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate {
|
||||
private var continuation: CheckedContinuation<Void, Error>?
|
||||
private var activeRequest: SKReceiptRefreshRequest?
|
||||
|
||||
func refresh() async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
let request = SKReceiptRefreshRequest()
|
||||
self.activeRequest = request
|
||||
request.delegate = self
|
||||
request.start()
|
||||
}
|
||||
}
|
||||
|
||||
func requestDidFinish(_ request: SKRequest) {
|
||||
self.continuation?.resume(returning: ())
|
||||
self.continuation = nil
|
||||
self.activeRequest = nil
|
||||
}
|
||||
|
||||
func request(_ request: SKRequest, didFailWithError error: Error) {
|
||||
self.continuation?.resume(throwing: error)
|
||||
self.continuation = nil
|
||||
self.activeRequest = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct PushRelayAppAttestProof {
|
||||
var keyId: String
|
||||
var attestationObject: String?
|
||||
var assertion: String
|
||||
var clientDataHash: String
|
||||
var signedPayloadBase64: String
|
||||
}
|
||||
|
||||
private final class PushRelayAppAttestService {
|
||||
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
|
||||
let service = DCAppAttestService.shared
|
||||
guard service.isSupported else {
|
||||
throw PushRelayError.unsupportedAppAttest
|
||||
}
|
||||
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service)
|
||||
let attestationObject = try await self.attestKeyIfNeeded(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
challenge: challenge)
|
||||
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
|
||||
let assertion = try await self.generateAssertion(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
signedPayloadHash: signedPayloadHash)
|
||||
|
||||
return PushRelayAppAttestProof(
|
||||
keyId: keyID,
|
||||
attestationObject: attestationObject,
|
||||
assertion: assertion.base64EncodedString(),
|
||||
clientDataHash: Self.base64URL(signedPayloadHash),
|
||||
signedPayloadBase64: signedPayload.base64EncodedString())
|
||||
}
|
||||
|
||||
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let keyID = try await service.generateKey()
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
|
||||
return keyID
|
||||
}
|
||||
|
||||
private func attestKeyIfNeeded(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
challenge: String)
|
||||
async throws -> String? {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
|
||||
return nil
|
||||
}
|
||||
let challengeData = Data(challenge.utf8)
|
||||
let clientDataHash = Data(SHA256.hash(data: challengeData))
|
||||
let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash)
|
||||
// Apple treats App Attest key attestation as a one-time operation. Save the
|
||||
// attested marker immediately so later receipt/network failures do not cause a
|
||||
// permanently broken re-attestation loop on the same key.
|
||||
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
|
||||
return attestation.base64EncodedString()
|
||||
}
|
||||
|
||||
private func generateAssertion(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
signedPayloadHash: Data)
|
||||
async throws -> Data {
|
||||
do {
|
||||
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
|
||||
} catch {
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class PushRelayReceiptProvider {
|
||||
func loadReceiptBase64() async throws -> String {
|
||||
if let receipt = self.readReceiptData() {
|
||||
return receipt.base64EncodedString()
|
||||
}
|
||||
let refreshCoordinator = PushRelayReceiptRefreshCoordinator()
|
||||
try await refreshCoordinator.refresh()
|
||||
if let refreshed = self.readReceiptData() {
|
||||
return refreshed.base64EncodedString()
|
||||
}
|
||||
throw PushRelayError.missingReceipt
|
||||
}
|
||||
|
||||
private func readReceiptData() -> Data? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL else { return nil }
|
||||
guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil }
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
||||
final class PushRelayClient: @unchecked Sendable {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
private let jsonEncoder = JSONEncoder()
|
||||
private let appAttest = PushRelayAppAttestService()
|
||||
private let receiptProvider = PushRelayReceiptProvider()
|
||||
|
||||
init(baseURL: URL, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
}
|
||||
|
||||
var normalizedBaseURLString: String {
|
||||
Self.normalizeBaseURLString(self.baseURL)
|
||||
}
|
||||
|
||||
func register(
|
||||
installationId: String,
|
||||
bundleId: String,
|
||||
appVersion: String,
|
||||
environment: PushAPNsEnvironment,
|
||||
distribution: PushDistributionMode,
|
||||
apnsTokenHex: String,
|
||||
gatewayIdentity: PushRelayGatewayIdentity)
|
||||
async throws -> PushRelayRegisterResponse {
|
||||
let challenge = try await self.fetchChallenge()
|
||||
let signedPayload = PushRelayRegisterSignedPayload(
|
||||
challengeId: challenge.challengeId,
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
environment: environment.rawValue,
|
||||
distribution: distribution.rawValue,
|
||||
gateway: gatewayIdentity,
|
||||
appVersion: appVersion,
|
||||
apnsToken: apnsTokenHex)
|
||||
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
|
||||
let appAttest = try await self.appAttest.createProof(
|
||||
challenge: challenge.challenge,
|
||||
signedPayload: signedPayloadData)
|
||||
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
|
||||
let requestBody = PushRelayRegisterRequest(
|
||||
challengeId: signedPayload.challengeId,
|
||||
installationId: signedPayload.installationId,
|
||||
bundleId: signedPayload.bundleId,
|
||||
environment: signedPayload.environment,
|
||||
distribution: signedPayload.distribution,
|
||||
gateway: signedPayload.gateway,
|
||||
appVersion: signedPayload.appVersion,
|
||||
apnsToken: signedPayload.apnsToken,
|
||||
appAttest: PushRelayAppAttestPayload(
|
||||
keyId: appAttest.keyId,
|
||||
attestationObject: appAttest.attestationObject,
|
||||
assertion: appAttest.assertion,
|
||||
clientDataHash: appAttest.clientDataHash,
|
||||
signedPayloadBase64: appAttest.signedPayloadBase64),
|
||||
receipt: PushRelayReceiptPayload(base64: receiptBase64))
|
||||
|
||||
let endpoint = self.baseURL.appending(path: "v1/push/register")
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 20
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try self.jsonEncoder.encode(requestBody)
|
||||
|
||||
let (data, response) = try await self.session.data(for: request)
|
||||
let status = Self.statusCode(from: response)
|
||||
guard (200..<300).contains(status) else {
|
||||
if status == 401 {
|
||||
// If the relay rejects registration, drop local App Attest state so the next
|
||||
// attempt re-attests instead of getting stuck without an attestation object.
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
}
|
||||
throw PushRelayError.requestFailed(
|
||||
status: status,
|
||||
message: Self.decodeErrorMessage(data: data))
|
||||
}
|
||||
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
||||
let endpoint = self.baseURL.appending(path: "v1/push/challenge")
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 10
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = Data("{}".utf8)
|
||||
|
||||
let (data, response) = try await self.session.data(for: request)
|
||||
let status = Self.statusCode(from: response)
|
||||
guard (200..<300).contains(status) else {
|
||||
throw PushRelayError.requestFailed(
|
||||
status: status,
|
||||
message: Self.decodeErrorMessage(data: data))
|
||||
}
|
||||
return try self.decode(PushRelayChallengeResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||
do {
|
||||
return try self.jsonDecoder.decode(type, from: data)
|
||||
} catch {
|
||||
throw PushRelayError.invalidResponse(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func statusCode(from response: URLResponse) -> Int {
|
||||
(response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
}
|
||||
|
||||
private static func normalizeBaseURLString(_ url: URL) -> String {
|
||||
var absolute = url.absoluteString
|
||||
while absolute.hasSuffix("/") {
|
||||
absolute.removeLast()
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
private static func decodeErrorMessage(data: Data) -> String {
|
||||
if let decoded = try? JSONDecoder().decode(RelayErrorResponse.self, from: data) {
|
||||
let message = decoded.message ?? decoded.reason ?? decoded.error ?? ""
|
||||
if !message.isEmpty {
|
||||
return message
|
||||
}
|
||||
}
|
||||
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty ? "unknown relay error" : raw
|
||||
}
|
||||
}
|
||||
112
apps/ios/Sources/Push/PushRelayKeychainStore.swift
Normal file
112
apps/ios/Sources/Push/PushRelayKeychainStore.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
private struct StoredPushRelayRegistrationState: Codable {
|
||||
var relayHandle: String
|
||||
var sendGrant: String
|
||||
var relayOrigin: String?
|
||||
var gatewayDeviceId: String
|
||||
var relayHandleExpiresAtMs: Int64?
|
||||
var tokenDebugSuffix: String?
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
private static let service = "ai.openclaw.pushrelay"
|
||||
private static let registrationStateAccount = "registration-state"
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
|
||||
struct RegistrationState: Codable {
|
||||
var relayHandle: String
|
||||
var sendGrant: String
|
||||
var relayOrigin: String?
|
||||
var gatewayDeviceId: String
|
||||
var relayHandleExpiresAtMs: Int64?
|
||||
var tokenDebugSuffix: String?
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
}
|
||||
|
||||
static func loadRegistrationState() -> RegistrationState? {
|
||||
guard let raw = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.registrationStateAccount),
|
||||
let data = raw.data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode(StoredPushRelayRegistrationState.self, from: data)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return RegistrationState(
|
||||
relayHandle: decoded.relayHandle,
|
||||
sendGrant: decoded.sendGrant,
|
||||
relayOrigin: decoded.relayOrigin,
|
||||
gatewayDeviceId: decoded.gatewayDeviceId,
|
||||
relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs,
|
||||
tokenDebugSuffix: decoded.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
|
||||
installationId: decoded.installationId,
|
||||
lastTransport: decoded.lastTransport)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveRegistrationState(_ state: RegistrationState) -> Bool {
|
||||
let stored = StoredPushRelayRegistrationState(
|
||||
relayHandle: state.relayHandle,
|
||||
sendGrant: state.sendGrant,
|
||||
relayOrigin: state.relayOrigin,
|
||||
gatewayDeviceId: state.gatewayDeviceId,
|
||||
relayHandleExpiresAtMs: state.relayHandleExpiresAtMs,
|
||||
tokenDebugSuffix: state.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
|
||||
installationId: state.installationId,
|
||||
lastTransport: state.lastTransport)
|
||||
guard let data = try? JSONEncoder().encode(stored),
|
||||
let raw = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearRegistrationState() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAppAttestKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
|
||||
}
|
||||
|
||||
static func loadAttestedKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAttestedKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAttestedKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,17 @@ targets:
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
configs:
|
||||
Debug:
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
Release:
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
@@ -131,6 +142,10 @@ targets:
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
|
||||
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
|
||||
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
|
||||
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
|
||||
@@ -892,7 +892,8 @@ public actor GatewayChannelActor {
|
||||
return (id: id, data: data)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user