Files
openclaw/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift
Peter Steinberger 4cc92009ed feat(mac): unified Liquid Glass pairing approval panel (#102601)
* feat(mac): unified Liquid Glass pairing approval panel

Replace the serial node/device pairing NSAlerts with one floating
approval panel. Both prompters feed request cards into a shared
PairingApprovalCenter; each card shows a hardware icon, platform and
model, app/core version, source IP, a copyable short id, request age,
a trust line (first connection / already-paired-id caution / repair
token rotation), and a friendly access summary with an elevated
warning for system.run-class commands. Requests resolved elsewhere
disappear live; Not Now snoozes without resolving (gateway TTL
applies) and the menu-bar pending line reopens the panel. Liquid
Glass surface on macOS 26+, material fallback on macOS 15. Deletes
the NSAlert + invisible-host-window machinery, dead node isRepair
handling, and text-only describe() body.

Closes #102535

* fix(mac): satisfy swiftformat conditionalAssignment and resync native i18n inventory
2026-07-09 10:31:42 +01:00

93 lines
2.8 KiB
Swift

import Foundation
import OpenClawKit
import OSLog
/// 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
}
}
@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
}
}
}