diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 45816fd4e7f8..ffd9878b597c 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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, diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index 5763a208d407..39bbee0374ff 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -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", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index 0456d25584f4..9557f2a708ad 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -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", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index 00d676f32440..b5bca4689f73 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -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", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index 6bcdb20310b8..bbbf2dd6c0e2 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -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", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index 02fa3e480c2e..acb3da69a1f8 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -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", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index 0e4d3de65455..c70632de8704 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -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", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index 50b5c1cbf4bd..ed04f9c4bfe5 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -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", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index 33e62a83ca61..7997319a1ac5 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -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", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index 98ab6b4b79e9..8f4434c50002 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -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", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index 57c7095a5b5f..109d75068c4d 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -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", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index ca72097d4cef..bede7b3526c8 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -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", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index 72bb3b190bdc..7e4d33572b72 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -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", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index 44f989e4ad72..6d08740563d4 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -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", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index 7dbcacc8fd89..dfc123fd75bd 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -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", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index c3ff9287fab7..063d13c4897e 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -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", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index 748b31dd6a03..b7cc607af576 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -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", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index 212256cc7ae7..d03253250cb1 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -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", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index 2e92cbe668b8..b5596dcf91eb 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -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", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 07877a27958c..1ca742e729ed 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -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", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index 151d00ba4f39..3b7396f550fa 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -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", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index 19ee371e419b..903e0f6857e2 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -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", diff --git a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift index edeee0b418f5..2620e4590812 100644 --- a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift @@ -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 = [] + /// Requests whose approve/reject RPC is still in flight; their cards are + /// hidden optimistically and restored by the failure path. + private var pendingLocalDecisionRequestIds: Set = [] 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 diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift index 0d9ea432e391..a2169e7eb1d9 100644 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -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( diff --git a/apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift b/apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift index c8a0382312ce..865b92c88e09 100644 --- a/apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift +++ b/apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift @@ -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? @@ -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 diff --git a/apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift b/apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift index 69b9e7824370..0cf16ddc57c6 100644 --- a/apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift +++ b/apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift @@ -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 { diff --git a/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift b/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift index 3c14a1df8018..0fef05c88a88 100644 --- a/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift +++ b/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift @@ -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,