Files
openclaw/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.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

274 lines
10 KiB
Swift

import AppKit
import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import OSLog
@MainActor
@Observable
final class DevicePairingApprovalPrompter {
static let shared = DevicePairingApprovalPrompter()
private let logger = Logger(subsystem: "ai.openclaw", category: "device-pairing")
private var task: Task<Void, Never>?
private var isStopping = false
private var listFetchGeneration = 0
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
/// Device ids already paired on the gateway (from the last list fetch);
/// drives the "previously paired" trust signal on cards.
private var pairedDeviceIds: Set<String> = []
/// Requests that arrived via push after the last list fetch; their trust
/// state is unknown until fresh gateway truth applies (stale snapshots
/// must not produce a positive "previously paired" claim).
private var trustUnknownRequestIds: Set<String> = []
/// Requests whose approve/reject RPC is still in flight; their cards are
/// hidden optimistically and restored by the failure path.
private var pendingLocalDecisionRequestIds: Set<String> = []
private struct PairingList: Codable {
let pending: [PendingRequest]
let paired: [PairedDevice]?
}
private struct PairedDevice: Codable, Equatable {
let deviceId: String
let approvedAtMs: Double?
let displayName: String?
let platform: String?
let remoteIp: String?
}
struct PendingRequest: Codable, Equatable, Identifiable {
let requestId: String
let deviceId: String
let publicKey: String
let displayName: String?
let platform: String?
let clientId: String?
let clientMode: String?
let role: String?
let scopes: [String]?
let remoteIp: String?
let silent: Bool?
let isRepair: Bool?
let ts: Double
var id: String {
self.requestId
}
}
private typealias PairingResolvedEvent = PairingPromptSupport.PairingResolvedEvent
func start() {
PairingApprovalCenter.shared.register(kind: .device) { [weak self] card, decision in
await self?.handleDecision(card: card, decision: decision)
}
self.startPushTask()
}
private func startPushTask() {
PairingPromptSupport.startPairingPushTask(
task: &self.task,
isStopping: &self.isStopping,
loadPending: self.loadPendingRequestsFromGateway,
handlePush: self.handle(push:))
}
func stop() {
PairingPromptSupport.stopPairingPrompter(
isStopping: &self.isStopping,
task: &self.task,
queue: &self.queue)
PairingApprovalCenter.shared.unregister(kind: .device)
self.pendingLocalDecisionRequestIds.removeAll(keepingCapacity: false)
self.updatePendingCounts()
}
private func loadPendingRequestsFromGateway() async {
// Push-triggered refreshes can overlap; only the newest snapshot may
// replace the queue or an older read would drop just-arrived requests.
self.listFetchGeneration += 1
let generation = self.listFetchGeneration
do {
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
guard generation == self.listFetchGeneration else { return }
self.apply(list: list)
} catch {
self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)")
}
}
private func apply(list: PairingList) {
if self.isStopping {
return
}
self.pairedDeviceIds = Set((list.paired ?? []).map(\.deviceId))
self.queue = list.pending.sorted(by: { $0.ts < $1.ts })
// This snapshot is authoritative for every pending request in it.
self.trustUnknownRequestIds.removeAll()
self.updatePendingCounts()
self.syncCards()
}
private func updatePendingCounts() {
self.pendingCount = self.queue.count
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
}
private func syncCards() {
guard !self.isStopping else { return }
// A pending local decision hides the card immediately (the decision is
// optimistic); the failure path re-syncs so the card can come back.
let cards = self.queue
.filter { !self.pendingLocalDecisionRequestIds.contains($0.requestId) }
.map { self.card(for: $0) }
PairingApprovalCenter.shared.sync(kind: .device, cards: cards)
}
private func card(for req: PendingRequest) -> PairingApprovalCenter.Card {
PairingApprovalCenter.Card(
kind: .device,
requestId: req.requestId,
subjectId: req.deviceId,
displayName: req.displayName,
platform: req.platform,
deviceFamily: nil,
modelIdentifier: nil,
version: nil,
coreVersion: nil,
remoteIp: req.remoteIp,
role: req.role,
scopes: req.scopes ?? [],
caps: [],
commands: [],
isRepair: req.isRepair == true,
previouslyPaired: self.trustUnknownRequestIds.contains(req.requestId)
? nil
: self.pairedDeviceIds.contains(req.deviceId),
requestedAt: Date(timeIntervalSince1970: req.ts / 1000))
}
private func handleDecision(card: PairingApprovalCenter.Card, decision: PairingApprovalCenter.Decision) async {
guard !self.isStopping else { return }
guard let request = self.queue.first(where: { $0.requestId == card.requestId }) else { return }
self.pendingLocalDecisionRequestIds.insert(request.requestId)
// Optimistic dismiss: the card leaves the panel before the RPC
// round-trip.
self.syncCards()
let rpcOk: Bool = switch decision {
case .approve:
await self.approve(requestId: request.requestId)
case .reject:
await self.reject(requestId: request.requestId)
}
self.pendingLocalDecisionRequestIds.remove(request.requestId)
if !rpcOk {
// Stale request (expired/superseded/resolved elsewhere) or gateway
// failure: re-sync with gateway truth so stale cards collapse. A
// request that is genuinely still pending comes back, and the
// notification explains why the optimistic dismiss did not stick.
await self.loadPendingRequestsFromGateway()
self.syncCards()
if self.queue.contains(where: { $0.requestId == request.requestId }) {
await PairingPromptSupport.notifyDecisionFailed(
kind: .device,
decision: decision,
subject: PairingPromptSupport.subjectLabel(
displayName: request.displayName,
fallback: request.deviceId))
}
return
}
// Discard any in-flight list snapshot: it predates this resolution
// and applying it would resurrect the just-resolved card.
self.listFetchGeneration += 1
self.queue.removeAll { $0.requestId == request.requestId }
self.updatePendingCounts()
self.syncCards()
}
private func approve(requestId: String) async -> Bool {
await PairingPromptSupport.approveRequest(
requestId: requestId,
kind: "device",
logger: self.logger)
{
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
}
}
private func reject(requestId: String) async -> Bool {
await PairingPromptSupport.rejectRequest(
requestId: requestId,
kind: "device",
logger: self.logger)
{
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
}
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "device.pair.requested":
guard let payload = evt.payload else { return }
do {
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
self.enqueue(req)
} catch {
self.logger
.error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)")
}
case let .event(evt) where evt.event == "device.pair.resolved":
guard let payload = evt.payload else { return }
do {
let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self)
self.handleResolved(resolved)
} catch {
self.logger
.error(
"failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)")
}
default:
break
}
}
/// The gateway keeps at most one live pending request per device, so a new
/// requestId for the same device supersedes anything still queued for it.
/// Without this, missed/dropped resolve pushes pile up as cards whose
/// approval can no longer succeed. Returns nil when the request is already queued.
static func coalescedQueue(_ queue: [PendingRequest], adding req: PendingRequest) -> [PendingRequest]? {
guard !queue.contains(where: { $0.requestId == req.requestId }) else { return nil }
return queue.filter { $0.deviceId != req.deviceId } + [req]
}
private func enqueue(_ req: PendingRequest) {
guard let next = Self.coalescedQueue(self.queue, adding: req) else { return }
self.queue = next
self.trustUnknownRequestIds.insert(req.requestId)
self.updatePendingCounts()
self.syncCards()
// The "previously paired" trust signal must not come from a stale
// startup snapshot; re-fetch gateway truth for each new request.
Task { @MainActor [weak self] in
await self?.loadPendingRequestsFromGateway()
}
}
private func handleResolved(_ resolved: PairingResolvedEvent) {
// Discard any in-flight list snapshot taken before this resolution
// so it cannot resurrect the resolved card.
self.listFetchGeneration += 1
self.queue.removeAll { $0.requestId == resolved.requestId }
self.updatePendingCounts()
self.syncCards()
}
}