From 3dbd6766abfcfb7ef70df7927591c5e7472e0a6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 01:13:38 +0100 Subject: [PATCH] fix(macos): improve onboarding discovery + restart onboarding --- .../macos/Sources/Clawdbot/DebugActions.swift | 9 +++ .../Sources/Clawdbot/DebugSettings.swift | 1 + .../Sources/Clawdbot/GeneralSettings.swift | 13 ++-- .../Sources/Clawdbot/MenuContentView.swift | 5 ++ apps/macos/Sources/Clawdbot/Onboarding.swift | 5 ++ .../GatewayDiscoveryModel.swift | 71 +++++++++++-------- .../WideAreaGatewayDiscovery.swift | 63 +++++++++++++--- 7 files changed, 122 insertions(+), 45 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/DebugActions.swift b/apps/macos/Sources/Clawdbot/DebugActions.swift index 81c5e741b..1bfbeeb24 100644 --- a/apps/macos/Sources/Clawdbot/DebugActions.swift +++ b/apps/macos/Sources/Clawdbot/DebugActions.swift @@ -5,6 +5,7 @@ import SwiftUI enum DebugActions { private static let verboseDefaultsKey = "clawdbot.debug.verboseMain" private static let sessionMenuLimit = 12 + private static let onboardingSeenKey = "clawdbot.onboardingSeen" @MainActor static func openAgentEventsWindow() { @@ -183,6 +184,14 @@ enum DebugActions { NSApp.terminate(nil) } + @MainActor + static func restartOnboarding() { + UserDefaults.standard.set(false, forKey: self.onboardingSeenKey) + UserDefaults.standard.set(0, forKey: onboardingVersionKey) + AppStateStore.shared.onboardingSeen = false + OnboardingController.shared.restart() + } + @MainActor private static func resolveSessionStorePath() -> String { let defaultPath = SessionLoader.defaultStorePath diff --git a/apps/macos/Sources/Clawdbot/DebugSettings.swift b/apps/macos/Sources/Clawdbot/DebugSettings.swift index f2675b116..2ab0d3203 100644 --- a/apps/macos/Sources/Clawdbot/DebugSettings.swift +++ b/apps/macos/Sources/Clawdbot/DebugSettings.swift @@ -486,6 +486,7 @@ struct DebugSettings: View { HStack(spacing: 8) { Button("Restart app") { DebugActions.restartApp() } + Button("Restart onboarding") { DebugActions.restartOnboarding() } Button("Reveal app in Finder") { self.revealApp() } Spacer(minLength: 0) } diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index e12041803..8dda277ee 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -30,10 +30,15 @@ struct GeneralSettings: View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 18) { if !self.state.onboardingSeen { - Text("Complete onboarding to finish setup") - .font(.callout.weight(.semibold)) - .foregroundColor(.accentColor) - .padding(.bottom, 2) + Button { + OnboardingController.shared.show() + } label: { + Text("Complete onboarding to finish setup") + .font(.callout.weight(.semibold)) + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .padding(.bottom, 2) } VStack(alignment: .leading, spacing: 12) { diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Clawdbot/MenuContentView.swift index 3e9caf4fd..e16e114e6 100644 --- a/apps/macos/Sources/Clawdbot/MenuContentView.swift +++ b/apps/macos/Sources/Clawdbot/MenuContentView.swift @@ -283,6 +283,11 @@ struct MenuContent: View { Label("Restart Gateway", systemImage: "arrow.clockwise") } } + Button { + DebugActions.restartOnboarding() + } label: { + Label("Restart Onboarding", systemImage: "arrow.counterclockwise") + } Button { DebugActions.restartApp() } label: { diff --git a/apps/macos/Sources/Clawdbot/Onboarding.swift b/apps/macos/Sources/Clawdbot/Onboarding.swift index fea9f1085..3e94dc79d 100644 --- a/apps/macos/Sources/Clawdbot/Onboarding.swift +++ b/apps/macos/Sources/Clawdbot/Onboarding.swift @@ -48,6 +48,11 @@ final class OnboardingController { self.window?.close() self.window = nil } + + func restart() { + self.close() + self.show() + } } struct OnboardingView: View { diff --git a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift index cf38e6fbb..fe626154a 100644 --- a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift @@ -213,40 +213,51 @@ public final class GatewayDiscoveryModel { private func scheduleWideAreaFallback() { let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain + if ProcessInfo.processInfo.isRunningTests { return } guard self.wideAreaFallbackTask == nil else { return } self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in guard let self else { return } - if Task.isCancelled { return } - let hasResults = await MainActor.run { - !(self.gatewaysByDomain[domain]?.isEmpty ?? true) - } - if hasResults { return } - - let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 3.0) - if beacons.isEmpty { return } - - await MainActor.run { [weak self] in - guard let self else { return } - self.wideAreaFallbackGateways = beacons.map { beacon in - let stableID = "wide-area|\(domain)|\(beacon.instanceName)" - let isLocal = Self.isLocalGateway( - lanHost: beacon.lanHost, - tailnetDns: beacon.tailnetDns, - displayName: beacon.displayName, - serviceName: beacon.instanceName, - local: self.localIdentity) - return DiscoveredGateway( - displayName: beacon.displayName, - lanHost: beacon.lanHost, - tailnetDns: beacon.tailnetDns, - sshPort: beacon.sshPort ?? 22, - gatewayPort: beacon.gatewayPort, - cliPath: beacon.cliPath, - stableID: stableID, - debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", - isLocal: isLocal) + var attempt = 0 + let startedAt = Date() + while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { + let hasResults = await MainActor.run { + !(self.gatewaysByDomain[domain]?.isEmpty ?? true) } - self.recomputeGateways() + if hasResults { return } + + // Wide-area discovery can be racy (Tailscale not yet up, DNS zone not + // published yet). Retry with a short backoff while onboarding is open. + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0) + if !beacons.isEmpty { + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = beacons.map { beacon in + let stableID = "wide-area|\(domain)|\(beacon.instanceName)" + let isLocal = Self.isLocalGateway( + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + displayName: beacon.displayName, + serviceName: beacon.instanceName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: beacon.displayName, + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + sshPort: beacon.sshPort ?? 22, + gatewayPort: beacon.gatewayPort, + cliPath: beacon.cliPath, + stableID: stableID, + debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", + isLocal: isLocal) + } + self.recomputeGateways() + } + return + } + + attempt += 1 + let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7)) + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) } } } diff --git a/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift index 44acb98ff..f70e862a0 100644 --- a/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift @@ -18,6 +18,7 @@ enum WideAreaGatewayDiscovery { private static let maxCandidates = 40 private static let digPath = "/usr/bin/dig" private static let defaultTimeoutSeconds: TimeInterval = 0.2 + private static let nameserverProbeConcurrency = 6 struct DiscoveryContext: Sendable { var tailscaleStatus: @Sendable () -> String? @@ -153,25 +154,65 @@ enum WideAreaGatewayDiscovery { private static func findNameserver( candidates: inout [String], remaining: () -> TimeInterval, - dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String? + dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String? { let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)" - while !candidates.isEmpty { - if remaining() <= 0 { break } - let ip = candidates.removeFirst() - if let stdout = dig( - ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], - min(defaultTimeoutSeconds, remaining())), - stdout.split(whereSeparator: \.isNewline).isEmpty == false - { - return ip + let ips = candidates + candidates.removeAll(keepingCapacity: true) + if ips.isEmpty { return nil } + + final class ProbeState: @unchecked Sendable { + let lock = NSLock() + var nextIndex = 0 + var found: String? + } + + let state = ProbeState() + let deadline = Date().addingTimeInterval(max(0, remaining())) + let workerCount = min(self.nameserverProbeConcurrency, ips.count) + let group = DispatchGroup() + + for _ in 0..= ips.count { return } + let ip = ips[i] + let budget = deadline.timeIntervalSinceNow + if budget <= 0 { return } + + if let stdout = dig( + ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], + min(defaultTimeoutSeconds, budget)), + stdout.split(whereSeparator: \.isNewline).isEmpty == false + { + state.lock.lock() + if state.found == nil { + state.found = ip + } + state.lock.unlock() + return + } + } } } - return nil + _ = group.wait(timeout: .now() + max(0.0, remaining())) + return state.found } private static func runDig(args: [String], timeout: TimeInterval) -> String? {