diff --git a/tauri-app/backend/src/keychain.rs b/tauri-app/backend/src/keychain.rs index 484fcd7..5aed5c2 100644 --- a/tauri-app/backend/src/keychain.rs +++ b/tauri-app/backend/src/keychain.rs @@ -12,6 +12,13 @@ const VAULT_ACCOUNT: &str = "vault"; struct Vault { /// pubkey -> private key keys: HashMap, + /// The public key of the account the user last made active. Persisted so + /// the app restores the same account on restart instead of picking an + /// arbitrary entry from `keys` (a HashMap has no stable order). Optional + /// and `#[serde(default)]` for backward compatibility with older vaults + /// written before this field existed. + #[serde(default)] + active: Option, } /// Manages private keys in the OS keychain with an in-memory cache. @@ -115,6 +122,11 @@ impl KeychainManager { pub fn delete_key(&self, public_key: &str) -> Result<(), String> { let mut vault = self.load_vault()?; vault.keys.remove(public_key); + // Drop the active pointer if it referenced the key we just removed, + // so a restart doesn't try to restore a deleted account. + if vault.active.as_deref() == Some(public_key) { + vault.active = None; + } self.save_vault(&vault) } @@ -123,6 +135,30 @@ impl KeychainManager { Ok(vault.keys.keys().cloned().collect()) } + /// Persist which account is active so it survives an app restart. Only + /// records the pointer if the key exists in the vault. + pub fn set_active(&self, public_key: &str) -> Result<(), String> { + let mut vault = self.load_vault()?; + if !vault.keys.contains_key(public_key) { + return Err(format!( + "Cannot set active account: no key in vault for {}", + &public_key[..20.min(public_key.len())] + )); + } + vault.active = Some(public_key.to_string()); + self.save_vault(&vault) + } + + /// The persisted active account, if any. Returns `None` when the pointer + /// is unset or refers to a key that no longer exists in the vault. + pub fn get_active(&self) -> Result, String> { + let vault = self.load_vault()?; + match vault.active { + Some(ref pk) if vault.keys.contains_key(pk) => Ok(Some(pk.clone())), + _ => Ok(None), + } + } + #[cfg(not(target_os = "android"))] pub fn clear_all(&self) -> Result<(), String> { let entry = Self::entry().map_err(|e| format!("Keychain entry error: {}", e))?; @@ -249,3 +285,42 @@ mod android { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + // Serde-only tests for the Vault format. These deliberately avoid the OS + // keychain (no D-Bus / Secret Service needed) so they run in headless CI. + + /// Vaults written before the `active` field existed (only `keys`) must + /// still deserialize, defaulting `active` to `None`. + #[test] + fn test_vault_deserializes_legacy_json_without_active() { + let legacy = r#"{"keys":{"npub_abc":"nsec_abc"}}"#; + let vault: Vault = serde_json::from_str(legacy).unwrap(); + assert_eq!(vault.keys.get("npub_abc").map(String::as_str), Some("nsec_abc")); + assert_eq!(vault.active, None); + } + + /// The active pointer round-trips through serialization. + #[test] + fn test_vault_roundtrips_active() { + let mut vault = Vault::default(); + vault.keys.insert("npub_abc".to_string(), "nsec_abc".to_string()); + vault.active = Some("npub_abc".to_string()); + + let json = serde_json::to_string(&vault).unwrap(); + let restored: Vault = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.active.as_deref(), Some("npub_abc")); + assert_eq!(restored.keys, vault.keys); + } + + /// A freshly created vault has no active account. + #[test] + fn test_vault_default_has_no_active() { + let vault = Vault::default(); + assert!(vault.active.is_none()); + assert!(vault.keys.is_empty()); + } +} diff --git a/tauri-app/backend/src/lib.rs b/tauri-app/backend/src/lib.rs index 494ef1c..f0cad3a 100644 --- a/tauri-app/backend/src/lib.rs +++ b/tauri-app/backend/src/lib.rs @@ -1210,6 +1210,20 @@ fn keychain_list_accounts(state: tauri::State) -> Result Result<(), String> { + let private_key = state.keychain.get_key(public_key)? + .ok_or_else(|| format!("No key found in keychain for {}", &public_key[..20.min(public_key.len())]))?; + let secret_key = nostr_sdk::prelude::SecretKey::from_bech32(&private_key).map_err(|e| e.to_string())?; + let keys = nostr_sdk::prelude::Keys::new(secret_key); + *state.current_keys.lock().unwrap() = Some(keys); + *state.current_private_key.lock().unwrap() = Some(private_key); + Ok(()) +} + #[tauri::command] fn keychain_set_active_account(public_key: String, state: tauri::State) -> Result<(), String> { println!("[RUST] keychain_set_active_account called for: {}...", &public_key[..20.min(public_key.len())]); @@ -1217,21 +1231,36 @@ fn keychain_set_active_account(public_key: String, state: tauri::State // watcher and pooled connections so nothing credential-bound to the old // user is reused. The frontend restarts IDLE for the new account. state.stop_idle_watcher(); - let private_key = state.keychain.get_key(&public_key)? - .ok_or_else(|| format!("No key found in keychain for {}", &public_key[..20.min(public_key.len())]))?; - let secret_key = nostr_sdk::prelude::SecretKey::from_bech32(&private_key).map_err(|e| e.to_string())?; - let keys = nostr_sdk::prelude::Keys::new(secret_key); - *state.current_keys.lock().unwrap() = Some(keys); - *state.current_private_key.lock().unwrap() = Some(private_key); + activate_account_in_memory(&public_key, &state)?; + // Persist the choice so the same account is restored on the next app + // launch. Non-fatal if it fails — the account is still active this session. + if let Err(e) = state.keychain.set_active(&public_key) { + println!("[RUST] Warning: failed to persist active account: {}", e); + } Ok(()) } #[tauri::command] fn keychain_get_active_account(state: tauri::State) -> Result, String> { - let pubkey = state.current_private_key.lock().unwrap() + // Fast path: an account is already loaded in memory this session. + let in_memory = state.current_private_key.lock().unwrap() .as_ref() .and_then(|pk| crypto::get_public_key_from_private(pk).ok()); - Ok(pubkey) + if in_memory.is_some() { + return Ok(in_memory); + } + + // Cold start (e.g. after an app restart): nothing is loaded yet. Restore + // the account the user last made active from the persisted vault pointer, + // loading its key into memory so the rest of the app sees it as active. + match state.keychain.get_active()? { + Some(pubkey) => { + activate_account_in_memory(&pubkey, &state)?; + println!("[RUST] Restored persisted active account: {}...", &pubkey[..20.min(pubkey.len())]); + Ok(Some(pubkey)) + } + None => Ok(None), + } } #[tauri::command]