fix(macos): sync node pairing approvals

This commit is contained in:
Peter Steinberger
2025-12-17 19:04:01 +00:00
parent 84d5f24f5f
commit db7eeee07b
3 changed files with 291 additions and 10 deletions

View File

@@ -182,6 +182,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let state {
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
}
TerminationSignalWatcher.shared.start()
NodePairingApprovalPrompter.shared.start()
VoiceWakeGlobalSettingsSync.shared.start()
Task { PresenceReporter.shared.start() }
@@ -202,6 +203,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
TerminationSignalWatcher.shared.stop()
VoiceWakeGlobalSettingsSync.shared.stop()
WebChatManager.shared.close()
WebChatManager.shared.resetTunnels()

View File

@@ -1,7 +1,9 @@
import AppKit
import ClawdisIPC
import ClawdisProtocol
import Foundation
import OSLog
import UserNotifications
@MainActor
final class NodePairingApprovalPrompter {
@@ -9,8 +11,28 @@ final class NodePairingApprovalPrompter {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
private var task: Task<Void, Never>?
private var reconcileTask: Task<Void, Never>?
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
@@ -25,11 +47,22 @@ final class NodePairingApprovalPrompter {
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 }
@@ -39,10 +72,159 @@ final class NodePairingApprovalPrompter {
}
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: .abortModalResponse)
}
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) {
@@ -64,6 +246,7 @@ final class NodePairingApprovalPrompter {
}
private func presentNextIfNeeded() {
guard !self.isStopping else { return }
guard !self.isPresenting else { return }
guard let next = self.queue.first else { return }
self.isPresenting = true
@@ -71,22 +254,33 @@ final class NodePairingApprovalPrompter {
}
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")
alert.addButton(withTitle: "Later")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
let response = alert.runModal()
Task { [weak self] in
await self?.handleAlertResponse(response, request: req)
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)
}
}
}
@@ -101,14 +295,26 @@ final class NodePairingApprovalPrompter {
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:
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
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
}
}
@@ -167,4 +373,25 @@ final class NodePairingApprovalPrompter {
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)
}
}

View File

@@ -0,0 +1,52 @@
import AppKit
import Foundation
import OSLog
@MainActor
final class TerminationSignalWatcher {
static let shared = TerminationSignalWatcher()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "lifecycle")
private var sources: [DispatchSourceSignal] = []
private var terminationRequested = false
func start() {
guard self.sources.isEmpty else { return }
self.install(SIGTERM)
self.install(SIGINT)
}
func stop() {
for s in self.sources {
s.cancel()
}
self.sources.removeAll(keepingCapacity: false)
self.terminationRequested = false
}
private func install(_ sig: Int32) {
// Make sure the default action doesn't kill the process before we can gracefully shut down.
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
source.setEventHandler { [weak self] in
self?.handle(sig)
}
source.resume()
self.sources.append(source)
}
private func handle(_ sig: Int32) {
guard !self.terminationRequested else { return }
self.terminationRequested = true
self.logger.info("received signal \(sig, privacy: .public); terminating")
// Ensure any pairing prompt can't accidentally approve during shutdown.
NodePairingApprovalPrompter.shared.stop()
NSApp.terminate(nil)
// Safety net: don't hang forever if something blocks termination.
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
exit(0)
}
}
}