commit 7e66e9a039bed7a760bed8a0a8d7728139bf41e2
parent 69534aab0780caa719e9a775dd4d08543d7b8b75
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 22:14:43 +0000
ios: add non-blocking offline geocoder init
- add the radroots geocoder dependency to the ios launcher slice and stage the offline geocoder in app-local storage
- package the optional copied geocoder db into the ios app bundle through a post-build sync script 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 ios tests for staged geocoder paths and unavailable-state messaging alongside the existing host build validation
Diffstat:
7 files changed, 243 insertions(+), 5 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2901,6 +2901,7 @@ dependencies = [
"eframe",
"radroots-app-apple-security",
"radroots-app-core",
+ "radroots-geocoder",
"radroots-identity",
"radroots-nostr-accounts",
"wgpu",
diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml
@@ -18,6 +18,7 @@ crate-type = ["staticlib", "rlib"]
eframe = { workspace = true, features = ["wgpu"] }
radroots-app-apple-security.workspace = true
radroots-app-core = { path = "../core" }
+radroots-geocoder.workspace = true
radroots-identity.workspace = true
radroots-nostr-accounts.workspace = true
zeroize.workspace = true
diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs
@@ -9,7 +9,8 @@ use radroots_app_core::IdentityGateState;
#[cfg(target_os = "ios")]
use radroots_app_core::{
APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, ImportActionState,
- PasteActionState, RadrootsApp, RadrootsAppBackend, SetupActionState,
+ PasteActionState, RadrootsApp, RadrootsAppBackend, RadrootsOfflineGeocoderState,
+ SetupActionState,
};
#[cfg(any(target_os = "ios", test))]
use radroots_identity::RadrootsIdentity;
@@ -23,10 +24,15 @@ use std::path::Path;
use zeroize::Zeroizing;
#[cfg(any(target_os = "ios", test))]
+mod offline_geocoder;
+#[cfg(any(target_os = "ios", test))]
mod storage;
#[cfg(any(target_os = "ios", test))]
-struct IosBackend;
+#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
+struct IosBackend {
+ offline_geocoder: offline_geocoder::IosOfflineGeocoder,
+}
#[cfg(target_os = "ios")]
unsafe extern "C" {
@@ -37,6 +43,22 @@ unsafe extern "C" {
#[cfg(any(target_os = "ios", test))]
impl IosBackend {
#[cfg(target_os = "ios")]
+ fn new() -> Self {
+ let offline_geocoder = match storage::app_data_root() {
+ Ok(app_data_root) => offline_geocoder::IosOfflineGeocoder::start(app_data_root),
+ Err(debug_message) => offline_geocoder::IosOfflineGeocoder::from_state(
+ RadrootsOfflineGeocoderState::Unavailable {
+ user_message: "Offline geocoder could not be initialized on this device."
+ .to_owned(),
+ debug_message,
+ },
+ ),
+ };
+
+ Self { offline_geocoder }
+ }
+
+ #[cfg(target_os = "ios")]
fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> {
storage::accounts_manager()
}
@@ -210,6 +232,14 @@ impl RadrootsAppBackend for IosBackend {
Self::identity_state_from_manager(&manager)
}
+ 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 {
SetupActionState {
label: "Generate New Key".to_owned(),
@@ -305,7 +335,7 @@ pub fn run() -> Result<(), String> {
eframe::run_native(
APP_NAME,
native_options(),
- Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(IosBackend))))),
+ Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(IosBackend::new()))))),
)
.map_err(|err| err.to_string())
}
diff --git a/crates/ios/src/offline_geocoder.rs b/crates/ios/src/offline_geocoder.rs
@@ -0,0 +1,152 @@
+#![cfg_attr(not(target_os = "ios"), allow(dead_code))]
+
+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 IosOfflineGeocoder {
+ current: Arc<Mutex<RadrootsOfflineGeocoderState>>,
+ changed: Arc<AtomicBool>,
+}
+
+impl IosOfflineGeocoder {
+ 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: "ios 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 = bundled_asset_path()?;
+ if !source_path.is_file() {
+ return Err(format!(
+ "ios bundled geocoder asset missing at {}",
+ source_path.display()
+ ));
+ }
+
+ let staged_path = staged_db_path(app_data_root);
+ stage_bundled_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 ios geocoder db: {source}"))
+}
+
+fn bundled_asset_path() -> Result<PathBuf, String> {
+ let executable_path = std::env::current_exe()
+ .map_err(|source| format!("failed to resolve ios executable path: {source}"))?;
+ let Some(parent) = executable_path.parent() else {
+ return Err("ios 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_bundled_asset(source_path: &Path, staged_path: &Path) -> Result<(), String> {
+ let Some(parent) = staged_path.parent() else {
+ return Err("staged ios geocoder path did not have a parent directory".to_owned());
+ };
+ std::fs::create_dir_all(parent)
+ .map_err(|source| format!("failed to create ios geocoder directory: {source}"))?;
+ std::fs::copy(source_path, staged_path)
+ .map_err(|source| format!("failed to stage ios 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_ios_geocoder_directory() {
+ let app_data_root = PathBuf::from(
+ "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios",
+ );
+
+ assert_eq!(
+ staged_db_path(app_data_root.as_path()),
+ PathBuf::from(
+ "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios/geocoder/geonames.db"
+ )
+ );
+ }
+
+ #[test]
+ fn missing_asset_maps_to_build_unavailable_message() {
+ let state = classify_initialize_error(
+ "ios 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: "ios bundled geocoder asset missing at /tmp/geonames.db".to_owned(),
+ }
+ );
+ }
+}
diff --git a/crates/ios/src/storage.rs b/crates/ios/src/storage.rs
@@ -22,6 +22,16 @@ pub(crate) fn accounts_path() -> Result<PathBuf, String> {
}
#[cfg(target_os = "ios")]
+pub(crate) fn app_data_root() -> Result<PathBuf, String> {
+ let home = std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .ok_or_else(|| "failed to resolve ios app container home directory".to_owned())?;
+ let root = app_data_root_from_home(home.as_path());
+ ensure_private_directory_tree(root.as_path())?;
+ Ok(root)
+}
+
+#[cfg(target_os = "ios")]
pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> {
let store = Arc::new(RadrootsNostrFileAccountStore::new(accounts_path()?));
let vault = Arc::new(RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE));
@@ -29,13 +39,17 @@ pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String>
}
fn accounts_path_from_home(home: &Path) -> PathBuf {
+ app_data_root_from_home(home)
+ .join("nostr")
+ .join("accounts.json")
+}
+
+fn app_data_root_from_home(home: &Path) -> PathBuf {
home.join("Library")
.join("Application Support")
.join("RadRoots")
.join("app")
.join("ios")
- .join("nostr")
- .join("accounts.json")
}
#[cfg(target_os = "ios")]
@@ -64,4 +78,16 @@ mod tests {
)
);
}
+
+ #[test]
+ fn app_data_root_uses_ios_application_support_layout() {
+ let home = PathBuf::from("/var/mobile/Containers/Data/Application/example");
+
+ assert_eq!(
+ app_data_root_from_home(home.as_path()),
+ PathBuf::from(
+ "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios"
+ )
+ );
+ }
}
diff --git a/platforms/ios/Scripts/sync_geocoder_resource.sh b/platforms/ios/Scripts/sync_geocoder_resource.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+app_root="$(cd "$script_dir/../../.." && pwd -P)"
+
+source_db="$app_root/assets/geocoder/geonames.db"
+target_dir="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
+target_db="$target_dir/geonames.db"
+
+mkdir -p "$target_dir"
+
+if [[ -f "$source_db" ]]; then
+ cp "$source_db" "$target_db"
+ printf 'synced ios geocoder asset: %s\n' "$target_db"
+ exit 0
+fi
+
+if [[ -f "$target_db" ]]; then
+ rm -f "$target_db"
+fi
+
+printf 'ios geocoder asset not present at build time: %s\n' "$source_db"
diff --git a/platforms/ios/project.yml b/platforms/ios/project.yml
@@ -61,6 +61,11 @@ targetTemplates:
outputFiles:
- $(SRCROOT)/../../target/$(RUST_TARGET_TRIPLE)/$(RUST_CARGO_PROFILE)/libradroots_app_ios.a
- $(SRCROOT)/../../target/x86_64-apple-ios/$(RUST_CARGO_PROFILE)/libradroots_app_ios.a
+ postBuildScripts:
+ - name: Sync iOS Geocoder Asset
+ script: |
+ "$SRCROOT/Scripts/sync_geocoder_resource.sh"
+ basedOnDependencyAnalysis: false
dependencies:
- sdk: UIKit.framework
- sdk: Foundation.framework