Files
moltbot/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift
2026-03-03 03:07:54 +00:00

278 lines
9.0 KiB
Swift

import AppKit
import OpenClawKit
import OSLog
final class PairingAlertHostWindow: NSWindow {
override var canBecomeKey: Bool {
true
}
override var canBecomeMain: Bool {
true
}
}
@MainActor
final class PairingAlertState {
var activeAlert: NSAlert?
var activeRequestId: String?
var alertHostWindow: NSWindow?
}
@MainActor
enum PairingAlertSupport {
enum PairingResolution: String {
case approved
case rejected
}
struct PairingResolvedEvent: Codable {
let requestId: String
let decision: String
let ts: Double
}
static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) {
guard let alert = activeAlert else { return }
if let parent = alert.window.sheetParent {
parent.endSheet(alert.window, returnCode: .abort)
}
activeAlert = nil
activeRequestId = nil
}
static func endActiveAlert(state: PairingAlertState) {
self.endActiveAlert(activeAlert: &state.activeAlert, activeRequestId: &state.activeRequestId)
}
static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow {
if let alertHostWindow {
return alertHostWindow
}
let window = PairingAlertHostWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
styleMask: [.borderless],
backing: .buffered,
defer: false)
window.title = ""
window.isReleasedWhenClosed = false
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.isOpaque = false
window.hasShadow = false
window.backgroundColor = .clear
window.ignoresMouseEvents = true
alertHostWindow = window
return window
}
static func configureDefaultPairingAlert(
_ alert: NSAlert,
messageText: String,
informativeText: String)
{
alert.alertStyle = .warning
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Later")
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
}
static func beginCenteredSheet(
alert: NSAlert,
hostWindow: NSWindow,
completionHandler: @escaping (NSApplication.ModalResponse) -> Void)
{
let sheetSize = alert.window.frame.size
if let screen = hostWindow.screen ?? NSScreen.main {
let bounds = screen.visibleFrame
let x = bounds.midX - (sheetSize.width / 2)
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
} else {
hostWindow.center()
}
hostWindow.makeKeyAndOrderFront(nil)
alert.beginSheetModal(for: hostWindow, completionHandler: completionHandler)
}
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 beginPairingAlert(
messageText: String,
informativeText: String,
alertHostWindow: inout NSWindow?,
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert
{
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
self.configureDefaultPairingAlert(alert, messageText: messageText, informativeText: informativeText)
let hostWindow = self.requireAlertHostWindow(alertHostWindow: &alertHostWindow)
self.beginCenteredSheet(alert: alert, hostWindow: hostWindow) { response in
completion(response, hostWindow)
}
return alert
}
static func presentPairingAlert(
requestId: String,
messageText: String,
informativeText: String,
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
alertHostWindow: inout NSWindow?,
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void)
{
activeRequestId = requestId
activeAlert = self.beginPairingAlert(
messageText: messageText,
informativeText: informativeText,
alertHostWindow: &alertHostWindow,
completion: completion)
}
static func presentPairingAlert<Request>(
request: Request,
requestId: String,
messageText: String,
informativeText: String,
state: PairingAlertState,
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
{
self.presentPairingAlert(
requestId: requestId,
messageText: messageText,
informativeText: informativeText,
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
alertHostWindow: &state.alertHostWindow,
completion: { response, hostWindow in
Task { @MainActor in
self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
await onResponse(response, request)
}
})
}
static func clearActivePairingAlert(
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
hostWindow: NSWindow)
{
activeRequestId = nil
activeAlert = nil
hostWindow.orderOut(nil)
}
static func clearActivePairingAlert(state: PairingAlertState, hostWindow: NSWindow) {
self.clearActivePairingAlert(
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
hostWindow: hostWindow)
}
static func stopPairingPrompter(
isStopping: inout Bool,
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
task: inout Task<Void, Never>?,
queue: inout [some Any],
isPresenting: inout Bool,
alertHostWindow: inout NSWindow?)
{
isStopping = true
self.endActiveAlert(activeAlert: &activeAlert, activeRequestId: &activeRequestId)
task?.cancel()
task = nil
queue.removeAll(keepingCapacity: false)
isPresenting = false
activeRequestId = nil
alertHostWindow?.orderOut(nil)
alertHostWindow?.close()
alertHostWindow = nil
}
static func stopPairingPrompter(
isStopping: inout Bool,
task: inout Task<Void, Never>?,
queue: inout [some Any],
isPresenting: inout Bool,
state: PairingAlertState)
{
self.stopPairingPrompter(
isStopping: &isStopping,
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
task: &task,
queue: &queue,
isPresenting: &isPresenting,
alertHostWindow: &state.alertHostWindow)
}
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
}
}
static func rejectRequest(
requestId: String,
kind: String,
logger: Logger,
action: @escaping () async throws -> Void) async
{
do {
try await action()
logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
} catch {
logger.error("reject failed requestId=\(requestId, privacy: .public)")
logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
}