app

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

commit afac741ccc895dfc6372fa004732ecc1c48c4892
parent 6a18f9c2ffe5dab3dfce87a295b41c76ff01091b
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 00:59:38 +0000

ios: use stamped offline geocoder revisions

- bundle the stamped geocoder revision sidecar alongside the optional ios geocoder db asset
- load the ios offline geocoder revision from the bundled sidecar instead of file metadata heuristics
- treat an invalid or missing bundled revision sidecar as a build-asset unavailability state at runtime
- extend ios tests to cover the stamped revision parser in addition to the existing staging and prune flows

Diffstat:
Mcrates/ios/src/offline_geocoder.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mplatforms/ios/Scripts/sync_geocoder_resource.sh | 11+++++++++++
2 files changed, 71 insertions(+), 20 deletions(-)

diff --git a/crates/ios/src/offline_geocoder.rs b/crates/ios/src/offline_geocoder.rs @@ -5,9 +5,9 @@ use radroots_geocoder::Geocoder; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::UNIX_EPOCH; const GEOCODER_ASSET_FILENAME: &str = "geonames.db"; +const GEOCODER_REVISION_FILENAME: &str = "geonames.revision"; #[derive(Clone)] pub(crate) struct IosOfflineGeocoder { @@ -88,7 +88,7 @@ fn initialize_offline_geocoder_inner( )); } - let revision = asset_revision(source_path.as_path())?; + let revision = bundled_asset_revision(source_path.parent().unwrap_or_else(|| Path::new(".")))?; let staged_path = staged_db_path(app_data_root, revision.as_str()); stage_bundled_asset(source_path.as_path(), staged_path.as_path()).map_err(|debug_message| { ( @@ -115,28 +115,34 @@ fn bundled_asset_path() -> Result<PathBuf, String> { Ok(parent.join(GEOCODER_ASSET_FILENAME)) } -fn asset_revision( - source_path: &Path, +fn bundled_asset_revision( + asset_dir: &Path, ) -> Result<String, (RadrootsOfflineGeocoderUnavailableKind, String)> { - let metadata = std::fs::metadata(source_path).map_err(|source| { + let revision_path = asset_dir.join(GEOCODER_REVISION_FILENAME); + let revision = std::fs::read_to_string(revision_path.as_path()).map_err(|source| { ( - RadrootsOfflineGeocoderUnavailableKind::InitializationFailed, - format!("failed to read ios geocoder asset metadata: {source}"), - ) - })?; - let modified = metadata.modified().map_err(|source| { - ( - RadrootsOfflineGeocoderUnavailableKind::InitializationFailed, - format!("failed to read ios geocoder asset mtime: {source}"), - ) - })?; - let modified = modified.duration_since(UNIX_EPOCH).map_err(|source| { - ( - RadrootsOfflineGeocoderUnavailableKind::InitializationFailed, - format!("failed to normalize ios geocoder asset mtime: {source}"), + RadrootsOfflineGeocoderUnavailableKind::MissingBuildAsset, + format!( + "ios bundled geocoder revision asset missing at {}: {source}", + revision_path.display() + ), ) })?; - Ok(format!("{:x}-{:x}", metadata.len(), modified.as_secs())) + let revision = revision.trim(); + if !is_valid_revision(revision) { + return Err(( + RadrootsOfflineGeocoderUnavailableKind::MissingBuildAsset, + format!( + "ios bundled geocoder revision asset invalid at {}", + revision_path.display() + ), + )); + } + Ok(revision.to_owned()) +} + +fn is_valid_revision(revision: &str) -> bool { + revision.len() == 64 && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) } fn staged_geocoder_root(app_data_root: &Path) -> PathBuf { @@ -222,6 +228,17 @@ mod tests { } #[test] + fn valid_revision_requires_sha256_hex() { + assert!(is_valid_revision( + "6ca5f1a324de02922d40b1ff33eedf3a5a133c978de921eee5130a0c7876079c" + )); + assert!(!is_valid_revision("abcd")); + assert!(!is_valid_revision( + "6ca5f1a324de02922d40b1ff33eedf3a5a133c978de921eee5130a0c7876079z" + )); + } + + #[test] fn missing_asset_maps_to_build_unavailable_message() { let state = RadrootsOfflineGeocoderState::unavailable( RadrootsOfflineGeocoderUnavailableKind::MissingBuildAsset, @@ -290,4 +307,27 @@ mod tests { std::fs::remove_dir_all(temp_root.as_path()).unwrap(); } + + #[test] + fn bundled_asset_revision_reads_stamped_sidecar() { + let temp_root = std::env::temp_dir().join(format!( + "radroots-ios-geocoder-revision-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let revision_path = temp_root.join(GEOCODER_REVISION_FILENAME); + let revision = "6ca5f1a324de02922d40b1ff33eedf3a5a133c978de921eee5130a0c7876079c"; + + std::fs::create_dir_all(temp_root.as_path()).unwrap(); + std::fs::write(revision_path.as_path(), format!("{revision}\n")).unwrap(); + + assert_eq!( + bundled_asset_revision(temp_root.as_path()).unwrap(), + revision.to_owned() + ); + + std::fs::remove_dir_all(temp_root.as_path()).unwrap(); + } } diff --git a/platforms/ios/Scripts/sync_geocoder_resource.sh b/platforms/ios/Scripts/sync_geocoder_resource.sh @@ -5,19 +5,30 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" app_root="$(cd "$script_dir/../../.." && pwd -P)" source_db="$app_root/assets/geocoder/geonames.db" +source_revision="$app_root/assets/geocoder/geonames.revision" target_dir="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" target_db="$target_dir/geonames.db" +target_revision="$target_dir/geonames.revision" mkdir -p "$target_dir" if [[ -f "$source_db" ]]; then + if [[ ! -f "$source_revision" ]]; then + printf 'stamped ios geocoder revision asset missing at build time: %s\n' "$source_revision" >&2 + exit 1 + fi cp "$source_db" "$target_db" + cp "$source_revision" "$target_revision" printf 'synced ios geocoder asset: %s\n' "$target_db" + printf 'synced ios geocoder revision: %s\n' "$target_revision" exit 0 fi if [[ -f "$target_db" ]]; then rm -f "$target_db" fi +if [[ -f "$target_revision" ]]; then + rm -f "$target_revision" +fi printf 'ios geocoder asset not present at build time: %s\n' "$source_db"