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 },
+ );
}