app

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

commit daee3e6648b63b1e3180c2ed7150515b5c063884
parent cee710880cf5a40687412feba0c2ccb8d6578f5d
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 20:11:42 +0000

ios: add secret-key import for local identities

- add the setup-side secret-key import action to the ios backend
- import a provided nsec through the existing accounts manager and select the local identity
- add ios-native clipboard paste support for the import field through the host bridge
- cover the import and clipboard normalization paths with ios and core tests

Diffstat:
Mcrates/core/src/lib.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ios/src/lib.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mplatforms/ios/App/Bridge/RadRootsIOSBridge.h | 2++
Mplatforms/ios/App/main.swift | 20++++++++++++++++++++
4 files changed, 253 insertions(+), 2 deletions(-)

diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -20,6 +20,13 @@ pub struct ImportActionState { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct PasteActionState { + pub label: String, + pub enabled: bool, + pub pending: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct HomeActionState { pub kind: HomeActionKind, pub label: String, @@ -62,6 +69,12 @@ pub trait RadrootsAppBackend { ) -> Result<Option<IdentityGateState>, String> { Ok(None) } + fn import_paste_action_state(&self) -> Option<PasteActionState> { + None + } + fn request_import_paste_action(&self) -> Result<Option<String>, String> { + Ok(None) + } fn home_action_states(&self) -> Vec<HomeActionState> { Vec::new() } @@ -169,6 +182,20 @@ impl RadrootsApp { } } + fn request_import_paste_action(&mut self) { + self.status_message = None; + self.revealed_secret_key = None; + match self.backend.request_import_paste_action() { + Ok(Some(secret_key)) => { + self.secret_key_input = Zeroizing::new(secret_key); + } + Ok(None) => {} + Err(err) => { + self.status_message = Some(err); + } + } + } + fn request_home_action(&mut self, action: HomeActionKind) { self.status_message = None; self.revealed_secret_key = None; @@ -252,6 +279,12 @@ impl eframe::App for RadrootsApp { ctx.request_repaint(); } } + let import_paste_action = self.backend.import_paste_action_state(); + if let Some(import_paste_action) = &import_paste_action { + if import_paste_action.pending { + ctx.request_repaint(); + } + } ui.label("setup"); ui.add_space(8.0); @@ -279,6 +312,20 @@ impl eframe::App for RadrootsApp { .desired_width(ui.available_width()), ); ui.add_space(8.0); + if let Some(import_paste_action) = &import_paste_action { + let paste_clicked = ui + .add_enabled( + import_paste_action.enabled, + egui::Button::new( + import_paste_action.label.clone(), + ), + ) + .clicked(); + if paste_clicked { + self.request_import_paste_action(); + } + ui.add_space(8.0); + } ui.horizontal_centered(|ui| { let confirm_clicked = ui .add_enabled( @@ -393,9 +440,11 @@ mod tests { load: Result<IdentityGateState, String>, action_state: Rc<RefCell<SetupActionState>>, import_action_state: Rc<RefCell<Option<ImportActionState>>>, + import_paste_action_state: Rc<RefCell<Option<PasteActionState>>>, home_action_states: Rc<RefCell<Vec<HomeActionState>>>, request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, import_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, + import_paste_request: Rc<RefCell<VecDeque<Result<Option<String>, String>>>>, home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<HomeActionResult, String>)>>>, home_poll: Rc<RefCell<VecDeque<Result<Option<HomeActionResult>, String>>>>, poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, @@ -412,9 +461,11 @@ mod tests { load, action_state: Rc::new(RefCell::new(action_state)), import_action_state: Rc::new(RefCell::new(None)), + import_paste_action_state: Rc::new(RefCell::new(None)), home_action_states: Rc::new(RefCell::new(Vec::new())), request: Rc::new(RefCell::new(request.into())), import_request: Rc::new(RefCell::new(VecDeque::new())), + import_paste_request: Rc::new(RefCell::new(VecDeque::new())), home_request: Rc::new(RefCell::new(VecDeque::new())), home_poll: Rc::new(RefCell::new(VecDeque::new())), poll: Rc::new(RefCell::new(poll.into())), @@ -431,6 +482,16 @@ mod tests { self } + fn with_import_paste_action( + self, + action_state: PasteActionState, + request: Vec<Result<Option<String>, String>>, + ) -> Self { + *self.import_paste_action_state.borrow_mut() = Some(action_state); + self.import_paste_request.borrow_mut().extend(request); + self + } + fn with_home_action( self, action_state: HomeActionState, @@ -486,6 +547,17 @@ mod tests { .unwrap_or(Ok(None)) } + fn import_paste_action_state(&self) -> Option<PasteActionState> { + self.import_paste_action_state.borrow().clone() + } + + fn request_import_paste_action(&self) -> Result<Option<String>, String> { + self.import_paste_request + .borrow_mut() + .pop_front() + .unwrap_or(Ok(None)) + } + fn home_action_states(&self) -> Vec<HomeActionState> { self.home_action_states.borrow().clone() } @@ -821,6 +893,44 @@ mod tests { } #[test] + fn import_paste_action_populates_secret_key_input() { + let mut app = RadrootsApp::new(Box::new( + MockBackend::new( + Ok(IdentityGateState::Missing), + vec![], + vec![], + SetupActionState { + label: "Generate New Key".into(), + enabled: true, + pending: false, + }, + ) + .with_import_action( + ImportActionState { + label: "Import Secret Key".into(), + enabled: true, + pending: false, + }, + vec![], + ) + .with_import_paste_action( + PasteActionState { + label: "Paste Secret Key".into(), + enabled: true, + pending: false, + }, + vec![Ok(Some("nsec1example".into()))], + ), + )); + + app.pending_import_entry = true; + app.request_import_paste_action(); + + assert_eq!(app.secret_key_input.as_str(), "nsec1example"); + assert_eq!(app.status_message, None); + } + + #[test] fn backup_home_action_reveals_secret_key_without_leaving_home() { let mut app = RadrootsApp::new(Box::new( MockBackend::new( diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -8,8 +8,8 @@ use radroots_app_apple_security::verify_user_presence; use radroots_app_core::IdentityGateState; #[cfg(target_os = "ios")] use radroots_app_core::{ - APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, RadrootsApp, RadrootsAppBackend, - SetupActionState, + APP_NAME, HomeActionKind, HomeActionResult, HomeActionState, ImportActionState, + PasteActionState, RadrootsApp, RadrootsAppBackend, SetupActionState, }; #[cfg(any(target_os = "ios", test))] use radroots_identity::RadrootsIdentity; @@ -28,6 +28,12 @@ mod storage; #[cfg(any(target_os = "ios", test))] struct IosBackend; +#[cfg(target_os = "ios")] +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); +} + #[cfg(any(target_os = "ios", test))] impl IosBackend { #[cfg(target_os = "ios")] @@ -105,6 +111,50 @@ impl IosBackend { Ok(identity.nsec()) } + fn import_local_identity( + manager: &RadrootsNostrAccountsManager, + secret_key: &str, + ) -> Result<IdentityGateState, String> { + let identity = RadrootsIdentity::from_secret_key_str(secret_key) + .map_err(|_| "invalid secret key".to_owned())?; + + manager + .upsert_identity(&identity, None, true) + .map_err(|source| source.to_string())?; + + Self::identity_state_from_manager(manager) + } + + fn normalize_clipboard_secret_key_text(clipboard_text: &str) -> Result<String, String> { + let trimmed = clipboard_text.trim(); + if trimmed.is_empty() { + return Err("clipboard does not contain text".to_owned()); + } + + Ok(match trimmed.len() == clipboard_text.len() { + true => clipboard_text.to_owned(), + false => trimmed.to_owned(), + }) + } + + #[cfg(target_os = "ios")] + 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() { + return Err("clipboard does not contain text".to_owned()); + } + + let clipboard_text = unsafe { + let value = std::ffi::CStr::from_ptr(clipboard_text_ptr) + .to_string_lossy() + .into_owned(); + radroots_ios_string_free(clipboard_text_ptr); + value + }; + + Self::normalize_clipboard_secret_key_text(&clipboard_text) + } + #[cfg(target_os = "ios")] fn authorize_secret_key_export() -> Result<(), String> { verify_user_presence("reveal the current secret key").map_err(|source| source.to_string()) @@ -173,6 +223,31 @@ impl RadrootsAppBackend for IosBackend { Self::generate_local_identity(&manager).map(Some) } + fn import_action_state(&self) -> Option<ImportActionState> { + Some(ImportActionState { + label: "Import Secret Key".to_owned(), + enabled: true, + pending: false, + }) + } + + fn request_import_action(&self, secret_key: &str) -> Result<Option<IdentityGateState>, String> { + let manager = Self::accounts_manager()?; + Self::import_local_identity(&manager, secret_key).map(Some) + } + + fn import_paste_action_state(&self) -> Option<PasteActionState> { + Some(PasteActionState { + label: "Paste Secret Key".to_owned(), + enabled: true, + pending: false, + }) + } + + fn request_import_paste_action(&self) -> Result<Option<String>, String> { + Self::paste_secret_key_from_clipboard().map(Some) + } + fn home_action_states(&self) -> Vec<HomeActionState> { vec![ HomeActionState { @@ -341,6 +416,50 @@ mod tests { } #[test] + fn import_local_identity_imports_nsec_and_selects_account() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let identity = RadrootsIdentity::generate(); + + let state = + IosBackend::import_local_identity(&manager, identity.nsec().as_str()).expect("import"); + + assert_eq!( + state, + IdentityGateState::Ready { + account_id: identity.id().to_string(), + npub: identity.npub(), + } + ); + assert_eq!( + manager.selected_account_id().expect("selected"), + Some(identity.id()) + ); + assert_eq!(manager.list_accounts().expect("list").len(), 1); + assert_eq!( + manager + .export_secret_hex(&identity.id()) + .expect("export secret"), + Some(identity.secret_key_hex()) + ); + } + + #[test] + fn normalize_clipboard_secret_key_text_trims_wrapping_whitespace() { + let normalized = IosBackend::normalize_clipboard_secret_key_text(" nsec1example \n") + .expect("normalize secret key"); + + assert_eq!(normalized, "nsec1example"); + } + + #[test] + fn normalize_clipboard_secret_key_text_rejects_blank_text() { + assert_eq!( + IosBackend::normalize_clipboard_secret_key_text(" \n\t"), + Err("clipboard does not contain text".to_owned()) + ); + } + + #[test] fn remove_accounts_file_if_present_deletes_existing_file() { let unique = format!( "radroots-ios-reset-{}-{}.json", diff --git a/platforms/ios/App/Bridge/RadRootsIOSBridge.h b/platforms/ios/App/Bridge/RadRootsIOSBridge.h @@ -1,3 +1,5 @@ #include <stdint.h> int32_t radroots_ios_run(void); +char *radroots_ios_clipboard_text_copy(void); +void radroots_ios_string_free(char *value); diff --git a/platforms/ios/App/main.swift b/platforms/ios/App/main.swift @@ -1,3 +1,23 @@ import Foundation +import UIKit + +@_cdecl("radroots_ios_clipboard_text_copy") +func radroots_ios_clipboard_text_copy() -> UnsafeMutablePointer<CChar>? { + guard let clipboardText = UIPasteboard.general.string? + .trimmingCharacters(in: .whitespacesAndNewlines), + !clipboardText.isEmpty + else { + return nil + } + + return clipboardText.withCString { value in + strdup(value) + } +} + +@_cdecl("radroots_ios_string_free") +func radroots_ios_string_free(_ value: UnsafeMutablePointer<CChar>?) { + free(value) +} _ = radroots_ios_run()