commit 69534aab0780caa719e9a775dd4d08543d7b8b75
parent 72694b715000952c36c5b0e6feb1bcc6339fb9c7
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 22:09:43 +0000
desktop: add non-blocking offline geocoder init
- add the radroots geocoder dependency back for the desktop launcher slice only
- copy the optional app-owned geocoder asset next to the desktop binary at build time without making it a hard build requirement
- initialize the offline geocoder on a background thread and surface ready or unavailable runtime state without blocking app startup
- add focused desktop tests for staged geocoder paths and unavailable-state messaging
Diffstat:
6 files changed, 519 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1047,6 +1047,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1225,11 +1237,24 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
]
[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
name = "gl_generator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1419,6 +1444,12 @@ dependencies = [
]
[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1536,6 +1567,12 @@ dependencies = [
]
[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1578,6 +1615,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -1751,6 +1790,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1795,6 +1840,17 @@ dependencies = [
]
[[package]]
+name = "libsqlite3-sys"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "linux-keyutils"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2710,6 +2766,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2770,6 +2836,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
name = "radroots-app-android"
version = "0.1.0"
dependencies = [
@@ -2815,6 +2887,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"radroots-app-apple-security",
"radroots-app-core",
+ "radroots-geocoder",
"radroots-identity",
"radroots-nostr-accounts",
"wgpu",
@@ -2866,6 +2939,15 @@ dependencies = [
]
[[package]]
+name = "radroots-geocoder"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "rusqlite",
+ "serde",
+ "thiserror 1.0.69",
+]
+
+[[package]]
name = "radroots-identity"
version = "0.1.0-alpha.1"
dependencies = [
@@ -2894,6 +2976,7 @@ version = "0.1.0-alpha.1"
dependencies = [
"keyring",
"radroots-identity",
+ "radroots-nostr-signer",
"radroots-runtime",
"serde",
"serde_json",
@@ -2902,6 +2985,33 @@ dependencies = [
]
[[package]]
+name = "radroots-nostr-connect"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "nostr",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "url",
+]
+
+[[package]]
+name = "radroots-nostr-signer"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "hex",
+ "nostr",
+ "radroots-identity",
+ "radroots-nostr-connect",
+ "radroots-runtime",
+ "serde",
+ "sha2",
+ "thiserror 1.0.69",
+ "url",
+ "uuid",
+]
+
+[[package]]
name = "radroots-runtime"
version = "0.1.0-alpha.1"
dependencies = [
@@ -3045,6 +3155,30 @@ dependencies = [
]
[[package]]
+name = "rsqlite-vfs"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
+dependencies = [
+ "hashbrown 0.16.1",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
+dependencies = [
+ "bitflags 2.11.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "libsqlite3-sys",
+ "smallvec",
+ "sqlite-wasm-rs",
+]
+
+[[package]]
name = "rust-ini"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3458,6 +3592,18 @@ dependencies = [
]
[[package]]
+name = "sqlite-wasm-rs"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b"
+dependencies = [
+ "cc",
+ "js-sys",
+ "rsqlite-vfs",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3891,6 +4037,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3920,6 +4072,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
+name = "uuid"
+version = "1.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3963,6 +4126,15 @@ dependencies = [
]
[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4022,6 +4194,40 @@ dependencies = [
]
[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.11.0",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
name = "wayland-backend"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4733,6 +4939,88 @@ name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.11.0",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
[[package]]
name = "writeable"
diff --git a/Cargo.toml b/Cargo.toml
@@ -32,6 +32,7 @@ nostr = { version = "0.44.1", default-features = false, features = ["std"] }
nostr-browser-signer = "0.44.1"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std"] }
radroots-app-apple-security = { path = "crates/apple/security" }
+radroots-geocoder = { path = "../lib/crates/geocoder" }
radroots-identity = { path = "../lib/crates/identity", default-features = false, features = ["std"] }
radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "file-store", "os-keyring"] }
wasm-bindgen-futures = "0.4.50"
diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml
@@ -20,6 +20,7 @@ eframe = { workspace = true, features = ["wgpu", "wayland", "x11"] }
egui.workspace = true
image.workspace = true
radroots-app-core = { path = "../core" }
+radroots-geocoder.workspace = true
radroots-nostr-accounts.workspace = true
zeroize.workspace = true
diff --git a/crates/desktop/build.rs b/crates/desktop/build.rs
@@ -4,6 +4,7 @@ use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
+ sync_optional_geocoder_asset();
if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("macos") {
return;
@@ -46,6 +47,44 @@ fn main() {
);
}
+fn sync_optional_geocoder_asset() {
+ let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
+ let source_path = manifest_dir.join("../../assets/geocoder/geonames.db");
+ println!("cargo:rerun-if-changed={}", source_path.display());
+
+ let profile_dir = target_profile_dir();
+ let target_path = profile_dir.join("geonames.db");
+
+ if source_path.is_file() {
+ std::fs::copy(&source_path, &target_path).unwrap_or_else(|err| {
+ panic!(
+ "failed to copy optional desktop geocoder asset from {} to {}: {err}",
+ source_path.display(),
+ target_path.display()
+ )
+ });
+ return;
+ }
+
+ if target_path.exists() {
+ std::fs::remove_file(&target_path).unwrap_or_else(|err| {
+ panic!(
+ "failed to remove stale desktop geocoder asset at {}: {err}",
+ target_path.display()
+ )
+ });
+ }
+}
+
+fn target_profile_dir() -> PathBuf {
+ let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
+ out_dir
+ .ancestors()
+ .nth(3)
+ .unwrap_or_else(|| panic!("unexpected cargo OUT_DIR layout: {}", out_dir.display()))
+ .to_path_buf()
+}
+
fn emit_rerun_paths(package_dir: &Path) {
println!(
"cargo:rerun-if-changed={}",
diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs
@@ -10,7 +10,8 @@ use radroots_app_apple_security::{
};
use radroots_app_core::{
APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState,
- ImportActionState, RadrootsApp, RadrootsAppBackend, SetupActionState,
+ ImportActionState, RadrootsApp, RadrootsAppBackend, RadrootsOfflineGeocoderState,
+ SetupActionState,
};
#[cfg(target_os = "macos")]
use radroots_identity::RadrootsIdentity;
@@ -24,6 +25,10 @@ use std::sync::Arc;
#[cfg(target_os = "macos")]
use zeroize::Zeroizing;
+mod offline_geocoder;
+
+use offline_geocoder::DesktopOfflineGeocoder;
+
const RADROOTS_DESKTOP_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/radroots-logo.ico");
#[cfg(target_os = "macos")]
@@ -50,9 +55,35 @@ fn desktop_icon() -> Option<egui::IconData> {
})
}
-struct DesktopBackend;
+struct DesktopBackend {
+ offline_geocoder: DesktopOfflineGeocoder,
+}
impl DesktopBackend {
+ fn new() -> Self {
+ #[cfg(target_os = "macos")]
+ let offline_geocoder = match Self::app_data_root() {
+ Ok(app_data_root) => DesktopOfflineGeocoder::start(app_data_root),
+ Err(debug_message) => {
+ DesktopOfflineGeocoder::from_state(RadrootsOfflineGeocoderState::Unavailable {
+ user_message: "Offline geocoder could not be initialized on this device."
+ .to_owned(),
+ debug_message,
+ })
+ }
+ };
+
+ #[cfg(not(target_os = "macos"))]
+ let offline_geocoder =
+ DesktopOfflineGeocoder::from_state(RadrootsOfflineGeocoderState::Unavailable {
+ user_message: "Offline geocoder is not available in this desktop build.".to_owned(),
+ debug_message: "desktop offline geocoder initialization is only wired for macos"
+ .to_owned(),
+ });
+
+ Self { offline_geocoder }
+ }
+
#[cfg(target_os = "macos")]
fn radroots_root() -> Result<PathBuf, String> {
let base_dirs =
@@ -252,6 +283,14 @@ impl RadrootsAppBackend for DesktopBackend {
}
}
+ fn offline_geocoder_state(&self) -> Option<RadrootsOfflineGeocoderState> {
+ Some(self.offline_geocoder.current_state())
+ }
+
+ fn poll_offline_geocoder_state(&self) -> Result<Option<RadrootsOfflineGeocoderState>, String> {
+ Ok(self.offline_geocoder.take_update())
+ }
+
fn setup_action_state(&self) -> SetupActionState {
#[cfg(target_os = "macos")]
{
@@ -400,7 +439,7 @@ fn main() -> eframe::Result<()> {
eframe::run_native(
APP_NAME,
options,
- Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(DesktopBackend))))),
+ Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(DesktopBackend::new()))))),
)
}
diff --git a/crates/desktop/src/offline_geocoder.rs b/crates/desktop/src/offline_geocoder.rs
@@ -0,0 +1,147 @@
+use radroots_app_core::RadrootsOfflineGeocoderState;
+use radroots_geocoder::Geocoder;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+
+const GEOCODER_ASSET_FILENAME: &str = "geonames.db";
+
+#[derive(Clone)]
+pub(crate) struct DesktopOfflineGeocoder {
+ current: Arc<Mutex<RadrootsOfflineGeocoderState>>,
+ changed: Arc<AtomicBool>,
+}
+
+impl DesktopOfflineGeocoder {
+ pub(crate) fn from_state(state: RadrootsOfflineGeocoderState) -> Self {
+ Self {
+ current: Arc::new(Mutex::new(state)),
+ changed: Arc::new(AtomicBool::new(false)),
+ }
+ }
+
+ pub(crate) fn start(app_data_root: PathBuf) -> Self {
+ let tracker = Self::from_state(RadrootsOfflineGeocoderState::Initializing);
+
+ let current = Arc::clone(&tracker.current);
+ let changed = Arc::clone(&tracker.changed);
+ std::thread::spawn(move || {
+ let state = initialize_offline_geocoder(app_data_root.as_path());
+ if let Ok(mut slot) = current.lock() {
+ *slot = state;
+ changed.store(true, Ordering::Release);
+ }
+ });
+
+ tracker
+ }
+
+ pub(crate) fn current_state(&self) -> RadrootsOfflineGeocoderState {
+ self.current
+ .lock()
+ .map(|state| state.clone())
+ .unwrap_or_else(|_| RadrootsOfflineGeocoderState::Unavailable {
+ user_message: "Offline geocoder is unavailable on this device.".to_owned(),
+ debug_message: "desktop offline geocoder state lock poisoned".to_owned(),
+ })
+ }
+
+ pub(crate) fn take_update(&self) -> Option<RadrootsOfflineGeocoderState> {
+ if self.changed.swap(false, Ordering::AcqRel) {
+ Some(self.current_state())
+ } else {
+ None
+ }
+ }
+}
+
+fn initialize_offline_geocoder(app_data_root: &Path) -> RadrootsOfflineGeocoderState {
+ match initialize_offline_geocoder_inner(app_data_root) {
+ Ok(()) => RadrootsOfflineGeocoderState::Ready,
+ Err(debug_message) => classify_initialize_error(debug_message),
+ }
+}
+
+fn initialize_offline_geocoder_inner(app_data_root: &Path) -> Result<(), String> {
+ let source_path = runtime_asset_path()?;
+ if !source_path.is_file() {
+ return Err(format!(
+ "desktop bundled geocoder asset missing at {}",
+ source_path.display()
+ ));
+ }
+
+ let staged_path = staged_db_path(app_data_root);
+ stage_runtime_asset(source_path.as_path(), staged_path.as_path())?;
+ Geocoder::open_path(staged_path.as_path())
+ .map(|_| ())
+ .map_err(|source| format!("failed to open staged geocoder db: {source}"))
+}
+
+fn runtime_asset_path() -> Result<PathBuf, String> {
+ let executable_path = std::env::current_exe()
+ .map_err(|source| format!("failed to resolve desktop executable path: {source}"))?;
+ let Some(parent) = executable_path.parent() else {
+ return Err("desktop executable path did not have a parent directory".to_owned());
+ };
+ Ok(parent.join(GEOCODER_ASSET_FILENAME))
+}
+
+fn staged_db_path(app_data_root: &Path) -> PathBuf {
+ app_data_root.join("geocoder").join(GEOCODER_ASSET_FILENAME)
+}
+
+fn stage_runtime_asset(source_path: &Path, staged_path: &Path) -> Result<(), String> {
+ let Some(parent) = staged_path.parent() else {
+ return Err("staged desktop geocoder path did not have a parent directory".to_owned());
+ };
+ std::fs::create_dir_all(parent)
+ .map_err(|source| format!("failed to create desktop geocoder directory: {source}"))?;
+ std::fs::copy(source_path, staged_path)
+ .map_err(|source| format!("failed to stage desktop geocoder asset: {source}"))?;
+ Ok(())
+}
+
+fn classify_initialize_error(debug_message: String) -> RadrootsOfflineGeocoderState {
+ let user_message = if debug_message.contains("asset missing") {
+ "Offline geocoder is not available in this build.".to_owned()
+ } else {
+ "Offline geocoder could not be initialized on this device.".to_owned()
+ };
+
+ RadrootsOfflineGeocoderState::Unavailable {
+ user_message,
+ debug_message,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn staged_db_path_uses_app_geocoder_directory() {
+ let app_data_root = PathBuf::from("/Users/example/.radroots/app/desktop");
+
+ assert_eq!(
+ staged_db_path(app_data_root.as_path()),
+ PathBuf::from("/Users/example/.radroots/app/desktop/geocoder/geonames.db")
+ );
+ }
+
+ #[test]
+ fn missing_asset_maps_to_build_unavailable_message() {
+ let state = classify_initialize_error(
+ "desktop bundled geocoder asset missing at /tmp/geonames.db".to_owned(),
+ );
+
+ assert_eq!(
+ state,
+ RadrootsOfflineGeocoderState::Unavailable {
+ user_message: "Offline geocoder is not available in this build.".to_owned(),
+ debug_message: "desktop bundled geocoder asset missing at /tmp/geonames.db"
+ .to_owned(),
+ }
+ );
+ }
+}