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:
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()