summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock29
-rw-r--r--app/Cargo.toml3
-rw-r--r--app/src/lib.rs64
-rw-r--r--app/src/oauth.rs177
-rw-r--r--app/src/persistence.rs291
-rw-r--r--ui/src/root.tsx20
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>
       ):(