fix(macos): onboarding location + layout
This commit is contained in:
@@ -157,13 +157,18 @@ struct GeneralSettings: View {
|
|||||||
|
|
||||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||||
guard mode != .off else { return true }
|
guard mode != .off else { return true }
|
||||||
|
guard CLLocationManager.locationServicesEnabled() else {
|
||||||
|
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let status = CLLocationManager().authorizationStatus
|
let status = CLLocationManager().authorizationStatus
|
||||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
let requireAlways = mode == .always
|
||||||
if status == .authorizedAlways {
|
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||||
return updated == .authorizedAlways
|
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionSection: some View {
|
private var connectionSection: some View {
|
||||||
|
|||||||
@@ -222,7 +222,15 @@ actor MacNodeRuntime {
|
|||||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||||
let services = await self.mainActorServices()
|
let services = await self.mainActorServices()
|
||||||
let status = await services.locationAuthorizationStatus()
|
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(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class OnboardingController {
|
|||||||
let hosting = NSHostingController(rootView: OnboardingView())
|
let hosting = NSHostingController(rootView: OnboardingView())
|
||||||
let window = NSWindow(contentViewController: hosting)
|
let window = NSWindow(contentViewController: hosting)
|
||||||
window.title = UIStrings.welcomeTitle
|
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.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
@@ -98,7 +98,10 @@ struct OnboardingView: View {
|
|||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
var permissionMonitor: PermissionMonitor
|
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 contentHeight: CGFloat = 460
|
||||||
let connectionPageIndex = 1
|
let connectionPageIndex = 1
|
||||||
let anthropicAuthPageIndex = 2
|
let anthropicAuthPageIndex = 2
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ extension OnboardingView {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
self.navigationBar
|
self.navigationBar
|
||||||
}
|
}
|
||||||
.frame(width: self.pageWidth, height: 684)
|
.frame(width: self.pageWidth, height: Self.windowHeight)
|
||||||
.background(Color(NSColor.windowBackgroundColor))
|
.background(Color(NSColor.windowBackgroundColor))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.currentPage = 0
|
self.currentPage = 0
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import Speech
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
enum PermissionManager {
|
enum PermissionManager {
|
||||||
|
static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways _: Bool) -> Bool {
|
||||||
|
status == .authorizedAlways
|
||||||
|
}
|
||||||
|
|
||||||
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
||||||
var results: [Capability: Bool] = [:]
|
var results: [Capability: Bool] = [:]
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
@@ -138,18 +142,23 @@ enum PermissionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func ensureLocation(interactive: Bool) async -> Bool {
|
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
|
let status = CLLocationManager().authorizationStatus
|
||||||
switch status {
|
switch status {
|
||||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
|
||||||
case .authorizedAlways:
|
case .authorizedAlways:
|
||||||
return true
|
return true
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
guard interactive else { return false }
|
guard interactive else { return false }
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: false)
|
let updated = await LocationPermissionRequester.shared.request(always: false)
|
||||||
return updated == .authorizedAlways
|
return self.isLocationAuthorized(status: updated, requireAlways: false)
|
||||||
case .denied, .restricted:
|
case .denied, .restricted:
|
||||||
if interactive {
|
if interactive {
|
||||||
LocationPermissionHelper.openSettings()
|
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@@ -202,8 +211,8 @@ enum PermissionManager {
|
|||||||
|
|
||||||
case .location:
|
case .location:
|
||||||
let status = CLLocationManager().authorizationStatus
|
let status = CLLocationManager().authorizationStatus
|
||||||
// Note: macOS only supports authorizedAlways
|
results[cap] = CLLocationManager.locationServicesEnabled()
|
||||||
results[cap] = status == .authorizedAlways
|
&& self.isLocationAuthorized(status: status, requireAlways: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
@@ -282,13 +291,21 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func request(always: Bool) async -> CLAuthorizationStatus {
|
func request(always: Bool) async -> CLAuthorizationStatus {
|
||||||
if always {
|
let current = self.manager.authorizationStatus
|
||||||
self.manager.requestAlwaysAuthorization()
|
if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) {
|
||||||
} else {
|
return current
|
||||||
self.manager.requestWhenInUseAuthorization()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await withCheckedContinuation { cont in
|
return await withCheckedContinuation { cont in
|
||||||
self.continuation = cont
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,14 @@
|
|||||||
<string>Clawdbot captures the screen when the agent needs screenshots for context.</string>
|
<string>Clawdbot captures the screen when the agent needs screenshots for context.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Clawdbot can capture photos or short video clips when requested by the agent.</string>
|
<string>Clawdbot can capture photos or short video clips when requested by the agent.</string>
|
||||||
<key>NSLocationUsageDescription</key>
|
<key>NSLocationUsageDescription</key>
|
||||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
|
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
<string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||||
<key>NSAppleEventsUsageDescription</key>
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,8 +52,10 @@ public struct ClawdbotChatView: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ClawdbotChatTheme.background
|
if self.style == .standard {
|
||||||
.ignoresSafeArea()
|
ClawdbotChatTheme.background
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
VStack(spacing: Layout.stackSpacing) {
|
VStack(spacing: Layout.stackSpacing) {
|
||||||
self.messageList
|
self.messageList
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ cat > "$ENT_TMP_APP_BASE" <<'PLIST'
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
@@ -176,6 +178,8 @@ cat > "$ENT_TMP_APP" <<'PLIST'
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|||||||
Reference in New Issue
Block a user