From fb6d05f93ccd5fd6b43f220221ee206031eb67bd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 11:16:51 +0000 Subject: [PATCH] Persist active account so it survives app restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active account was only ever held in memory (AppState.current_private_key), so on restart the backend started with no active account. keychain_get_active_account returned None, and the frontend fell back to accounts[0] — an arbitrary entry from the keychain vault's HashMap, which has no stable order. The result: after a restart the app could load a different user than the one that was active before. Persist the active pubkey in the keychain vault (new Vault.active field, with serde(default) for backward compatibility) whenever an account is made active, and restore it into memory on cold start. keychain_get_active_account now returns the previously-active account after a restart instead of None. - keychain.rs: add Vault.active, set_active/get_active, and clear the pointer when the active key is deleted. Add serde round-trip / legacy-compat unit tests. - lib.rs: extract activate_account_in_memory helper; set_active_account persists the choice; get_active_account restores the persisted account on cold start. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016iwHXKaFc8y3GhMDLDJueS --- tauri-app/backend/src/keychain.rs | 75 +++++++++++++++++++++++++++++++ tauri-app/backend/src/lib.rs | 45 +++++++++++++++---- 2 files changed, 112 insertions(+), 8 deletions(-) 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]