use std::{collections::HashMap, io::Write, sync::Mutex}; use nostr::{Keys, ToBech32}; use tauri::{AppHandle, Manager}; use crate::managed_agents::ManagedAgentProcess; pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, pub configured_api_token: Option, pub session_token: Mutex>, pub managed_agents_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, } pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. let (keys, source) = match std::env::var("SPROUT_PRIVATE_KEY") { Ok(nsec) => match Keys::parse(nsec.trim()) { Ok(keys) => (keys, "configured"), Err(error) => { eprintln!("sprout-desktop: invalid SPROUT_PRIVATE_KEY: {error}"); (Keys::generate(), "ephemeral") } }, Err(std::env::VarError::NotUnicode(_)) => { eprintln!("sprout-desktop: SPROUT_PRIVATE_KEY contains invalid UTF-8"); (Keys::generate(), "ephemeral") } Err(std::env::VarError::NotPresent) => (Keys::generate(), "ephemeral"), }; if source == "configured" { eprintln!( "sprout-desktop: configured identity pubkey {}", keys.public_key().to_hex() ); } let api_token = match std::env::var("SPROUT_API_TOKEN") { Ok(token) if !token.trim().is_empty() => Some(token), Ok(_) | Err(std::env::VarError::NotPresent) => None, Err(std::env::VarError::NotUnicode(_)) => { eprintln!("sprout-desktop: SPROUT_API_TOKEN contains invalid UTF-8"); None } }; AppState { keys: Mutex::new(keys), http_client: reqwest::Client::new(), configured_api_token: api_token, session_token: Mutex::new(None), managed_agents_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), } } /// Resolve the user's identity key from the app data directory. /// /// Priority: `SPROUT_PRIVATE_KEY` env var (already handled in `build_app_state`) /// → `{app_data_dir}/identity.key` file → generate + save. /// /// Writes use `atomic-write-file` which handles temp file creation, fsync, /// atomic rename, and directory sync — no partial or corrupt files on disk. pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(), String> { // Only skip file-based resolution if the env var was present AND parsed // successfully. A malformed env var should fall through to the persisted // key rather than leaving the app on an ephemeral identity. if let Ok(nsec) = std::env::var("SPROUT_PRIVATE_KEY") { if Keys::parse(nsec.trim()).is_ok() { return Ok(()); } } let data_dir = app .path() .app_data_dir() .map_err(|e| format!("app data dir: {e}"))?; std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); // Try to load an existing key. if key_path.exists() { match load_key_file(&key_path) { Ok(keys) => { eprintln!( "sprout-desktop: persisted identity pubkey {}", keys.public_key().to_hex() ); *state.keys.lock().map_err(|e| e.to_string())? = keys; return Ok(()); } Err(error) => { // Corrupted — quarantine with a timestamp so prior backups // are never overwritten. let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let bad_name = format!("identity.key.bad.{ts}"); eprintln!( "sprout-desktop: corrupt identity.key ({error}), quarantining to {bad_name}" ); let bad_path = data_dir.join(bad_name); if std::fs::rename(&key_path, &bad_path).is_err() { let _ = std::fs::remove_file(&key_path); } } } } // First run (or recovery from corruption): generate and save. let keys = Keys::generate(); save_key_file(&key_path, &keys)?; eprintln!( "sprout-desktop: generated and saved identity pubkey {}", keys.public_key().to_hex() ); *state.keys.lock().map_err(|e| e.to_string())? = keys; Ok(()) } fn load_key_file(path: &std::path::Path) -> Result { let content = std::fs::read_to_string(path).map_err(|e| format!("read identity.key: {e}"))?; let trimmed = content.trim(); if trimmed.is_empty() { return Err("empty identity.key".to_string()); } Keys::parse(trimmed).map_err(|e| format!("parse identity.key: {e}")) } /// Atomically write the key to disk. Uses `atomic-write-file` which: /// 1. Writes to a temp file in the same directory /// 2. Calls fsync on the file /// 3. Renames temp → target (atomic on POSIX, best-effort on Windows) /// 4. Calls fsync on the parent directory /// /// On Unix, the file is created with mode 0600 (owner read/write only). /// On Windows, default ACLs apply — the app data directory is already /// per-user, so the key is not world-readable in practice. fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), String> { use atomic_write_file::AtomicWriteFile; let nsec = keys .secret_key() .to_bech32() .map_err(|e| format!("encode nsec: {e}"))?; let mut file = AtomicWriteFile::open(path) .map_err(|e| format!("open identity.key for atomic write: {e}"))?; // Set owner-only permissions before writing the secret. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; file.set_permissions(std::fs::Permissions::from_mode(0o600)) .map_err(|e| format!("set identity.key permissions: {e}"))?; } file.write_all(nsec.as_bytes()) .map_err(|e| format!("write identity.key: {e}"))?; file.commit() .map_err(|e| format!("commit identity.key: {e}")) } #[cfg(test)] mod tests { use super::*; fn assert_key_eq(a: &Keys, b: &Keys) { assert_eq!(a.public_key().to_hex(), b.public_key().to_hex()); } #[test] fn save_and_load_round_trip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("identity.key"); let keys = Keys::generate(); save_key_file(&path, &keys).unwrap(); let loaded = load_key_file(&path).unwrap(); assert_key_eq(&keys, &loaded); } #[test] fn load_rejects_empty_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("identity.key"); std::fs::write(&path, "").unwrap(); assert!(load_key_file(&path).is_err()); } #[test] fn load_rejects_corrupt_content() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("identity.key"); std::fs::write(&path, "not-a-valid-nsec").unwrap(); assert!(load_key_file(&path).is_err()); } #[test] fn load_missing_file_is_err() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("nonexistent.key"); assert!(load_key_file(&path).is_err()); } #[test] fn save_creates_file_with_valid_nsec() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("identity.key"); let keys = Keys::generate(); save_key_file(&path, &keys).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); assert!(content.starts_with("nsec1")); } #[cfg(unix)] #[test] fn save_creates_file_with_restricted_permissions() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("identity.key"); let keys = Keys::generate(); save_key_file(&path, &keys).unwrap(); let perms = std::fs::metadata(&path).unwrap().permissions(); assert_eq!(perms.mode() & 0o777, 0o600); } #[test] fn save_overwrites_existing_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("identity.key"); let keys1 = Keys::generate(); save_key_file(&path, &keys1).unwrap(); let keys2 = Keys::generate(); save_key_file(&path, &keys2).unwrap(); let loaded = load_key_file(&path).unwrap(); assert_key_eq(&keys2, &loaded); } }