Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
92
apps/ios/Sources/Push/BackgroundAliveBeacon.swift
Normal file
92
apps/ios/Sources/Push/BackgroundAliveBeacon.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum BackgroundAliveBeacon {
|
||||
static let eventName = "node.presence.alive"
|
||||
static let minSuccessIntervalSeconds: TimeInterval = 10 * 60
|
||||
|
||||
enum Trigger: String, CaseIterable, Codable {
|
||||
case background
|
||||
case silentPush = "silent_push"
|
||||
case bgAppRefresh = "bg_app_refresh"
|
||||
case significantLocation = "significant_location"
|
||||
case manual
|
||||
case connect
|
||||
}
|
||||
|
||||
struct Payload: Encodable {
|
||||
var trigger: String
|
||||
var sentAtMs: Int64
|
||||
var displayName: String
|
||||
var version: String
|
||||
var platform: String
|
||||
var deviceFamily: String
|
||||
var modelIdentifier: String
|
||||
var pushTransport: String?
|
||||
}
|
||||
|
||||
struct NodeEventRequestPayload: Codable {
|
||||
var event: String = BackgroundAliveBeacon.eventName
|
||||
var payloadJSON: String
|
||||
}
|
||||
|
||||
struct NodeEventResponsePayload: Decodable {
|
||||
var ok: Bool?
|
||||
var event: String?
|
||||
var handled: Bool?
|
||||
var reason: String?
|
||||
}
|
||||
|
||||
static func normalizeTrigger(_ raw: String) -> Trigger {
|
||||
let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return Trigger(rawValue: normalized) ?? .background
|
||||
}
|
||||
|
||||
static func shouldSkipRecentSuccess(
|
||||
isGatewayConnected: Bool,
|
||||
now: Date,
|
||||
lastSuccessAtMs: Double?,
|
||||
minInterval: TimeInterval = Self.minSuccessIntervalSeconds) -> Bool
|
||||
{
|
||||
guard isGatewayConnected else { return false }
|
||||
guard let lastSuccessAtMs, lastSuccessAtMs > 0 else { return false }
|
||||
let elapsed = now.timeIntervalSince1970 - (lastSuccessAtMs / 1000.0)
|
||||
return elapsed >= 0 && elapsed < minInterval
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makePayload(trigger: Trigger, displayName: String, pushTransport: String?) -> Payload {
|
||||
Payload(
|
||||
trigger: trigger.rawValue,
|
||||
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
displayName: displayName,
|
||||
version: DeviceInfoHelper.appVersion(),
|
||||
platform: DeviceInfoHelper.platformString(),
|
||||
deviceFamily: DeviceInfoHelper.deviceFamily(),
|
||||
modelIdentifier: DeviceInfoHelper.modelIdentifier(),
|
||||
pushTransport: pushTransport)
|
||||
}
|
||||
|
||||
static func makeNodeEventRequestPayloadJSON(
|
||||
payload: Payload,
|
||||
encoder: JSONEncoder = JSONEncoder()) throws -> String
|
||||
{
|
||||
let payloadData = try encoder.encode(payload)
|
||||
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
|
||||
throw EncodingError.invalidValue(payload, EncodingError.Context(
|
||||
codingPath: [],
|
||||
debugDescription: "Failed to encode background alive payload as UTF-8"))
|
||||
}
|
||||
let requestData = try encoder.encode(NodeEventRequestPayload(payloadJSON: payloadJSON))
|
||||
guard let requestJSON = String(data: requestData, encoding: .utf8) else {
|
||||
throw EncodingError.invalidValue(payload, EncodingError.Context(
|
||||
codingPath: [],
|
||||
debugDescription: "Failed to encode node.event payload as UTF-8"))
|
||||
}
|
||||
return requestJSON
|
||||
}
|
||||
|
||||
static func decodeResponse(_ data: Data) -> NodeEventResponsePayload? {
|
||||
try? JSONDecoder().decode(NodeEventResponsePayload.self, from: data)
|
||||
}
|
||||
}
|
||||
115
apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift
Normal file
115
apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
struct ExecApprovalNotificationPrompt: Equatable {
|
||||
let approvalId: String
|
||||
}
|
||||
|
||||
enum ExecApprovalNotificationBridge {
|
||||
static let requestedKind = "exec.approval.requested"
|
||||
static let resolvedKind = "exec.approval.resolved"
|
||||
static let categoryIdentifier = "openclaw.exec-approval"
|
||||
static let reviewActionIdentifier = "openclaw.exec-approval.review"
|
||||
|
||||
private static let localRequestPrefix = "exec.approval."
|
||||
|
||||
static func registerCategory(center: UNUserNotificationCenter = .current()) {
|
||||
let category = UNNotificationCategory(
|
||||
identifier: self.categoryIdentifier,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: self.reviewActionIdentifier,
|
||||
title: "Review",
|
||||
options: [.foreground]),
|
||||
],
|
||||
intentIdentifiers: [],
|
||||
options: [])
|
||||
|
||||
center.getNotificationCategories { categories in
|
||||
var updated = categories
|
||||
updated.update(with: category)
|
||||
center.setNotificationCategories(updated)
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
|
||||
self.payloadKind(userInfo: userInfo) == self.requestedKind
|
||||
}
|
||||
|
||||
static func parsePrompt(
|
||||
actionIdentifier: String,
|
||||
userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt?
|
||||
{
|
||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|
||||
|| actionIdentifier == self.reviewActionIdentifier
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
|
||||
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
|
||||
return ExecApprovalNotificationPrompt(approvalId: approvalId)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func handleResolvedPushIfNeeded(
|
||||
userInfo: [AnyHashable: Any],
|
||||
notificationCenter: NotificationCentering) async -> Bool
|
||||
{
|
||||
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
||||
let approvalId = self.approvalID(from: userInfo)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func removeNotifications(
|
||||
forApprovalID approvalId: String,
|
||||
notificationCenter: NotificationCentering) async
|
||||
{
|
||||
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
|
||||
await notificationCenter.removePendingNotificationRequests(
|
||||
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
|
||||
|
||||
let delivered = await notificationCenter.deliveredNotifications()
|
||||
let identifiers = delivered.compactMap { snapshot -> String? in
|
||||
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
|
||||
return snapshot.identifier
|
||||
}
|
||||
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func localRequestIdentifier(for approvalId: String) -> String {
|
||||
"\(self.localRequestPrefix)\(approvalId)"
|
||||
}
|
||||
|
||||
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
|
||||
private static func openClawPayload(userInfo: [AnyHashable: Any]) -> [String: Any]? {
|
||||
if let payload = userInfo["openclaw"] as? [String: Any] {
|
||||
return payload
|
||||
}
|
||||
if let payload = userInfo["openclaw"] as? [AnyHashable: Any] {
|
||||
return payload.reduce(into: [String: Any]()) { partialResult, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
partialResult[key] = pair.value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
149
apps/ios/Sources/Push/PushBuildConfig.swift
Normal file
149
apps/ios/Sources/Push/PushBuildConfig.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
import Foundation
|
||||
|
||||
enum PushTransportMode: String {
|
||||
case direct
|
||||
case relay
|
||||
}
|
||||
|
||||
enum PushDistributionMode: String {
|
||||
case local
|
||||
case official
|
||||
}
|
||||
|
||||
enum PushAPNsEnvironment: String {
|
||||
case sandbox
|
||||
case production
|
||||
}
|
||||
|
||||
enum PushRelayProfile: String {
|
||||
case production
|
||||
case deviceSandbox
|
||||
case simulatorSandbox
|
||||
}
|
||||
|
||||
enum PushProofPolicy: String {
|
||||
case appleStrict
|
||||
case appleDevelopment
|
||||
case internalSimulator
|
||||
}
|
||||
|
||||
enum PushBuildMode: String {
|
||||
case localSandbox
|
||||
case localProduction
|
||||
case appStore
|
||||
case deviceSandbox
|
||||
case simulatorSandbox
|
||||
}
|
||||
|
||||
struct PushBuildConfig {
|
||||
let mode: PushBuildMode
|
||||
let transport: PushTransportMode
|
||||
let distribution: PushDistributionMode
|
||||
let relayBaseURL: URL?
|
||||
let apnsEnvironment: PushAPNsEnvironment
|
||||
let relayProfile: PushRelayProfile
|
||||
let proofPolicy: PushProofPolicy
|
||||
|
||||
static let current = PushBuildConfig()
|
||||
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
|
||||
static let openClawSandboxRelayHost = "ios-push-relay-sandbox.openclaw.ai"
|
||||
|
||||
var usesOpenClawHostedRelay: Bool {
|
||||
guard self.transport == .relay, self.distribution == .official else { return false }
|
||||
guard let relayBaseURL = self.relayBaseURL,
|
||||
let components = URLComponents(url: relayBaseURL, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return components.scheme?.lowercased() == "https"
|
||||
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
|
||||
.contains(components.host?.lowercased() ?? "")
|
||||
&& components.user == nil
|
||||
&& components.password == nil
|
||||
}
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
self.init(readValue: { bundle.object(forInfoDictionaryKey: $0) })
|
||||
}
|
||||
|
||||
init(infoDictionary: [String: Any]) {
|
||||
self.init(readValue: { infoDictionary[$0] })
|
||||
}
|
||||
|
||||
private init(readValue: (String) -> Any?) {
|
||||
self.mode = Self.readEnum(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushMode",
|
||||
fallback: .localSandbox)
|
||||
let relayBaseURLOverride = Self.readURL(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushRelayBaseURL")
|
||||
switch self.mode {
|
||||
case .localSandbox:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .localProduction:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .appStore:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = URL(string: "https://\(Self.openClawHostedRelayHost)")!
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .deviceSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .simulatorSandbox
|
||||
self.proofPolicy = .internalSimulator
|
||||
}
|
||||
}
|
||||
|
||||
private static func readURL(readValue: (String) -> Any?, key: String) -> URL? {
|
||||
guard let raw = readValue(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>(
|
||||
readValue: (String) -> Any?,
|
||||
key: String,
|
||||
fallback: T)
|
||||
-> T where T.RawValue == String {
|
||||
guard let raw = readValue(key) as? String else { return fallback }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
|
||||
}
|
||||
}
|
||||
19
apps/ios/Sources/Push/PushEnrollmentConsent.swift
Normal file
19
apps/ios/Sources/Push/PushEnrollmentConsent.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum PushEnrollmentConsent {
|
||||
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
|
||||
|
||||
static var disclosureAccepted: Bool {
|
||||
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
static func markDisclosureAccepted() {
|
||||
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func reset() {
|
||||
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
211
apps/ios/Sources/Push/PushRegistrationManager.swift
Normal file
211
apps/ios/Sources/Push/PushRegistrationManager.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
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 relayOrigin: 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 an official push build mode")
|
||||
}
|
||||
try Self.validateRelayContract(
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment,
|
||||
proofPolicy: self.buildConfig.proofPolicy)
|
||||
GatewayDiagnostics.pushRelay.stage(
|
||||
"contract validated apns=\(self.buildConfig.apnsEnvironment.rawValue) "
|
||||
+ "profile=\(self.buildConfig.relayProfile.rawValue) "
|
||||
+ "proof=\(self.buildConfig.proofPolicy.rawValue)")
|
||||
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.apnsEnvironment == self.buildConfig.apnsEnvironment.rawValue,
|
||||
stored.relayProfile == self.buildConfig.relayProfile.rawValue,
|
||||
stored.proofPolicy == self.buildConfig.proofPolicy.rawValue,
|
||||
stored.lastAPNsTokenHashHex == tokenHashHex,
|
||||
!Self.isExpired(stored.relayHandleExpiresAtMs)
|
||||
{
|
||||
GatewayDiagnostics.pushRelay.stage("using cached relay registration")
|
||||
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,
|
||||
relayOrigin: relayOrigin,
|
||||
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
GatewayDiagnostics.pushRelay.stage("relay registration cache miss")
|
||||
let response = try await relayClient.register(PushRelayRegistrationInput(
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
appVersion: DeviceInfoHelper.appVersion(),
|
||||
environment: self.buildConfig.apnsEnvironment,
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
proofPolicy: self.buildConfig.proofPolicy,
|
||||
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,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
relayProfile: self.buildConfig.relayProfile.rawValue,
|
||||
proofPolicy: self.buildConfig.proofPolicy.rawValue)
|
||||
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
|
||||
GatewayDiagnostics.pushRelay.stage("stored relay registration hasExpiry=\(response.expiresAtMs != nil)")
|
||||
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,
|
||||
relayOrigin: relayOrigin,
|
||||
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 + 60000
|
||||
}
|
||||
|
||||
private static func validateRelayContract(
|
||||
relayProfile: PushRelayProfile,
|
||||
apnsEnvironment: PushAPNsEnvironment,
|
||||
proofPolicy: PushProofPolicy)
|
||||
throws {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
guard apnsEnvironment == .production, proofPolicy == .appleStrict else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"production relay profile requires production APNs and appleStrict proof")
|
||||
}
|
||||
case .deviceSandbox:
|
||||
guard apnsEnvironment == .sandbox, proofPolicy == .appleDevelopment else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"deviceSandbox relay profile requires sandbox APNs and appleDevelopment proof")
|
||||
}
|
||||
case .simulatorSandbox:
|
||||
guard apnsEnvironment == .sandbox, proofPolicy == .internalSimulator else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"simulatorSandbox relay profile requires sandbox APNs and internalSimulator proof")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
492
apps/ios/Sources/Push/PushRelayClient.swift
Normal file
492
apps/ios/Sources/Push/PushRelayClient.swift
Normal file
@@ -0,0 +1,492 @@
|
||||
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 app transaction 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 relayProfile: String
|
||||
var apnsEnvironment: String
|
||||
var proofPolicy: 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 relayProfile: String
|
||||
var apnsEnvironment: String
|
||||
var proofPolicy: String
|
||||
var distribution: String
|
||||
var gateway: PushRelayGatewayIdentity
|
||||
var appVersion: String
|
||||
var apnsToken: String
|
||||
var appAttest: PushRelayAppAttestPayload?
|
||||
var receipt: PushRelayReceiptPayload?
|
||||
var simulatorProof: PushRelaySimulatorProofPayload?
|
||||
}
|
||||
|
||||
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 struct PushRelayAppAttestProof {
|
||||
var keyId: String
|
||||
var attestationObject: String?
|
||||
var assertion: String
|
||||
var clientDataHash: String
|
||||
var signedPayloadBase64: String
|
||||
}
|
||||
|
||||
private struct PushRelaySimulatorProofPayload: Encodable {
|
||||
var signedPayloadBase64: String
|
||||
var hmacSha256Base64Url: String
|
||||
}
|
||||
|
||||
private final class PushRelayAppAttestService {
|
||||
func createProof(
|
||||
challenge: String,
|
||||
signedPayload: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> PushRelayAppAttestProof {
|
||||
let service = DCAppAttestService.shared
|
||||
guard service.isSupported else {
|
||||
throw PushRelayError.unsupportedAppAttest
|
||||
}
|
||||
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
|
||||
let attestationObject = try await self.attestKeyIfNeeded(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
challenge: challenge,
|
||||
scope: scope)
|
||||
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
|
||||
let assertion = try await self.generateAssertion(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
signedPayloadHash: signedPayloadHash,
|
||||
scope: scope)
|
||||
|
||||
return PushRelayAppAttestProof(
|
||||
keyId: keyID,
|
||||
attestationObject: attestationObject,
|
||||
assertion: assertion.base64EncodedString(),
|
||||
clientDataHash: Self.base64URL(signedPayloadHash),
|
||||
signedPayloadBase64: signedPayload.base64EncodedString())
|
||||
}
|
||||
|
||||
private func loadOrCreateKeyID(
|
||||
using service: DCAppAttestService,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
|
||||
!existing.isEmpty
|
||||
{
|
||||
return existing
|
||||
}
|
||||
let keyID = try await service.generateKey()
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
|
||||
return keyID
|
||||
}
|
||||
|
||||
private func attestKeyIfNeeded(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
challenge: String,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> String? {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == 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, scope: scope)
|
||||
return attestation.base64EncodedString()
|
||||
}
|
||||
|
||||
private func generateAssertion(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
signedPayloadHash: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> Data {
|
||||
do {
|
||||
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
|
||||
} catch {
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
|
||||
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 {
|
||||
do {
|
||||
let result = try await AppTransaction.shared
|
||||
return try Self.appTransactionBase64(result)
|
||||
} catch {
|
||||
let refreshed = try await AppTransaction.refresh()
|
||||
return try Self.appTransactionBase64(refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
private static func appTransactionBase64(
|
||||
_ result: StoreKit.VerificationResult<AppTransaction>) throws -> String
|
||||
{
|
||||
let jws = result.jwsRepresentation.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !jws.isEmpty else {
|
||||
throw PushRelayError.missingReceipt
|
||||
}
|
||||
return Data(jws.utf8).base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
private final class PushRelaySimulatorProofProvider {
|
||||
func createProof(signedPayload: Data) throws -> PushRelaySimulatorProofPayload {
|
||||
#if targetEnvironment(simulator)
|
||||
guard let secret = ProcessInfo.processInfo.environment["OPENCLAW_SIMULATOR_PUSH_PROOF_SECRET"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!secret.isEmpty
|
||||
else {
|
||||
throw PushRelayError.relayMisconfigured("Simulator push proof secret missing")
|
||||
}
|
||||
let signedPayloadBase64 = signedPayload.base64EncodedString()
|
||||
let signature = HMAC<SHA256>.authenticationCode(
|
||||
for: Data(signedPayloadBase64.utf8),
|
||||
using: SymmetricKey(data: Data(secret.utf8)))
|
||||
return PushRelaySimulatorProofPayload(
|
||||
signedPayloadBase64: signedPayloadBase64,
|
||||
hmacSha256Base64Url: Self.base64URL(Data(signature)))
|
||||
#else
|
||||
throw PushRelayError.relayMisconfigured("Simulator proof is only available in iOS Simulator")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
struct PushRelayRegistrationInput {
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var appVersion: String
|
||||
var environment: PushAPNsEnvironment
|
||||
var relayProfile: PushRelayProfile
|
||||
var proofPolicy: PushProofPolicy
|
||||
var distribution: PushDistributionMode
|
||||
var apnsTokenHex: String
|
||||
var gatewayIdentity: PushRelayGatewayIdentity
|
||||
}
|
||||
|
||||
/// 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()
|
||||
private let simulatorProofProvider = PushRelaySimulatorProofProvider()
|
||||
|
||||
init(baseURL: URL, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
}
|
||||
|
||||
var normalizedBaseURLString: String {
|
||||
Self.normalizeBaseURLString(self.baseURL)
|
||||
}
|
||||
|
||||
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
|
||||
GatewayDiagnostics.pushRelay.stage(
|
||||
"registration start origin=\(self.normalizedBaseURLString) "
|
||||
+ "apns=\(input.environment.rawValue) "
|
||||
+ "profile=\(input.relayProfile.rawValue) "
|
||||
+ "proof=\(input.proofPolicy.rawValue)")
|
||||
let challenge: PushRelayChallengeResponse
|
||||
do {
|
||||
GatewayDiagnostics.pushRelay.stage("challenge request start")
|
||||
challenge = try await self.fetchChallenge()
|
||||
GatewayDiagnostics.pushRelay.stage("challenge received")
|
||||
} catch {
|
||||
GatewayDiagnostics.pushRelay.failed("challenge request", error: error)
|
||||
throw error
|
||||
}
|
||||
let signedPayload = PushRelayRegisterSignedPayload(
|
||||
challengeId: challenge.challengeId,
|
||||
installationId: input.installationId,
|
||||
bundleId: input.bundleId,
|
||||
environment: input.environment.rawValue,
|
||||
relayProfile: input.relayProfile.rawValue,
|
||||
apnsEnvironment: input.environment.rawValue,
|
||||
proofPolicy: input.proofPolicy.rawValue,
|
||||
distribution: input.distribution.rawValue,
|
||||
gateway: input.gatewayIdentity,
|
||||
appVersion: input.appVersion,
|
||||
apnsToken: input.apnsTokenHex)
|
||||
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
|
||||
let appAttestScope = PushRelayRegistrationStore.AppAttestScope(
|
||||
relayOrigin: self.normalizedBaseURLString,
|
||||
apnsEnvironment: input.environment.rawValue,
|
||||
relayProfile: input.relayProfile.rawValue,
|
||||
proofPolicy: input.proofPolicy.rawValue)
|
||||
let appAttest: PushRelayAppAttestProof?
|
||||
do {
|
||||
GatewayDiagnostics.pushRelay.stage("app attest proof start")
|
||||
appAttest = try await self.createAppAttestProofIfNeeded(
|
||||
proofPolicy: input.proofPolicy,
|
||||
challenge: challenge.challenge,
|
||||
signedPayloadData: signedPayloadData,
|
||||
scope: appAttestScope)
|
||||
GatewayDiagnostics.pushRelay.stage("app attest proof complete included=\(appAttest != nil)")
|
||||
} catch {
|
||||
GatewayDiagnostics.pushRelay.failed("app attest proof", error: error)
|
||||
throw error
|
||||
}
|
||||
let receipt: PushRelayReceiptPayload?
|
||||
do {
|
||||
GatewayDiagnostics.pushRelay.stage("receipt proof start")
|
||||
receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
|
||||
GatewayDiagnostics.pushRelay.stage("receipt proof complete included=\(receipt != nil)")
|
||||
} catch {
|
||||
GatewayDiagnostics.pushRelay.failed("receipt proof", error: error)
|
||||
throw error
|
||||
}
|
||||
let simulatorProof: PushRelaySimulatorProofPayload?
|
||||
do {
|
||||
simulatorProof = try self.createSimulatorProofIfNeeded(
|
||||
proofPolicy: input.proofPolicy,
|
||||
signedPayloadData: signedPayloadData)
|
||||
GatewayDiagnostics.pushRelay.stage("simulator proof complete included=\(simulatorProof != nil)")
|
||||
} catch {
|
||||
GatewayDiagnostics.pushRelay.failed("simulator proof", error: error)
|
||||
throw error
|
||||
}
|
||||
let requestBody = PushRelayRegisterRequest(
|
||||
challengeId: signedPayload.challengeId,
|
||||
installationId: signedPayload.installationId,
|
||||
bundleId: signedPayload.bundleId,
|
||||
environment: signedPayload.environment,
|
||||
relayProfile: signedPayload.relayProfile,
|
||||
apnsEnvironment: signedPayload.apnsEnvironment,
|
||||
proofPolicy: signedPayload.proofPolicy,
|
||||
distribution: signedPayload.distribution,
|
||||
gateway: signedPayload.gateway,
|
||||
appVersion: signedPayload.appVersion,
|
||||
apnsToken: signedPayload.apnsToken,
|
||||
appAttest: appAttest.map {
|
||||
PushRelayAppAttestPayload(
|
||||
keyId: $0.keyId,
|
||||
attestationObject: $0.attestationObject,
|
||||
assertion: $0.assertion,
|
||||
clientDataHash: $0.clientDataHash,
|
||||
signedPayloadBase64: $0.signedPayloadBase64)
|
||||
},
|
||||
receipt: receipt,
|
||||
simulatorProof: simulatorProof)
|
||||
|
||||
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: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
GatewayDiagnostics.pushRelay.stage("register request start")
|
||||
(data, response) = try await self.session.data(for: request)
|
||||
} catch {
|
||||
GatewayDiagnostics.pushRelay.failed("register request", error: error)
|
||||
throw error
|
||||
}
|
||||
let status = Self.statusCode(from: response)
|
||||
GatewayDiagnostics.pushRelay.stage("register response status=\(status)")
|
||||
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(scope: appAttestScope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
|
||||
}
|
||||
let relayError = PushRelayError.requestFailed(
|
||||
status: status,
|
||||
message: Self.decodeErrorMessage(data: data))
|
||||
GatewayDiagnostics.pushRelay.stage("register response failed status=\(status)")
|
||||
throw relayError
|
||||
}
|
||||
do {
|
||||
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
GatewayDiagnostics.pushRelay.stage("registration response decoded")
|
||||
return decoded
|
||||
} catch {
|
||||
GatewayDiagnostics.pushRelay.failed("registration response decode", error: error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func createAppAttestProofIfNeeded(
|
||||
proofPolicy: PushProofPolicy,
|
||||
challenge: String,
|
||||
signedPayloadData: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> PushRelayAppAttestProof? {
|
||||
guard proofPolicy != .internalSimulator else { return nil }
|
||||
return try await self.appAttest.createProof(
|
||||
challenge: challenge,
|
||||
signedPayload: signedPayloadData,
|
||||
scope: scope)
|
||||
}
|
||||
|
||||
private func createReceiptIfNeeded(
|
||||
proofPolicy: PushProofPolicy)
|
||||
async throws -> PushRelayReceiptPayload? {
|
||||
switch proofPolicy {
|
||||
case .appleStrict:
|
||||
return try await PushRelayReceiptPayload(base64: self.receiptProvider.loadReceiptBase64())
|
||||
case .appleDevelopment:
|
||||
guard let receiptBase64 = try? await self.receiptProvider.loadReceiptBase64() else {
|
||||
return nil
|
||||
}
|
||||
return PushRelayReceiptPayload(base64: receiptBase64)
|
||||
case .internalSimulator:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func createSimulatorProofIfNeeded(
|
||||
proofPolicy: PushProofPolicy,
|
||||
signedPayloadData: Data)
|
||||
throws -> PushRelaySimulatorProofPayload? {
|
||||
guard proofPolicy == .internalSimulator else { return nil }
|
||||
return try self.simulatorProofProvider.createProof(signedPayload: signedPayloadData)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
156
apps/ios/Sources/Push/PushRelayKeychainStore.swift
Normal file
156
apps/ios/Sources/Push/PushRelayKeychainStore.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
import CryptoKit
|
||||
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
|
||||
var apnsEnvironment: String?
|
||||
var relayProfile: String?
|
||||
var proofPolicy: String?
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
private static let service = "ai.openclawfoundation.app.pushrelay"
|
||||
private static let registrationStateAccount = "registration-state"
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
|
||||
struct AppAttestScope {
|
||||
var relayOrigin: String
|
||||
var apnsEnvironment: String
|
||||
var relayProfile: String
|
||||
var proofPolicy: String
|
||||
}
|
||||
|
||||
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
|
||||
var apnsEnvironment: String
|
||||
var relayProfile: String
|
||||
var proofPolicy: 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,
|
||||
apnsEnvironment: decoded.apnsEnvironment ?? "production",
|
||||
relayProfile: decoded.relayProfile ?? "production",
|
||||
proofPolicy: decoded.proofPolicy ?? "appleStrict")
|
||||
}
|
||||
|
||||
@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,
|
||||
apnsEnvironment: state.apnsEnvironment,
|
||||
relayProfile: state.relayProfile,
|
||||
proofPolicy: state.proofPolicy)
|
||||
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)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.delete(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAttestedKeyID(scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.delete(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
private static func scopedAccount(_ baseAccount: String, scope: AppAttestScope) -> String {
|
||||
let raw = [
|
||||
scope.relayOrigin,
|
||||
scope.apnsEnvironment,
|
||||
scope.relayProfile,
|
||||
scope.proofPolicy,
|
||||
].joined(separator: "\n")
|
||||
let digest = SHA256.hash(data: Data(raw.utf8))
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
// A relay sees an App Attest key as attested only after receiving that
|
||||
// key's attestation object, so keep key state isolated per relay context.
|
||||
return "\(baseAccount)-\(digest)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user