This commit is contained in:
osmaczko 2026-03-26 21:34:09 +01:00
parent 9a94f9a6d6
commit 6db3363aad
No known key found for this signature in database
GPG Key ID: 6A385380FD275B44
8 changed files with 209 additions and 8 deletions

1
Cargo.lock generated
View File

@ -124,6 +124,7 @@ name = "client"
version = "0.1.0"
dependencies = [
"libchat",
"tempfile",
]
[[package]]

View File

@ -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 {

View File

@ -8,3 +8,6 @@ crate-type = ["rlib"]
[dependencies]
libchat = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@ -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<D: DeliveryService> {
ctx: Context,
delivery: D,
}
impl ChatClient {
pub fn new(name: impl Into<String>) -> Self {
impl<D: DeliveryService> ChatClient<D> {
/// Create an in-memory, ephemeral client. Identity is lost on drop.
pub fn new(name: impl Into<String>, delivery: D) -> Self {
Self {
ctx: Context::new_with_name(name),
delivery,
}
}
pub fn create_bundle(&mut self) -> Result<Vec<u8>, 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<String>,
config: StorageConfig,
delivery: D,
) -> Result<Self, ClientError<D::Error>> {
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<Vec<u8>, ClientError<D::Error>> {
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<ConversationIdOwned, ClientError<D::Error>> {
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<Vec<ConversationIdOwned>, ClientError<D::Error>> {
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<D::Error>> {
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<Option<ContentData>, ClientError<D::Error>> {
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<AddressedEnvelope>,
) -> Result<(), ClientError<D::Error>> {
for env in envelopes {
self.delivery.deliver(env).map_err(ClientError::Delivery)?;
}
Ok(())
}
}

View File

@ -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<Vec<u8>>,
}
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(())
}
}

View File

@ -0,0 +1,15 @@
use libchat::ChatError;
#[derive(Debug)]
pub enum ClientError<D> {
Chat(ChatError),
/// Crypto state advanced but at least one envelope failed delivery.
/// Caller decides whether to retry.
Delivery(D),
}
impl<D> From<ChatError> for ClientError<D> {
fn from(e: ChatError) -> Self {
Self::Chat(e)
}
}

View File

@ -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};

View File

@ -0,0 +1,72 @@
use client::{ChatClient, ContentData, ConversationIdOwned, InMemoryDelivery};
use std::sync::Arc;
fn pop_and_receive(
sender: &mut ChatClient<InMemoryDelivery>,
receiver: &mut ChatClient<InMemoryDelivery>,
) -> Option<ContentData> {
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");
}