Files
openclaw-openclaw/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift
PollyBot13 a320f775f0 fix(ios): defer QR pairing after scanner dismissal (#99572)
* fix(ios): defer QR pairing after scanner dismissal

* fix(ios): process QR pairing after scanner dismissal

* fix(ios): harden QR scanner handoff

* fix(ios): give QR scanner dismissal more time

* fix(ios): keep onboarding open for QR trust prompt

* fix(ios): keep QR trust prompt owned by onboarding

* fix(ios): recover operator pairing after QR bootstrap

* fix(ios): cancel stale QR scanner handoffs

Co-authored-by: PollyBot13 <pollybot13@gmail.com>

* fix(ios): defer QR setup until onboarding closes

* fix(ios): keep QR setup links with visible settings

* fix(ios): consume setup links during onboarding

* fix(ios): handle setup links during onboarding launch

* fix(ios): route setup links through active onboarding

* fix(ios): harden QR gateway handoff

* fix(ios): cancel superseded gateway attempts

* fix(ios): serialize scanner result delivery

* fix(ios): prevent stale gateway reconnects

* fix(ios): serialize gateway target handoff

* fix(ios): disable stale gateway relaunch route

* fix(ios): await staged bootstrap reset

* test(ios): bound gateway reset handoff

* fix(ios): preserve explicit gateway handoff

* fix(ios): harden gateway lifecycle ownership

* chore(ios): sync native i18n inventory

* test(ios): align gateway ownership assertions

* refactor(ios): remove superseded gateway helpers

* fix(ios): keep gateway auth route scoped

* fix(ios): restore gateway target review state

* fix(protocol): refresh Swift plugin approval model

* test(ios): isolate state directory overrides

* fix(ios): preserve watch alerts across gateway switches

* fix(ios): bind deferred work to gateway ownership

* docs(changelog): credit iOS gateway handoff fix

* chore(i18n): sync native app inventory

* test(ios): remove unused Watch approval hooks

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-05 12:31:53 -07:00

141 lines
5.7 KiB
Swift

import Foundation
@preconcurrency import UserNotifications
struct ExecApprovalNotificationPrompt: Codable, Equatable, Hashable {
let approvalId: String
let gatewayDeviceId: 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: categoryIdentifier,
actions: [
UNNotificationAction(
identifier: 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.parsePush(userInfo: userInfo, expectedKind: self.requestedKind) != nil
}
static func parsePrompt(
actionIdentifier: String,
userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt?
{
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|| actionIdentifier == self.reviewActionIdentifier
else {
return nil
}
return self.parseRequestedPush(userInfo: userInfo)
}
static func parseRequestedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? {
self.parsePush(userInfo: userInfo, expectedKind: self.requestedKind)
}
static func parseResolvedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? {
self.parsePush(userInfo: userInfo, expectedKind: self.resolvedKind)
}
@MainActor
static func removeNotifications(
for push: ExecApprovalNotificationPrompt,
notificationCenter: NotificationCentering,
includingLegacyOwnerless: Bool = false) async
{
var pendingIdentifiers = [self.localRequestIdentifier(for: push)]
if includingLegacyOwnerless {
pendingIdentifiers.append("\(self.localRequestPrefix)\(push.approvalId)")
pendingIdentifiers.append(self.localRequestIdentifier(for: ExecApprovalNotificationPrompt(
approvalId: push.approvalId,
gatewayDeviceId: nil)))
}
var seenPendingIdentifiers = Set<String>()
pendingIdentifiers = pendingIdentifiers.filter { seenPendingIdentifiers.insert($0).inserted }
await notificationCenter.removePendingNotificationRequests(
withIdentifiers: pendingIdentifiers)
let delivered = await notificationCenter.deliveredNotifications()
let identifiers = delivered.compactMap { snapshot -> String? in
guard let requestedPush = self.parseRequestedPush(userInfo: snapshot.userInfo) else { return nil }
let matchesCurrentOwner = requestedPush == push
let matchesLegacyOwnerless = includingLegacyOwnerless &&
requestedPush.approvalId == push.approvalId &&
requestedPush.gatewayDeviceId == nil
guard matchesCurrentOwner || matchesLegacyOwnerless 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 gatewayDeviceID(from userInfo: [AnyHashable: Any]) -> String? {
let raw = self.openClawPayload(userInfo: userInfo)?["gatewayDeviceId"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func parsePush(
userInfo: [AnyHashable: Any],
expectedKind: String) -> ExecApprovalNotificationPrompt?
{
guard self.payloadKind(userInfo: userInfo) == expectedKind,
let approvalId = approvalID(from: userInfo)
else {
return nil
}
return ExecApprovalNotificationPrompt(
approvalId: approvalId,
gatewayDeviceId: self.gatewayDeviceID(from: userInfo))
}
private static func localRequestIdentifier(for push: ExecApprovalNotificationPrompt) -> String {
let owner = push.gatewayDeviceId ?? "legacy"
return "\(self.localRequestPrefix)\(owner).\(push.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
}
}