feat: add node location support

This commit is contained in:
Peter Steinberger
2026-01-04 00:54:44 +01:00
parent 52f59e6dc1
commit e1dd764504
32 changed files with 1398 additions and 8 deletions

View File

@@ -25,6 +25,8 @@ let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let remoteCliPathKey = "clawdis.remoteCliPath"
let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled"
let locationModeKey = "clawdis.locationMode"
let locationPreciseKey = "clawdis.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
let deepLinkKeyKey = "clawdis.deepLinkKey"
let modelCatalogPathKey = "clawdis.modelCatalogPath"

View File

@@ -1,11 +1,15 @@
import AppKit
import ClawdisIPC
import ClawdisKit
import CoreLocation
import Observation
import SwiftUI
struct GeneralSettings: View {
@Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdisLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel()
@@ -18,6 +22,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
var body: some View {
ScrollView(.vertical) {
@@ -64,6 +69,26 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdisLocationMode.off.rawValue)
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdisLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SettingsToggleRow(
title: "Enable Peekaboo Bridge",
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
@@ -90,12 +115,27 @@ struct GeneralSettings: View {
guard !self.isPreview else { return }
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
}
.onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled {
CanvasManager.shared.hideAll()
}
}
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
}
private var activeBinding: Binding<Bool> {
@@ -104,6 +144,29 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 })
}
private var locationMode: ClawdisLocationMode {
ClawdisLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = CLLocationManager.authorizationStatus()
if status == .authorizedAlways || status == .authorizedWhenInUse {
if mode == .always && status != .authorizedAlways {
let updated = await LocationPermissionRequester.shared.request(always: true)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
}
return true
}
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
switch updated {
case .authorizedAlways, .authorizedWhenInUse:
return true
default:
return false
}
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Clawdis runs")

View File

@@ -0,0 +1,109 @@
import ClawdisKit
import CoreLocation
import Foundation
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
enum Error: Swift.Error {
case timeout
case unavailable
}
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(macOS 11.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
}
func currentLocation(
desiredAccuracy: ClawdisLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
guard CLLocationManager.locationServicesEnabled() else {
throw Error.unavailable
}
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
}
}
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
return kCLLocationAccuracyKilometer
case .balanced:
return kCLLocationAccuracyHundredMeters
case .precise:
return kCLLocationAccuracyBest
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: error)
}
}

View File

@@ -110,6 +110,10 @@ final class MacNodeModeCoordinator {
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
caps.append(ClawdisCapability.camera.rawValue)
}
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
if ClawdisLocationMode(rawValue: rawLocationMode) != .off {
caps.append(ClawdisCapability.location.rawValue)
}
return caps
}
@@ -139,6 +143,9 @@ final class MacNodeModeCoordinator {
commands.append(ClawdisCameraCommand.snap.rawValue)
commands.append(ClawdisCameraCommand.clip.rawValue)
}
if capsSet.contains(ClawdisCapability.location.rawValue) {
commands.append(ClawdisLocationCommand.get.rawValue)
}
return commands
}

View File

@@ -6,6 +6,7 @@ import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService()
@MainActor private let locationService = MacNodeLocationService()
// swiftlint:disable:next function_body_length cyclomatic_complexity
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -168,6 +169,63 @@ actor MacNodeRuntime {
let payload = try Self.encodePayload(["devices": devices])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisLocationCommand.get.rawValue:
let mode = Self.locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
ClawdisLocationGetParams()
let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced)
let status = await self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
do {
let location = try await self.locationService.currentLocation(
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdisLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
} catch MacNodeLocationService.Error.timeout {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_TIMEOUT: no fix in time"))
} catch {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_UNAVAILABLE: \(error.localizedDescription)"))
}
case MacNodeScreenCommand.record.rawValue:
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
MacNodeScreenRecordParams()
@@ -413,6 +471,16 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private nonisolated static func locationMode() -> ClawdisLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return ClawdisLocationMode(rawValue: raw) ?? .off
}
private nonisolated static func locationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: locationPreciseKey) == nil { return true }
return UserDefaults.standard.bool(forKey: locationPreciseKey)
}
private static func errorResponse(
_ req: BridgeInvokeRequest,
code: ClawdisNodeErrorCode,

View File

@@ -3,6 +3,7 @@ import ApplicationServices
import AVFoundation
import ClawdisIPC
import CoreGraphics
import CoreLocation
import Foundation
import Observation
import Speech
@@ -33,6 +34,8 @@ enum PermissionManager {
await self.ensureSpeechRecognition(interactive: interactive)
case .camera:
await self.ensureCamera(interactive: interactive)
case .location:
await self.ensureLocation(interactive: interactive)
}
}
@@ -134,6 +137,25 @@ enum PermissionManager {
}
}
private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
case .denied, .restricted:
if interactive {
LocationPermissionHelper.openSettings()
}
return false
@unknown default:
return false
}
}
static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
@@ -176,6 +198,9 @@ enum PermissionManager {
case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
case .location:
let status = CLLocationManager.authorizationStatus()
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
}
}
return results
@@ -227,6 +252,50 @@ enum CameraPermissionHelper {
}
}
enum LocationPermissionHelper {
static func openSettings() {
let candidates = [
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
"x-apple.systempreferences:com.apple.preference.security",
]
for candidate in candidates {
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
return
}
}
}
}
@MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
override init() {
super.init()
self.manager.delegate = self
}
func request(always: Bool) async -> CLAuthorizationStatus {
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
return await withCheckedContinuation { cont in
self.continuation = cont
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: manager.authorizationStatus)
}
}
enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")

View File

@@ -121,6 +121,7 @@ struct PermissionRow: View {
case .microphone: "Microphone"
case .speechRecognition: "Speech Recognition"
case .camera: "Camera"
case .location: "Location"
}
}
@@ -134,6 +135,7 @@ struct PermissionRow: View {
case .microphone: "Allow Voice Wake and audio capture"
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
case .camera: "Capture photos and video from the camera"
case .location: "Share location when requested by the agent"
}
}
@@ -146,6 +148,7 @@ struct PermissionRow: View {
case .microphone: "mic"
case .speechRecognition: "waveform"
case .camera: "camera"
case .location: "location"
}
}
}

View File

@@ -47,6 +47,8 @@
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
<key>NSCameraUsageDescription</key>
<string>Clawdis can capture photos or short video clips when requested by the agent.</string>
<key>NSLocationUsageDescription</key>
<string>Clawdis can share your location when requested by the agent.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSSpeechRecognitionUsageDescription</key>

View File

@@ -12,6 +12,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
case microphone
case speechRecognition
case camera
case location
}
public enum CameraFacing: String, Codable, Sendable {