diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 8dda277eef..1dfe06ae45 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -157,13 +157,18 @@ struct GeneralSettings: View { private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool { guard mode != .off else { return true } + guard CLLocationManager.locationServicesEnabled() else { + await MainActor.run { LocationPermissionHelper.openSettings() } + return false + } + let status = CLLocationManager().authorizationStatus - // Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only) - if status == .authorizedAlways { + let requireAlways = mode == .always + if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) { return true } - let updated = await LocationPermissionRequester.shared.request(always: mode == .always) - return updated == .authorizedAlways + let updated = await LocationPermissionRequester.shared.request(always: requireAlways) + return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways) } private var connectionSection: some View { diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index b439c66eaa..92a86529b8 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -222,7 +222,15 @@ actor MacNodeRuntime { (Self.locationPreciseEnabled() ? .precise : .balanced) let services = await self.mainActorServices() let status = await services.locationAuthorizationStatus() - if status != .authorizedAlways { + let hasPermission = switch mode { + case .always: + status == .authorizedAlways + case .whileUsing: + status == .authorizedAlways + case .off: + false + } + if !hasPermission { return BridgeInvokeResponse( id: req.id, ok: false, diff --git a/apps/macos/Sources/Clawdbot/Onboarding.swift b/apps/macos/Sources/Clawdbot/Onboarding.swift index 3e94dc79de..17c34150f3 100644 --- a/apps/macos/Sources/Clawdbot/Onboarding.swift +++ b/apps/macos/Sources/Clawdbot/Onboarding.swift @@ -32,7 +32,7 @@ final class OnboardingController { let hosting = NSHostingController(rootView: OnboardingView()) let window = NSWindow(contentViewController: hosting) window.title = UIStrings.welcomeTitle - window.setContentSize(NSSize(width: 630, height: 684)) + window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)) window.styleMask = [.titled, .closable, .fullSizeContentView] window.titlebarAppearsTransparent = true window.titleVisibility = .hidden @@ -98,7 +98,10 @@ struct OnboardingView: View { @Bindable var state: AppState var permissionMonitor: PermissionMonitor - let pageWidth: CGFloat = 630 + static let windowWidth: CGFloat = 630 + static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content + + let pageWidth: CGFloat = Self.windowWidth let contentHeight: CGFloat = 460 let connectionPageIndex = 1 let anthropicAuthPageIndex = 2 diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift index e312537385..062c90d375 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift @@ -27,7 +27,7 @@ extension OnboardingView { Spacer(minLength: 0) self.navigationBar } - .frame(width: self.pageWidth, height: 684) + .frame(width: self.pageWidth, height: Self.windowHeight) .background(Color(NSColor.windowBackgroundColor)) .onAppear { self.currentPage = 0 diff --git a/apps/macos/Sources/Clawdbot/PermissionManager.swift b/apps/macos/Sources/Clawdbot/PermissionManager.swift index 3104087309..353a52ff5e 100644 --- a/apps/macos/Sources/Clawdbot/PermissionManager.swift +++ b/apps/macos/Sources/Clawdbot/PermissionManager.swift @@ -10,6 +10,10 @@ import Speech import UserNotifications enum PermissionManager { + static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways _: Bool) -> Bool { + status == .authorizedAlways + } + static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { var results: [Capability: Bool] = [:] for cap in caps { @@ -138,18 +142,23 @@ enum PermissionManager { } private static func ensureLocation(interactive: Bool) async -> Bool { + guard CLLocationManager.locationServicesEnabled() else { + if interactive { + await MainActor.run { LocationPermissionHelper.openSettings() } + } + return false + } let status = CLLocationManager().authorizationStatus switch status { - // Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only) case .authorizedAlways: return true case .notDetermined: guard interactive else { return false } let updated = await LocationPermissionRequester.shared.request(always: false) - return updated == .authorizedAlways + return self.isLocationAuthorized(status: updated, requireAlways: false) case .denied, .restricted: if interactive { - LocationPermissionHelper.openSettings() + await MainActor.run { LocationPermissionHelper.openSettings() } } return false @unknown default: @@ -202,8 +211,8 @@ enum PermissionManager { case .location: let status = CLLocationManager().authorizationStatus - // Note: macOS only supports authorizedAlways - results[cap] = status == .authorizedAlways + results[cap] = CLLocationManager.locationServicesEnabled() + && self.isLocationAuthorized(status: status, requireAlways: false) } } return results @@ -282,13 +291,21 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { } func request(always: Bool) async -> CLAuthorizationStatus { - if always { - self.manager.requestAlwaysAuthorization() - } else { - self.manager.requestWhenInUseAuthorization() + let current = self.manager.authorizationStatus + if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) { + return current } + return await withCheckedContinuation { cont in self.continuation = cont + if always { + self.manager.requestAlwaysAuthorization() + } else { + self.manager.requestWhenInUseAuthorization() + } + + // On macOS, requesting an actual fix makes the prompt more reliable. + self.manager.requestLocation() } } diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist index 2e69df3de9..e01130f3ff 100644 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist @@ -47,10 +47,14 @@ Clawdbot captures the screen when the agent needs screenshots for context. NSCameraUsageDescription Clawdbot can capture photos or short video clips when requested by the agent. - NSLocationUsageDescription - Clawdbot can share your location when requested by the agent. - NSMicrophoneUsageDescription - Clawdbot needs the mic for Voice Wake tests and agent audio capture. + NSLocationUsageDescription + Clawdbot can share your location when requested by the agent. + NSLocationWhenInUseUsageDescription + Clawdbot can share your location when requested by the agent. + NSLocationAlwaysAndWhenInUseUsageDescription + Clawdbot can share your location when requested by the agent. + NSMicrophoneUsageDescription + Clawdbot needs the mic for Voice Wake tests and agent audio capture. NSSpeechRecognitionUsageDescription Clawdbot uses speech recognition to detect your Voice Wake trigger phrase. NSAppleEventsUsageDescription diff --git a/apps/macos/Tests/ClawdbotIPCTests/PermissionManagerLocationTests.swift b/apps/macos/Tests/ClawdbotIPCTests/PermissionManagerLocationTests.swift new file mode 100644 index 0000000000..d84f264cb9 --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/PermissionManagerLocationTests.swift @@ -0,0 +1,20 @@ +import CoreLocation +import Testing + +@testable import Clawdbot + +@Suite("PermissionManager Location") +struct PermissionManagerLocationTests { + @Test("authorizedAlways counts for both modes") + func authorizedAlwaysCountsForBothModes() { + #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false)) + #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true)) + } + + @Test("other statuses not authorized") + func otherStatusesNotAuthorized() { + #expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false)) + #expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false)) + #expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false)) + } +} diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift index d9df658ffc..5376470014 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift @@ -52,8 +52,10 @@ public struct ClawdbotChatView: View { public var body: some View { ZStack { - ClawdbotChatTheme.background - .ignoresSafeArea() + if self.style == .standard { + ClawdbotChatTheme.background + .ignoresSafeArea() + } VStack(spacing: Layout.stackSpacing) { self.messageList diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index 5b55d86e35..798aa45d21 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -146,6 +146,8 @@ cat > "$ENT_TMP_APP_BASE" <<'PLIST' com.apple.security.device.camera + com.apple.security.personal-information.location + PLIST @@ -176,6 +178,8 @@ cat > "$ENT_TMP_APP" <<'PLIST' com.apple.security.device.camera + com.apple.security.personal-information.location + PLIST