import AppKit import ClawdisIPC import ClawdisProtocol import Foundation import OSLog import UserNotifications @MainActor final class NodePairingApprovalPrompter { static let shared = NodePairingApprovalPrompter() private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing") private var task: Task? private var reconcileTask: Task? private var isStopping = false private var isPresenting = false private var queue: [PendingRequest] = [] private var activeAlert: NSAlert? private var activeRequestId: String? private var alertHostWindow: NSWindow? private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] private struct PairingList: Codable { let pending: [PendingRequest] let paired: [PairedNode]? } private struct PairedNode: Codable, Equatable { let nodeId: String let approvedAtMs: Double? let displayName: String? let platform: String? let version: String? let remoteIp: String? } 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 } } private enum PairingResolution: String { case approved case rejected } func start() { guard self.task == nil else { return } self.isStopping = false self.reconcileTask?.cancel() self.reconcileTask = Task { [weak self] in await self?.reconcileLoop() } self.task = Task { [weak self] in guard let self else { return } _ = try? await GatewayConnection.shared.refresh() await self.loadPendingRequestsFromGateway() 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.isStopping = true self.endActiveAlert() self.task?.cancel() self.task = nil self.reconcileTask?.cancel() self.reconcileTask = nil self.queue.removeAll(keepingCapacity: false) self.isPresenting = false self.activeRequestId = nil self.alertHostWindow?.orderOut(nil) self.alertHostWindow?.close() self.alertHostWindow = nil self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) } private func loadPendingRequestsFromGateway() async { // The gateway process may start slightly after the app. Retry a bit so // pending pairing prompts are still shown on launch. var delayMs: UInt64 = 200 for attempt in 1...8 { if Task.isCancelled { return } do { let data = try await GatewayConnection.shared.request( method: "node.pair.list", params: nil, timeoutMs: 6000) guard !data.isEmpty else { return } let list = try JSONDecoder().decode(PairingList.self, from: data) let pending = list.pending.sorted { $0.ts < $1.ts } guard !pending.isEmpty else { return } await MainActor.run { [weak self] in guard let self else { return } self.logger.info( "loaded \(pending.count, privacy: .public) pending node pairing request(s) on startup") for req in pending { self.enqueue(req) } } return } catch { if attempt == 8 { self.logger .error( "failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)") return } try? await Task.sleep(nanoseconds: delayMs * 1_000_000) delayMs = min(delayMs * 2, 2000) } } } private func reconcileLoop() async { // Reconcile requests periodically so multiple running apps stay in sync // (e.g. close dialogs + notify if another machine approves/rejects via app or CLI). let intervalMs: UInt64 = 800 while !Task.isCancelled { if self.isStopping { return } do { let list = try await self.fetchPairingList(timeoutMs: 2500) await self.apply(list: list) } catch { // best effort: ignore transient connectivity failures } try? await Task.sleep(nanoseconds: intervalMs * 1_000_000) } } private func fetchPairingList(timeoutMs: Double) async throws -> PairingList { let data = try await GatewayConnection.shared.request( method: "node.pair.list", params: nil, timeoutMs: timeoutMs) return try JSONDecoder().decode(PairingList.self, from: data) } private func apply(list: PairingList) async { if self.isStopping { return } let pendingById = Dictionary( uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) }) // Enqueue any missing requests (covers missed pushes while reconnecting). for req in list.pending.sorted(by: { $0.ts < $1.ts }) { self.enqueue(req) } // Detect resolved requests (approved/rejected elsewhere). let queued = self.queue for req in queued { if pendingById[req.requestId] != nil { continue } let resolution = self.inferResolution(for: req, list: list) if self.activeRequestId == req.requestId, self.activeAlert != nil { self.remoteResolutionsByRequestId[req.requestId] = resolution self.logger.info( "pairing request resolved elsewhere; closing dialog requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)") self.endActiveAlert() continue } self.logger.info( "pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)") self.queue.removeAll { $0 == req } Task { @MainActor in await self.notify(resolution: resolution, request: req, via: "remote") } } if self.queue.isEmpty { self.isPresenting = false } self.presentNextIfNeeded() } private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution { let paired = list.paired ?? [] guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else { return .rejected } if request.isRepair == true, let approvedAtMs = node.approvedAtMs { return approvedAtMs >= request.ts ? .approved : .rejected } return .approved } private func endActiveAlert() { guard let alert = self.activeAlert else { return } if let parent = alert.window.sheetParent { parent.endSheet(alert.window, returnCode: .abort) } self.activeAlert = nil self.activeRequestId = nil } private func requireAlertHostWindow() -> NSWindow { if let alertHostWindow { return alertHostWindow } let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 440, height: 1), styleMask: [.titled], backing: .buffered, defer: false) window.title = "Clawdis" window.isReleasedWhenClosed = false window.level = .floating window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.center() self.alertHostWindow = window return window } 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.isStopping else { return } 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) { self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)") NSApp.activate(ignoringOtherApps: true) let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Allow node to connect?" alert.informativeText = Self.describe(req) // Fail-safe ordering: if the dialog can't be presented, default to "Later". 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 } self.activeAlert = alert self.activeRequestId = req.requestId let hostWindow = self.requireAlertHostWindow() hostWindow.makeKeyAndOrderFront(nil) alert.beginSheetModal(for: hostWindow) { [weak self] response in Task { @MainActor [weak self] in guard let self else { return } self.activeRequestId = nil self.activeAlert = nil await self.handleAlertResponse(response, request: req) hostWindow.orderOut(nil) } } } 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() } // Never approve/reject while shutting down (alerts can get dismissed during app termination). guard !self.isStopping else { return } if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) { await self.notify(resolution: resolved, request: request, via: "remote") return } switch response { case .alertFirstButtonReturn: // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. return case .alertSecondButtonReturn: await self.approve(requestId: request.requestId) await self.notify(resolution: .approved, request: request, via: "local") case .alertThirdButtonReturn: await self.reject(requestId: request.requestId) await self.notify(resolution: .rejected, request: request, via: "local") default: return } } private func approve(requestId: String) async { do { try await GatewayConnection.shared.nodePairApprove(requestId: requestId) 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.nodePairReject(requestId: requestId) 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 } 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 } 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 body = "\(device)\n(via \(via))" _ = await NotificationManager().send( title: title, body: body, sound: nil, priority: .active) } }