app

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

commit 267c083d8889c91848f030da6ee8726c56ee3486
parent d6793efde079a565c9c964e630c3be6034658d72
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 11:47:50 +0000

desktop: add async reverse lookup backend

- add a dedicated desktop reverse-lookup tracker that runs geocoder queries off the ui thread
- expose begin and poll hooks from the desktop backend without changing the current home lookup ui yet
- keep query results typed and one-shot so the core consumer can adopt the new poll contract cleanly
- cover the tracker behavior with focused tests and verify the full desktop crate still builds

Diffstat:
Mcrates/desktop/src/main.rs | 41+++++++++++++++++++++++++++++++++++++++--
Acrates/desktop/src/reverse_lookup.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 158 insertions(+), 2 deletions(-)

diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -13,7 +13,8 @@ use radroots_app_core::{ ImportActionState, RadrootsApp, RadrootsAppBackend, RadrootsLocationCountry, RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, RadrootsOfflineGeocoderPlatform, RadrootsOfflineGeocoderState, - RadrootsOfflineGeocoderUnavailableKind, RadrootsResolvedLocation, SetupActionState, + RadrootsOfflineGeocoderUnavailableKind, RadrootsResolvedLocation, + RadrootsReverseLocationLookupResult, SetupActionState, }; #[cfg(target_os = "macos")] use radroots_identity::RadrootsIdentity; @@ -28,8 +29,10 @@ use std::sync::Arc; use zeroize::Zeroizing; mod offline_geocoder; +mod reverse_lookup; use offline_geocoder::DesktopOfflineGeocoder; +use reverse_lookup::DesktopReverseLookup; const RADROOTS_DESKTOP_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/radroots-logo.ico"); @@ -59,6 +62,7 @@ fn desktop_icon() -> Option<egui::IconData> { struct DesktopBackend { offline_geocoder: DesktopOfflineGeocoder, + reverse_lookup: DesktopReverseLookup, } impl DesktopBackend { @@ -83,7 +87,10 @@ impl DesktopBackend { "desktop offline geocoder initialization is only wired for macos", )); - Self { offline_geocoder } + Self { + offline_geocoder, + reverse_lookup: DesktopReverseLookup::new(), + } } #[cfg(target_os = "macos")] @@ -317,6 +324,36 @@ impl RadrootsAppBackend for DesktopBackend { } } + fn request_reverse_location_lookup( + &self, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + #[cfg(target_os = "macos")] + { + let app_data_root = Self::app_data_root() + .map_err(|message| RadrootsLocationResolverError::QueryFailed { message })?; + return self.reverse_lookup.begin( + app_data_root, + self.offline_geocoder.current_state(), + point, + options, + ); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (point, options); + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn poll_reverse_location_lookup_result( + &self, + ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { + Ok(self.reverse_lookup.take_update()) + } + fn list_location_countries( &self, ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { diff --git a/crates/desktop/src/reverse_lookup.rs b/crates/desktop/src/reverse_lookup.rs @@ -0,0 +1,119 @@ +use crate::offline_geocoder; +use radroots_app_core::{ + RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, + RadrootsOfflineGeocoderState, RadrootsReverseLocationLookupResult, +}; +#[cfg(target_os = "macos")] +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub(crate) struct DesktopReverseLookup { + result: Arc<Mutex<Option<RadrootsReverseLocationLookupResult>>>, + changed: Arc<AtomicBool>, + pending: Arc<AtomicBool>, +} + +impl DesktopReverseLookup { + pub(crate) fn new() -> Self { + Self::default() + } + + #[cfg(target_os = "macos")] + pub(crate) fn begin( + &self, + app_data_root: PathBuf, + geocoder_state: RadrootsOfflineGeocoderState, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + if self.pending.swap(true, Ordering::AcqRel) { + return Err(RadrootsLocationResolverError::QueryFailed { + message: "offline location query is already running".to_owned(), + }); + } + + if let Ok(mut slot) = self.result.lock() { + *slot = None; + } + + let result = Arc::clone(&self.result); + let changed = Arc::clone(&self.changed); + let pending = Arc::clone(&self.pending); + std::thread::spawn(move || { + let lookup_result = offline_geocoder::reverse_location( + app_data_root.as_path(), + &geocoder_state, + point, + options, + ); + if let Ok(mut slot) = result.lock() { + *slot = Some(lookup_result); + changed.store(true, Ordering::Release); + } + pending.store(false, Ordering::Release); + }); + + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + pub(crate) fn begin( + &self, + _app_data_root: std::path::PathBuf, + _geocoder_state: RadrootsOfflineGeocoderState, + _point: RadrootsLocationPoint, + _options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + + pub(crate) fn take_update(&self) -> Option<RadrootsReverseLocationLookupResult> { + if !self.changed.swap(false, Ordering::AcqRel) { + return None; + } + + match self.result.lock() { + Ok(mut slot) => slot.take(), + Err(_) => Some(Err(RadrootsLocationResolverError::QueryFailed { + message: "desktop reverse lookup result lock poisoned".to_owned(), + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_app_core::RadrootsResolvedLocation; + + fn sample_result() -> RadrootsReverseLocationLookupResult { + Ok(vec![RadrootsResolvedLocation { + id: 7, + name: "example".to_owned(), + admin1_id: None, + admin1_name: None, + country_id: "US".to_owned(), + country_name: Some("United States".to_owned()), + point: RadrootsLocationPoint { lat: 1.0, lng: 2.0 }, + }]) + } + + #[test] + fn take_update_is_none_until_tracker_changes() { + let tracker = DesktopReverseLookup::new(); + + assert_eq!(tracker.take_update(), None); + } + + #[test] + fn take_update_returns_queued_result_once() { + let tracker = DesktopReverseLookup::new(); + *tracker.result.lock().unwrap() = Some(sample_result()); + tracker.changed.store(true, Ordering::Release); + + assert!(matches!(tracker.take_update(), Some(Ok(results)) if results.len() == 1)); + assert_eq!(tracker.take_update(), None); + } +}