mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 22:01:11 +00:00
* feat(ios): render plugin approvals as native Allow/Deny notifications Extend the iOS approval handling from exec to plugin approvals so the native push the gateway now sends (#108505) shows a phone notification with Allow/Deny. Extract a shared ApprovalNotificationBridge with exec/plugin wrappers owning their own kind constants and category; tag the parsed prompt with its kind; register the plugin category and route plugin pushes in the app delegate; add a .plugin case that maps the plugin presentation (title/description/pluginId/ severity) into the existing prompt and widens the exec-only validation, current, cache-restore, and resolve guards to accept plugin; parameterize the dialog copy by kind. Watch snapshot publication stays exec-only; the phone notification still mirrors to the watch via WatchConnectivity. Reuses the unified approval.get/ approval.resolve RPCs (already kind-generic); no server or protocol changes. * chore(i18n): sync native inventory after rebase * chore(ios): drop accidentally committed local Version.xcconfig build override * fix(ios): sync Apple i18n catalog and remove dead approval-bridge methods Sync apps/ios/Resources/Localizable.xcstrings for the new plugin-approval strings (native inventory sync alone did not cover the Apple catalog). Remove the superseded per-bridge registerCategory variants and the redundant exec parseResolvedPush wrapper flagged by the iOS Periphery dead-code scan; registration uses ApprovalNotificationBridge.registerCategories and the shared parse helpers. * chore(ios): drop generated SwiftSources filelist; reconcile native i18n inventory * chore(ios): drop generated Xcode project from tracking Codex generated OpenClaw.xcodeproj for its simulator test run and the initial apps/ios directory commit force-added it; xcodegen regenerates it in CI, so it must not be tracked (matches main). * chore(ios): reconcile native i18n inventory after rebase
454 lines
17 KiB
Swift
454 lines
17 KiB
Swift
import Foundation
|
|
import OpenClawProtocol
|
|
@preconcurrency import UserNotifications
|
|
|
|
private struct ApprovalNotificationUTF8Key: Hashable {
|
|
let bytes: [UInt8]
|
|
|
|
init(_ rawValue: String) {
|
|
self.bytes = Array(rawValue.utf8)
|
|
}
|
|
|
|
var notificationComponent: String {
|
|
let hexDigits = Array("0123456789ABCDEF".utf8)
|
|
var encoded: [UInt8] = []
|
|
encoded.reserveCapacity(self.bytes.count)
|
|
for byte in self.bytes {
|
|
switch byte {
|
|
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x2D, 0x2E, 0x5F, 0x7E:
|
|
encoded.append(byte)
|
|
default:
|
|
encoded.append(0x25)
|
|
encoded.append(hexDigits[Int(byte >> 4)])
|
|
encoded.append(hexDigits[Int(byte & 0x0F)])
|
|
}
|
|
}
|
|
guard let component = String(bytes: encoded, encoding: .utf8) else {
|
|
preconditionFailure("Percent-encoded approval ID must be UTF-8")
|
|
}
|
|
return component
|
|
}
|
|
}
|
|
|
|
private enum ApprovalNotificationID {
|
|
static func validated(_ rawValue: String?) -> String? {
|
|
ExecApprovalIdentifier.exact(rawValue)
|
|
}
|
|
|
|
static func key(_ rawValue: String?) -> ApprovalNotificationUTF8Key? {
|
|
self.validated(rawValue).map(ApprovalNotificationUTF8Key.init)
|
|
}
|
|
}
|
|
|
|
struct ApprovalNotificationPrompt: Codable, Equatable, Hashable {
|
|
let approvalId: String
|
|
let gatewayDeviceId: String?
|
|
let kind: ApprovalKind
|
|
|
|
init(
|
|
approvalId: String,
|
|
gatewayDeviceId: String?,
|
|
kind: ApprovalKind = .exec)
|
|
{
|
|
self.approvalId = approvalId
|
|
self.gatewayDeviceId = gatewayDeviceId
|
|
self.kind = kind
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case approvalId
|
|
case gatewayDeviceId
|
|
case kind
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.approvalId = try container.decode(String.self, forKey: .approvalId)
|
|
self.gatewayDeviceId = try container.decodeIfPresent(String.self, forKey: .gatewayDeviceId)
|
|
// Persisted exec recovery pushes predate the kind tag.
|
|
self.kind = try container.decodeIfPresent(ApprovalKind.self, forKey: .kind) ?? .exec
|
|
}
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
let sameApprovalID = ApprovalNotificationUTF8Key(lhs.approvalId) ==
|
|
ApprovalNotificationUTF8Key(rhs.approvalId)
|
|
let sameGatewayID = lhs.gatewayDeviceId.map(ApprovalNotificationUTF8Key.init) ==
|
|
rhs.gatewayDeviceId.map(ApprovalNotificationUTF8Key.init)
|
|
return lhs.kind == rhs.kind && sameApprovalID && sameGatewayID
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(self.kind)
|
|
hasher.combine(ApprovalNotificationUTF8Key(self.approvalId))
|
|
hasher.combine(self.gatewayDeviceId.map(ApprovalNotificationUTF8Key.init))
|
|
}
|
|
}
|
|
|
|
typealias ExecApprovalNotificationPrompt = ApprovalNotificationPrompt
|
|
|
|
private struct ApprovalNotificationConfiguration {
|
|
let kind: ApprovalKind
|
|
let requestedKind: String
|
|
let resolvedKind: String
|
|
let categoryIdentifier: String
|
|
let reviewActionIdentifier: String
|
|
let encodedRequestPrefix: String
|
|
let legacyRequestPrefix: String
|
|
}
|
|
|
|
enum ApprovalNotificationBridge {
|
|
static func registerCategories(center: UNUserNotificationCenter = .current()) {
|
|
let categories = [
|
|
ExecApprovalNotificationBridge.configuration,
|
|
PluginApprovalNotificationBridge.configuration,
|
|
].map(self.category(for:))
|
|
center.getNotificationCategories { existingCategories in
|
|
var updated = existingCategories
|
|
for category in categories {
|
|
updated.update(with: category)
|
|
}
|
|
center.setNotificationCategories(updated)
|
|
}
|
|
}
|
|
|
|
static func parsePrompt(
|
|
actionIdentifier: String,
|
|
userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt?
|
|
{
|
|
self.parsePrompt(
|
|
actionIdentifier: actionIdentifier,
|
|
userInfo: userInfo,
|
|
configuration: ExecApprovalNotificationBridge.configuration)
|
|
?? self.parsePrompt(
|
|
actionIdentifier: actionIdentifier,
|
|
userInfo: userInfo,
|
|
configuration: PluginApprovalNotificationBridge.configuration)
|
|
}
|
|
|
|
static func parseRequestedPush(userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt? {
|
|
let exec = ExecApprovalNotificationBridge.configuration
|
|
let plugin = PluginApprovalNotificationBridge.configuration
|
|
return self.parsePush(userInfo: userInfo, expectedKind: exec.requestedKind, configuration: exec)
|
|
?? self.parsePush(
|
|
userInfo: userInfo,
|
|
expectedKind: plugin.requestedKind,
|
|
configuration: plugin)
|
|
}
|
|
|
|
static func parseResolvedPush(userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt? {
|
|
let exec = ExecApprovalNotificationBridge.configuration
|
|
let plugin = PluginApprovalNotificationBridge.configuration
|
|
return self.parsePush(userInfo: userInfo, expectedKind: exec.resolvedKind, configuration: exec)
|
|
?? self.parsePush(
|
|
userInfo: userInfo,
|
|
expectedKind: plugin.resolvedKind,
|
|
configuration: plugin)
|
|
}
|
|
|
|
@MainActor
|
|
static func removeNotifications(
|
|
for push: ApprovalNotificationPrompt,
|
|
notificationCenter: NotificationCentering,
|
|
includingLegacyOwnerless: Bool = false) async
|
|
{
|
|
guard let configuration = configuration(for: push.kind) else { return }
|
|
await self.removeNotifications(
|
|
for: push,
|
|
notificationCenter: notificationCenter,
|
|
includingLegacyOwnerless: includingLegacyOwnerless,
|
|
configuration: configuration)
|
|
}
|
|
|
|
fileprivate static func shouldPresentNotification(
|
|
userInfo: [AnyHashable: Any],
|
|
configuration: ApprovalNotificationConfiguration) -> Bool
|
|
{
|
|
self.parsePush(
|
|
userInfo: userInfo,
|
|
expectedKind: configuration.requestedKind,
|
|
configuration: configuration) != nil
|
|
}
|
|
|
|
fileprivate static func parsePrompt(
|
|
actionIdentifier: String,
|
|
userInfo: [AnyHashable: Any],
|
|
configuration: ApprovalNotificationConfiguration) -> ApprovalNotificationPrompt?
|
|
{
|
|
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|
|
|| actionIdentifier == configuration.reviewActionIdentifier
|
|
else {
|
|
return nil
|
|
}
|
|
return self.parsePush(
|
|
userInfo: userInfo,
|
|
expectedKind: configuration.requestedKind,
|
|
configuration: configuration)
|
|
}
|
|
|
|
fileprivate static func parseRequestedPush(
|
|
userInfo: [AnyHashable: Any],
|
|
configuration: ApprovalNotificationConfiguration) -> ApprovalNotificationPrompt?
|
|
{
|
|
self.parsePush(
|
|
userInfo: userInfo,
|
|
expectedKind: configuration.requestedKind,
|
|
configuration: configuration)
|
|
}
|
|
|
|
fileprivate static func parseResolvedPush(
|
|
userInfo: [AnyHashable: Any],
|
|
configuration: ApprovalNotificationConfiguration) -> ApprovalNotificationPrompt?
|
|
{
|
|
self.parsePush(
|
|
userInfo: userInfo,
|
|
expectedKind: configuration.resolvedKind,
|
|
configuration: configuration)
|
|
}
|
|
|
|
@MainActor
|
|
fileprivate static func removeNotifications(
|
|
for push: ApprovalNotificationPrompt,
|
|
notificationCenter: NotificationCentering,
|
|
includingLegacyOwnerless: Bool,
|
|
configuration: ApprovalNotificationConfiguration) async
|
|
{
|
|
guard push.kind == configuration.kind,
|
|
let requestIdentifier = localRequestIdentifier(for: push, configuration: configuration)
|
|
else { return }
|
|
let legacyOwner = push.gatewayDeviceId ?? "legacy"
|
|
var pendingIdentifiers = [
|
|
requestIdentifier,
|
|
"\(configuration.legacyRequestPrefix)\(legacyOwner).\(push.approvalId)",
|
|
]
|
|
if includingLegacyOwnerless {
|
|
pendingIdentifiers.append("\(configuration.legacyRequestPrefix)\(push.approvalId)")
|
|
if let ownerlessIdentifier = localRequestIdentifier(
|
|
for: ApprovalNotificationPrompt(
|
|
approvalId: push.approvalId,
|
|
gatewayDeviceId: nil,
|
|
kind: push.kind),
|
|
configuration: configuration)
|
|
{
|
|
pendingIdentifiers.append(ownerlessIdentifier)
|
|
}
|
|
}
|
|
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,
|
|
configuration: configuration)
|
|
else { return nil }
|
|
let matchesCurrentOwner = requestedPush == push
|
|
let matchesLegacyOwnerless = includingLegacyOwnerless &&
|
|
ApprovalNotificationUTF8Key(requestedPush.approvalId) ==
|
|
ApprovalNotificationUTF8Key(push.approvalId) &&
|
|
requestedPush.gatewayDeviceId == nil
|
|
guard matchesCurrentOwner || matchesLegacyOwnerless else { return nil }
|
|
return snapshot.identifier
|
|
}
|
|
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
|
}
|
|
|
|
private static func category(
|
|
for configuration: ApprovalNotificationConfiguration) -> UNNotificationCategory
|
|
{
|
|
UNNotificationCategory(
|
|
identifier: configuration.categoryIdentifier,
|
|
actions: [
|
|
UNNotificationAction(
|
|
identifier: configuration.reviewActionIdentifier,
|
|
title: "Review",
|
|
options: [.foreground]),
|
|
],
|
|
intentIdentifiers: [],
|
|
options: [])
|
|
}
|
|
|
|
private static func configuration(for kind: ApprovalKind) -> ApprovalNotificationConfiguration? {
|
|
switch kind {
|
|
case .exec:
|
|
ExecApprovalNotificationBridge.configuration
|
|
case .plugin:
|
|
PluginApprovalNotificationBridge.configuration
|
|
case .systemAgent:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
|
|
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
|
|
return ApprovalNotificationID.validated(raw)
|
|
}
|
|
|
|
private static func parsePush(
|
|
userInfo: [AnyHashable: Any],
|
|
expectedKind: String,
|
|
configuration: ApprovalNotificationConfiguration) -> ApprovalNotificationPrompt?
|
|
{
|
|
guard let payload = openClawPayload(userInfo: userInfo),
|
|
payloadKind(userInfo: userInfo) == expectedKind,
|
|
let approvalId = approvalID(from: userInfo)
|
|
else {
|
|
return nil
|
|
}
|
|
let gatewayDeviceId: String?
|
|
if let rawGatewayDeviceId = payload["gatewayDeviceId"] {
|
|
guard let rawGatewayDeviceId = rawGatewayDeviceId as? String,
|
|
let exactGatewayDeviceId = GatewayStableIdentifier.exact(rawGatewayDeviceId)
|
|
else { return nil }
|
|
gatewayDeviceId = exactGatewayDeviceId
|
|
} else {
|
|
gatewayDeviceId = nil
|
|
}
|
|
return ApprovalNotificationPrompt(
|
|
approvalId: approvalId,
|
|
gatewayDeviceId: gatewayDeviceId,
|
|
kind: configuration.kind)
|
|
}
|
|
|
|
private static func localRequestIdentifier(
|
|
for push: ApprovalNotificationPrompt,
|
|
configuration: ApprovalNotificationConfiguration) -> String?
|
|
{
|
|
let owner = push.gatewayDeviceId ?? "legacy"
|
|
guard let approvalComponent = ApprovalNotificationID.key(push.approvalId)?.notificationComponent else {
|
|
return nil
|
|
}
|
|
let ownerComponent = ApprovalNotificationUTF8Key(owner).notificationComponent
|
|
return "\(configuration.encodedRequestPrefix)\(ownerComponent.utf8.count):" +
|
|
"\(ownerComponent).\(approvalComponent)"
|
|
}
|
|
|
|
private 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
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
fileprivate static let configuration = ApprovalNotificationConfiguration(
|
|
kind: .exec,
|
|
requestedKind: ExecApprovalNotificationBridge.requestedKind,
|
|
resolvedKind: ExecApprovalNotificationBridge.resolvedKind,
|
|
categoryIdentifier: ExecApprovalNotificationBridge.categoryIdentifier,
|
|
reviewActionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier,
|
|
encodedRequestPrefix: "exec.approval-v2.",
|
|
legacyRequestPrefix: "exec.approval.")
|
|
|
|
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
|
|
ApprovalNotificationBridge.shouldPresentNotification(
|
|
userInfo: userInfo,
|
|
configuration: self.configuration)
|
|
}
|
|
|
|
static func parsePrompt(
|
|
actionIdentifier: String,
|
|
userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt?
|
|
{
|
|
ApprovalNotificationBridge.parsePrompt(
|
|
actionIdentifier: actionIdentifier,
|
|
userInfo: userInfo,
|
|
configuration: self.configuration)
|
|
}
|
|
|
|
static func parseRequestedPush(userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt? {
|
|
ApprovalNotificationBridge.parseRequestedPush(
|
|
userInfo: userInfo,
|
|
configuration: self.configuration)
|
|
}
|
|
|
|
@MainActor
|
|
static func removeNotifications(
|
|
for push: ApprovalNotificationPrompt,
|
|
notificationCenter: NotificationCentering,
|
|
includingLegacyOwnerless: Bool = false) async
|
|
{
|
|
await ApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: notificationCenter,
|
|
includingLegacyOwnerless: includingLegacyOwnerless,
|
|
configuration: self.configuration)
|
|
}
|
|
}
|
|
|
|
enum PluginApprovalNotificationBridge {
|
|
static let requestedKind = "plugin.approval.requested"
|
|
static let resolvedKind = "plugin.approval.resolved"
|
|
static let categoryIdentifier = "openclaw.plugin-approval"
|
|
static let reviewActionIdentifier = "openclaw.plugin-approval.review"
|
|
|
|
fileprivate static let configuration = ApprovalNotificationConfiguration(
|
|
kind: .plugin,
|
|
requestedKind: PluginApprovalNotificationBridge.requestedKind,
|
|
resolvedKind: PluginApprovalNotificationBridge.resolvedKind,
|
|
categoryIdentifier: PluginApprovalNotificationBridge.categoryIdentifier,
|
|
reviewActionIdentifier: PluginApprovalNotificationBridge.reviewActionIdentifier,
|
|
encodedRequestPrefix: "plugin.approval-v2.",
|
|
legacyRequestPrefix: "plugin.approval.")
|
|
|
|
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
|
|
ApprovalNotificationBridge.shouldPresentNotification(
|
|
userInfo: userInfo,
|
|
configuration: self.configuration)
|
|
}
|
|
|
|
static func parsePrompt(
|
|
actionIdentifier: String,
|
|
userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt?
|
|
{
|
|
ApprovalNotificationBridge.parsePrompt(
|
|
actionIdentifier: actionIdentifier,
|
|
userInfo: userInfo,
|
|
configuration: self.configuration)
|
|
}
|
|
|
|
static func parseRequestedPush(userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt? {
|
|
ApprovalNotificationBridge.parseRequestedPush(
|
|
userInfo: userInfo,
|
|
configuration: self.configuration)
|
|
}
|
|
|
|
static func parseResolvedPush(userInfo: [AnyHashable: Any]) -> ApprovalNotificationPrompt? {
|
|
ApprovalNotificationBridge.parseResolvedPush(
|
|
userInfo: userInfo,
|
|
configuration: self.configuration)
|
|
}
|
|
|
|
@MainActor
|
|
static func removeNotifications(
|
|
for push: ApprovalNotificationPrompt,
|
|
notificationCenter: NotificationCentering,
|
|
includingLegacyOwnerless: Bool = false) async
|
|
{
|
|
await ApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: notificationCenter,
|
|
includingLegacyOwnerless: includingLegacyOwnerless,
|
|
configuration: self.configuration)
|
|
}
|
|
}
|