app

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

commit 10d377c5534e15c371658b6a856941f29a5c5083
parent 04294001f94b2c7abac848682db02ec6d23e0303
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 13:45:52 +0000

web: add wasm offline geocoder parity

- package the optional geocoder assets into the trunk web dist output
- initialize the byte-backed geocoder asynchronously in the wasm backend without blocking app startup
- replace the web location-resolver unavailable stub with real reverse and country lookup request-poll flows
- keep missing or invalid web geocoder assets in the existing typed unavailable diagnostics path

Diffstat:
MCargo.lock | 2++
Mcrates/web/Cargo.toml | 4+++-
Mcrates/web/index.html | 1+
Mcrates/web/src/lib.rs | 406++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 379 insertions(+), 34 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2916,10 +2916,12 @@ name = "radroots-app-web" version = "0.1.0" dependencies = [ "eframe", + "js-sys", "log", "nostr", "nostr-browser-signer", "radroots-app-core", + "radroots-geocoder", "wasm-bindgen-futures", "web-sys", "wgpu", diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml @@ -16,10 +16,12 @@ crate-type = ["cdylib", "rlib"] [dependencies] eframe = { workspace = true, features = ["wgpu"] } +js-sys = "0.3.91" log.workspace = true radroots-app-core = { path = "../core" } +radroots-geocoder.workspace = true wasm-bindgen-futures.workspace = true -web-sys.workspace = true +web-sys = { workspace = true, features = ["Document", "Element", "HtmlCanvasElement", "Response", "Window"] } [target.'cfg(target_arch = "wasm32")'.dependencies] nostr.workspace = true diff --git a/crates/web/index.html b/crates/web/index.html @@ -53,6 +53,7 @@ <canvas id="radroots_app_canvas"></canvas> <div id="loading_text">loading rad roots…</div> </div> + <link data-trunk rel="copy-dir" href="../../assets" /> <link data-trunk rel="rust" href="Cargo.toml" data-bin="radroots-app-web" /> </body> </html> diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs @@ -6,15 +6,15 @@ use std::cell::RefCell; use std::rc::Rc; #[cfg(target_arch = "wasm32")] -use eframe::wasm_bindgen::JsCast as _; +use eframe::wasm_bindgen::{JsCast as _, JsValue}; +#[cfg(target_arch = "wasm32")] +use js_sys::Uint8Array; #[cfg(target_arch = "wasm32")] use nostr::nips::nip19::ToBech32; #[cfg(target_arch = "wasm32")] use nostr::signer::NostrSigner; #[cfg(target_arch = "wasm32")] use nostr_browser_signer::{BrowserSigner, Error as BrowserSignerError}; -#[cfg(test)] -use radroots_app_core::RadrootsLocationResolverError; #[cfg(target_arch = "wasm32")] use radroots_app_core::{ HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, RadrootsApp, @@ -28,19 +28,180 @@ use radroots_app_core::{ RadrootsOfflineGeocoderPlatform, RadrootsOfflineGeocoderState, RadrootsOfflineGeocoderUnavailableKind, }; +#[cfg(target_arch = "wasm32")] +use radroots_geocoder::{ + Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderPoint, GeocoderReverseOptions, + GeocoderReverseResult, +}; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; + +#[cfg(target_arch = "wasm32")] +const GEOCODER_DB_ASSET_PATH: &str = "assets/geocoder/geonames.db"; +#[cfg(target_arch = "wasm32")] +const GEOCODER_REVISION_ASSET_PATH: &str = "assets/geocoder/geonames.revision"; #[cfg(any(target_arch = "wasm32", test))] -fn offline_geocoder_unavailable_state() -> RadrootsOfflineGeocoderState { +fn offline_geocoder_missing_build_asset_state( + debug_message: impl Into<String>, +) -> RadrootsOfflineGeocoderState { RadrootsOfflineGeocoderState::unavailable( RadrootsOfflineGeocoderUnavailableKind::MissingBuildAsset, RadrootsOfflineGeocoderPlatform::Web, - "radroots-geocoder currently depends on rusqlite and is not wired for wasm runtime initialization.", + debug_message, ) } #[cfg(any(target_arch = "wasm32", test))] -fn location_resolver_unavailable_error() -> RadrootsLocationResolverError { - RadrootsLocationResolverError::Unavailable +fn offline_geocoder_initialization_failed_state( + asset_revision: impl Into<String>, + debug_message: impl Into<String>, +) -> RadrootsOfflineGeocoderState { + RadrootsOfflineGeocoderState::unavailable_with_revision( + RadrootsOfflineGeocoderUnavailableKind::InitializationFailed, + RadrootsOfflineGeocoderPlatform::Web, + asset_revision, + debug_message, + ) +} + +#[cfg(any(target_arch = "wasm32", test))] +fn is_valid_asset_revision(revision: &str) -> bool { + revision.len() == 64 && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(target_arch = "wasm32")] +fn js_error_message(value: JsValue) -> String { + value + .as_string() + .unwrap_or_else(|| "javascript error".to_owned()) +} + +#[cfg(target_arch = "wasm32")] +async fn fetch_response(path: &str) -> Result<web_sys::Response, String> { + let window = web_sys::window().ok_or_else(|| "window unavailable".to_owned())?; + let response_value = JsFuture::from(window.fetch_with_str(path)) + .await + .map_err(|err| format!("failed to fetch {path}: {}", js_error_message(err)))?; + let response = response_value + .dyn_into::<web_sys::Response>() + .map_err(|_| format!("fetch for {path} did not return a response"))?; + if !response.ok() { + return Err(format!( + "fetch for {path} failed with http {}", + response.status() + )); + } + Ok(response) +} + +#[cfg(target_arch = "wasm32")] +async fn fetch_text_asset(path: &str) -> Result<String, String> { + let response = fetch_response(path).await?; + let text_promise = response.text().map_err(|err| { + format!( + "failed to read text body for {path}: {}", + js_error_message(err) + ) + })?; + let text_value = JsFuture::from(text_promise).await.map_err(|err| { + format!( + "failed to load text asset {path}: {}", + js_error_message(err) + ) + })?; + text_value + .as_string() + .ok_or_else(|| format!("text asset {path} did not decode to a string")) +} + +#[cfg(target_arch = "wasm32")] +async fn fetch_bytes_asset(path: &str) -> Result<Vec<u8>, String> { + let response = fetch_response(path).await?; + let buffer_promise = response.array_buffer().map_err(|err| { + format!( + "failed to read binary body for {path}: {}", + js_error_message(err) + ) + })?; + let buffer = JsFuture::from(buffer_promise).await.map_err(|err| { + format!( + "failed to load binary asset {path}: {}", + js_error_message(err) + ) + })?; + Ok(Uint8Array::new(&buffer).to_vec()) +} + +#[cfg(target_arch = "wasm32")] +async fn initialize_offline_geocoder() -> Result<Geocoder, RadrootsOfflineGeocoderState> { + let revision_text = fetch_text_asset(GEOCODER_REVISION_ASSET_PATH) + .await + .map_err(offline_geocoder_missing_build_asset_state)?; + let revision = revision_text.trim().to_owned(); + if !is_valid_asset_revision(revision.as_str()) { + return Err(offline_geocoder_missing_build_asset_state(format!( + "web geocoder revision asset invalid at {GEOCODER_REVISION_ASSET_PATH}" + ))); + } + + let bytes = fetch_bytes_asset(GEOCODER_DB_ASSET_PATH) + .await + .map_err(|debug_message| { + RadrootsOfflineGeocoderState::unavailable_with_revision( + RadrootsOfflineGeocoderUnavailableKind::MissingBuildAsset, + RadrootsOfflineGeocoderPlatform::Web, + revision.clone(), + debug_message, + ) + })?; + + Geocoder::open_bytes(bytes.as_slice()).map_err(|source| { + offline_geocoder_initialization_failed_state( + revision, + format!("failed to open wasm geocoder from {GEOCODER_DB_ASSET_PATH}: {source}"), + ) + }) +} + +#[cfg(target_arch = "wasm32")] +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, + }, + } +} + +#[cfg(target_arch = "wasm32")] +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, + }, + } +} + +#[cfg(target_arch = "wasm32")] +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(), + }, + } } #[cfg(target_arch = "wasm32")] @@ -62,6 +223,12 @@ enum WebConnectionState { struct WebBackendState { connection: WebConnectionState, pending_result: Option<Result<ConnectedSigner, String>>, + offline_geocoder_state: RadrootsOfflineGeocoderState, + pending_offline_geocoder_update: Option<RadrootsOfflineGeocoderState>, + geocoder: Option<Rc<Geocoder>>, + pending_reverse_lookup_result: Option<RadrootsReverseLocationLookupResult>, + pending_country_list_result: Option<RadrootsLocationCountryListResult>, + pending_country_center_result: Option<RadrootsLocationCountryCenterLookupResult>, } #[cfg(target_arch = "wasm32")] @@ -73,12 +240,20 @@ struct WebBackend { #[cfg(target_arch = "wasm32")] impl WebBackend { fn new() -> Self { - Self { + let backend = Self { state: Rc::new(RefCell::new(WebBackendState { connection: WebConnectionState::Disconnected, pending_result: None, + offline_geocoder_state: RadrootsOfflineGeocoderState::Initializing, + pending_offline_geocoder_update: None, + geocoder: None, + pending_reverse_lookup_result: None, + pending_country_list_result: None, + pending_country_center_result: None, })), - } + }; + backend.start_offline_geocoder_init(); + backend } fn identity_state_for_ready(connected: &ConnectedSigner) -> IdentityGateState { @@ -104,6 +279,47 @@ impl WebBackend { state.pending_result = None; IdentityGateState::Missing } + + fn start_offline_geocoder_init(&self) { + let shared_state = Rc::clone(&self.state); + wasm_bindgen_futures::spawn_local(async move { + let result = initialize_offline_geocoder().await; + let mut state = shared_state.borrow_mut(); + match result { + Ok(geocoder) => { + state.geocoder = Some(Rc::new(geocoder)); + state.offline_geocoder_state = RadrootsOfflineGeocoderState::Ready; + state.pending_offline_geocoder_update = + Some(RadrootsOfflineGeocoderState::Ready); + } + Err(offline_geocoder_state) => { + state.geocoder = None; + state.offline_geocoder_state = offline_geocoder_state.clone(); + state.pending_offline_geocoder_update = Some(offline_geocoder_state); + } + } + }); + } + + fn ready_geocoder(&self) -> Result<Rc<Geocoder>, RadrootsLocationResolverError> { + let state = self.state.borrow(); + match &state.offline_geocoder_state { + RadrootsOfflineGeocoderState::Initializing => { + Err(RadrootsLocationResolverError::Initializing) + } + RadrootsOfflineGeocoderState::Unavailable { .. } => { + Err(RadrootsLocationResolverError::Unavailable) + } + RadrootsOfflineGeocoderState::Ready => { + state + .geocoder + .clone() + .ok_or_else(|| RadrootsLocationResolverError::QueryFailed { + message: "web geocoder was ready without an initialized engine".to_owned(), + }) + } + } + } } #[cfg(target_arch = "wasm32")] @@ -119,65 +335,159 @@ impl RadrootsAppBackend for WebBackend { } fn offline_geocoder_state(&self) -> Option<RadrootsOfflineGeocoderState> { - Some(offline_geocoder_unavailable_state()) + Some(self.state.borrow().offline_geocoder_state.clone()) + } + + fn poll_offline_geocoder_state(&self) -> Result<Option<RadrootsOfflineGeocoderState>, String> { + Ok(self + .state + .borrow_mut() + .pending_offline_geocoder_update + .take()) } fn reverse_location( &self, - _point: RadrootsLocationPoint, - _options: Option<RadrootsLocationReverseOptions>, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, ) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> { - Err(location_resolver_unavailable_error()) + let geocoder = self.ready_geocoder()?; + 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(), + }) } fn request_reverse_location_lookup( &self, - _point: RadrootsLocationPoint, - _options: Option<RadrootsLocationReverseOptions>, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, ) -> Result<(), RadrootsLocationResolverError> { - Err(location_resolver_unavailable_error()) + let geocoder = self.ready_geocoder()?; + { + let mut state = self.state.borrow_mut(); + state.pending_reverse_lookup_result = None; + } + let shared_state = Rc::clone(&self.state); + wasm_bindgen_futures::spawn_local(async move { + let options = options.map(|options| GeocoderReverseOptions { + limit: options.limit, + degree_offset: options.degree_offset, + }); + let result = 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(), + }); + shared_state.borrow_mut().pending_reverse_lookup_result = Some(result); + }); + Ok(()) } fn poll_reverse_location_lookup_result( &self, ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { - Ok(None) + Ok(self.state.borrow_mut().pending_reverse_lookup_result.take()) } fn request_location_country_list(&self) -> Result<(), RadrootsLocationResolverError> { - Err(location_resolver_unavailable_error()) + let geocoder = self.ready_geocoder()?; + { + let mut state = self.state.borrow_mut(); + state.pending_country_list_result = None; + } + let shared_state = Rc::clone(&self.state); + wasm_bindgen_futures::spawn_local(async move { + let result = geocoder + .country_list() + .map(|results| results.into_iter().map(map_country_result).collect()) + .map_err(|source| RadrootsLocationResolverError::QueryFailed { + message: source.to_string(), + }); + shared_state.borrow_mut().pending_country_list_result = Some(result); + }); + Ok(()) } fn poll_location_country_list_result( &self, ) -> Result<Option<RadrootsLocationCountryListResult>, String> { - Ok(None) + Ok(self.state.borrow_mut().pending_country_list_result.take()) } fn request_location_country_center_lookup( &self, - _country_id: &str, + country_id: &str, ) -> Result<(), RadrootsLocationResolverError> { - Err(location_resolver_unavailable_error()) + let geocoder = self.ready_geocoder()?; + { + let mut state = self.state.borrow_mut(); + state.pending_country_center_result = None; + } + let shared_state = Rc::clone(&self.state); + let country_id = country_id.to_owned(); + wasm_bindgen_futures::spawn_local(async move { + let result = geocoder + .country_center(country_id.as_str()) + .map(|point| RadrootsLocationPoint { + lat: point.lat, + lng: point.lng, + }) + .map_err(map_country_center_error); + shared_state.borrow_mut().pending_country_center_result = Some(result); + }); + Ok(()) } fn poll_location_country_center_lookup_result( &self, ) -> Result<Option<RadrootsLocationCountryCenterLookupResult>, String> { - Ok(None) + Ok(self.state.borrow_mut().pending_country_center_result.take()) } fn list_location_countries( &self, ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { - Err(location_resolver_unavailable_error()) + let geocoder = self.ready_geocoder()?; + geocoder + .country_list() + .map(|results| results.into_iter().map(map_country_result).collect()) + .map_err(|source| RadrootsLocationResolverError::QueryFailed { + message: source.to_string(), + }) } fn location_country_center( &self, - _country_id: &str, + country_id: &str, ) -> Result<RadrootsLocationPoint, RadrootsLocationResolverError> { - Err(location_resolver_unavailable_error()) + let geocoder = self.ready_geocoder()?; + geocoder + .country_center(country_id) + .map(|point| RadrootsLocationPoint { + lat: point.lat, + lng: point.lng, + }) + .map_err(map_country_center_error) } fn setup_action_state(&self) -> SetupActionState { @@ -354,8 +664,9 @@ mod tests { use super::*; #[test] - fn offline_geocoder_unavailable_state_is_stable() { - let state = offline_geocoder_unavailable_state(); + fn missing_build_asset_state_is_stable() { + let state = + offline_geocoder_missing_build_asset_state("web geocoder asset missing from build"); assert_eq!(state.summary_label(), "Offline geocoder unavailable"); assert_eq!( @@ -368,17 +679,46 @@ mod tests { ); assert_eq!( state.debug_message(), - Some( - "radroots-geocoder currently depends on rusqlite and is not wired for wasm runtime initialization.", - ) + Some("web geocoder asset missing from build") + ); + } + + #[test] + fn wasm_revision_validation_matches_stamped_sha256_contract() { + assert!(is_valid_asset_revision( + "6ca5f1a324de02922d40b1ff33eedf3a5a133c978de921eee5130a0c7876079c" + )); + assert!(!is_valid_asset_revision("abcd")); + assert!(!is_valid_asset_revision( + "not-a-valid-revision-because-it-is-not-hexadecimal-or-64-bytes-long" + )); + } + + #[test] + fn initialization_failed_state_includes_revision_context() { + let state = offline_geocoder_initialization_failed_state( + "6ca5f1a324de02922d40b1ff33eedf3a5a133c978de921eee5130a0c7876079c", + "failed to open wasm geocoder bytes", + ); + let diagnostic = state.diagnostic().expect("diagnostic"); + + assert_eq!(diagnostic.platform_code, "web"); + assert_eq!( + diagnostic.asset_revision.as_deref(), + Some("6ca5f1a324de02922d40b1ff33eedf3a5a133c978de921eee5130a0c7876079c") + ); + assert_eq!(diagnostic.code, "initialization_failed"); + assert_eq!( + state.debug_message(), + Some("failed to open wasm geocoder bytes") ); } #[test] - fn location_resolver_reports_unavailable_instead_of_unsupported() { + fn location_resolver_unavailable_code_is_stable() { assert_eq!( - location_resolver_unavailable_error(), - RadrootsLocationResolverError::Unavailable + radroots_app_core::RadrootsLocationResolverError::Unavailable.code(), + "unavailable" ); } }