diff options
-rw-r--r-- | Cargo.lock | 29 | ||||
-rw-r--r-- | app/Cargo.toml | 3 | ||||
-rw-r--r-- | app/src/lib.rs | 64 | ||||
-rw-r--r-- | app/src/oauth.rs | 177 | ||||
-rw-r--r-- | app/src/persistence.rs | 291 | ||||
-rw-r--r-- | ui/src/root.tsx | 20 |
6 files changed, 448 insertions, 136 deletions
diff --git a/Cargo.lock b/Cargo.lock index a15f8e8..214e87e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1110,6 +1110,8 @@ dependencies = [ name = "foxfleet" version = "0.1.0" dependencies = [ + "dirs 6.0.0", + "keyring", "reqwest", "serde", "serde_json", @@ -1119,6 +1121,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tokio", + "toml 0.8.20", "url", "uuid", ] @@ -2053,6 +2056,17 @@ dependencies = [ ] [[package]] +name = "keyring" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8fe839464d4e4b37d756d7e910063696af79a7e877282cb1825e4ec5f10833" +dependencies = [ + "log", + "security-framework 2.11.1", + "security-framework 3.2.0", +] + +[[package]] name = "kuchikiki" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2258,7 +2272,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -3483,6 +3497,19 @@ dependencies = [ ] [[package]] +name = "security-framework" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +dependencies = [ + "bitflags 2.8.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] name = "security-framework-sys" version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/app/Cargo.toml b/app/Cargo.toml index 31bce47..e0def75 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -11,6 +11,8 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +dirs = "6.0.0" +keyring = { version = "3", features = ["apple-native"] } reqwest = { version = "0.12.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -19,5 +21,6 @@ tauri-plugin-opener = "2" tauri-plugin-deep-link = "2" tauri-plugin-single-instance = {version = "2", features = ["deep-link"] } tokio = "1.43.0" +toml = "0.8.20" url = "2.5.4" uuid = {version="1.13.1", features= ["v4"] } diff --git a/app/src/lib.rs b/app/src/lib.rs index 98ada19..91071fa 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1,17 +1,19 @@ mod oauth; -use oauth::OAuthController; +use oauth::{OAuthController, SigninResult}; +mod persistence; + +use persistence::{Account, PersistenceController}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_opener::OpenerExt; use tauri::{Manager, State, AppHandle}; -use url::Host; -use uuid::Uuid; pub const OAUTH_CLIENT_NAME: &'static str = "foxfleet_test"; struct AppState { - oauth_controller: OAuthController + oauth_controller: OAuthController, + persistence_controller: PersistenceController, } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -23,9 +25,11 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { - let oauth_controller = OAuthController::new(tauri::async_runtime::handle()); + let persistence_controller = PersistenceController::new(tauri::async_runtime::handle()); + let oauth_controller = OAuthController::new(tauri::async_runtime::handle(), persistence_controller.clone()); app.manage(AppState { - oauth_controller: oauth_controller.clone() + oauth_controller: oauth_controller.clone(), + persistence_controller, }); #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] @@ -37,15 +41,19 @@ pub fn run() { }); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_account_auth, get_self]) + .invoke_handler(tauri::generate_handler![start_account_auth, get_self, get_all_accounts]) .run(tauri::generate_context!()) .expect("Error starting") } +#[tauri::command] +async fn get_all_accounts(state: State<'_, AppState>) -> Result<Vec<Account>, String> { + Ok(state.persistence_controller.get_all_accounts().await) +} #[tauri::command] async fn start_account_auth(app_handle: AppHandle, state: State<'_, AppState>, instance_domain: &str) -> Result<Vec<String>, ()> { - let add_result = state.oauth_controller.add_server(instance_domain).await; + let add_result = state.oauth_controller.start_authorization(instance_domain).await; let state_nonce = match add_result { Ok(result) => { @@ -63,10 +71,10 @@ async fn start_account_auth(app_handle: AppHandle, state: State<'_, AppState>, i } }; - let signin_result = state.oauth_controller.finish_signin(state_nonce).await; + let signin_result = state.oauth_controller.finish_authorization(state_nonce).await; match signin_result { - Ok((server_domain, username)) => { - println!("Signed in successfully"); + Ok(SigninResult {server_domain, username}) => { + println!("Signed in successfully @{username}@{server_domain}"); Ok(vec!(server_domain, username)) } Err(err) => { @@ -80,24 +88,20 @@ async fn start_account_auth(app_handle: AppHandle, state: State<'_, AppState>, i async fn get_self(state: State<'_, AppState>, server_domain: String, username: String) -> Result<String, String> { let client = reqwest::Client::builder().user_agent("Foxfleet v0.0.1").build().expect("Could not construct client"); - let api_key = state.oauth_controller.get_api_token(server_domain, username).await; - match api_key { - Err(err) => { - println!("Error getting API token: {err}"); - return Err(err) - } - Ok(api_key) => { - if let Ok(result) = client.get("https://social.tempest.dev/api/v1/accounts/verify_credentials") - .bearer_auth(api_key) - .send().await { - if let Ok(result) = result.text().await { - return Ok(result) - } else { - return Err("Error decoding response".to_string()); - } - } else { - return Err("Error fetching account".to_string()); - } + let account = state.persistence_controller.get_account(&username, &server_domain).await + .ok_or(format!("No account for @{username}@{server_domain}"))?; + + let api_key = state.persistence_controller.get_credential(account.api_credential_id).await?; + + if let Ok(result) = client.get("https://social.tempest.dev/api/v1/accounts/verify_credentials") + .bearer_auth(api_key) + .send().await { + if let Ok(result) = result.text().await { + return Ok(result) + } else { + return Err("Error decoding response".to_string()); + } + } else { + return Err("Error fetching account".to_string()); } - } } diff --git a/app/src/oauth.rs b/app/src/oauth.rs index 657db30..1b0661c 100644 --- a/app/src/oauth.rs +++ b/app/src/oauth.rs @@ -3,46 +3,35 @@ use serde::Deserialize; use tauri::async_runtime::RuntimeHandle; use tokio::sync::Mutex; use tokio::sync::oneshot::{channel, Sender, Receiver}; -use url::{Host, Url}; +use url::Url; use uuid::Uuid; +use crate::persistence::PersistenceController; use crate::OAUTH_CLIENT_NAME; + #[derive(Clone)] -pub struct OAuthController (Arc<Mutex<OAuthInternal>>, RuntimeHandle); +pub struct OAuthController { + int: Arc<Mutex<OAuthInternal>>, + runtime: RuntimeHandle, + persistence: PersistenceController, +} type ServerDomain = String; -type AccountIdentifier = String; type StateCode = Uuid; struct OAuthInternal { - servers: HashMap<ServerDomain, Server>, open_callbacks: HashMap<StateCode, Callback> } -#[derive(Clone)] -struct Server { - client_name: String, - client_id: String, - client_secret: String, - accounts: Vec<Account>, -} - struct Callback { - server: ServerDomain, + server_domain: ServerDomain, pkce_verifier: String, pkce_challenge: String, code_channel: (Option<Sender<String>>, Option<Receiver<String>>), } -#[derive(Clone)] -struct Account { - username: String, - auth_session: AuthSession, -} - -#[derive(Clone)] -pub struct AuthSession { - pub api_token: String, +struct AuthSession { + api_token: String, refresh_token: Option<String>, } @@ -51,12 +40,20 @@ pub struct AddServerResult { pub auth_state: StateCode, } +pub struct SigninResult { + pub server_domain: String, + pub username: String, +} + impl OAuthController { - pub fn new(runtime_handle: RuntimeHandle) -> Self { - Self(Arc::new(Mutex::new(OAuthInternal { - servers: HashMap::new(), - open_callbacks: HashMap::new(), - })), runtime_handle) + pub fn new(runtime_handle: RuntimeHandle, persistence_controller: PersistenceController) -> Self { + Self { + int: Arc::new(Mutex::new(OAuthInternal { + open_callbacks: HashMap::new(), + })), + runtime: runtime_handle, + persistence: persistence_controller, + } } pub fn handle_deeplink(&self, urls: &Vec<Url>) -> Option<()> { @@ -84,49 +81,51 @@ impl OAuthController { } - pub async fn add_server(&self, instance_domain: &str) -> Result<AddServerResult, String> { + pub async fn start_authorization(&self, instance_domain: &str) -> Result<AddServerResult, String> { let registration_endpoint = format!("https://{instance_domain}/api/v1/apps"); let http_client = reqwest::Client::builder().user_agent("Foxfleet v0.0.1").build().expect("Could not construct client"); - #[derive(Deserialize)] - struct RegistrationResponse { - id: String, - name: String, - client_id: String, - client_secret: String, - } + let server_client = match self.persistence.get_server(&instance_domain.to_string()).await { + Some(client) => client, + None => { + println!("Registering a new client for {instance_domain}"); + + #[derive(Deserialize)] + struct RegistrationResponse { + id: String, + name: String, + client_id: String, + client_secret: String, + } - let registration_response : RegistrationResponse = http_client.post(registration_endpoint) - .json(&HashMap::from([ - ("client_name", OAUTH_CLIENT_NAME), - ("redirect_uris", "dev.tempest.foxfleet://oauth-response"), - ("scopes", "read write"), - ])).send().await.expect("Could not send client registration") - .json().await.expect("Could not parse client registration response"); + let registration_response : RegistrationResponse = http_client.post(registration_endpoint) + .json(&HashMap::from([ + ("client_name", OAUTH_CLIENT_NAME), + ("redirect_uris", "dev.tempest.foxfleet://oauth-response"), + ("scopes", "read write"), + ])).send().await.expect("Could not send client registration") + .json().await.expect("Could not parse client registration response"); - let server = Server { - client_name: registration_response.name, - client_id: registration_response.client_id.clone(), - client_secret: registration_response.client_secret, - accounts: Vec::new(), - }; + self.persistence.new_server(instance_domain.to_string(), registration_response.client_id.clone(), registration_response.name.to_string(), registration_response.client_secret).await; - {self.0.lock().await.servers.insert(instance_domain.to_string(), server)}; + self.persistence.get_server(&instance_domain.to_string()).await.unwrap() + }, + }; let (sender, receiver) = channel::<String>(); // TODO: PKCE params for real let auth_state = Uuid::new_v4(); let auth_callback = Callback { - server: instance_domain.to_string(), + server_domain: instance_domain.to_string(), pkce_verifier: String::new(), pkce_challenge: String::new(), code_channel: (Some(sender), Some(receiver)), }; - let client_id = registration_response.client_id; + let client_id = server_client.client_id; - {self.0.lock().await.open_callbacks.insert(auth_state.clone(), auth_callback)}; + {self.int.lock().await.open_callbacks.insert(auth_state.clone(), auth_callback)}; let auth_url = format!("https://{instance_domain}/oauth/authorize?client_id={client_id}&redirect_uri=dev.tempest.foxfleet://oauth-response&response_type=code&scope=read+write&state={auth_state}"); @@ -137,8 +136,8 @@ impl OAuthController { } fn resolve_code(&self, state: StateCode, auth_code: String) { - let runtime = self.1.clone(); - let inner_self = self.0.clone(); + let runtime = self.runtime.clone(); + let inner_self = self.int.clone(); runtime.spawn(async move { if let Some(sender) = inner_self.lock().await.open_callbacks.get_mut(&state).map(|callback| callback.code_channel.0.take() ).flatten() { @@ -147,32 +146,28 @@ impl OAuthController { }); } - pub async fn finish_signin(&self, state: StateCode) -> Result<(String, String), String> { + pub async fn finish_authorization(&self, state: StateCode) -> Result<SigninResult, String> { + let domain = {self.int.lock().await.open_callbacks.get(&state).map(|cb| cb.server_domain.clone())} + .ok_or(format!("No callback code {state}"))?; + let auth_code = self.await_auth_code(&state).await?; let auth_session = self.exchange_api_token(&state, &auth_code).await?; let username = self.resolve_account(&state, &auth_session).await?; - let account = Account { - username: username.clone(), - auth_session - }; - let mut inner_state = self.0.lock().await; + self.persistence.new_account(username.clone(), domain.clone(), auth_session.api_token).await; - if let Some(callback) = inner_state.open_callbacks.remove(&state) { - if let Some (server) = inner_state.servers.get_mut(&callback.server) { - server.accounts.push(account); - return Ok((callback.server.clone(), username)) - } else { - Err("Unknown server URL".to_string()) - } - } else { - Err("Unknown state".to_string()) - } + // Remove state callback record + {self.int.lock().await.open_callbacks.remove(&state)}; + + return Ok(SigninResult { + server_domain: domain, + username + }) } async fn await_auth_code(&self, state: &StateCode) -> Result<String, String> { - let maybe_receiver = {self.0.lock().await.open_callbacks.get_mut(&state).map(|callback| callback.code_channel.1.take() ).flatten()}; + let maybe_receiver = {self.int.lock().await.open_callbacks.get_mut(&state).map(|callback| callback.code_channel.1.take() ).flatten()}; if let Some(receiver) = maybe_receiver { if let Ok(auth_code) = receiver.await { Ok(auth_code) @@ -186,24 +181,11 @@ impl OAuthController { // TODO: Send PKCE stuff async fn exchange_api_token(&self, state: &StateCode, auth_code: &String) -> Result<AuthSession, String> { - let maybe_server = { - let inner_state = self.0.lock().await; - if let Some(server_domain) = inner_state.open_callbacks.get(&state).map(|callback| callback.server.clone()) { - if let Some(server) = inner_state.servers.get(&server_domain) { - Some((server_domain, server.clone())) - } else { - None - } - } else { - None - } - }; - - if maybe_server.is_none() { - return Err("Unknown auth state".to_string()); - } - - let (server_domain, server) = maybe_server.unwrap(); + let server_domain = {self.int.lock().await.open_callbacks.get(&state).map(|cb| cb.server_domain.clone())} + .ok_or(format!("No callback code {state}"))?; + let server_client = self.persistence.get_server(&server_domain).await + .ok_or("Could not look up client for server")?; + let client_secret = self.persistence.get_credential(server_client.client_credential_id).await?; #[derive(Deserialize)] struct TokenResponse { @@ -218,8 +200,8 @@ impl OAuthController { let token_response : TokenResponse = http_client.post(token_endpoint) .json(&HashMap::from([ ("redirect_uri", "dev.tempest.foxfleet://oauth-response"), - ("client_id", server.client_id.as_str()), - ("client_secret", server.client_secret.as_str()), + ("client_id", server_client.client_id.as_str()), + ("client_secret", client_secret.as_str()), ("grant_type", "authorization_code"), ("code", auth_code.as_str()), ])).send().await.expect("Could not get API token") @@ -232,7 +214,7 @@ impl OAuthController { } async fn resolve_account(&self, state: &StateCode, auth_session: &AuthSession) -> Result<String, String> { - let instance_url = { self.0.lock().await.open_callbacks.get(&state).map(|callback| callback.server.clone()) }; + let instance_url = { self.int.lock().await.open_callbacks.get(&state).map(|callback| callback.server_domain.clone()) }; if let Some(instance_url) = instance_url { #[derive(Deserialize)] @@ -251,15 +233,4 @@ impl OAuthController { Err("No instance URL".to_string()) } } - - pub async fn get_api_token(&self, server_domain: String, username: String) -> Result<String, String> { - let api_token_maybe = { self.0.lock().await.servers.get(&server_domain).map(|server| { - server.accounts.iter().find(|account| account.username == username).map(|account| account.auth_session.api_token.clone()) - }).flatten()}; - - match api_token_maybe { - Some(api_token) => Ok(api_token), - None => Err("Could not look up {server_domain} / {username}".to_string()), - } - } } diff --git a/app/src/persistence.rs b/app/src/persistence.rs new file mode 100644 index 0000000..028eded --- /dev/null +++ b/app/src/persistence.rs @@ -0,0 +1,291 @@ +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; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Server { + pub domain: String, + pub client_name: String, + pub client_id: String, + pub client_credential_id: Uuid, +} + +#[derive(Clone, Serialize, Deserialize)] +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)] +struct DiskState { + servers: Vec<Server>, + accounts: Vec<Account>, +} + +enum CredentialThreadRequest { + PutCredential {uuid: Uuid, credential: String, callback: oneshot::Sender<()>}, + GetCredential {uuid: Uuid, callback: oneshot::Sender<String>}, + Close, +} + +enum DiskThreadRequest { + Write {state: DiskState, callback: oneshot::Sender<()> }, + Read {callback: oneshot::Sender<DiskState>}, + Close, +} + +struct PersistenceState { + servers: Vec<Server>, + accounts: Vec<Account>, + credential_cache: HashMap<Uuid, String>, + credential_channel: mpsc::Sender<CredentialThreadRequest>, + disk_channel: mpsc::Sender<DiskThreadRequest>, + credential_joinhandle: JoinHandle<()>, + disk_joinhandle: JoinHandle<()>, + async_runtime: RuntimeHandle, +} + +#[derive(Clone)] +pub struct PersistenceController (Arc<Mutex<PersistenceState>>); + +impl PersistenceController { + pub fn new(runtime_handle: RuntimeHandle) -> Self { + let (credential_sender, credential_receiver) = mpsc::channel::<CredentialThreadRequest>(); + let (disk_sender, disk_receiver) = mpsc::channel::<DiskThreadRequest>(); + + 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"); + + // Quickly wait on the disk thread to load config + let (load_send, load_recv) = oneshot::channel::<DiskState>(); + disk_sender.send(DiskThreadRequest::Read { callback: load_send }).expect("Could not load accounts"); + let loaded_state = runtime_handle.block_on(async { + load_recv.await.expect("Could not load accounts") + }); + + return PersistenceController(Arc::new(Mutex::new(PersistenceState { + servers: loaded_state.servers, + accounts: loaded_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<Server, String> { + 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(format!("Server already exists in state with 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<Server> { + 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<Account, String> { + 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(format!("Account already exists for @{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<Account> { + 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<Account> { + let accounts = {self.0.lock().await.accounts.clone()}; + return accounts + } + + async fn persist_credential(&self, credential_id: Uuid, value: String) { + 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)}; + } + + pub async fn get_credential(&self, credential_id: Uuid) -> Result<String, String> { + 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::<String>(); + credential_channel.send(CredentialThreadRequest::GetCredential { uuid: credential_id, callback: sender }); + let retrieved = receiver.await; + + if retrieved.is_err() { + return Err(format!("Could not retrieve credential: {credential_id}")) + } + + let retrieved = retrieved.unwrap(); + {self.0.lock().await.credential_cache.insert(credential_id, retrieved.clone())}; + + return Ok(retrieved) + } + + async fn persist_disk(&self) { + 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; + } +} + +struct DiskThread; +impl DiskThread { + fn start(receiver: mpsc::Receiver<DiskThreadRequest>) { + loop { + match receiver.recv() { + Ok(DiskThreadRequest::Write { state, callback }) => Self::write(state, callback), + Ok(DiskThreadRequest::Read { callback }) => Self::read(callback), + Ok(DiskThreadRequest::Close) => return, + Err(_) => { + println!("Disk thread hung up on, exiting"); + return + }, + } + } + } + + fn write(state: DiskState, callback: oneshot::Sender<()>) { + Self::ensure_dir_exists(); + let file_path = Self::file_path(); + let data = toml::to_string(&state).unwrap(); + + fs::write(file_path, data).unwrap(); + callback.send(()).unwrap(); + } + + fn read(callback: oneshot::Sender<DiskState>) { + Self::ensure_dir_exists(); + let file_path = Self::file_path(); + let data = match fs::read_to_string(file_path) { + Ok(data) => data, + Err(_) => { + let _ = callback.send(DiskState { + servers: Vec::new(), + accounts: Vec::new(), + }); + return + }, + }; + + let result : DiskState = toml::from_str(&data).unwrap(); + let _ = callback.send(result); + } + + fn file_path() -> PathBuf { + dirs::data_dir().unwrap().join("foxfleet/data.toml") + } + + fn ensure_dir_exists() { + let dir = dirs::data_dir().unwrap().join("foxfleet"); + if !fs::exists(&dir).unwrap() { + fs::create_dir(&dir).expect("Could not create data directory"); + } + } +} + +struct CredentialThread; +impl CredentialThread { + fn start(receiver: mpsc::Receiver<CredentialThreadRequest>) { + loop { + match receiver.recv() { + Ok(CredentialThreadRequest::PutCredential { uuid, credential, callback }) => Self::put(uuid, credential, callback), + Ok(CredentialThreadRequest::GetCredential { uuid, callback }) => Self::get(uuid, callback), + Ok(CredentialThreadRequest::Close) => return, + Err(_) => { + println!("Credential thread hung up on, exiting"); + return + }, + } + } + } + + fn get(uuid: Uuid, callback: oneshot::Sender<String>) { + let entry = Entry::new("dev.tempest.foxfleet", &uuid.to_string()).unwrap(); + let credential = entry.get_password().unwrap(); + callback.send(credential).unwrap(); + } + + fn put(uuid: Uuid, credential: String, callback: oneshot::Sender<()>) { + let entry = Entry::new("dev.tempest.foxfleet", &uuid.to_string()).unwrap(); + entry.set_password(credential.as_str()).unwrap(); + callback.send(()).unwrap(); + } +} diff --git a/ui/src/root.tsx b/ui/src/root.tsx index bb4b763..13b3021 100644 --- a/ui/src/root.tsx +++ b/ui/src/root.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; export default function Root() { + const [existingAccounts, setExistingAccounts] = useState<any>([]) const [signedIn, setSignedIn] = useState<{serverDomain: string, username: string} | null>(null) const [accountData, setAccountData] = useState('') @@ -17,10 +18,25 @@ export default function Root() { setAccountData(JSON.parse(result)) } + useEffect(() => { + (async () => { + setExistingAccounts(await invoke('get_all_accounts')) + })() + }, []) + return ( <> {!signedIn ? ( - <button onClick={signIn}>Sign in</button> + <> + <p>Existing accounts:</p> + {existingAccounts.map(({username, server_domain}: {username: string, server_domain: string}) => ( + <> + <button key={username + server_domain} onClick={() => setSignedIn({serverDomain: server_domain, username})}>@{username}@{server_domain}</button> + <br/> + </> + ))} + <button onClick={signIn}>Add another account</button> + </> ) : (!accountData ? ( <button onClick={getSelf}>Retrieve account data</button> ):( |