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
This commit is contained in:
Peter Steinberger
2026-07-13 07:08:14 -07:00
committed by GitHub
parent 471f60bc84
commit 50e5ffcd25
27 changed files with 347 additions and 89 deletions
+33 -17
View File
@@ -29563,7 +29563,7 @@
},
{
"kind": "conditional-branch",
"line": 426,
"line": 435,
"path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift",
"source": "Node pairing approved",
"surface": "apple",
@@ -29571,7 +29571,7 @@
},
{
"kind": "conditional-branch",
"line": 426,
"line": 435,
"path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift",
"source": "Node pairing rejected",
"surface": "apple",
@@ -30811,7 +30811,7 @@
},
{
"kind": "ui-call",
"line": 59,
"line": 40,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Not Now",
"surface": "apple",
@@ -30819,7 +30819,7 @@
},
{
"kind": "ui-call",
"line": 76,
"line": 56,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Reject All",
"surface": "apple",
@@ -30827,7 +30827,7 @@
},
{
"kind": "ui-call",
"line": 83,
"line": 63,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Approve All",
"surface": "apple",
@@ -30835,7 +30835,7 @@
},
{
"kind": "conditional-branch",
"line": 103,
"line": 82,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Pairing Request",
"surface": "apple",
@@ -30843,7 +30843,7 @@
},
{
"kind": "conditional-branch",
"line": 103,
"line": 82,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Pairing Requests",
"surface": "apple",
@@ -30851,7 +30851,7 @@
},
{
"kind": "ui-modifier",
"line": 212,
"line": 188,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Copy full ID",
"surface": "apple",
@@ -30859,7 +30859,7 @@
},
{
"kind": "conditional-branch",
"line": 227,
"line": 203,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Approve Device",
"surface": "apple",
@@ -30867,7 +30867,7 @@
},
{
"kind": "conditional-branch",
"line": 227,
"line": 203,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Approve Node",
"surface": "apple",
@@ -30875,7 +30875,7 @@
},
{
"kind": "ui-call",
"line": 238,
"line": 214,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Reject",
"surface": "apple",
@@ -30883,7 +30883,7 @@
},
{
"kind": "conditional-branch",
"line": 295,
"line": 271,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "A node wants to connect to OpenClaw.",
"surface": "apple",
@@ -30891,7 +30891,7 @@
},
{
"kind": "conditional-branch",
"line": 296,
"line": 272,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "A device wants to connect to OpenClaw.",
"surface": "apple",
@@ -30899,7 +30899,7 @@
},
{
"kind": "conditional-branch",
"line": 306,
"line": 282,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "New device",
"surface": "apple",
@@ -30907,7 +30907,7 @@
},
{
"kind": "conditional-branch",
"line": 306,
"line": 282,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "OpenClaw Mac app",
"surface": "apple",
@@ -30915,7 +30915,7 @@
},
{
"kind": "conditional-branch",
"line": 323,
"line": 299,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Operator",
"surface": "apple",
@@ -30923,12 +30923,28 @@
},
{
"kind": "conditional-branch",
"line": 523,
"line": 499,
"path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift",
"source": "Mac",
"surface": "apple",
"id": "native.apple.7a62dc7bc8d3fab9"
},
{
"kind": "conditional-branch",
"line": 102,
"path": "apps/macos/Sources/OpenClaw/PairingPromptSupport.swift",
"source": "Device",
"surface": "apple",
"id": "native.apple.fb09a404bfd12fa9"
},
{
"kind": "conditional-branch",
"line": 102,
"path": "apps/macos/Sources/OpenClaw/PairingPromptSupport.swift",
"source": "Node",
"surface": "apple",
"id": "native.apple.7ed2af81aa7647fb"
},
{
"kind": "ui-named-argument",
"line": 15,
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "الجهاز"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "العقدة"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Gerät"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Knoten"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Dispositivo"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Nodo"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "دستگاه"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "گره"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Appareil"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Nœud"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "डिवाइस"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "नोड"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Perangkat"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Node"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Dispositivo"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Nodo"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "デバイス"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "ノード"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "기기"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "노드"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Apparaat"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Node"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Urządzenie"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Węzeł"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Dispositivo"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Nó"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Устройство"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Узел"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Enhet"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Nod"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "อุปกรณ์"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "โหนด"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Aygıt"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Düğüm"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Пристрій"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Вузол"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "Thiết bị"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "Nút"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "设备"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "节点"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
+10
View File
@@ -19333,6 +19333,16 @@
"source": "Mac",
"translated": "Mac"
},
{
"id": "native.apple.fb09a404bfd12fa9",
"source": "Device",
"translated": "裝置"
},
{
"id": "native.apple.7ed2af81aa7647fb",
"source": "Node",
"translated": "節點"
},
{
"id": "native.apple.6223e5f12aa9464b",
"source": "Permissions",
@@ -24,6 +24,9 @@ final class DevicePairingApprovalPrompter {
/// 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]
@@ -81,6 +84,7 @@ final class DevicePairingApprovalPrompter {
task: &self.task,
queue: &self.queue)
PairingApprovalCenter.shared.unregister(kind: .device)
self.pendingLocalDecisionRequestIds.removeAll(keepingCapacity: false)
self.updatePendingCounts()
}
@@ -117,7 +121,11 @@ final class DevicePairingApprovalPrompter {
private func syncCards() {
guard !self.isStopping else { return }
let cards = self.queue.map { self.card(for: $0) }
// 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)
}
@@ -148,21 +156,34 @@ final class DevicePairingApprovalPrompter {
guard !self.isStopping else { return }
guard let request = self.queue.first(where: { $0.requestId == card.requestId }) else { return }
switch decision {
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:
if await !(self.approve(requestId: request.requestId)) {
// Stale request (expired or superseded on the gateway): re-sync the
// queue with gateway truth so accumulated stale cards collapse at once.
await self.loadPendingRequestsFromGateway()
return
}
await self.approve(requestId: request.requestId)
case .reject:
if await !(self.reject(requestId: request.requestId)) {
// Failed reject leaves the request pending on the gateway;
// re-sync instead of hiding a still-live card.
await self.loadPendingRequestsFromGateway()
return
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
@@ -6,7 +6,6 @@ import OpenClawIPC
import OpenClawKit
import OpenClawProtocol
import OSLog
import UserNotifications
enum NodePairingReconcilePolicy {
static let activeIntervalMs: UInt64 = 15000
@@ -330,8 +329,13 @@ final class NodePairingApprovalPrompter {
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.autoApproveInFlight.contains($0.requestId) }
.filter {
!self.autoApproveInFlight.contains($0.requestId) &&
!self.pendingLocalDecisionRequestIds.contains($0.requestId)
}
.map { self.card(for: $0) }
PairingApprovalCenter.shared.sync(kind: .node, cards: cards)
}
@@ -364,6 +368,9 @@ final class NodePairingApprovalPrompter {
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; the outcome arrives as a notification instead.
self.syncCards()
let expected: PairingResolution = decision == .approve ? .approved : .rejected
let rpcOk: Bool = switch decision {
case .approve:
@@ -382,8 +389,16 @@ final class NodePairingApprovalPrompter {
} else if rpcOk {
await self.notify(resolution: expected, request: request, via: "local")
} else {
// RPC failed and nothing resolved it elsewhere: keep the card and
// RPC failed and nothing resolved it elsewhere: bring the card
// back, tell the user the optimistic dismiss did not stick, and
// re-sync with gateway truth instead of claiming an outcome.
self.syncCards()
await PairingPromptSupport.notifyDecisionFailed(
kind: .node,
decision: decision,
subject: PairingPromptSupport.subjectLabel(
displayName: request.displayName,
fallback: request.nodeId))
self.scheduleReconcileOnce(delayMs: 0)
return
}
@@ -415,17 +430,12 @@ final class NodePairingApprovalPrompter {
}
private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional
else {
return
}
guard await PairingPromptSupport.notificationsAuthorized() else { return }
let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected"
let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
let device = name?.isEmpty == false ? name! : request.nodeId
let device = PairingPromptSupport.subjectLabel(
displayName: request.displayName,
fallback: request.nodeId)
let body = "\(device)\n(via \(via))"
_ = await NotificationManager().send(
@@ -191,20 +191,10 @@ final class PairingApprovalCenter {
#endif
}
/// Borderless floating panel hosting the SwiftUI approval UI. Replaces the
/// old invisible-host-window + NSAlert sheet machinery.
/// Floating panel with native window chrome hosting the SwiftUI approval UI,
/// so the prompt reads like a standard system dialog.
@MainActor
final class PairingApprovalPanelController {
private final class KeyablePanel: NSPanel {
override var canBecomeKey: Bool {
true
}
override var canBecomeMain: Bool {
true
}
}
private let center: PairingApprovalCenter
private var panel: NSPanel?
private var hostingView: NSHostingView<PairingApprovalPanelView>?
@@ -268,22 +258,24 @@ final class PairingApprovalPanelController {
if let panel = self.panel {
return panel
}
let panel = KeyablePanel(
// Titled so the system draws normal dialog chrome (opaque background,
// rounded corners, shadow, key-window focus); the title bar itself is
// invisible and buttonless so it reads as an alert, not a document.
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: Self.panelWidth, height: 200),
styleMask: [.borderless, .nonactivatingPanel],
styleMask: [.titled, .fullSizeContentView],
backing: .buffered,
defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
// The SwiftUI view draws its own rounded shadow; the window shadow
// would trace the square window frame and look like a border.
panel.hasShadow = false
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
for buttonType in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
panel.standardWindowButton(buttonType)?.isHidden = true
}
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.hidesOnDeactivate = false
panel.isMovableByWindowBackground = true
panel.isReleasedWhenClosed = false
panel.becomesKeyOnlyIfNeeded = false
let host = NSHostingView(rootView: PairingApprovalPanelView(center: self.center))
panel.contentView = host
@@ -1,32 +1,14 @@
import AppKit
import SwiftUI
/// Floating approval UI listing every pending pairing request as a card.
/// Liquid Glass surface on macOS 26+, material fallback on macOS 15.
/// Approval dialog listing every pending pairing request as a card. The host
/// panel draws native window chrome; this view only lays out the content.
struct PairingApprovalPanelView: View {
let center: PairingApprovalCenter
/// Transparent margin around the glass so the drawn shadow has room;
/// the NSPanel shadow is disabled (it would trace a square window edge).
static let shadowMargin: CGFloat = 32
var body: some View {
self.surface
self.content
.frame(width: PairingApprovalPanelController.panelWidth)
.compositingGroup()
.shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 10)
.padding(Self.shadowMargin)
}
@ViewBuilder
private var surface: some View {
if #available(macOS 26.0, *) {
self.content
.glassEffect(.regular, in: .rect(cornerRadius: 24))
} else {
self.content
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
}
}
private var content: some View {
@@ -40,7 +22,6 @@ struct PairingApprovalPanelView: View {
ForEach(cards) { card in
PairingRequestCardView(
card: card,
isBusy: self.center.decisionsInFlight.contains(card.requestId),
isOnlyRequest: cards.count == 1,
onDecision: { self.center.decide(card, $0) })
}
@@ -61,7 +42,6 @@ struct PairingApprovalPanelView: View {
.buttonStyle(.plain)
.foregroundStyle(.secondary)
if cards.count > 1 {
let allBusy = cards.allSatisfy { self.center.decisionsInFlight.contains($0.id) }
HStack(spacing: 8) {
notNow
Spacer()
@@ -85,7 +65,6 @@ struct PairingApprovalPanelView: View {
}
.pairingActionStyle(prominent: true)
}
.disabled(allBusy)
} else {
HStack {
Spacer()
@@ -113,7 +92,6 @@ struct PairingApprovalPanelView: View {
struct PairingRequestCardView: View {
let card: PairingApprovalCenter.Card
let isBusy: Bool
let isOnlyRequest: Bool
let onDecision: (PairingApprovalCenter.Decision) -> Void
@@ -149,8 +127,6 @@ struct PairingRequestCardView: View {
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 16).fill(.quinary))
.disabled(self.isBusy)
.opacity(self.isBusy ? 0.6 : 1)
}
private var icon: some View {
@@ -1,6 +1,7 @@
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.
@@ -72,6 +73,38 @@ enum PairingPromptSupport {
}
}
/// 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,