fix(macos): improve onboarding discovery + restart onboarding
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,15 @@ 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 {
|
||||||
Text("Complete onboarding to finish setup")
|
Button {
|
||||||
.font(.callout.weight(.semibold))
|
OnboardingController.shared.show()
|
||||||
.foregroundColor(.accentColor)
|
} label: {
|
||||||
.padding(.bottom, 2)
|
Text("Complete onboarding to finish setup")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.bottom, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -213,40 +213,51 @@ 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 hasResults = await MainActor.run {
|
let startedAt = Date()
|
||||||
!(self.gatewaysByDomain[domain]?.isEmpty ?? true)
|
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
||||||
}
|
let hasResults = await MainActor.run {
|
||||||
if hasResults { return }
|
!(self.gatewaysByDomain[domain]?.isEmpty ?? true)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
if let stdout = dig(
|
|
||||||
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
|
final class ProbeState: @unchecked Sendable {
|
||||||
min(defaultTimeoutSeconds, remaining())),
|
let lock = NSLock()
|
||||||
stdout.split(whereSeparator: \.isNewline).isEmpty == false
|
var nextIndex = 0
|
||||||
{
|
var found: String?
|
||||||
return ip
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
["+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? {
|
private static func runDig(args: [String], timeout: TimeInterval) -> String? {
|
||||||
|
|||||||
Reference in New Issue
Block a user