app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 43cfc92d1332543f1fce5c7aab60d0355c628cdf
parent 4b5d56777a4909500ef4a1b9214ce4ac09c69008
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 01:25:48 +0000

desktop: add lazy location resolver boundary

- implement desktop reverse lookup country list and country center queries behind the shared location resolver contract
- keep the resolver lazy by opening the stamped staged geocoder only on demand after offline geocoder init reports ready
- return typed resolver errors for initializing unavailable and query failures instead of reusing startup-only strings
- add desktop tests for resolver result mapping and query gating while the offline geocoder is not ready

Diffstat:
Mcrates/desktop/src/main.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/desktop/src/offline_geocoder.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 236 insertions(+), 5 deletions(-)

diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -10,8 +10,10 @@ use radroots_app_apple_security::{ }; use radroots_app_core::{ APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, - ImportActionState, RadrootsApp, RadrootsAppBackend, RadrootsOfflineGeocoderPlatform, - RadrootsOfflineGeocoderState, RadrootsOfflineGeocoderUnavailableKind, SetupActionState, + ImportActionState, RadrootsApp, RadrootsAppBackend, RadrootsLocationCountry, + RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, + RadrootsOfflineGeocoderPlatform, RadrootsOfflineGeocoderState, + RadrootsOfflineGeocoderUnavailableKind, RadrootsResolvedLocation, SetupActionState, }; #[cfg(target_os = "macos")] use radroots_identity::RadrootsIdentity; @@ -291,6 +293,71 @@ impl RadrootsAppBackend for DesktopBackend { Ok(self.offline_geocoder.take_update()) } + fn reverse_location( + &self, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> { + #[cfg(target_os = "macos")] + { + let app_data_root = Self::app_data_root() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + return offline_geocoder::reverse_location( + app_data_root.as_path(), + &self.offline_geocoder.current_state(), + point, + options, + ); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (point, options); + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn list_location_countries( + &self, + ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { + #[cfg(target_os = "macos")] + { + let app_data_root = Self::app_data_root() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + return offline_geocoder::list_countries( + app_data_root.as_path(), + &self.offline_geocoder.current_state(), + ); + } + + #[cfg(not(target_os = "macos"))] + { + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn location_country_center( + &self, + country_id: &str, + ) -> Result<RadrootsLocationPoint, RadrootsLocationResolverError> { + #[cfg(target_os = "macos")] + { + let app_data_root = Self::app_data_root() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + return offline_geocoder::country_center( + app_data_root.as_path(), + &self.offline_geocoder.current_state(), + country_id, + ); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = country_id; + Err(RadrootsLocationResolverError::Unsupported) + } + } + fn setup_action_state(&self) -> SetupActionState { #[cfg(target_os = "macos")] { diff --git a/crates/desktop/src/offline_geocoder.rs b/crates/desktop/src/offline_geocoder.rs @@ -1,8 +1,12 @@ use radroots_app_core::{ - RadrootsOfflineGeocoderPlatform, RadrootsOfflineGeocoderState, - RadrootsOfflineGeocoderUnavailableKind, + RadrootsLocationCountry, RadrootsLocationPoint, RadrootsLocationResolverError, + RadrootsLocationReverseOptions, RadrootsOfflineGeocoderPlatform, RadrootsOfflineGeocoderState, + RadrootsOfflineGeocoderUnavailableKind, RadrootsResolvedLocation, +}; +use radroots_geocoder::{ + Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderPoint, GeocoderReverseOptions, + GeocoderReverseResult, }; -use radroots_geocoder::Geocoder; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -65,6 +69,59 @@ impl DesktopOfflineGeocoder { } } +pub(crate) fn reverse_location( + app_data_root: &Path, + state: &RadrootsOfflineGeocoderState, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, +) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> { + let geocoder = geocoder_for_queries(app_data_root, state)?; + let options = options.map(|options| GeocoderReverseOptions { + limit: options.limit, + degree_offset: options.degree_offset, + }); + geocoder + .reverse( + GeocoderPoint { + lat: point.lat, + lng: point.lng, + }, + options, + ) + .map(|results| results.into_iter().map(map_reverse_result).collect()) + .map_err(|source| RadrootsLocationResolverError::QueryFailed { + message: source.to_string(), + }) +} + +pub(crate) fn list_countries( + app_data_root: &Path, + state: &RadrootsOfflineGeocoderState, +) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { + let geocoder = geocoder_for_queries(app_data_root, state)?; + geocoder + .country_list() + .map(|results| results.into_iter().map(map_country_result).collect()) + .map_err(|source| RadrootsLocationResolverError::QueryFailed { + message: source.to_string(), + }) +} + +pub(crate) fn country_center( + app_data_root: &Path, + state: &RadrootsOfflineGeocoderState, + country_id: &str, +) -> Result<RadrootsLocationPoint, RadrootsLocationResolverError> { + let geocoder = geocoder_for_queries(app_data_root, state)?; + geocoder + .country_center(country_id) + .map(|point| RadrootsLocationPoint { + lat: point.lat, + lng: point.lng, + }) + .map_err(map_country_center_error) +} + fn initialize_offline_geocoder(app_data_root: &Path) -> RadrootsOfflineGeocoderState { let source_path = runtime_asset_path().map_err(|debug_message| { RadrootsOfflineGeocoderState::unavailable( @@ -129,6 +186,72 @@ fn runtime_asset_path() -> Result<PathBuf, String> { Ok(parent.join(GEOCODER_ASSET_FILENAME)) } +fn geocoder_for_queries( + app_data_root: &Path, + state: &RadrootsOfflineGeocoderState, +) -> Result<Geocoder, RadrootsLocationResolverError> { + match state { + RadrootsOfflineGeocoderState::Initializing => { + return Err(RadrootsLocationResolverError::Initializing); + } + RadrootsOfflineGeocoderState::Unavailable { .. } => { + return Err(RadrootsLocationResolverError::Unavailable); + } + RadrootsOfflineGeocoderState::Ready => {} + } + + let source_path = runtime_asset_path() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + let revision = + runtime_asset_revision(source_path.parent().unwrap_or_else(|| Path::new("."))) + .map_err(|(_, message)| RadrootsLocationResolverError::QueryFailed { message })?; + let staged_path = staged_db_path(app_data_root, revision.as_str()); + stage_runtime_asset(source_path.as_path(), staged_path.as_path()) + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + Geocoder::open_path(staged_path.as_path()).map_err(|source| { + RadrootsLocationResolverError::QueryFailed { + message: source.to_string(), + } + }) +} + +fn map_reverse_result(result: GeocoderReverseResult) -> RadrootsResolvedLocation { + RadrootsResolvedLocation { + id: result.id, + name: result.name, + admin1_id: result.admin1_id, + admin1_name: result.admin1_name, + country_id: result.country_id, + country_name: result.country_name, + point: RadrootsLocationPoint { + lat: result.latitude, + lng: result.longitude, + }, + } +} + +fn map_country_result(result: GeocoderCountryListResult) -> RadrootsLocationCountry { + RadrootsLocationCountry { + country_id: result.country_id, + country_name: result.country, + center: RadrootsLocationPoint { + lat: result.lat, + lng: result.lng, + }, + } +} + +fn map_country_center_error(source: GeocoderError) -> RadrootsLocationResolverError { + match source { + GeocoderError::CountryCenterNotFound { country_id } => { + RadrootsLocationResolverError::CountryCenterNotFound { country_id } + } + other => RadrootsLocationResolverError::QueryFailed { + message: other.to_string(), + }, + } +} + fn runtime_asset_revision( asset_dir: &Path, ) -> Result<String, (RadrootsOfflineGeocoderUnavailableKind, String)> { @@ -347,4 +470,45 @@ mod tests { std::fs::remove_dir_all(temp_root.as_path()).unwrap(); } + + #[test] + fn reverse_result_mapping_preserves_location_fields() { + let mapped = map_reverse_result(GeocoderReverseResult { + id: 42, + name: "Oslo".to_owned(), + admin1_id: Some(12), + admin1_name: Some("Oslo".to_owned()), + country_id: "NO".to_owned(), + country_name: Some("Norway".to_owned()), + latitude: 59.9139, + longitude: 10.7522, + }); + + assert_eq!(mapped.id, 42); + assert_eq!(mapped.name, "Oslo"); + assert_eq!(mapped.admin1_id, Some(12)); + assert_eq!(mapped.admin1_name.as_deref(), Some("Oslo")); + assert_eq!(mapped.country_id, "NO"); + assert_eq!(mapped.country_name.as_deref(), Some("Norway")); + assert_eq!(mapped.point.lat, 59.9139); + assert_eq!(mapped.point.lng, 10.7522); + } + + #[test] + fn unavailable_state_blocks_queries_until_ready() { + let temp_root = std::env::temp_dir().join(format!( + "radroots-desktop-geocoder-query-state-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + let result = list_countries( + temp_root.as_path(), + &RadrootsOfflineGeocoderState::Initializing, + ); + + assert_eq!(result, Err(RadrootsLocationResolverError::Initializing)); + } }