Files
openclaw/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift
Peter Steinberger 50e5ffcd25 feat(mac): native pairing approval dialog with instant decisions (#106091)
* feat(mac): native pairing approval dialog with instant decisions

* chore(i18n): sync native inventory and locale artifacts

* chore(i18n): regenerate native inventory, locale artifacts, and projections

* chore(i18n): re-sync native inventory ordering
2026-07-13 07:08:14 -07:00

126 lines
4.4 KiB
Swift

import Foundation
import OpenClawKit
import OSLog
import UserNotifications
/// Shared plumbing for the node/device pairing prompters: gateway push
/// subscription lifecycle and approve/reject RPC logging.
@MainActor
enum PairingPromptSupport {
enum PairingResolution: String {
case approved
case rejected
}
struct PairingResolvedEvent: Codable {
let requestId: String
let decision: String
let ts: Double
}
static func runPairingPushTask(
bufferingNewest: Int = 200,
loadPending: @escaping @MainActor () async -> Void,
handlePush: @escaping @MainActor (GatewayPush) -> Void) async
{
_ = try? await GatewayConnection.shared.refresh()
await loadPending()
await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush)
}
static func startPairingPushTask(
task: inout Task<Void, Never>?,
isStopping: inout Bool,
bufferingNewest: Int = 200,
loadPending: @escaping @MainActor () async -> Void,
handlePush: @escaping @MainActor (GatewayPush) -> Void)
{
guard task == nil else { return }
isStopping = false
task = Task {
await self.runPairingPushTask(
bufferingNewest: bufferingNewest,
loadPending: loadPending,
handlePush: handlePush)
}
}
static func stopPairingPrompter(
isStopping: inout Bool,
task: inout Task<Void, Never>?,
queue: inout [some Any])
{
isStopping = true
task?.cancel()
task = nil
queue.removeAll(keepingCapacity: false)
}
static func approveRequest(
requestId: String,
kind: String,
logger: Logger,
action: @escaping () async throws -> Void) async -> Bool
{
do {
try await action()
logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
return true
} catch {
logger.error("approve failed requestId=\(requestId, privacy: .public)")
logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
/// Human-readable subject for pairing notifications: display name when
/// present, otherwise the raw node/device id.
static func subjectLabel(displayName: String?, fallback: String) -> String {
let name = displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
return name?.isEmpty == false ? name! : fallback
}
static func notificationsAuthorized() async -> Bool {
let settings = await UNUserNotificationCenter.current().notificationSettings()
return settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional
}
/// Decisions resolve the card optimistically before the RPC returns; when
/// the RPC then fails the card comes back and this explains why. A failed
/// RPC does not prove the gateway rejected the decision (it may have
/// committed before a timeout), so the copy claims only lost confirmation;
/// resolved events / reconcile report the authoritative outcome.
static func notifyDecisionFailed(
kind: PairingApprovalCenter.Kind,
decision: PairingApprovalCenter.Decision,
subject: String) async
{
guard await self.notificationsAuthorized() else { return }
let action = decision == .approve ? "approval" : "rejection"
_ = await NotificationManager().send(
title: "\(kind == .node ? "Node" : "Device") pairing \(action) not confirmed",
body: "\(subject)\nThe gateway did not confirm the \(action); the request may still be pending.",
sound: nil,
priority: .active)
}
@discardableResult
static func rejectRequest(
requestId: String,
kind: String,
logger: Logger,
action: @escaping () async throws -> Void) async -> Bool
{
do {
try await action()
logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
return true
} catch {
logger.error("reject failed requestId=\(requestId, privacy: .public)")
logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
}