fix(macos): stabilize onboarding discovery

This commit is contained in:
Peter Steinberger
2026-01-11 03:02:47 +01:00
parent 920436da65
commit 84d9c5f5e5
7 changed files with 68 additions and 27 deletions

View File

@@ -31,7 +31,7 @@ struct GeneralSettings: View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen { if !self.state.onboardingSeen {
Button { Button {
OnboardingController.shared.show() DebugActions.restartOnboarding()
} label: { } label: {
Text("Complete onboarding to finish setup") Text("Complete onboarding to finish setup")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))

View File

@@ -173,6 +173,24 @@ extension OnboardingView {
.shadow(color: .black.opacity(0.06), radius: 8, y: 3)) .shadow(color: .black.opacity(0.06), radius: 8, y: 3))
} }
func onboardingGlassCard(
spacing: CGFloat = 12,
padding: CGFloat = 16,
@ViewBuilder _ content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: spacing) {
content()
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)))
}
func featureRow(title: String, subtitle: String, systemImage: String) -> some View { func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage) Image(systemName: systemImage)

View File

@@ -654,7 +654,7 @@ extension OnboardingView {
.frame(maxWidth: 520) .frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
self.onboardingCard(padding: 8) { self.onboardingGlassCard(padding: 8) {
ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding) ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} }

View File

@@ -10,8 +10,16 @@ import Speech
import UserNotifications import UserNotifications
enum PermissionManager { enum PermissionManager {
static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways _: Bool) -> Bool { static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool {
status == .authorizedAlways if requireAlways { return status == .authorizedAlways }
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .authorized: // deprecated, but still shows up on some macOS versions
return true
default:
return false
}
} }
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
@@ -150,7 +158,7 @@ enum PermissionManager {
} }
let status = CLLocationManager().authorizationStatus let status = CLLocationManager().authorizationStatus
switch status { switch status {
case .authorizedAlways: case .authorizedAlways, .authorizedWhenInUse, .authorized:
return true return true
case .notDetermined: case .notDetermined:
guard interactive else { return false } guard interactive else { return false }

View File

@@ -15,7 +15,7 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2) .padding(.horizontal, 2)
.padding(.vertical, 6) .padding(.vertical, 6)
Button("Show onboarding") { self.showOnboarding() } Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
} }

View File

@@ -58,7 +58,7 @@ struct SettingsRootView: View {
PermissionsSettings( PermissionsSettings(
status: self.permissionMonitor.status, status: self.permissionMonitor.status,
refresh: self.refreshPerms, refresh: self.refreshPerms,
showOnboarding: { OnboardingController.shared.show() }) showOnboarding: { DebugActions.restartOnboarding() })
.tabItem { Label("Permissions", systemImage: "lock.shield") } .tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions) .tag(SettingsTab.permissions)

View File

@@ -166,26 +166,22 @@ public final class GatewayDiscoveryModel {
} }
private func recomputeGateways() { private func recomputeGateways() {
var next = self.gatewaysByDomain.values let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
.flatMap(\.self) let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } if !primaryFiltered.isEmpty {
if self.gatewaysByDomain[ClawdbotBonjour.wideAreaBridgeServiceDomain]?.isEmpty ?? true, self.gateways = primaryFiltered
!self.wideAreaFallbackGateways.isEmpty return
{
next.append(contentsOf: self.wideAreaFallbackGateways)
} }
var seen = Set<String>()
let deduped = next.filter { gateway in // Bonjour can return only "local" results for the wide-area domain (or no results at all),
if seen.contains(gateway.stableID) { return false } // which makes onboarding look empty even though Tailscale DNS-SD can already see bridges.
seen.insert(gateway.stableID) guard !self.wideAreaFallbackGateways.isEmpty else {
return true self.gateways = primaryFiltered
return
} }
let sorted = deduped.sorted {
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
} self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
self.gateways = self.filterLocalGateways
? sorted.filter { !$0.isLocal }
: sorted
} }
private func updateGateways(for domain: String) { private func updateGateways(for domain: String) {
@@ -240,7 +236,7 @@ public final class GatewayDiscoveryModel {
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain, if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
!(self.gatewaysByDomain[domain]?.isEmpty ?? true) self.hasUsableWideAreaResults
{ {
self.wideAreaFallbackGateways = [] self.wideAreaFallbackGateways = []
} }
@@ -256,7 +252,7 @@ public final class GatewayDiscoveryModel {
let startedAt = Date() let startedAt = Date()
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
let hasResults = await MainActor.run { let hasResults = await MainActor.run {
!(self.gatewaysByDomain[domain]?.isEmpty ?? true) self.hasUsableWideAreaResults
} }
if hasResults { return } if hasResults { return }
@@ -279,6 +275,25 @@ public final class GatewayDiscoveryModel {
} }
} }
private var hasUsableWideAreaResults: Bool {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
if !self.filterLocalGateways { return true }
return gateways.contains(where: { !$0.isLocal })
}
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
var seen = Set<String>()
let deduped = gateways.filter { gateway in
if seen.contains(gateway.stableID) { return false }
seen.insert(gateway.stableID)
return true
}
return deduped.sorted {
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
}
}
private nonisolated static var isRunningTests: Bool { private nonisolated static var isRunningTests: Bool {
// Keep discovery background work from running forever during SwiftPM test runs. // Keep discovery background work from running forever during SwiftPM test runs.
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }