From 6db3363aada62af005fbe0a1dc1feb71970cba4d Mon Sep 17 00:00:00 2001 From: osmaczko <33099791+osmaczko@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:34:09 +0100 Subject: [PATCH] wip --- Cargo.lock | 1 + core/conversations/src/lib.rs | 4 +- crates/client/Cargo.toml | 3 + crates/client/src/client.rs | 91 +++++++++++++++++++++++++--- crates/client/src/delivery.rs | 24 ++++++++ crates/client/src/errors.rs | 15 +++++ crates/client/src/lib.rs | 7 +++ crates/client/tests/alice_and_bob.rs | 72 ++++++++++++++++++++++ 8 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 crates/client/src/delivery.rs create mode 100644 crates/client/src/errors.rs create mode 100644 crates/client/tests/alice_and_bob.rs diff --git a/Cargo.lock b/Cargo.lock index d8fbc00..73f6810 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,7 @@ name = "client" version = "0.1.0" dependencies = [ "libchat", + "tempfile", ] [[package]] diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index d81eb91..0a8a11e 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -12,8 +12,10 @@ mod types; mod utils; pub use api::*; -pub use context::{Context, Introduction}; +pub use context::{Context, ConversationIdOwned, Introduction}; pub use errors::ChatError; +pub use ::storage::StorageConfig; +pub use types::{AddressedEnvelope, ContentData}; #[cfg(test)] mod tests { diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index d3cfb2a..39f1a5d 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -8,3 +8,6 @@ crate-type = ["rlib"] [dependencies] libchat = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index a26908a..c3ef4ca 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,18 +1,95 @@ -use libchat::ChatError; -use libchat::Context; +use libchat::{ + AddressedEnvelope, ContentData, Context, ConversationIdOwned, Introduction, StorageConfig, +}; -pub struct ChatClient { +use crate::{delivery::DeliveryService, errors::ClientError}; + +pub struct ChatClient { ctx: Context, + delivery: D, } -impl ChatClient { - pub fn new(name: impl Into) -> Self { +impl ChatClient { + /// Create an in-memory, ephemeral client. Identity is lost on drop. + pub fn new(name: impl Into, delivery: D) -> Self { Self { ctx: Context::new_with_name(name), + delivery, } } - pub fn create_bundle(&mut self) -> Result, ChatError> { - self.ctx.create_intro_bundle() + /// Open or create a persistent client backed by `StorageConfig`. + /// + /// If an identity already exists in storage it is loaded; otherwise a new + /// one is created and saved. + pub fn open( + name: impl Into, + config: StorageConfig, + delivery: D, + ) -> Result> { + let ctx = Context::open(name, config)?; + Ok(Self { ctx, delivery }) + } + + /// Returns the installation name (identity label) of this client. + pub fn installation_name(&self) -> &str { + self.ctx.installation_name() + } + + /// Produce a serialised introduction bundle for sharing out-of-band. + pub fn create_intro_bundle(&mut self) -> Result, ClientError> { + self.ctx.create_intro_bundle().map_err(Into::into) + } + + /// Parse intro bundle bytes, initiate a private conversation, and deliver + /// all outbound envelopes. Returns this side's conversation ID. + pub fn create_conversation( + &mut self, + intro_bundle: &[u8], + initial_content: &[u8], + ) -> Result> { + let intro = Introduction::try_from(intro_bundle)?; + let (convo_id, envelopes) = self.ctx.create_private_convo(&intro, initial_content); + self.dispatch_all(envelopes)?; + Ok(convo_id) + } + + /// List all conversation IDs known to this client. + pub fn list_conversations(&self) -> Result, ClientError> { + self.ctx.list_conversations().map_err(Into::into) + } + + /// Encrypt `content` and dispatch all outbound envelopes. + pub fn send_message( + &mut self, + convo_id: &ConversationIdOwned, + content: &[u8], + ) -> Result<(), ClientError> { + let envelopes = self.ctx.send_content(convo_id.as_ref(), content)?; + self.dispatch_all(envelopes) + } + + /// Decrypt an inbound payload. Returns `Some(ContentData)` for user + /// content, `None` for protocol frames. + pub fn receive( + &mut self, + payload: &[u8], + ) -> Result, ClientError> { + self.ctx.handle_payload(payload).map_err(Into::into) + } + + /// Access the delivery service (e.g. to pop inbound envelopes in tests). + pub fn delivery_mut(&mut self) -> &mut D { + &mut self.delivery + } + + fn dispatch_all( + &mut self, + envelopes: Vec, + ) -> Result<(), ClientError> { + for env in envelopes { + self.delivery.deliver(env).map_err(ClientError::Delivery)?; + } + Ok(()) } } diff --git a/crates/client/src/delivery.rs b/crates/client/src/delivery.rs new file mode 100644 index 0000000..b93a367 --- /dev/null +++ b/crates/client/src/delivery.rs @@ -0,0 +1,24 @@ +use std::collections::VecDeque; + +use libchat::AddressedEnvelope; + +pub trait DeliveryService { + type Error: std::fmt::Debug; + fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error>; +} + +/// In-memory delivery for tests. Envelopes are pushed to `inbox`; tests pop +/// them and feed bytes to the peer's `receive()`. +#[derive(Default)] +pub struct InMemoryDelivery { + pub inbox: VecDeque>, +} + +impl DeliveryService for InMemoryDelivery { + type Error = std::convert::Infallible; + + fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error> { + self.inbox.push_back(envelope.data); + Ok(()) + } +} diff --git a/crates/client/src/errors.rs b/crates/client/src/errors.rs new file mode 100644 index 0000000..532c651 --- /dev/null +++ b/crates/client/src/errors.rs @@ -0,0 +1,15 @@ +use libchat::ChatError; + +#[derive(Debug)] +pub enum ClientError { + Chat(ChatError), + /// Crypto state advanced but at least one envelope failed delivery. + /// Caller decides whether to retry. + Delivery(D), +} + +impl From for ClientError { + fn from(e: ChatError) -> Self { + Self::Chat(e) + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 008d68a..e3c6186 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1,3 +1,10 @@ mod client; +mod delivery; +mod errors; pub use client::ChatClient; +pub use delivery::{DeliveryService, InMemoryDelivery}; +pub use errors::ClientError; + +// Re-export types callers need to interact with ChatClient +pub use libchat::{ContentData, ConversationIdOwned, StorageConfig}; diff --git a/crates/client/tests/alice_and_bob.rs b/crates/client/tests/alice_and_bob.rs new file mode 100644 index 0000000..b4ce971 --- /dev/null +++ b/crates/client/tests/alice_and_bob.rs @@ -0,0 +1,72 @@ +use client::{ChatClient, ContentData, ConversationIdOwned, InMemoryDelivery}; +use std::sync::Arc; + +fn pop_and_receive( + sender: &mut ChatClient, + receiver: &mut ChatClient, +) -> Option { + let raw = sender.delivery_mut().inbox.pop_front().expect("expected envelope"); + receiver.receive(&raw).expect("receive failed") +} + +#[test] +fn alice_bob_message_exchange() { + let mut alice = ChatClient::new("alice", InMemoryDelivery::default()); + let mut bob = ChatClient::new("bob", InMemoryDelivery::default()); + + // Exchange intro bundles out-of-band + let bob_bundle = bob.create_intro_bundle().unwrap(); + + // Alice initiates conversation with Bob + let alice_convo_id = alice + .create_conversation(&bob_bundle, b"hello bob") + .unwrap(); + + // Bob receives Alice's initial message + let content = pop_and_receive(&mut alice, &mut bob).expect("expected content"); + assert_eq!(content.data, b"hello bob"); + assert!(content.is_new_convo); + + let bob_convo_id: ConversationIdOwned = Arc::from(content.conversation_id.as_str()); + + // Bob replies + bob.send_message(&bob_convo_id, b"hi alice").unwrap(); + let content = pop_and_receive(&mut bob, &mut alice).expect("expected content"); + assert_eq!(content.data, b"hi alice"); + assert!(!content.is_new_convo); + + // Multiple back-and-forth rounds + for i in 0u8..5 { + let msg = format!("msg {i}"); + alice.send_message(&alice_convo_id, msg.as_bytes()).unwrap(); + let content = pop_and_receive(&mut alice, &mut bob).expect("expected content"); + assert_eq!(content.data, msg.as_bytes()); + + let reply = format!("reply {i}"); + bob.send_message(&bob_convo_id, reply.as_bytes()).unwrap(); + let content = pop_and_receive(&mut bob, &mut alice).expect("expected content"); + assert_eq!(content.data, reply.as_bytes()); + } + + // Both sides have exactly one conversation + assert_eq!(alice.list_conversations().unwrap().len(), 1); + assert_eq!(bob.list_conversations().unwrap().len(), 1); +} + +#[test] +fn open_persistent_client() { + use client::StorageConfig; + + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db").to_string_lossy().to_string(); + let config = StorageConfig::File(db_path); + + let client1 = ChatClient::open("alice", config.clone(), InMemoryDelivery::default()).unwrap(); + let name1 = client1.installation_name().to_string(); + drop(client1); + + let client2 = ChatClient::open("alice", config, InMemoryDelivery::default()).unwrap(); + let name2 = client2.installation_name().to_string(); + + assert_eq!(name1, name2, "installation name should persist across restarts"); +}