From 1aa138cf2ae314a4a815e034b801dd6983bd8046 Mon Sep 17 00:00:00 2001 From: Ashlee Radka Date: Tue, 24 Feb 2026 14:40:01 -0500 Subject: [PATCH] fix: re-register QR pairing before TTL and handle missing daemon Adds a timer to re-register the pairing request before the 5-minute TTL expires, keeping the QR code valid while the sheet is open. Also shows an error when daemon client is unavailable instead of rendering an empty sheet. Co-Authored-By: Claude Opus 4.6 --- .../Settings/PairingQRCodeSheet.swift | 129 +++++++++++------- .../Settings/SettingsConnectTab.swift | 10 +- 2 files changed, 85 insertions(+), 54 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/Settings/PairingQRCodeSheet.swift b/clients/macos/vellum-assistant/Features/Settings/PairingQRCodeSheet.swift index 4374167f8a2..f15aa8f686d 100644 --- a/clients/macos/vellum-assistant/Features/Settings/PairingQRCodeSheet.swift +++ b/clients/macos/vellum-assistant/Features/Settings/PairingQRCodeSheet.swift @@ -17,7 +17,7 @@ struct PairingQRCodeSheet: View { @Environment(\.dismiss) var dismiss let gatewayUrl: String - let daemonClient: DaemonClient + let daemonClient: DaemonClient? @State private var hostId: String = "" @State private var pairingRequestId: String = UUID().uuidString @@ -25,6 +25,10 @@ struct PairingQRCodeSheet: View { @State private var localLanUrl: String? = nil @State private var registrationState: RegistrationState = .idle @State private var registrationError: String? = nil + @State private var refreshTask: Task? = nil + + /// Re-register every 4 minutes to stay ahead of the 5-minute TTL. + private static let refreshInterval: UInt64 = 4 * 60 * 1_000_000_000 enum RegistrationState { case idle, registering, registered, failed @@ -45,54 +49,58 @@ struct PairingQRCodeSheet: View { Button("Done") { dismiss() } } - switch registrationState { - case .idle, .registering: - VStack(spacing: VSpacing.sm) { - ProgressView() - .controlSize(.large) - Text("Registering pairing request...") - .font(VFont.body) - .foregroundColor(VColor.textSecondary) - } - .frame(width: 220, height: 220) - - case .registered: - if let qrImage = generateQRImage() { - Image(nsImage: qrImage) - .resizable() - .interpolation(.none) - .scaledToFit() - .frame(width: 220, height: 220) - .padding(VSpacing.md) - .background(Color.white) - .cornerRadius(VRadius.md) - } else { - errorContent("Failed to generate QR code.") - } - - case .failed: - errorContent(registrationError ?? "Could not register pairing request. Ensure the daemon is running.") - } + if daemonClient == nil { + errorContent("Cannot generate QR code \u{2014} daemon not connected. Please wait for the daemon to start and try again.") + } else { + switch registrationState { + case .idle, .registering: + VStack(spacing: VSpacing.sm) { + ProgressView() + .controlSize(.large) + Text("Registering pairing request...") + .font(VFont.body) + .foregroundColor(VColor.textSecondary) + } + .frame(width: 220, height: 220) + + case .registered: + if let qrImage = generateQRImage() { + Image(nsImage: qrImage) + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(width: 220, height: 220) + .padding(VSpacing.md) + .background(Color.white) + .cornerRadius(VRadius.md) + } else { + errorContent("Failed to generate QR code.") + } - // State indicator - if canGenerateQR { - HStack(spacing: VSpacing.sm) { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(VColor.success) - .font(.system(size: 14)) - Text("Ready to pair with iOS") - .font(VFont.body) - .foregroundColor(VColor.success) + case .failed: + errorContent(registrationError ?? "Could not register pairing request. Ensure the daemon is running.") } - if localLanUrl != nil { - HStack(spacing: VSpacing.xs) { - Image(systemName: "wifi") - .foregroundColor(VColor.textMuted) - .font(.system(size: 12)) - Text("LAN pairing available") - .font(VFont.caption) - .foregroundColor(VColor.textMuted) + // State indicator + if canGenerateQR { + HStack(spacing: VSpacing.sm) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(VColor.success) + .font(.system(size: 14)) + Text("Ready to pair with iOS") + .font(VFont.body) + .foregroundColor(VColor.success) + } + + if localLanUrl != nil { + HStack(spacing: VSpacing.xs) { + Image(systemName: "wifi") + .foregroundColor(VColor.textMuted) + .font(.system(size: 12)) + Text("LAN pairing available") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + } } } } @@ -102,7 +110,7 @@ struct PairingQRCodeSheet: View { .foregroundColor(VColor.textSecondary) .multilineTextAlignment(.center) - if registrationState == .failed { + if registrationState == .failed && daemonClient != nil { Button("Retry") { pairingRequestId = UUID().uuidString pairingSecret = Self.generatePairingSecret() @@ -116,7 +124,12 @@ struct PairingQRCodeSheet: View { .onAppear { hostId = Self.computeHostId() localLanUrl = computeLocalLanUrl() + guard daemonClient != nil else { return } registerWithDaemon() + startRefreshTimer() + } + .onDisappear { + stopRefreshTimer() } } @@ -133,13 +146,33 @@ struct PairingQRCodeSheet: View { .frame(width: 220, height: 220) } + // MARK: - Refresh Timer + + private func startRefreshTimer() { + stopRefreshTimer() + refreshTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.refreshInterval) + guard !Task.isCancelled else { break } + pairingRequestId = UUID().uuidString + pairingSecret = Self.generatePairingSecret() + registerWithDaemon() + } + } + } + + private func stopRefreshTimer() { + refreshTask?.cancel() + refreshTask = nil + } + // MARK: - Registration private func registerWithDaemon() { registrationState = .registering registrationError = nil - guard let port = daemonClient.httpPort else { + guard let port = daemonClient?.httpPort else { registrationState = .failed registrationError = "Daemon HTTP server not running." return diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift index 836411b5a90..de9a2ac1e99 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift @@ -88,12 +88,10 @@ struct SettingsConnectTab: View { Text("This will replace the current bearer token and restart the daemon. Any paired devices will need to reconnect.") } .sheet(isPresented: $showingPairingQR) { - if let client = daemonClient { - PairingQRCodeSheet( - gatewayUrl: store.resolvedIosGatewayUrl, - daemonClient: client - ) - } + PairingQRCodeSheet( + gatewayUrl: store.resolvedIosGatewayUrl, + daemonClient: daemonClient + ) } }