app

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

commit ca30868e90ad52e27e3ada236d2eb4ec68b3807f
parent 2bf0680eb18353f242769b5906e90e3553f9c40b
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 00:05:01 +0000

ios: make offline geocoder staging idempotent

- version the staged ios geocoder path from the bundled asset metadata instead of rewriting one fixed file
- reuse the current staged geocoder database when it already exists for the active bundled asset revision
- narrow the ios crate unsafe allowance to the clipboard ffi and exported entrypoint sites that require it
- add an ios regression test that proves existing staged geocoder copies are not overwritten on startup

Diffstat:
Mcrates/ios/Cargo.toml | 3---
Mcrates/ios/src/lib.rs | 5+++--
Mcrates/ios/src/offline_geocoder.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
3 files changed, 67 insertions(+), 12 deletions(-)

diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml @@ -25,6 +25,3 @@ zeroize.workspace = true [target.'cfg(target_os = "ios")'.dependencies] wgpu = { workspace = true, features = ["metal", "wgsl"] } - -[lints.rust] -unsafe_code = { level = "allow", priority = 1 } diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(unsafe_code)] - #[cfg(target_os = "ios")] use eframe::egui::ViewportBuilder; #[cfg(target_os = "ios")] @@ -35,6 +33,7 @@ struct IosBackend { } #[cfg(target_os = "ios")] +#[allow(unsafe_code)] unsafe extern "C" { fn radroots_ios_clipboard_text_copy() -> *mut std::ffi::c_char; fn radroots_ios_string_free(value: *mut std::ffi::c_char); @@ -159,6 +158,7 @@ impl IosBackend { } #[cfg(target_os = "ios")] + #[allow(unsafe_code)] fn paste_secret_key_from_clipboard() -> Result<String, String> { let clipboard_text_ptr = unsafe { radroots_ios_clipboard_text_copy() }; if clipboard_text_ptr.is_null() { @@ -346,6 +346,7 @@ pub fn run() -> Result<(), String> { pub const ENTRYPOINT_SYMBOL: &str = "radroots_ios_run"; +#[allow(unsafe_code)] #[unsafe(no_mangle)] pub extern "C" fn radroots_ios_run() -> i32 { match run() { diff --git a/crates/ios/src/offline_geocoder.rs b/crates/ios/src/offline_geocoder.rs @@ -7,6 +7,7 @@ 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"; @@ -86,7 +87,7 @@ fn initialize_offline_geocoder_inner( )); } - let staged_path = staged_db_path(app_data_root); + let staged_path = staged_db_path(app_data_root, asset_revision(source_path.as_path())?); stage_bundled_asset(source_path.as_path(), staged_path.as_path()).map_err(|debug_message| { ( RadrootsOfflineGeocoderUnavailableKind::InitializationFailed, @@ -112,24 +113,55 @@ fn bundled_asset_path() -> Result<PathBuf, String> { 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 asset_revision( + source_path: &Path, +) -> Result<String, (RadrootsOfflineGeocoderUnavailableKind, String)> { + let metadata = std::fs::metadata(source_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}"), + ) + })?; + Ok(format!("{:x}-{:x}", metadata.len(), modified.as_secs())) } -fn stage_bundled_asset(source_path: &Path, staged_path: &Path) -> Result<(), String> { +fn staged_db_path(app_data_root: &Path, revision: String) -> PathBuf { + app_data_root + .join("geocoder") + .join(revision) + .join(GEOCODER_ASSET_FILENAME) +} + +fn stage_bundled_asset(source_path: &Path, staged_path: &Path) -> Result<bool, 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}"))?; + if staged_path.is_file() { + return Ok(false); + } std::fs::copy(source_path, staged_path) .map_err(|source| format!("failed to stage ios geocoder asset: {source}"))?; - Ok(()) + Ok(true) } #[cfg(test)] mod tests { use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn staged_db_path_uses_ios_geocoder_directory() { @@ -138,9 +170,9 @@ mod tests { ); assert_eq!( - staged_db_path(app_data_root.as_path()), + staged_db_path(app_data_root.as_path(), "abcd".to_owned()), PathBuf::from( - "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios/geocoder/geonames.db" + "/var/mobile/Containers/Data/Application/example/Library/Application Support/RadRoots/app/ios/geocoder/abcd/geonames.db" ) ); } @@ -161,4 +193,29 @@ mod tests { } ); } + + #[test] + fn stage_bundled_asset_reuses_existing_staged_copy() { + let temp_root = std::env::temp_dir().join(format!( + "radroots-ios-geocoder-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let source_path = temp_root.join("source.db"); + let staged_path = temp_root.join("staged").join("geonames.db"); + + std::fs::create_dir_all(temp_root.as_path()).unwrap(); + std::fs::write(source_path.as_path(), b"source").unwrap(); + std::fs::create_dir_all(staged_path.parent().unwrap()).unwrap(); + std::fs::write(staged_path.as_path(), b"existing").unwrap(); + + let copied = stage_bundled_asset(source_path.as_path(), staged_path.as_path()).unwrap(); + + assert!(!copied); + assert_eq!(std::fs::read(staged_path.as_path()).unwrap(), b"existing"); + + std::fs::remove_dir_all(temp_root.as_path()).unwrap(); + } }