Files
clawdbot/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift
2025-12-13 23:32:57 +00:00

171 lines
6.3 KiB
Swift

import AppKit
import ClawdisProtocol
import Foundation
import OSLog
@MainActor
final class NodePairingApprovalPrompter {
static let shared = NodePairingApprovalPrompter()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
private var task: Task<Void, Never>?
private var isPresenting = false
private var queue: [PendingRequest] = []
private struct PendingRequest: Codable, Equatable, Identifiable {
let requestId: String
let nodeId: String
let displayName: String?
let platform: String?
let version: String?
let remoteIp: String?
let isRepair: Bool?
let ts: Double
var id: String { self.requestId }
}
func start() {
guard self.task == nil else { return }
self.task = Task { [weak self] in
guard let self else { return }
_ = try? await GatewayConnection.shared.refresh()
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in self?.handle(push: push) }
}
}
}
func stop() {
self.task?.cancel()
self.task = nil
self.queue.removeAll(keepingCapacity: false)
self.isPresenting = false
}
private func handle(push: GatewayPush) {
guard case let .event(evt) = push else { return }
guard evt.event == "node.pair.requested" else { return }
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 pairing request: \(error.localizedDescription, privacy: .public)")
}
}
private func enqueue(_ req: PendingRequest) {
if self.queue.contains(req) { return }
self.queue.append(req)
self.presentNextIfNeeded()
}
private func presentNextIfNeeded() {
guard !self.isPresenting else { return }
guard let next = self.queue.first else { return }
self.isPresenting = true
self.presentAlert(for: next)
}
private func presentAlert(for req: PendingRequest) {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow node to connect?"
alert.informativeText = Self.describe(req)
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
alert.addButton(withTitle: "Later")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
}
let response = alert.runModal()
Task { [weak self] in
await self?.handleAlertResponse(response, request: req)
}
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
defer {
if self.queue.first == request {
self.queue.removeFirst()
} else {
self.queue.removeAll { $0 == request }
}
self.isPresenting = false
self.presentNextIfNeeded()
}
switch response {
case .alertFirstButtonReturn:
await self.approve(requestId: request.requestId)
case .alertSecondButtonReturn:
await self.reject(requestId: request.requestId)
default:
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
return
}
}
private func approve(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.approve",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
}
}
private func reject(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.reject",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
private static func describe(_ req: PendingRequest) -> String {
let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
let platform = self.prettyPlatform(req.platform)
let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines)
let ip = self.prettyIP(req.remoteIp)
var lines: [String] = []
lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")")
lines.append("Node ID: \(req.nodeId)")
if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") }
if let version, !version.isEmpty { lines.append("App: \(version)") }
if let ip, !ip.isEmpty { lines.append("IP: \(ip)") }
if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") }
return lines.joined(separator: "\n")
}
private static func prettyIP(_ ip: String?) -> String? {
let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let trimmed, !trimmed.isEmpty else { return nil }
return trimmed.replacingOccurrences(of: "::ffff:", with: "")
}
private static func prettyPlatform(_ platform: String?) -> String? {
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let raw, !raw.isEmpty else { return nil }
if raw.lowercased() == "ios" { return "iOS" }
if raw.lowercased() == "macos" { return "macOS" }
return raw
}
}