commit e4433c33db4566ac37b203d3716bb3b274110a71
parent f11101f749ffdc4cfcbcefad7e0e7635f5fd6d02
Author: triesap <triesap@radroots.dev>
Date: Mon, 19 Jan 2026 02:46:34 +0000
app-core: add web geolocation adapter
- implement web geolocation position resolver with permissions checks
- expose web geolocation module in public exports
- expand web-sys features for geolocation and permissions APIs
- add non-wasm geolocation coverage test
Diffstat:
3 files changed, 183 insertions(+), 0 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -44,10 +44,20 @@ web-sys = { version = "0.3.77", features = [
"IdbRequest",
"IdbTransaction",
"IdbTransactionMode",
+ "Coordinates",
+ "Geolocation",
"Navigator",
"Notification",
"NotificationOptions",
"NotificationPermission",
+ "PermissionDescriptor",
+ "PermissionName",
+ "PermissionState",
+ "PermissionStatus",
+ "Permissions",
+ "Position",
+ "PositionError",
+ "PositionOptions",
] }
wasm-bindgen-futures = "0.4"
base64 = "0.22"
diff --git a/crates/core/src/geolocation/mod.rs b/crates/core/src/geolocation/mod.rs
@@ -1,5 +1,6 @@
pub mod error;
pub mod types;
+pub mod web;
pub use error::{RadrootsClientGeolocationError, RadrootsClientGeolocationErrorMessage};
pub use types::{
@@ -7,3 +8,4 @@ pub use types::{
RadrootsClientGeolocationPosition,
RadrootsClientGeolocationResult,
};
+pub use web::RadrootsClientWebGeolocation;
diff --git a/crates/core/src/geolocation/web.rs b/crates/core/src/geolocation/web.rs
@@ -0,0 +1,171 @@
+use async_trait::async_trait;
+
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen::{JsCast, JsValue};
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen_futures::JsFuture;
+
+use super::{
+ RadrootsClientGeolocation,
+ RadrootsClientGeolocationError,
+ RadrootsClientGeolocationPosition,
+ RadrootsClientGeolocationResult,
+};
+
+pub struct RadrootsClientWebGeolocation;
+
+impl RadrootsClientWebGeolocation {
+ #[cfg(target_arch = "wasm32")]
+ fn policy_allows_geolocation(
+ document: &web_sys::Document,
+ ) -> Option<bool> {
+ let policy = js_sys::Reflect::get(document.as_ref(), &JsValue::from_str("permissionsPolicy"))
+ .ok()?;
+ if policy.is_null() || policy.is_undefined() {
+ return None;
+ }
+ let allows = js_sys::Reflect::get(&policy, &JsValue::from_str("allowsFeature"))
+ .ok()?;
+ let allows = allows.dyn_into::<js_sys::Function>().ok()?;
+ let result = allows
+ .call1(&policy, &JsValue::from_str("geolocation"))
+ .ok()?;
+ result.as_bool()
+ }
+
+ #[cfg(target_arch = "wasm32")]
+ async fn permission_state(
+ navigator: &web_sys::Navigator,
+ ) -> Option<web_sys::PermissionState> {
+ let permissions = navigator.permissions().ok()?;
+ let descriptor = web_sys::PermissionDescriptor::new(web_sys::PermissionName::Geolocation);
+ let promise = permissions.query(&descriptor).ok()?;
+ let status = JsFuture::from(promise).await.ok()?;
+ let status: web_sys::PermissionStatus = status.dyn_into().ok()?;
+ Some(status.state())
+ }
+
+ #[cfg(target_arch = "wasm32")]
+ async fn get_current_position(
+ geolocation: &web_sys::Geolocation,
+ ) -> Result<web_sys::Position, JsValue> {
+ let geolocation = geolocation.clone();
+ let promise = js_sys::Promise::new(&mut |resolve, reject| {
+ let success = wasm_bindgen::closure::Closure::once(
+ move |position: web_sys::Position| {
+ let _ = resolve.call1(&JsValue::NULL, &position);
+ },
+ );
+ let failure = wasm_bindgen::closure::Closure::once(
+ move |error: web_sys::PositionError| {
+ let _ = reject.call1(&JsValue::NULL, &error);
+ },
+ );
+ let mut options = web_sys::PositionOptions::new();
+ options.enable_high_accuracy(true);
+ options.timeout(10000.0);
+ options.maximum_age(30000.0);
+ if geolocation
+ .get_current_position_with_error_callback_and_options(
+ success.as_ref().unchecked_ref(),
+ Some(failure.as_ref().unchecked_ref()),
+ &options,
+ )
+ .is_err()
+ {
+ let _ = reject.call0(&JsValue::NULL);
+ }
+ success.forget();
+ failure.forget();
+ });
+ JsFuture::from(promise).await
+ }
+
+ #[cfg(target_arch = "wasm32")]
+ fn map_error(
+ policy_allows: Option<bool>,
+ error: &web_sys::PositionError,
+ ) -> RadrootsClientGeolocationError {
+ match error.code() {
+ web_sys::PositionError::PERMISSION_DENIED => {
+ if policy_allows == Some(false) {
+ RadrootsClientGeolocationError::BlockedByPermissionsPolicy
+ } else {
+ RadrootsClientGeolocationError::PermissionDenied
+ }
+ }
+ web_sys::PositionError::POSITION_UNAVAILABLE => {
+ RadrootsClientGeolocationError::PositionUnavailable
+ }
+ web_sys::PositionError::TIMEOUT => RadrootsClientGeolocationError::Timeout,
+ _ => RadrootsClientGeolocationError::UnknownError,
+ }
+ }
+}
+
+#[async_trait(?Send)]
+impl RadrootsClientGeolocation for RadrootsClientWebGeolocation {
+ async fn current(
+ &self,
+ ) -> RadrootsClientGeolocationResult<RadrootsClientGeolocationPosition> {
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ return Err(RadrootsClientGeolocationError::LocationUnavailable);
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ let window =
+ web_sys::window().ok_or(RadrootsClientGeolocationError::LocationUnavailable)?;
+ let document = window
+ .document()
+ .ok_or(RadrootsClientGeolocationError::LocationUnavailable)?;
+ let navigator = window.navigator();
+ let geolocation = navigator
+ .geolocation()
+ .map_err(|_| RadrootsClientGeolocationError::LocationUnavailable)?;
+
+ let policy_allows = Self::policy_allows_geolocation(&document);
+ let _ = Self::permission_state(&navigator).await;
+
+ if policy_allows == Some(false) {
+ return Err(RadrootsClientGeolocationError::BlockedByPermissionsPolicy);
+ }
+
+ match Self::get_current_position(&geolocation).await {
+ Ok(position) => {
+ let coords = position.coords();
+ Ok(RadrootsClientGeolocationPosition {
+ lat: coords.latitude(),
+ lng: coords.longitude(),
+ accuracy: Some(coords.accuracy()),
+ altitude: coords.altitude(),
+ altitude_accuracy: coords.altitude_accuracy(),
+ })
+ }
+ Err(err) => {
+ if let Ok(position_error) = err.dyn_into::<web_sys::PositionError>() {
+ return Err(Self::map_error(policy_allows, &position_error));
+ }
+ Err(RadrootsClientGeolocationError::UnknownError)
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::RadrootsClientWebGeolocation;
+ use crate::geolocation::{
+ RadrootsClientGeolocation,
+ RadrootsClientGeolocationError,
+ };
+
+ #[test]
+ fn non_wasm_current_errors() {
+ let geo = RadrootsClientWebGeolocation;
+ let err = futures::executor::block_on(geo.current())
+ .expect_err("location unavailable");
+ assert_eq!(err, RadrootsClientGeolocationError::LocationUnavailable);
+ }
+}