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:
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"
);
}
}