From 1865b072e65af82e5734aad45a2b2ae215b3bd43 Mon Sep 17 00:00:00 2001 From: Ashlee Radka Date: Tue, 24 Feb 2026 14:37:19 -0500 Subject: [PATCH] fix: send deny on PairingApprovalWindow close and supersede When the pairing approval window is closed via the X button or superseded by a new request, sends a deny response to the daemon. Prevents iOS devices from hanging in pending state indefinitely. - Track currentPairingRequestId and responseSent flag - Add WindowCloseDelegate (NSWindowDelegate) for X-button close - denyIfNeeded() sends deny for unanswered requests in close() - Set responseSent = true in onDecision before calling close() Co-Authored-By: Claude Opus 4.6 --- .../Settings/PairingApprovalWindow.swift | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/clients/macos/vellum-assistant/Features/Settings/PairingApprovalWindow.swift b/clients/macos/vellum-assistant/Features/Settings/PairingApprovalWindow.swift index 900ee80558e..cd2c07af853 100644 --- a/clients/macos/vellum-assistant/Features/Settings/PairingApprovalWindow.swift +++ b/clients/macos/vellum-assistant/Features/Settings/PairingApprovalWindow.swift @@ -8,6 +8,9 @@ import VellumAssistantShared final class PairingApprovalWindow { private var window: NSWindow? private let daemonClient: DaemonClient + private var currentPairingRequestId: String? + private var responseSent: Bool = false + private var windowDelegate: WindowCloseDelegate? init(daemonClient: DaemonClient) { self.daemonClient = daemonClient @@ -15,12 +18,15 @@ final class PairingApprovalWindow { /// Show the pairing approval prompt for a specific device. /// If a window is already showing, it is closed first (one prompt at a time). + /// Closing the previous window sends a deny for the superseded request. func show(pairingRequestId: String, deviceName: String) { - // Close any existing prompt before showing a new one + // Close any existing prompt before showing a new one. + // This will send a deny for the previous request if unanswered. close() let view = PairingApprovalView(deviceName: deviceName) { [weak self] decision in guard let self else { return } + self.responseSent = true try? self.daemonClient.sendPairingApprovalResponse( pairingRequestId: pairingRequestId, decision: decision @@ -43,10 +49,19 @@ final class PairingApprovalWindow { window.isReleasedWhenClosed = false window.center() + // Delegate catches X-button close and sends deny if no response was sent. + let delegate = WindowCloseDelegate { [weak self] in + self?.handleWindowClosed() + } + window.delegate = delegate + self.windowDelegate = delegate + window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) self.window = window + self.currentPairingRequestId = pairingRequestId + self.responseSent = false } var isVisible: Bool { @@ -54,7 +69,45 @@ final class PairingApprovalWindow { } func close() { + denyIfNeeded() window?.close() window = nil + windowDelegate = nil + } + + // MARK: - Private + + /// Sends a deny for the current request if no explicit response has been sent yet. + private func denyIfNeeded() { + guard let requestId = currentPairingRequestId, !responseSent else { return } + responseSent = true + try? daemonClient.sendPairingApprovalResponse( + pairingRequestId: requestId, + decision: "deny" + ) + } + + /// Called by the window delegate when the user clicks the X button. + private func handleWindowClosed() { + denyIfNeeded() + window = nil + windowDelegate = nil + } +} + +// MARK: - WindowCloseDelegate + +/// Lightweight NSWindowDelegate that forwards windowWillClose to a closure. +private final class WindowCloseDelegate: NSObject, NSWindowDelegate { + private let onClose: @MainActor () -> Void + + init(onClose: @escaping @MainActor () -> Void) { + self.onClose = onClose + } + + func windowWillClose(_ notification: Notification) { + MainActor.assumeIsolated { + onClose() + } } }