From 37eb2749b2be9ec95c0d1e6a0f92f236417682b0 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:32:47 +0800 Subject: [PATCH] feat: persist identity --- conversations/src/api.rs | 22 +++++++++++ conversations/src/context.rs | 76 +++++++++++++++++++++++++++++++++--- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index bd1e300..8ba81b9 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -13,6 +13,8 @@ use safer_ffi::{ prelude::{c_slice, repr_c}, }; +use storage::StorageConfig; + use crate::{ context::{Context, Introduction}, errors::ChatError, @@ -54,6 +56,26 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { Box::new(ContextHandle(Context::new_with_name(&*name))).into() } +/// Creates a new libchat Context with file-based persistent storage. +/// +/// The identity will be loaded from storage if it exists, or created and saved if not. +/// +/// # Parameters +/// - name: Friendly name for the identity (used if creating new identity) +/// - db_path: Path to the SQLite database file +/// +/// # Returns +/// Opaque handle to the context. Must be freed with destroy_context() +#[ffi_export] +pub fn create_context_with_storage( + name: repr_c::String, + db_path: repr_c::String, +) -> repr_c::Box { + let config = StorageConfig::File(db_path.to_string()); + let ctx = Context::open(&*name, config).expect("failed to open context with storage"); + Box::new(ContextHandle(ctx)).into() +} + /// Returns the friendly name of the contexts installation. /// #[ffi_export] diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 2b36f68..87a68fe 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,34 +1,73 @@ use std::rc::Rc; +use storage::StorageConfig; + use crate::{ conversation::{ConversationId, ConversationStore, Convo, Id}, errors::ChatError, identity::Identity, inbox::Inbox, proto::{EncryptedPayload, EnvelopeV1, Message}, + storage::{ChatStorage, StorageError}, types::{AddressedEnvelope, ContentData}, }; pub use crate::conversation::ConversationIdOwned; pub use crate::inbox::Introduction; +/// Error type for Context operations. +#[derive(Debug, thiserror::Error)] +pub enum ContextError { + #[error("chat error: {0}")] + Chat(#[from] ChatError), + + #[error("storage error: {0}")] + Storage(#[from] StorageError), +} + // This is the main entry point to the conversations api. // Ctx manages lifetimes of objects to process and generate payloads. pub struct Context { _identity: Rc, store: ConversationStore, inbox: Inbox, + storage: ChatStorage, } impl Context { - pub fn new_with_name(name: impl Into) -> Self { - let identity = Rc::new(Identity::new(name)); - let inbox = Inbox::new(Rc::clone(&identity)); // - Self { + /// Opens or creates a Context with the given storage configuration. + /// + /// If an identity exists in storage, it will be restored. + /// Otherwise, a new identity will be created with the given name and saved. + pub fn open(name: impl Into, config: StorageConfig) -> Result { + let mut storage = ChatStorage::new(config)?; + let name = name.into(); + + // Load or create identity + let identity = if let Some(identity) = storage.load_identity()? { + identity + } else { + let identity = Identity::new(&name); + storage.save_identity(&identity)?; + identity + }; + + let identity = Rc::new(identity); + let inbox = Inbox::new(Rc::clone(&identity)); + + Ok(Self { _identity: identity, store: ConversationStore::new(), inbox, - } + storage, + }) + } + + /// Creates a new in-memory Context (for testing). + /// + /// Uses in-memory SQLite database. Each call creates a new isolated database. + pub fn new_with_name(name: impl Into) -> Self { + Self::open(name, StorageConfig::InMemory).expect("in-memory storage should not fail") } pub fn installation_name(&self) -> &str { @@ -195,4 +234,31 @@ mod tests { send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content); } } + + #[test] + fn identity_persistence() { + // Use file-based storage to test real persistence + let dir = tempfile::tempdir().unwrap(); + let db_path = dir + .path() + .join("test_identity.db") + .to_string_lossy() + .to_string(); + let config = StorageConfig::File(db_path); + + // Create context - this should create and save a new identity + let ctx1 = Context::open("alice", config.clone()).unwrap(); + let pubkey1 = ctx1._identity.public_key(); + let name1 = ctx1.installation_name().to_string(); + + // Drop and reopen - should load the same identity + drop(ctx1); + let ctx2 = Context::open("alice", config).unwrap(); + let pubkey2 = ctx2._identity.public_key(); + let name2 = ctx2.installation_name().to_string(); + + // Identity should be the same + assert_eq!(pubkey1, pubkey2, "public key should persist"); + assert_eq!(name1, name2, "name should persist"); + } }