fix(macos): stabilize onboarding discovery
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user