app

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

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:
MCargo.lock | 1+
Mcrates/ios/Cargo.toml | 1+
Mcrates/ios/src/lib.rs | 36+++++++++++++++++++++++++++++++++---
Acrates/ios/src/offline_geocoder.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ios/src/storage.rs | 30++++++++++++++++++++++++++++--
Aplatforms/ios/Scripts/sync_geocoder_resource.sh | 23+++++++++++++++++++++++
Mplatforms/ios/project.yml | 5+++++
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