diff --git a/CHANGELOG.md b/CHANGELOG.md index 832679c31..05d08c49b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow. - Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. - Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions. +- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support. ### Fixes - CI: fix lint ordering after merge cleanup (#156) — thanks @steipete. diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 93794dd08..883b59df2 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -9,12 +9,9 @@ - - + + + = runtime.instanceId val displayName: StateFlow = runtime.displayName val cameraEnabled: StateFlow = runtime.cameraEnabled + val locationMode: StateFlow = runtime.locationMode + val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled val preventSleep: StateFlow = runtime.preventSleep val wakeWords: StateFlow> = runtime.wakeWords val voiceWakeMode: StateFlow = runtime.voiceWakeMode @@ -70,6 +72,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setCameraEnabled(value) } + fun setLocationMode(mode: LocationMode) { + runtime.setLocationMode(mode) + } + + fun setLocationPreciseEnabled(value: Boolean) { + runtime.setLocationPreciseEnabled(value) + } + fun setPreventSleep(value: Boolean) { runtime.setPreventSleep(value) } diff --git a/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt b/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt index 46b9b136a..e19951495 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt @@ -3,6 +3,7 @@ package com.clawdis.android import android.Manifest import android.content.Context import android.content.pm.PackageManager +import android.location.LocationManager import android.os.Build import android.os.SystemClock import androidx.core.content.ContextCompat @@ -16,6 +17,7 @@ import com.clawdis.android.bridge.BridgeEndpoint import com.clawdis.android.bridge.BridgePairingClient import com.clawdis.android.bridge.BridgeSession import com.clawdis.android.node.CameraCaptureManager +import com.clawdis.android.node.LocationCaptureManager import com.clawdis.android.BuildConfig import com.clawdis.android.node.CanvasController import com.clawdis.android.node.ScreenRecordManager @@ -24,6 +26,7 @@ import com.clawdis.android.protocol.ClawdisCameraCommand import com.clawdis.android.protocol.ClawdisCanvasA2UIAction import com.clawdis.android.protocol.ClawdisCanvasA2UICommand import com.clawdis.android.protocol.ClawdisCanvasCommand +import com.clawdis.android.protocol.ClawdisLocationCommand import com.clawdis.android.protocol.ClawdisScreenCommand import com.clawdis.android.voice.TalkModeManager import com.clawdis.android.voice.VoiceWakeManager @@ -31,6 +34,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -55,6 +59,7 @@ class NodeRuntime(context: Context) { val prefs = SecurePrefs(appContext) val canvas = CanvasController() val camera = CameraCaptureManager(appContext) + val location = LocationCaptureManager(appContext) val screenRecorder = ScreenRecordManager(appContext) private val json = Json { ignoreUnknownKeys = true } @@ -186,6 +191,8 @@ class NodeRuntime(context: Context) { val instanceId: StateFlow = prefs.instanceId val displayName: StateFlow = prefs.displayName val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled val preventSleep: StateFlow = prefs.preventSleep val wakeWords: StateFlow> = prefs.wakeWords val voiceWakeMode: StateFlow = prefs.voiceWakeMode @@ -312,6 +319,14 @@ class NodeRuntime(context: Context) { prefs.setCameraEnabled(value) } + fun setLocationMode(mode: LocationMode) { + prefs.setLocationMode(mode) + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.setLocationPreciseEnabled(value) + } + fun setPreventSleep(value: Boolean) { prefs.setPreventSleep(value) } @@ -373,6 +388,9 @@ class NodeRuntime(context: Context) { add(ClawdisCameraCommand.Snap.rawValue) add(ClawdisCameraCommand.Clip.rawValue) } + if (locationMode.value != LocationMode.Off) { + add(ClawdisLocationCommand.Get.rawValue) + } } val resolved = if (storedToken.isNullOrBlank()) { @@ -384,6 +402,9 @@ class NodeRuntime(context: Context) { if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) { add(ClawdisCapability.VoiceWake.rawValue) } + if (locationMode.value != LocationMode.Off) { + add(ClawdisCapability.Location.rawValue) + } } val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } val advertisedVersion = @@ -444,6 +465,9 @@ class NodeRuntime(context: Context) { if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) { add(ClawdisCapability.VoiceWake.rawValue) } + if (locationMode.value != LocationMode.Off) { + add(ClawdisCapability.Location.rawValue) + } }, commands = invokeCommands, ), @@ -458,6 +482,28 @@ class NodeRuntime(context: Context) { ) } + private fun hasFineLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + private fun hasCoarseLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + private fun hasBackgroundLocationPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + fun connectManual() { val host = manualHost.value.trim() val port = manualPort.value @@ -667,6 +713,14 @@ class NodeRuntime(context: Context) { message = "CAMERA_DISABLED: enable Camera in Settings", ) } + if (command.startsWith(ClawdisLocationCommand.NamespacePrefix) && + locationMode.value == LocationMode.Off + ) { + return BridgeSession.InvokeResult.error( + code = "LOCATION_DISABLED", + message = "LOCATION_DISABLED: enable Location in Settings", + ) + } return when (command) { ClawdisCanvasCommand.Present.rawValue -> { @@ -787,6 +841,59 @@ class NodeRuntime(context: Context) { if (includeAudio) externalAudioCaptureActive.value = false } } + ClawdisLocationCommand.Get.rawValue -> { + val mode = locationMode.value + if (!isForeground.value && mode != LocationMode.Always) { + return BridgeSession.InvokeResult.error( + code = "LOCATION_BACKGROUND_UNAVAILABLE", + message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always", + ) + } + if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { + return BridgeSession.InvokeResult.error( + code = "LOCATION_PERMISSION_REQUIRED", + message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", + ) + } + if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) { + return BridgeSession.InvokeResult.error( + code = "LOCATION_PERMISSION_REQUIRED", + message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings", + ) + } + val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) + val preciseEnabled = locationPreciseEnabled.value + val accuracy = + when (desiredAccuracy) { + "precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + "coarse" -> "coarse" + else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + } + val providers = + when (accuracy) { + "precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + "coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) + else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) + } + try { + val payload = + location.getLocation( + desiredProviders = providers, + maxAgeMs = maxAgeMs, + timeoutMs = timeoutMs, + isPrecise = accuracy == "precise", + ) + BridgeSession.InvokeResult.ok(payload.payloadJson) + } catch (err: TimeoutCancellationException) { + BridgeSession.InvokeResult.error( + code = "LOCATION_TIMEOUT", + message = "LOCATION_TIMEOUT: no fix in time", + ) + } catch (err: Throwable) { + val message = err.message ?: "LOCATION_UNAVAILABLE: no fix" + BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) + } + } ClawdisScreenCommand.Record.rawValue -> { // Status pill mirrors screen recording state so it stays visible without overlay stacking. _screenRecordActive.value = true @@ -840,6 +947,25 @@ class NodeRuntime(context: Context) { return code to "$code: $message" } + private fun parseLocationParams(paramsJson: String?): Triple { + if (paramsJson.isNullOrBlank()) { + return Triple(null, 10_000L, null) + } + val root = + try { + json.parseToJsonElement(paramsJson).asObjectOrNull() + } catch (_: Throwable) { + null + } + val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull() + val timeoutMs = + (root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L) + ?: 10_000L + val desiredAccuracy = + (root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase() + return Triple(maxAgeMs, timeoutMs, desiredAccuracy) + } + private fun resolveA2uiHostUrl(): String? { val raw = session.currentCanvasHostUrl()?.trim().orEmpty() if (raw.isBlank()) return null diff --git a/apps/android/app/src/main/java/com/clawdis/android/SecurePrefs.kt b/apps/android/app/src/main/java/com/clawdis/android/SecurePrefs.kt index 8b1b240b3..ef2ccf004 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/SecurePrefs.kt @@ -47,6 +47,14 @@ class SecurePrefs(context: Context) { private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) val cameraEnabled: StateFlow = _cameraEnabled + private val _locationMode = + MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) + val locationMode: StateFlow = _locationMode + + private val _locationPreciseEnabled = + MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) + val locationPreciseEnabled: StateFlow = _locationPreciseEnabled + private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) val preventSleep: StateFlow = _preventSleep @@ -93,6 +101,16 @@ class SecurePrefs(context: Context) { _cameraEnabled.value = value } + fun setLocationMode(mode: LocationMode) { + prefs.edit { putString("location.enabledMode", mode.rawValue) } + _locationMode.value = mode + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.edit { putBoolean("location.preciseEnabled", value) } + _locationPreciseEnabled.value = value + } + fun setPreventSleep(value: Boolean) { prefs.edit { putBoolean("screen.preventSleep", value) } _preventSleep.value = value diff --git a/apps/android/app/src/main/java/com/clawdis/android/node/LocationCaptureManager.kt b/apps/android/app/src/main/java/com/clawdis/android/node/LocationCaptureManager.kt new file mode 100644 index 000000000..ace3bdc03 --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdis/android/node/LocationCaptureManager.kt @@ -0,0 +1,98 @@ +package com.clawdis.android.node + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationManager +import android.os.CancellationSignal +import java.time.Instant +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +class LocationCaptureManager(private val context: Context) { + data class Payload(val payloadJson: String) + + suspend fun getLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): Payload = + withContext(Dispatchers.Main) { + val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) && + !manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + ) { + throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled") + } + + val cached = bestLastKnown(manager, desiredProviders, maxAgeMs) + val location = + cached ?: requestCurrent(manager, desiredProviders, timeoutMs) + + val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time)) + val source = location.provider + val altitudeMeters = if (location.hasAltitude()) location.altitude else null + val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null + val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null + Payload( + buildString { + append("{\"lat\":") + append(location.latitude) + append(",\"lon\":") + append(location.longitude) + append(",\"accuracyMeters\":") + append(location.accuracy.toDouble()) + if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters) + if (speedMps != null) append(",\"speedMps\":").append(speedMps) + if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg) + append(",\"timestamp\":\"").append(timestamp).append('"') + append(",\"isPrecise\":").append(isPrecise) + append(",\"source\":\"").append(source).append('"') + append('}') + }, + ) + } + + private fun bestLastKnown( + manager: LocationManager, + providers: List, + maxAgeMs: Long?, + ): Location? { + val now = System.currentTimeMillis() + val candidates = + providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) } + val freshest = candidates.maxByOrNull { it.time } ?: return null + if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null + return freshest + } + + @SuppressLint("MissingPermission") + private suspend fun requestCurrent( + manager: LocationManager, + providers: List, + timeoutMs: Long, + ): Location { + val resolved = + providers.firstOrNull { manager.isProviderEnabled(it) } + ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available") + return withTimeout(timeoutMs.coerceAtLeast(1)) { + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location -> + if (location != null) { + cont.resume(location) + } else { + cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix")) + } + } + } + } + } +} diff --git a/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt b/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt index ba9d2f3f1..7988f05c9 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt @@ -5,6 +5,7 @@ enum class ClawdisCapability(val rawValue: String) { Camera("camera"), Screen("screen"), VoiceWake("voiceWake"), + Location("location"), } enum class ClawdisCanvasCommand(val rawValue: String) { @@ -49,3 +50,12 @@ enum class ClawdisScreenCommand(val rawValue: String) { const val NamespacePrefix: String = "screen." } } + +enum class ClawdisLocationCommand(val rawValue: String) { + Get("location.get"), + ; + + companion object { + const val NamespacePrefix: String = "location." + } +} diff --git a/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt index 7e4b58f70..ec36c8343 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt @@ -1,8 +1,12 @@ package com.clawdis.android.ui import android.Manifest +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build +import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -42,12 +46,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.clawdis.android.BuildConfig +import com.clawdis.android.LocationMode import com.clawdis.android.MainViewModel import com.clawdis.android.NodeForegroundService import com.clawdis.android.VoiceWakeMode @@ -58,6 +64,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val instanceId by viewModel.instanceId.collectAsState() val displayName by viewModel.displayName.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState() + val locationMode by viewModel.locationMode.collectAsState() + val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() val preventSleep by viewModel.preventSleep.collectAsState() val wakeWords by viewModel.wakeWords.collectAsState() val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() @@ -101,6 +109,41 @@ fun SettingsSheet(viewModel: MainViewModel) { viewModel.setCameraEnabled(cameraOk) } + var pendingLocationMode by remember { mutableStateOf(null) } + var pendingPreciseToggle by remember { mutableStateOf(false) } + + val locationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> + val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true + val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true + val granted = fineOk || coarseOk + val requestedMode = pendingLocationMode + pendingLocationMode = null + + if (pendingPreciseToggle) { + pendingPreciseToggle = false + viewModel.setLocationPreciseEnabled(fineOk) + return@rememberLauncherForActivityResult + } + + if (!granted) { + viewModel.setLocationMode(LocationMode.Off) + return@rememberLauncherForActivityResult + } + + if (requestedMode != null) { + viewModel.setLocationMode(requestedMode) + if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val backgroundOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!backgroundOk) { + openAppSettings(context) + } + } + } + } + val audioPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> // Status text is handled by NodeRuntime. @@ -122,6 +165,47 @@ fun SettingsSheet(viewModel: MainViewModel) { } } + fun requestLocationPermissions(targetMode: LocationMode) { + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (fineOk || coarseOk) { + viewModel.setLocationMode(targetMode) + if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val backgroundOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!backgroundOk) { + openAppSettings(context) + } + } + } else { + pendingLocationMode = targetMode + locationPermissionLauncher.launch( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + ) + } + } + + fun setPreciseLocationChecked(checked: Boolean) { + if (!checked) { + viewModel.setLocationPreciseEnabled(false) + return + } + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (fineOk) { + viewModel.setLocationPreciseEnabled(true) + } else { + pendingPreciseToggle = true + locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)) + } + } + val visibleBridges = if (isConnected && remoteAddress != null) { bridges.filterNot { "${it.host}:${it.port}" == remoteAddress } @@ -149,7 +233,7 @@ fun SettingsSheet(viewModel: MainViewModel) { contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - // Order parity: Node → Bridge → Voice → Camera → Screen. + // Order parity: Node → Bridge → Voice → Camera → Location → Screen. item { Text("Node", style = MaterialTheme.typography.titleSmall) } item { OutlinedTextField( @@ -423,6 +507,64 @@ fun SettingsSheet(viewModel: MainViewModel) { item { HorizontalDivider() } + // Location + item { Text("Location", style = MaterialTheme.typography.titleSmall) } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Off") }, + supportingContent = { Text("Disable location sharing.") }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Off, + onClick = { viewModel.setLocationMode(LocationMode.Off) }, + ) + }, + ) + ListItem( + headlineContent = { Text("While Using") }, + supportingContent = { Text("Only while Clawdis is open.") }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.WhileUsing, + onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, + ) + }, + ) + ListItem( + headlineContent = { Text("Always") }, + supportingContent = { Text("Allow background location (requires system permission).") }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Always, + onClick = { requestLocationPermissions(LocationMode.Always) }, + ) + }, + ) + } + } + item { + ListItem( + headlineContent = { Text("Precise Location") }, + supportingContent = { Text("Use precise GPS when available.") }, + trailingContent = { + Switch( + checked = locationPreciseEnabled, + onCheckedChange = ::setPreciseLocationChecked, + enabled = locationMode != LocationMode.Off, + ) + }, + ) + } + item { + Text( + "Always may require Android Settings to allow background location.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { HorizontalDivider() } + // Screen item { Text("Screen", style = MaterialTheme.typography.titleSmall) } item { @@ -453,3 +595,12 @@ fun SettingsSheet(viewModel: MainViewModel) { item { Spacer(modifier = Modifier.height(20.dp)) } } } + +private fun openAppSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + context.startActivity(intent) +} diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 3c433b573..ea2db06b9 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -229,6 +229,10 @@ final class BridgeConnectionController { let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) } + let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" + let locationMode = ClawdisLocationMode(rawValue: locationModeRaw) ?? .off + if locationMode != .off { caps.append(ClawdisCapability.location.rawValue) } + return caps } @@ -251,6 +255,9 @@ final class BridgeConnectionController { commands.append(ClawdisCameraCommand.snap.rawValue) commands.append(ClawdisCameraCommand.clip.rawValue) } + if caps.contains(ClawdisCapability.location.rawValue) { + commands.append(ClawdisLocationCommand.get.rawValue) + } return commands } diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 8ceb3bf46..04e3c005b 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -35,6 +35,10 @@ Clawdis can capture photos or short video clips when requested via the bridge. NSLocalNetworkUsageDescription Clawdis discovers and connects to your Clawdis bridge on the local network. + NSLocationWhenInUseUsageDescription + Clawdis uses your location when you allow location sharing. + NSLocationAlwaysAndWhenInUseUsageDescription + Clawdis can share your location in the background when you enable Always. NSMicrophoneUsageDescription Clawdis needs microphone access for voice wake. NSSpeechRecognitionUsageDescription diff --git a/apps/ios/Sources/Location/LocationService.swift b/apps/ios/Sources/Location/LocationService.swift new file mode 100644 index 000000000..84b862a2d --- /dev/null +++ b/apps/ios/Sources/Location/LocationService.swift @@ -0,0 +1,142 @@ +import ClawdisKit +import CoreLocation +import Foundation + +@MainActor +final class LocationService: NSObject, CLLocationManagerDelegate { + enum Error: Swift.Error { + case timeout + case unavailable + } + + private let manager = CLLocationManager() + private var authContinuation: CheckedContinuation? + private var locationContinuation: CheckedContinuation? + + override init() { + super.init() + self.manager.delegate = self + self.manager.desiredAccuracy = kCLLocationAccuracyBest + } + + func authorizationStatus() -> CLAuthorizationStatus { + self.manager.authorizationStatus + } + + func accuracyAuthorization() -> CLAccuracyAuthorization { + if #available(iOS 14.0, *) { + return self.manager.accuracyAuthorization + } + return .fullAccuracy + } + + func ensureAuthorization(mode: ClawdisLocationMode) async -> CLAuthorizationStatus { + guard CLLocationManager.locationServicesEnabled() else { return .denied } + + let status = self.manager.authorizationStatus + if status == .notDetermined { + self.manager.requestWhenInUseAuthorization() + let updated = await self.awaitAuthorizationChange() + if mode != .always { return updated } + } + + if mode == .always { + let current = self.manager.authorizationStatus + if current == .authorizedWhenInUse { + self.manager.requestAlwaysAuthorization() + return await self.awaitAuthorizationChange() + } + return current + } + + return self.manager.authorizationStatus + } + + func currentLocation( + params: ClawdisLocationGetParams, + desiredAccuracy: ClawdisLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + { + 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 awaitAuthorizationChange() async -> CLAuthorizationStatus { + await withCheckedContinuation { cont in + self.authContinuation = cont + } + } + + private func withTimeout( + 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 locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + if let cont = self.authContinuation { + self.authContinuation = nil + cont.resume(returning: manager.authorizationStatus) + } + } + + 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) + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index c812cc47d..916cd4c51 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -31,6 +31,7 @@ final class NodeAppModel { @ObservationIgnored private var cameraHUDDismissTask: Task? let voiceWake = VoiceWakeManager() let talkMode = TalkModeManager() + private let locationService = LocationService() private var lastAutoA2uiURL: String? var bridgeSession: BridgeSession { self.bridge } @@ -188,6 +189,19 @@ final class NodeAppModel { self.talkMode.setEnabled(enabled) } + func requestLocationPermissions(mode: ClawdisLocationMode) async -> Bool { + guard mode != .off else { return true } + let status = await self.locationService.ensureAuthorization(mode: mode) + switch status { + case .authorizedAlways: + return true + case .authorizedWhenInUse: + return mode != .always + default: + return false + } + } + func connectToBridge( endpoint: NWEndpoint, hello: BridgeHello) @@ -466,6 +480,64 @@ final class NodeAppModel { do { switch command { + 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")) + } + if self.isBackgrounded, mode != .always { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: ClawdisNodeError( + code: .backgroundUnavailable, + message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always")) + } + let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ?? + ClawdisLocationGetParams() + let desired = params.desiredAccuracy ?? + (self.isLocationPreciseEnabled() ? .precise : .balanced) + let status = 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")) + } + if self.isBackgrounded && status != .authorizedAlways { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: ClawdisNodeError( + code: .unavailable, + message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access")) + } + let location = try await self.locationService.currentLocation( + params: params, + desiredAccuracy: desired, + maxAgeMs: params.maxAgeMs, + timeoutMs: params.timeoutMs) + let isPrecise = 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) + case ClawdisCanvasCommand.present.rawValue: let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ?? ClawdisCanvasPresentParams() @@ -696,6 +768,16 @@ final class NodeAppModel { } } + private func locationMode() -> ClawdisLocationMode { + let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" + return ClawdisLocationMode(rawValue: raw) ?? .off + } + + private func isLocationPreciseEnabled() -> Bool { + if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true } + return UserDefaults.standard.bool(forKey: "location.preciseEnabled") + } + private static func decodeParams(_ type: T.Type, from json: String?) throws -> T { guard let json, let data = json.data(using: .utf8) else { throw NSError(domain: "Bridge", code: 20, userInfo: [ diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 9ad4be859..63bcf89c6 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -23,6 +23,8 @@ struct SettingsTab: View { @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @AppStorage("camera.enabled") private var cameraEnabled: Bool = true + @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdisLocationMode.off.rawValue + @AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = "" @@ -34,6 +36,7 @@ struct SettingsTab: View { @State private var connectStatus = ConnectStatusStore() @State private var connectingBridgeID: String? @State private var localIPAddress: String? + @State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue var body: some View { NavigationStack { @@ -181,6 +184,22 @@ struct SettingsTab: View { .foregroundStyle(.secondary) } + Section("Location") { + Picker("Location Access", selection: self.$locationEnabledModeRaw) { + 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 requires system permission and may prompt to open Settings.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Section("Screen") { Toggle("Prevent Sleep", isOn: self.$preventSleep) Text("Keeps the screen awake while Clawdis is open.") @@ -201,6 +220,7 @@ struct SettingsTab: View { } .onAppear { self.localIPAddress = Self.primaryIPv4Address() + self.lastLocationModeRaw = self.locationEnabledModeRaw } .onChange(of: self.preferredBridgeStableID) { _, newValue in let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -210,6 +230,20 @@ struct SettingsTab: View { .onChange(of: self.appModel.bridgeServerName) { _, _ in self.connectStatus.text = nil } + .onChange(of: self.locationEnabledModeRaw) { _, newValue in + let previous = self.lastLocationModeRaw + self.lastLocationModeRaw = newValue + guard let mode = ClawdisLocationMode(rawValue: newValue) else { return } + Task { + let granted = await self.appModel.requestLocationPermissions(mode: mode) + if !granted { + await MainActor.run { + self.locationEnabledModeRaw = previous + self.lastLocationModeRaw = previous + } + } + } + } } } @@ -278,6 +312,10 @@ struct SettingsTab: View { return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" } + private var locationMode: ClawdisLocationMode { + ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off + } + private func appVersion() -> String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" } diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index a896432e6..888f8b73c 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -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" diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index c705941d2..6348abef3 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -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 { @@ -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") diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeLocationService.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeLocationService.swift new file mode 100644 index 000000000..a95d51b13 --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeLocationService.swift @@ -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? + + 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( + 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) + } +} diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift index 3fe83622e..3302b432f 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift @@ -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 } diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift index af429e326..335ad9d29 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift @@ -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, diff --git a/apps/macos/Sources/Clawdis/PermissionManager.swift b/apps/macos/Sources/Clawdis/PermissionManager.swift index 155d59ba6..ea43844ba 100644 --- a/apps/macos/Sources/Clawdis/PermissionManager.swift +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -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? + + 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") diff --git a/apps/macos/Sources/Clawdis/PermissionsSettings.swift b/apps/macos/Sources/Clawdis/PermissionsSettings.swift index 955e17edb..4254ea73e 100644 --- a/apps/macos/Sources/Clawdis/PermissionsSettings.swift +++ b/apps/macos/Sources/Clawdis/PermissionsSettings.swift @@ -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" } } } diff --git a/apps/macos/Sources/Clawdis/Resources/Info.plist b/apps/macos/Sources/Clawdis/Resources/Info.plist index 31e80842a..510f52814 100644 --- a/apps/macos/Sources/Clawdis/Resources/Info.plist +++ b/apps/macos/Sources/Clawdis/Resources/Info.plist @@ -47,6 +47,8 @@ Clawdis captures the screen when the agent needs screenshots for context. NSCameraUsageDescription Clawdis can capture photos or short video clips when requested by the agent. + NSLocationUsageDescription + Clawdis can share your location when requested by the agent. NSMicrophoneUsageDescription Clawdis needs the mic for Voice Wake tests and agent audio capture. NSSpeechRecognitionUsageDescription diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 0a7bea442..6275c98e6 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -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 { diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/Capabilities.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/Capabilities.swift index 2abd1a6ec..fb7698303 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/Capabilities.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/Capabilities.swift @@ -5,4 +5,5 @@ public enum ClawdisCapability: String, Codable, Sendable { case camera case screen case voiceWake + case location } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/LocationCommands.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/LocationCommands.swift new file mode 100644 index 000000000..f4bd9a1e2 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/LocationCommands.swift @@ -0,0 +1,57 @@ +import Foundation + +public enum ClawdisLocationCommand: String, Codable, Sendable { + case get = "location.get" +} + +public enum ClawdisLocationAccuracy: String, Codable, Sendable { + case coarse + case balanced + case precise +} + +public struct ClawdisLocationGetParams: Codable, Sendable, Equatable { + public var timeoutMs: Int? + public var maxAgeMs: Int? + public var desiredAccuracy: ClawdisLocationAccuracy? + + public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: ClawdisLocationAccuracy? = nil) { + self.timeoutMs = timeoutMs + self.maxAgeMs = maxAgeMs + self.desiredAccuracy = desiredAccuracy + } +} + +public struct ClawdisLocationPayload: Codable, Sendable, Equatable { + public var lat: Double + public var lon: Double + public var accuracyMeters: Double + public var altitudeMeters: Double? + public var speedMps: Double? + public var headingDeg: Double? + public var timestamp: String + public var isPrecise: Bool + public var source: String? + + public init( + lat: Double, + lon: Double, + accuracyMeters: Double, + altitudeMeters: Double? = nil, + speedMps: Double? = nil, + headingDeg: Double? = nil, + timestamp: String, + isPrecise: Bool, + source: String? = nil) + { + self.lat = lat + self.lon = lon + self.accuracyMeters = accuracyMeters + self.altitudeMeters = altitudeMeters + self.speedMps = speedMps + self.headingDeg = headingDeg + self.timestamp = timestamp + self.isPrecise = isPrecise + self.source = source + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/LocationSettings.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/LocationSettings.swift new file mode 100644 index 000000000..4d64103aa --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/LocationSettings.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum ClawdisLocationMode: String, Codable, Sendable, CaseIterable { + case off + case whileUsing + case always +} diff --git a/docs/location-command.md b/docs/location-command.md new file mode 100644 index 000000000..40b5624fe --- /dev/null +++ b/docs/location-command.md @@ -0,0 +1,95 @@ +--- +summary: "Location command for nodes (location.get), permission modes, and background behavior" +read_when: + - Adding location node support or permissions UI + - Designing background location + push flows +--- + +# Location command (nodes) + +## TL;DR +- `location.get` is a node command (via `node.invoke`). +- Off by default. +- Settings use a selector: Off / While Using / Always. +- Separate toggle: Precise Location. + +## Why a selector (not just a switch) +OS permissions are multi-level. We can expose a selector in-app, but the OS still decides the actual grant. +- iOS/macOS: user can choose **While Using** or **Always** in system prompts/Settings. App can request upgrade, but OS may require Settings. +- Android: background location is a separate permission; on Android 10+ it often requires a Settings flow. +- Precise location is a separate grant (iOS 14+ “Precise”, Android “fine” vs “coarse”). + +Selector in UI drives our requested mode; actual grant lives in OS settings. + +## Settings model +Per node device: +- `location.enabledMode`: `off | whileUsing | always` +- `location.preciseEnabled`: bool + +UI behavior: +- Selecting `whileUsing` requests foreground permission. +- Selecting `always` first ensures `whileUsing`, then requests background (or sends user to Settings if required). +- If OS denies requested level, revert to the highest granted level and show status. + +## Permissions mapping (node.permissions) +Optional. macOS node reports `location` via the permissions map; iOS/Android may omit it. + +## Command: `location.get` +Called via `node.invoke`. + +Params (suggested): +```json +{ + "timeoutMs": 10000, + "maxAgeMs": 15000, + "desiredAccuracy": "coarse|balanced|precise" +} +``` + +Response payload: +```json +{ + "lat": 48.20849, + "lon": 16.37208, + "accuracyMeters": 12.5, + "altitudeMeters": 182.0, + "speedMps": 0.0, + "headingDeg": 270.0, + "timestamp": "2026-01-03T12:34:56.000Z", + "isPrecise": true, + "source": "gps|wifi|cell|unknown" +} +``` + +Errors (stable codes): +- `LOCATION_DISABLED`: selector is off. +- `LOCATION_PERMISSION_REQUIRED`: permission missing for requested mode. +- `LOCATION_BACKGROUND_UNAVAILABLE`: app is backgrounded but only While Using allowed. +- `LOCATION_TIMEOUT`: no fix in time. +- `LOCATION_UNAVAILABLE`: system failure / no providers. + +## Background behavior (future) +Goal: model can request location even when node is backgrounded, but only when: +- User selected **Always**. +- OS grants background location. +- App is allowed to run in background for location (iOS background mode / Android foreground service or special allowance). + +Push-triggered flow (future): +1) Gateway sends a push to the node (silent push or FCM data). +2) Node wakes briefly and calls `location.get` internally. +3) Node forwards payload to Gateway. + +Notes: +- iOS: Always permission + background location mode required. Silent push may be throttled; expect intermittent failures. +- Android: background location may require a foreground service; otherwise, expect denial. + +## Model/tooling integration +- Tool surface: `nodes` tool adds `location_get` action (node required). +- CLI: `clawdis nodes location get --node `. +- Agent guidelines: only call when user enabled location and understands the scope. + +## UX copy (suggested) +- Off: “Location sharing is disabled.” +- While Using: “Only when Clawdis is open.” +- Always: “Allow background location. Requires system permission.” +- Precise: “Use precise GPS location. Toggle off to share approximate location.” diff --git a/docs/nodes.md b/docs/nodes.md index ae49bb47d..dcf74c866 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -94,6 +94,22 @@ Notes: - Screen recordings are clamped to `<= 60s`. - `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio). +## Location (nodes) + +Nodes expose `location.get` when Location is enabled in settings. + +CLI helper: + +```bash +clawdis nodes location get --node +clawdis nodes location get --node --accuracy precise --max-age 15000 --location-timeout 10000 +``` + +Notes: +- Location is **off by default**. +- “Always” requires system permission; background fetch is best-effort. +- The response includes lat/lon, accuracy (meters), and timestamp. + ## System commands (mac node) The macOS node exposes `system.run` and `system.notify`. diff --git a/docs/tools.md b/docs/tools.md index 25157e3b9..b14fa8059 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -78,11 +78,13 @@ Core actions: - `pending`, `approve`, `reject` (pairing) - `notify` (macOS `system.notify`) - `camera_snap`, `camera_clip`, `screen_record` +- `location_get` Notes: - Camera/screen commands require the node app to be foregrounded. - Images return image blocks + `MEDIA:`. - Videos return `FILE:` (mp4). +- Location returns a JSON payload (lat/lon/accuracy/timestamp). ### `cron` Manage Gateway cron jobs and wakeups. diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index e0c41ebd5..8afe39ee4 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -1217,6 +1217,22 @@ const NodesToolSchema = Type.Union([ includeAudio: Type.Optional(Type.Boolean()), outPath: Type.Optional(Type.String()), }), + Type.Object({ + action: Type.Literal("location_get"), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + node: Type.String(), + maxAgeMs: Type.Optional(Type.Number()), + locationTimeoutMs: Type.Optional(Type.Number()), + desiredAccuracy: Type.Optional( + Type.Union([ + Type.Literal("coarse"), + Type.Literal("balanced"), + Type.Literal("precise"), + ]), + ), + }), ]); function createNodesTool(): AnyAgentTool { @@ -1224,7 +1240,7 @@ function createNodesTool(): AnyAgentTool { label: "Nodes", name: "nodes", description: - "Discover and control paired nodes (status/describe/pairing/notify/camera/screen).", + "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location).", parameters: NodesToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -1516,6 +1532,36 @@ function createNodesTool(): AnyAgentTool { }, }; } + case "location_get": { + const node = readStringParam(params, "node", { required: true }); + const nodeId = await resolveNodeId(gatewayOpts, node); + const maxAgeMs = + typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs) + ? params.maxAgeMs + : undefined; + const desiredAccuracy = + params.desiredAccuracy === "coarse" || + params.desiredAccuracy === "balanced" || + params.desiredAccuracy === "precise" + ? params.desiredAccuracy + : undefined; + const locationTimeoutMs = + typeof params.locationTimeoutMs === "number" && + Number.isFinite(params.locationTimeoutMs) + ? params.locationTimeoutMs + : undefined; + const raw = (await callGatewayTool("node.invoke", gatewayOpts, { + nodeId, + command: "location.get", + params: { + maxAgeMs, + desiredAccuracy, + timeoutMs: locationTimeoutMs, + }, + idempotencyKey: crypto.randomUUID(), + })) as { payload?: unknown }; + return jsonResult(raw?.payload ?? {}); + } default: throw new Error(`Unknown action: ${action}`); } diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index 48e9a503d..4e7a322f0 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -158,4 +158,47 @@ describe("nodes-cli coverage", () => { delivery: "overlay", }); }); + + it("invokes location.get with params", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + const { registerNodesCli } = await import("./nodes-cli.js"); + const program = new Command(); + program.exitOverride(); + registerNodesCli(program); + + await program.parseAsync( + [ + "nodes", + "location", + "get", + "--node", + "mac-1", + "--accuracy", + "precise", + "--max-age", + "1000", + "--location-timeout", + "5000", + "--invoke-timeout", + "6000", + ], + { from: "user" }, + ); + + const invoke = callGateway.mock.calls.find( + (call) => call[0]?.method === "node.invoke", + )?.[0]; + + expect(invoke).toBeTruthy(); + expect(invoke?.params?.command).toBe("location.get"); + expect(invoke?.params?.params).toEqual({ + maxAgeMs: 1000, + desiredAccuracy: "precise", + timeoutMs: 5000, + }); + expect(invoke?.params?.timeoutMs).toBe(6000); + }); }); diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index e5bc2ecd3..0e446bbe4 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -45,6 +45,9 @@ type NodesRpcOpts = { quality?: string; delayMs?: string; deviceId?: string; + maxAge?: string; + accuracy?: string; + locationTimeout?: string; duration?: string; screen?: string; fps?: string; @@ -1204,4 +1207,101 @@ export function registerNodesCli(program: Command) { }), { timeoutMs: 180_000 }, ); + + const location = nodes + .command("location") + .description("Fetch location from a paired node"); + + nodesCallOpts( + location + .command("get") + .description("Fetch the current location from a node") + .requiredOption("--node ", "Node id, name, or IP") + .option("--max-age ", "Use cached location newer than this (ms)") + .option( + "--accuracy ", + "Desired accuracy (default: balanced/precise depending on node setting)", + ) + .option("--location-timeout ", "Location fix timeout (ms)", "10000") + .option( + "--invoke-timeout ", + "Node invoke timeout in ms (default 20000)", + "20000", + ) + .action(async (opts: NodesRpcOpts) => { + try { + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const maxAgeMs = opts.maxAge + ? Number.parseInt(String(opts.maxAge), 10) + : undefined; + const desiredAccuracyRaw = + typeof opts.accuracy === "string" + ? opts.accuracy.trim().toLowerCase() + : undefined; + const desiredAccuracy = + desiredAccuracyRaw === "coarse" || + desiredAccuracyRaw === "balanced" || + desiredAccuracyRaw === "precise" + ? desiredAccuracyRaw + : undefined; + const timeoutMs = opts.locationTimeout + ? Number.parseInt(String(opts.locationTimeout), 10) + : undefined; + const invokeTimeoutMs = opts.invokeTimeout + ? Number.parseInt(String(opts.invokeTimeout), 10) + : undefined; + + const invokeParams: Record = { + nodeId, + command: "location.get", + params: { + maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : undefined, + desiredAccuracy, + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, + }, + idempotencyKey: randomIdempotencyKey(), + }; + if ( + typeof invokeTimeoutMs === "number" && + Number.isFinite(invokeTimeoutMs) + ) { + invokeParams.timeoutMs = invokeTimeoutMs; + } + + const raw = (await callGatewayCli( + "node.invoke", + opts, + invokeParams, + )) as unknown; + const res = + typeof raw === "object" && raw !== null + ? (raw as { payload?: unknown }) + : {}; + const payload = + res.payload && typeof res.payload === "object" + ? (res.payload as Record) + : {}; + + if (opts.json) { + defaultRuntime.log(JSON.stringify(payload, null, 2)); + return; + } + + const lat = payload.lat; + const lon = payload.lon; + const acc = payload.accuracyMeters; + if (typeof lat === "number" && typeof lon === "number") { + const accText = + typeof acc === "number" ? ` ±${acc.toFixed(1)}m` : ""; + defaultRuntime.log(`${lat},${lon}${accText}`); + return; + } + defaultRuntime.log(JSON.stringify(payload)); + } catch (err) { + defaultRuntime.error(`nodes location get failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + { timeoutMs: 30_000 }, + ); }