fix(macos): improve onboarding discovery + restart onboarding

This commit is contained in:
Peter Steinberger
2026-01-11 01:13:38 +01:00
parent 318f59ec3e
commit 3dbd6766ab
7 changed files with 122 additions and 45 deletions

View File

@@ -5,6 +5,7 @@ import SwiftUI
enum DebugActions { enum DebugActions {
private static let verboseDefaultsKey = "clawdbot.debug.verboseMain" private static let verboseDefaultsKey = "clawdbot.debug.verboseMain"
private static let sessionMenuLimit = 12 private static let sessionMenuLimit = 12
private static let onboardingSeenKey = "clawdbot.onboardingSeen"
@MainActor @MainActor
static func openAgentEventsWindow() { static func openAgentEventsWindow() {
@@ -183,6 +184,14 @@ enum DebugActions {
NSApp.terminate(nil) 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 @MainActor
private static func resolveSessionStorePath() -> String { private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath let defaultPath = SessionLoader.defaultStorePath

View File

@@ -486,6 +486,7 @@ struct DebugSettings: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Restart app") { DebugActions.restartApp() } Button("Restart app") { DebugActions.restartApp() }
Button("Restart onboarding") { DebugActions.restartOnboarding() }
Button("Reveal app in Finder") { self.revealApp() } Button("Reveal app in Finder") { self.revealApp() }
Spacer(minLength: 0) Spacer(minLength: 0)
} }

View File

@@ -30,9 +30,14 @@ struct GeneralSettings: View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen { if !self.state.onboardingSeen {
Button {
OnboardingController.shared.show()
} label: {
Text("Complete onboarding to finish setup") Text("Complete onboarding to finish setup")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
}
.buttonStyle(.plain)
.padding(.bottom, 2) .padding(.bottom, 2)
} }

View File

@@ -283,6 +283,11 @@ struct MenuContent: View {
Label("Restart Gateway", systemImage: "arrow.clockwise") Label("Restart Gateway", systemImage: "arrow.clockwise")
} }
} }
Button {
DebugActions.restartOnboarding()
} label: {
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
}
Button { Button {
DebugActions.restartApp() DebugActions.restartApp()
} label: { } label: {

View File

@@ -48,6 +48,11 @@ final class OnboardingController {
self.window?.close() self.window?.close()
self.window = nil self.window = nil
} }
func restart() {
self.close()
self.show()
}
} }
struct OnboardingView: View { struct OnboardingView: View {

View File

@@ -213,18 +213,22 @@ public final class GatewayDiscoveryModel {
private func scheduleWideAreaFallback() { private func scheduleWideAreaFallback() {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
if ProcessInfo.processInfo.isRunningTests { return }
guard self.wideAreaFallbackTask == nil else { return } guard self.wideAreaFallbackTask == nil else { return }
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
guard let self else { return } guard let self else { return }
if Task.isCancelled { return } var attempt = 0
let startedAt = Date()
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
let hasResults = await MainActor.run { let hasResults = await MainActor.run {
!(self.gatewaysByDomain[domain]?.isEmpty ?? true) !(self.gatewaysByDomain[domain]?.isEmpty ?? true)
} }
if hasResults { return } if hasResults { return }
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 3.0) // Wide-area discovery can be racy (Tailscale not yet up, DNS zone not
if beacons.isEmpty { return } // 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 await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
self.wideAreaFallbackGateways = beacons.map { beacon in self.wideAreaFallbackGateways = beacons.map { beacon in
@@ -248,6 +252,13 @@ public final class GatewayDiscoveryModel {
} }
self.recomputeGateways() 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))
}
} }
} }

View File

@@ -18,6 +18,7 @@ enum WideAreaGatewayDiscovery {
private static let maxCandidates = 40 private static let maxCandidates = 40
private static let digPath = "/usr/bin/dig" private static let digPath = "/usr/bin/dig"
private static let defaultTimeoutSeconds: TimeInterval = 0.2 private static let defaultTimeoutSeconds: TimeInterval = 0.2
private static let nameserverProbeConcurrency = 6
struct DiscoveryContext: Sendable { struct DiscoveryContext: Sendable {
var tailscaleStatus: @Sendable () -> String? var tailscaleStatus: @Sendable () -> String?
@@ -153,25 +154,65 @@ enum WideAreaGatewayDiscovery {
private static func findNameserver( private static func findNameserver(
candidates: inout [String], candidates: inout [String],
remaining: () -> TimeInterval, remaining: () -> TimeInterval,
dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String? dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
{ {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)" let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
while !candidates.isEmpty { let ips = candidates
if remaining() <= 0 { break } candidates.removeAll(keepingCapacity: true)
let ip = candidates.removeFirst() 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..<workerCount {
group.enter()
DispatchQueue.global(qos: .utility).async {
defer { group.leave() }
while Date() < deadline {
state.lock.lock()
if state.found != nil {
state.lock.unlock()
return
}
let i = state.nextIndex
state.nextIndex += 1
state.lock.unlock()
if i >= ips.count { return }
let ip = ips[i]
let budget = deadline.timeIntervalSinceNow
if budget <= 0 { return }
if let stdout = dig( if let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining())), min(defaultTimeoutSeconds, budget)),
stdout.split(whereSeparator: \.isNewline).isEmpty == false stdout.split(whereSeparator: \.isNewline).isEmpty == false
{ {
return ip 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? { private static func runDig(args: [String], timeout: TimeInterval) -> String? {