use keyring::Entry; use serde::{Deserialize, Serialize}; /** * This module keeps persistent application account and credential state, * persists it to disk (and the OS keyring) and makes it available to * other modules in FoxFleet. * * All keystore and disk operations run on a secondary thread to * prevent causing hangups (and as the OS keystore APIs are synchronous) */ use tauri::async_runtime::RuntimeHandle; use uuid::Uuid; use std::path::PathBuf; use std::thread::{self, JoinHandle}; use std::{collections::HashMap, sync::Arc}; use tokio::sync::Mutex; use tokio::sync::oneshot; use std::sync::mpsc; use std::fs; use std::sync::mpsc::SendError; use tokio::sync::oneshot::error::RecvError; #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Server { pub domain: String, pub client_name: String, pub client_id: String, pub client_credential_id: Uuid, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Account { pub username: String, // Full @handle@domain identifier, used for persistence and key storage pub server_domain: String, // Web domain, used for API access pub api_credential_id: Uuid, } #[derive(Clone, Serialize, Deserialize, Debug)] struct DiskState { servers: Vec, accounts: Vec, } impl Default for DiskState { fn default() -> Self { Self { servers: Vec::new(), accounts: Vec::new(), } } } type PutResponse = Result<(), PersistenceError>; type LoadDiskResponse = Result; type GetCredentialResponse = Result; enum CredentialThreadRequest { PutCredential {uuid: Uuid, credential: String, callback: oneshot::Sender}, GetCredential {uuid: Uuid, callback: oneshot::Sender}, } enum DiskThreadRequest { Write {state: DiskState, callback: oneshot::Sender }, Read {callback: oneshot::Sender}, } #[derive(Debug)] pub enum PersistenceError { RecvError(RecvError), SendError, IoError(std::io::Error), NoDataDir, AccountNotFound {username: String, server_domain: String}, ServerNotFound {server_domain: String}, AccountAlreadyExists {username: String, server_domain: String}, ServerAlreadyRegistered { server_domain: String }, } impl From for PersistenceError { fn from(value: RecvError) -> Self { PersistenceError::RecvError(value) } } impl From> for PersistenceError { fn from(_value: SendError) -> Self { PersistenceError::SendError } } impl From for PersistenceError { fn from(value: std::io::Error) -> Self { PersistenceError::IoError(value) } } impl From for String { fn from(value: PersistenceError) -> Self { "PersistenceError".to_string() } } struct PersistenceState { servers: Vec, accounts: Vec, credential_cache: HashMap, credential_channel: mpsc::Sender, disk_channel: mpsc::Sender, credential_joinhandle: JoinHandle<()>, disk_joinhandle: JoinHandle<()>, async_runtime: RuntimeHandle, } #[derive(Clone)] pub struct PersistenceController (Arc>); impl PersistenceController { pub fn new(runtime_handle: RuntimeHandle) -> Self { let (credential_sender, credential_receiver) = mpsc::channel::(); let (disk_sender, disk_receiver) = mpsc::channel::(); let disk_joinhandle = thread::Builder::new() .name("foxfleet::persistence::disk".to_string()) .spawn(move || DiskThread::start(disk_receiver) ) .expect("Could not spawn disk thread"); let credential_joinhandle = thread::Builder::new() .name("foxfleet::persistence::keyring".to_string()) .spawn(move || CredentialThread::start(credential_receiver) ) .expect("Could not spawn keyring thread"); // Load saved or default config on startup let (load_send, load_recv) = oneshot::channel::(); let initial_state = disk_sender.send(DiskThreadRequest::Read { callback: load_send }).ok().map(|()| { runtime_handle.block_on(load_recv).ok().map(|response| response.ok()) }).flatten().flatten().unwrap_or_default(); return PersistenceController(Arc::new(Mutex::new(PersistenceState { servers: initial_state.servers, accounts: initial_state.accounts, credential_cache: HashMap::new(), credential_channel: credential_sender, disk_channel: disk_sender, credential_joinhandle, disk_joinhandle, async_runtime: runtime_handle, }))) } pub async fn new_server(&self, domain: String, client_id: String, client_name: String, client_secret: String) -> Result { let has_server_with_domain = {self.0.lock().await.servers.iter().find(|s| s.domain == domain).is_some()}; if has_server_with_domain { return Err(PersistenceError::ServerAlreadyRegistered { server_domain: domain }); } let client_credential_id = Uuid::new_v4(); let server = Server { domain, client_id, client_name, client_credential_id, }; self.persist_credential(client_credential_id, client_secret).await?; {self.0.lock().await.servers.push(server.clone())}; self.persist_disk().await?; return Ok(server) } pub async fn get_server(&self, domain: &String) -> Option { let server = {self.0.lock().await.servers.iter().find(|s| s.domain == *domain).cloned()}; return server } pub async fn new_account(&self, username: String, server_domain: String, api_token: String) -> Result { let has_account_already = {self.0.lock().await.accounts.iter().find(|a| a.username == username && a.server_domain == server_domain).is_some()}; if has_account_already { return Err(PersistenceError::AccountAlreadyExists {username, server_domain}); } let api_credential_id = Uuid::new_v4(); let account = Account { username, server_domain, api_credential_id }; self.persist_credential(api_credential_id, api_token).await?; {self.0.lock().await.accounts.push(account.clone())}; self.persist_disk().await?; return Ok(account) } pub async fn get_account(&self, username: &String, server_domain: &String) -> Option { let account = {self.0.lock().await.accounts.iter().find(|a| a.username == *username && a.server_domain == *server_domain).cloned()}; return account } pub async fn get_all_accounts(&self) -> Vec { let accounts = {self.0.lock().await.accounts.clone()}; return accounts } async fn persist_credential(&self, credential_id: Uuid, value: String) -> Result<(), PersistenceError> { let credential_channel = {self.0.lock().await.credential_channel.clone()}; let (sender, receiver) = oneshot::channel::(); credential_channel.send(CredentialThreadRequest::PutCredential { uuid: credential_id, credential: value.clone(), callback: sender })?; receiver.await??; {self.0.lock().await.credential_cache.insert(credential_id, value)}; Ok(()) } pub async fn get_credential(&self, credential_id: Uuid) -> Result { if let Some(cached) = {self.0.lock().await.credential_cache.get(&credential_id).cloned()} { return Ok(cached) } let credential_channel = {self.0.lock().await.credential_channel.clone()}; let (sender, receiver) = oneshot::channel::(); credential_channel.send(CredentialThreadRequest::GetCredential { uuid: credential_id, callback: sender })?; let retrieved = receiver.await??; {self.0.lock().await.credential_cache.insert(credential_id, retrieved.clone())}; return Ok(retrieved) } async fn persist_disk(&self) -> Result<(), PersistenceError> { let (disk_channel, accounts, servers) = { let state = self.0.lock().await; (state.disk_channel.clone(), state.accounts.clone(), state.servers.clone()) }; let (sender, receiver) = oneshot::channel::(); disk_channel.send(DiskThreadRequest::Write { state: DiskState { accounts, servers, }, callback: sender})?; receiver.await??; Ok(()) } } struct DiskThread; impl DiskThread { fn start(receiver: mpsc::Receiver) { loop { match receiver.recv() { Ok(DiskThreadRequest::Write { state, callback }) => callback.send(Self::write(state)).expect("Disk thread hung up on"), Ok(DiskThreadRequest::Read { callback }) => callback.send(Self::read()).expect("Disk thread hung up on"), Err(_) => panic!("Disk thread hung up on, exiting"), } } } fn write(state: DiskState) -> PutResponse { Self::ensure_dir_exists()?; let file_path = Self::file_path(); let data = toml::to_string(&state).unwrap(); fs::write(file_path, data)?; Ok(()) } fn read() -> LoadDiskResponse { Self::ensure_dir_exists()?; let file_path = Self::file_path(); let data = fs::read_to_string(file_path)?; let result : DiskState = toml::from_str(&data).unwrap(); Ok(result) } fn file_path() -> PathBuf { dirs::data_dir().unwrap().join("foxfleet/data.toml") } fn ensure_dir_exists() -> Result<(), PersistenceError> { let dir = dirs::data_dir().ok_or(PersistenceError::NoDataDir)?.join("foxfleet"); if !fs::exists(&dir)? { fs::create_dir(&dir)?; } Ok(()) } } struct CredentialThread; impl CredentialThread { fn start(receiver: mpsc::Receiver) { loop { match receiver.recv() { Ok(CredentialThreadRequest::PutCredential { uuid, credential, callback }) => callback.send(Self::put(uuid, credential)).expect("Credential thread hung up on"), Ok(CredentialThreadRequest::GetCredential { uuid, callback }) => callback.send(Self::get(uuid)).expect("Credential thread hung up on"), Err(_) => panic!("Credential thread hung up on, exiting"), } } } fn get(uuid: Uuid) -> GetCredentialResponse { let entry = Entry::new("dev.tempest.foxfleet", &uuid.to_string()).unwrap(); let credential = entry.get_password().unwrap(); Ok(credential) } fn put(uuid: Uuid, credential: String) -> PutResponse { let entry = Entry::new("dev.tempest.foxfleet", &uuid.to_string()).unwrap(); entry.set_password(credential.as_str()).unwrap(); Ok(()) } }