diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cbd2ad..bb8af3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,21 @@ jobs: working-directory: nim-bindings - run: nimble pingpong working-directory: nim-bindings + + c-ffi-smoketest: + name: C FFI Smoketest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup update stable && rustup default stable + - name: Install valgrind + run: sudo apt-get install -y valgrind + - name: Build C FFI example + run: make + working-directory: examples/c-ffi + - name: Run C FFI smoketest + run: ./c-client + working-directory: examples/c-ffi + - name: Run C FFI smoketest under valgrind + run: make valgrind + working-directory: examples/c-ffi diff --git a/.gitignore b/.gitignore index 1dc0b45..9d00a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,9 @@ target tmp .DS_Store + +# Generated C headers (produced by `make` in examples/c-ffi; do not commit) +crates/client-ffi/client_ffi.h + +# Compiled C FFI example binary +examples/c-ffi/c-client diff --git a/Cargo.lock b/Cargo.lock index d8fbc00..70d9c4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,16 @@ name = "client" version = "0.1.0" dependencies = [ "libchat", + "tempfile", +] + +[[package]] +name = "client-ffi" +version = "0.1.0" +dependencies = [ + "client", + "libchat", + "safer-ffi", ] [[package]] @@ -452,6 +462,13 @@ dependencies = [ "digest", ] +[[package]] +name = "in-process" +version = "0.1.0" +dependencies = [ + "client", +] + [[package]] name = "indexmap" version = "2.13.0" diff --git a/Cargo.toml b/Cargo.toml index ca37bad..a124ee1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ members = [ "core/double-ratchets", "core/storage", "crates/client", + "crates/client-ffi", + "examples/in-process", ] [workspace.dependencies] diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index de0c023..3dbf52e 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -10,9 +10,11 @@ mod storage; mod types; mod utils; +pub use ::storage::StorageConfig; pub use api::*; -pub use context::{Context, Introduction}; +pub use context::{Context, ConversationIdOwned, Introduction}; pub use errors::ChatError; +pub use types::{AddressedEnvelope, ContentData}; #[cfg(test)] mod tests { diff --git a/crates/client-ffi/Cargo.toml b/crates/client-ffi/Cargo.toml new file mode 100644 index 0000000..43d8014 --- /dev/null +++ b/crates/client-ffi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "client-ffi" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["staticlib", "rlib"] + +[[bin]] +name = "generate-headers" +required-features = ["headers"] + +[dependencies] +safer-ffi = "0.1.13" +client = { path = "../client" } +libchat = { workspace = true } + +[features] +headers = ["safer-ffi/headers"] diff --git a/crates/client-ffi/src/api.rs b/crates/client-ffi/src/api.rs new file mode 100644 index 0000000..d28d538 --- /dev/null +++ b/crates/client-ffi/src/api.rs @@ -0,0 +1,285 @@ +use safer_ffi::prelude::*; +use std::sync::Arc; + +use crate::delivery::{CDelivery, DeliverFn}; +use client::{ChatClient, ClientError}; + +// --------------------------------------------------------------------------- +// Opaque client handle +// --------------------------------------------------------------------------- + +#[derive_ReprC] +#[repr(opaque)] +pub struct ClientHandle(pub(crate) ChatClient); + +// --------------------------------------------------------------------------- +// Error codes +// --------------------------------------------------------------------------- + +#[derive_ReprC] +#[repr(i32)] +pub enum ErrorCode { + None = 0, + BadUtf8 = -1, + BadIntro = -2, + DeliveryFail = -3, + UnknownError = -4, +} + +// --------------------------------------------------------------------------- +// Result types (opaque, heap-allocated via repr_c::Box) +// --------------------------------------------------------------------------- + +#[derive_ReprC] +#[repr(opaque)] +pub struct CreateIntroResult { + error_code: i32, + data: Option>, +} + +#[derive_ReprC] +#[repr(opaque)] +pub struct CreateConvoResult { + error_code: i32, + convo_id: Option, +} + +#[derive_ReprC] +#[repr(opaque)] +pub struct PushInboundResult { + error_code: i32, + has_content: bool, + is_new_convo: bool, + convo_id: Option, + content: Option>, +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/// Create an ephemeral in-memory client. Returns NULL if `callback` is None or +/// `name` is not valid UTF-8. Free with `client_destroy`. +#[ffi_export] +fn client_create( + name: c_slice::Ref<'_, u8>, + callback: DeliverFn, +) -> Option> { + let name_str = match std::str::from_utf8(name.as_slice()) { + Ok(s) => s, + Err(_) => return None, + }; + callback?; + let delivery = CDelivery { callback }; + Some(Box::new(ClientHandle(ChatClient::new(name_str, delivery))).into()) +} + +/// Free a client handle. Must not be used after this call. +#[ffi_export] +fn client_destroy(handle: repr_c::Box) { + drop(handle) +} + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/// Return the installation name as an owned byte slice. +/// Free with `client_installation_name_free`. +#[ffi_export] +fn client_installation_name(handle: &ClientHandle) -> c_slice::Box { + handle + .0 + .installation_name() + .as_bytes() + .to_vec() + .into_boxed_slice() + .into() +} + +#[ffi_export] +fn client_installation_name_free(name: c_slice::Box) { + drop(name) +} + +// --------------------------------------------------------------------------- +// Intro bundle +// --------------------------------------------------------------------------- + +/// Produce a serialised introduction bundle for out-of-band sharing. +/// Free with `create_intro_result_free`. +#[ffi_export] +fn client_create_intro_bundle(handle: &mut ClientHandle) -> repr_c::Box { + let result = match handle.0.create_intro_bundle() { + Ok(bytes) => CreateIntroResult { + error_code: ErrorCode::None as i32, + data: Some(bytes), + }, + Err(_) => CreateIntroResult { + error_code: ErrorCode::UnknownError as i32, + data: None, + }, + }; + Box::new(result).into() +} + +#[ffi_export] +fn create_intro_result_error_code(r: &CreateIntroResult) -> i32 { + r.error_code +} + +/// Returns an empty slice when error_code != 0. +/// The slice is valid only while `r` is alive. +#[ffi_export] +fn create_intro_result_bytes(r: &CreateIntroResult) -> c_slice::Ref<'_, u8> { + r.data.as_deref().unwrap_or(&[]).into() +} + +#[ffi_export] +fn create_intro_result_free(r: repr_c::Box) { + drop(r) +} + +// --------------------------------------------------------------------------- +// Create conversation +// --------------------------------------------------------------------------- + +/// Parse an intro bundle and initiate a private conversation. +/// Outbound envelopes are dispatched through the delivery callback. +/// Free with `create_convo_result_free`. +#[ffi_export] +fn client_create_conversation( + handle: &mut ClientHandle, + bundle: c_slice::Ref<'_, u8>, + content: c_slice::Ref<'_, u8>, +) -> repr_c::Box { + let result = match handle + .0 + .create_conversation(bundle.as_slice(), content.as_slice()) + { + Ok(convo_id) => CreateConvoResult { + error_code: ErrorCode::None as i32, + convo_id: Some(convo_id.to_string()), + }, + Err(ClientError::Chat(_)) => CreateConvoResult { + error_code: ErrorCode::BadIntro as i32, + convo_id: None, + }, + Err(ClientError::Delivery(_)) => CreateConvoResult { + error_code: ErrorCode::DeliveryFail as i32, + convo_id: None, + }, + }; + Box::new(result).into() +} + +#[ffi_export] +fn create_convo_result_error_code(r: &CreateConvoResult) -> i32 { + r.error_code +} + +/// Returns an empty slice when error_code != 0. +/// The slice is valid only while `r` is alive. +#[ffi_export] +fn create_convo_result_id(r: &CreateConvoResult) -> c_slice::Ref<'_, u8> { + r.convo_id.as_deref().unwrap_or("").as_bytes().into() +} + +#[ffi_export] +fn create_convo_result_free(r: repr_c::Box) { + drop(r) +} + +// --------------------------------------------------------------------------- +// Send message +// --------------------------------------------------------------------------- + +/// Encrypt `content` and dispatch outbound envelopes. Returns an `ErrorCode`. +#[ffi_export] +fn client_send_message( + handle: &mut ClientHandle, + convo_id: c_slice::Ref<'_, u8>, + content: c_slice::Ref<'_, u8>, +) -> ErrorCode { + let id_str = match std::str::from_utf8(convo_id.as_slice()) { + Ok(s) => s, + Err(_) => return ErrorCode::BadUtf8, + }; + let convo_id_owned: client::ConversationIdOwned = Arc::from(id_str); + match handle.0.send_message(&convo_id_owned, content.as_slice()) { + Ok(()) => ErrorCode::None, + Err(ClientError::Delivery(_)) => ErrorCode::DeliveryFail, + Err(_) => ErrorCode::UnknownError, + } +} + +// --------------------------------------------------------------------------- +// Push inbound +// --------------------------------------------------------------------------- + +/// Decrypt an inbound payload. `has_content` is false for protocol frames. +/// Free with `push_inbound_result_free`. +#[ffi_export] +fn client_receive( + handle: &mut ClientHandle, + payload: c_slice::Ref<'_, u8>, +) -> repr_c::Box { + let result = match handle.0.receive(payload.as_slice()) { + Ok(Some(cd)) => PushInboundResult { + error_code: ErrorCode::None as i32, + has_content: true, + is_new_convo: cd.is_new_convo, + convo_id: Some(cd.conversation_id), + content: Some(cd.data), + }, + Ok(None) => PushInboundResult { + error_code: ErrorCode::None as i32, + has_content: false, + is_new_convo: false, + convo_id: None, + content: None, + }, + Err(_) => PushInboundResult { + error_code: ErrorCode::UnknownError as i32, + has_content: false, + is_new_convo: false, + convo_id: None, + content: None, + }, + }; + Box::new(result).into() +} + +#[ffi_export] +fn push_inbound_result_error_code(r: &PushInboundResult) -> i32 { + r.error_code +} + +#[ffi_export] +fn push_inbound_result_has_content(r: &PushInboundResult) -> bool { + r.has_content +} + +#[ffi_export] +fn push_inbound_result_is_new_convo(r: &PushInboundResult) -> bool { + r.is_new_convo +} + +/// Returns an empty slice when has_content is false. +/// The slice is valid only while `r` is alive. +#[ffi_export] +fn push_inbound_result_convo_id(r: &PushInboundResult) -> c_slice::Ref<'_, u8> { + r.convo_id.as_deref().unwrap_or("").as_bytes().into() +} + +/// Returns an empty slice when has_content is false. +/// The slice is valid only while `r` is alive. +#[ffi_export] +fn push_inbound_result_content(r: &PushInboundResult) -> c_slice::Ref<'_, u8> { + r.content.as_deref().unwrap_or(&[]).into() +} + +#[ffi_export] +fn push_inbound_result_free(r: repr_c::Box) { + drop(r) +} diff --git a/crates/client-ffi/src/bin/generate-headers.rs b/crates/client-ffi/src/bin/generate-headers.rs new file mode 100644 index 0000000..e985141 --- /dev/null +++ b/crates/client-ffi/src/bin/generate-headers.rs @@ -0,0 +1,6 @@ +fn main() -> std::io::Result<()> { + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "client_ffi.h".into()); + client_ffi::generate_headers(&path) +} diff --git a/crates/client-ffi/src/delivery.rs b/crates/client-ffi/src/delivery.rs new file mode 100644 index 0000000..e19ab28 --- /dev/null +++ b/crates/client-ffi/src/delivery.rs @@ -0,0 +1,31 @@ +use client::DeliveryService; +use libchat::AddressedEnvelope; + +/// C callback invoked for each outbound envelope. Return 0 or positive on success, negative on +/// error. `addr_ptr/addr_len` is the delivery address; `data_ptr/data_len` is the encrypted +/// payload. Both pointers are borrowed for the duration of the call only; the callee must not +/// retain or free them. +pub type DeliverFn = Option< + unsafe extern "C" fn( + addr_ptr: *const u8, + addr_len: usize, + data_ptr: *const u8, + data_len: usize, + ) -> i32, +>; + +pub struct CDelivery { + pub callback: DeliverFn, +} + +impl DeliveryService for CDelivery { + type Error = i32; + + fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), i32> { + let cb = self.callback.expect("callback must be non-null"); + let addr = envelope.delivery_address.as_bytes(); + let data = envelope.data.as_slice(); + let rc = unsafe { cb(addr.as_ptr(), addr.len(), data.as_ptr(), data.len()) }; + if rc < 0 { Err(rc) } else { Ok(()) } + } +} diff --git a/crates/client-ffi/src/lib.rs b/crates/client-ffi/src/lib.rs new file mode 100644 index 0000000..ba391a3 --- /dev/null +++ b/crates/client-ffi/src/lib.rs @@ -0,0 +1,7 @@ +mod api; +mod delivery; + +#[cfg(feature = "headers")] +pub fn generate_headers(path: &str) -> std::io::Result<()> { + safer_ffi::headers::builder().to_file(path)?.generate() +} 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..3ef82ea 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,18 +1,90 @@ -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) + } + + 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..4a61d62 --- /dev/null +++ b/crates/client/src/delivery.rs @@ -0,0 +1,6 @@ +use libchat::AddressedEnvelope; + +pub trait DeliveryService { + type Error: std::fmt::Debug; + fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error>; +} diff --git a/crates/client/src/delivery_in_process.rs b/crates/client/src/delivery_in_process.rs new file mode 100644 index 0000000..ee916c8 --- /dev/null +++ b/crates/client/src/delivery_in_process.rs @@ -0,0 +1,109 @@ +use crate::{AddressedEnvelope, delivery::DeliveryService}; +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; + +type Message = Vec; + +/// Shared in-process message bus. Cheap to clone — all clones share the same log. +/// +/// Messages are stored in an append-only log per delivery address. Readers hold +/// independent [`Cursor`]s and advance their position without consuming messages, +/// so multiple consumers on the same address each see every message. +#[derive(Clone, Default)] +pub struct MessageBus { + log: Arc>>>, +} + +impl MessageBus { + /// Returns a cursor positioned at the beginning of `address`. + /// The cursor will see all messages — past and future. + pub fn subscribe(&self, address: &str) -> Cursor { + Cursor { + bus: self.clone(), + address: address.to_string(), + pos: 0, + } + } + + /// Returns a cursor positioned at the current tail of `address`. + /// The cursor will only see messages delivered after this call. + pub fn subscribe_tail(&self, address: &str) -> Cursor { + let pos = self.log.lock().unwrap().get(address).map_or(0, |v| v.len()); + Cursor { + bus: self.clone(), + address: address.to_string(), + pos, + } + } + + fn push(&self, address: String, data: Message) { + self.log + .lock() + .unwrap() + .entry(address) + .or_default() + .push(data); + } +} + +/// Per-consumer read cursor into a [`MessageBus`] address slot. +/// +/// Reads are non-destructive: the underlying log is never modified. +/// Multiple cursors on the same address each advance independently. +pub struct Cursor { + bus: MessageBus, + address: String, + pos: usize, +} + +impl Iterator for Cursor { + type Item = Message; + + fn next(&mut self) -> Option { + let guard = self.bus.log.lock().unwrap(); + let msgs = guard.get(&self.address)?; + if self.pos < msgs.len() { + let msg = msgs[self.pos].clone(); + self.pos += 1; + Some(msg) + } else { + None + } + } +} + +/// In-process delivery service backed by a [`MessageBus`]. +/// +/// Cheap to clone — all clones share the same underlying bus, so multiple +/// clients can share one logical delivery service. Use [`InProcessDelivery::new`] +/// to get both the service and a bus handle for subscribing [`Cursor`]s. +#[derive(Clone)] +pub struct InProcessDelivery(MessageBus); + +impl InProcessDelivery { + /// Create a new delivery service with its own private bus. + /// Returns both the service and a handle to the bus so callers can + /// subscribe [`Cursor`]s to read delivered messages. + pub fn new() -> (Self, MessageBus) { + let bus = MessageBus::default(); + (Self(bus.clone()), bus) + } +} + +impl Default for InProcessDelivery { + /// Create a standalone delivery service with no externally-held bus handle. + /// Useful when routing is not needed (e.g. persistent-client tests). + fn default() -> Self { + Self(MessageBus::default()) + } +} + +impl DeliveryService for InProcessDelivery { + type Error = Infallible; + + fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Infallible> { + self.0.push(envelope.delivery_address, 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..cfd9074 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1,3 +1,12 @@ mod client; +mod delivery; +mod delivery_in_process; +mod errors; pub use client::ChatClient; +pub use delivery::DeliveryService; +pub use delivery_in_process::{Cursor, InProcessDelivery, MessageBus}; +pub use errors::ClientError; + +// Re-export types callers need to interact with ChatClient +pub use libchat::{AddressedEnvelope, 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..acef8e2 --- /dev/null +++ b/crates/client/tests/alice_and_bob.rs @@ -0,0 +1,71 @@ +use client::{ + ChatClient, ContentData, ConversationIdOwned, Cursor, InProcessDelivery, StorageConfig, +}; +use std::sync::Arc; + +fn receive(receiver: &mut ChatClient, cursor: &mut Cursor) -> ContentData { + let raw = cursor.next().expect("expected envelope"); + receiver + .receive(&raw) + .expect("receive failed") + .expect("expected content") +} + +#[test] +fn alice_bob_message_exchange() { + let (delivery, bus) = InProcessDelivery::new(); + let mut cursor = bus.subscribe_tail("delivery_address"); + + let mut alice = ChatClient::new("alice", delivery.clone()); + let mut bob = ChatClient::new("bob", delivery); + + let bob_bundle = bob.create_intro_bundle().unwrap(); + let alice_convo_id = alice + .create_conversation(&bob_bundle, b"hello bob") + .unwrap(); + + let content = receive(&mut bob, &mut cursor); + 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.send_message(&bob_convo_id, b"hi alice").unwrap(); + let content = receive(&mut alice, &mut cursor); + assert_eq!(content.data, b"hi alice"); + assert!(!content.is_new_convo); + + for i in 0u8..5 { + let msg = format!("msg {i}"); + alice.send_message(&alice_convo_id, msg.as_bytes()).unwrap(); + let content = receive(&mut bob, &mut cursor); + assert_eq!(content.data, msg.as_bytes()); + + let reply = format!("reply {i}"); + bob.send_message(&bob_convo_id, reply.as_bytes()).unwrap(); + let content = receive(&mut alice, &mut cursor); + assert_eq!(content.data, reply.as_bytes()); + } + + assert_eq!(alice.list_conversations().unwrap().len(), 1); + assert_eq!(bob.list_conversations().unwrap().len(), 1); +} + +#[test] +fn open_persistent_client() { + 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(), InProcessDelivery::default()).unwrap(); + let name1 = client1.installation_name().to_string(); + drop(client1); + + let client2 = ChatClient::open("alice", config, InProcessDelivery::default()).unwrap(); + let name2 = client2.installation_name().to_string(); + + assert_eq!( + name1, name2, + "installation name should persist across restarts" + ); +} diff --git a/examples/c-ffi/Makefile b/examples/c-ffi/Makefile new file mode 100644 index 0000000..a562867 --- /dev/null +++ b/examples/c-ffi/Makefile @@ -0,0 +1,39 @@ +REPO_ROOT := $(shell cd ../.. && pwd) +CARGO_PROFILE ?= debug +LIB_DIR := $(REPO_ROOT)/target/$(CARGO_PROFILE) +INCLUDE_DIR := $(REPO_ROOT)/crates/client-ffi +HEADER := $(INCLUDE_DIR)/client_ffi.h + +CC ?= cc +CFLAGS := -Wall -Wextra -std=c11 -I$(INCLUDE_DIR) +LIBS := -L$(LIB_DIR) -lclient_ffi -lpthread -ldl -lm + +.PHONY: all run valgrind clean generate-headers _cargo + +all: c-client + +generate-headers: + cargo run --manifest-path $(REPO_ROOT)/Cargo.toml \ + -p client-ffi --bin generate-headers --features headers \ + -- $(HEADER) + +_cargo: + cargo build --manifest-path $(REPO_ROOT)/Cargo.toml -p client-ffi \ + $(if $(filter release,$(CARGO_PROFILE)),--release,) + +c-client: src/main.c generate-headers _cargo + $(CC) $(CFLAGS) src/main.c $(LIBS) -o c-client + +run: c-client + ./c-client + +valgrind: c-client + valgrind \ + --error-exitcode=1 \ + --leak-check=full \ + --errors-for-leak-kinds=definite,indirect \ + --track-origins=yes \ + ./c-client + +clean: + rm -f c-client $(HEADER) diff --git a/examples/c-ffi/README.md b/examples/c-ffi/README.md new file mode 100644 index 0000000..b67b2a4 --- /dev/null +++ b/examples/c-ffi/README.md @@ -0,0 +1,21 @@ +# c-client + +An example C application built on top of [`crates/client-ffi`](../../crates/client-ffi). + +It demonstrates that the C ABI exposed by `crates/client-ffi` is straightforward to +consume from plain C — or from any language that can call into a C ABI. No Rust code, +no Cargo project: just a C source file linked against the pre-built static library. + +## Building and running + +```sh +make # builds client-ffi with Cargo, then compiles src/main.c +make run # build + execute +make clean # remove the compiled binary +``` + +For a release build: + +```sh +make CARGO_PROFILE=release +``` diff --git a/examples/c-ffi/src/main.c b/examples/c-ffi/src/main.c new file mode 100644 index 0000000..e4886a7 --- /dev/null +++ b/examples/c-ffi/src/main.c @@ -0,0 +1,202 @@ +/* + * c-client: Alice-Bob message exchange written entirely in C. + * + * Demonstrates that the client-ffi C API is straightforward to consume + * directly — no Rust glue required. Build with the provided Makefile. + */ + +#include "client_ffi.h" + +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ + * Convenience macros for building slice_ref_uint8_t values. + * SLICE(p, n) — arbitrary pointer + length. + * STR(s) — string literal (length computed at compile time). + * ------------------------------------------------------------------ */ + +#define SLICE(p, n) ((slice_ref_uint8_t){ .ptr = (const uint8_t *)(p), .len = (n) }) +#define STR(s) SLICE(s, sizeof(s) - 1) + +/* ------------------------------------------------------------------ + * In-memory delivery bus (shared by all clients, like InProcessDelivery) + * ------------------------------------------------------------------ */ + +#define MAX_ENVELOPES 32 +#define MAX_ENVELOPE_SZ 2048 + +typedef struct { + uint8_t data[MAX_ENVELOPE_SZ]; + size_t len; +} Envelope; + +typedef struct { + Envelope items[MAX_ENVELOPES]; + int head; + int tail; + int count; +} Queue; + +static Queue bus; + +static void queue_init(Queue *q) +{ + memset(q, 0, sizeof(*q)); +} + +static void queue_push(Queue *q, const uint8_t *data, size_t len) +{ + assert(q->count < MAX_ENVELOPES && "delivery queue overflow"); + assert(len <= MAX_ENVELOPE_SZ && "envelope too large"); + memcpy(q->items[q->tail].data, data, len); + q->items[q->tail].len = len; + q->tail = (q->tail + 1) % MAX_ENVELOPES; + q->count++; +} + +static int queue_pop(Queue *q, const uint8_t **data_out, size_t *len_out) +{ + if (q->count == 0) return 0; + *data_out = q->items[q->head].data; + *len_out = q->items[q->head].len; + q->head = (q->head + 1) % MAX_ENVELOPES; + q->count--; + return 1; +} + +/* ------------------------------------------------------------------ + * Delivery callback: all clients share one bus. + * ------------------------------------------------------------------ */ + +static int32_t deliver_cb( + const uint8_t *addr_ptr, size_t addr_len, + const uint8_t *data_ptr, size_t data_len) +{ + (void)addr_ptr; (void)addr_len; + queue_push(&bus, data_ptr, data_len); + return 0; +} + +/* ------------------------------------------------------------------ + * Helper: pop one envelope from the bus and push it into receiver. + * Returns a heap-allocated result; caller frees with + * push_inbound_result_free(). + * ------------------------------------------------------------------ */ + +static PushInboundResult_t *route(ClientHandle_t *receiver) +{ + const uint8_t *data; + size_t len; + int ok = queue_pop(&bus, &data, &len); + assert(ok && "expected an envelope in the bus"); + PushInboundResult_t *r = client_receive(receiver, SLICE(data, len)); + assert(push_inbound_result_error_code(r) == 0 && "push_inbound failed"); + return r; +} + +/* ------------------------------------------------------------------ + * Main + * ------------------------------------------------------------------ */ + +int main(void) +{ + queue_init(&bus); + + /* Create clients — both share the same delivery bus */ + ClientHandle_t *alice = client_create(STR("alice"), deliver_cb); + ClientHandle_t *bob = client_create(STR("bob"), deliver_cb); + + assert(alice && "client_create returned NULL for alice"); + assert(bob && "client_create returned NULL for bob"); + + /* Bob generates an intro bundle */ + CreateIntroResult_t *bob_intro = client_create_intro_bundle(bob); + assert(create_intro_result_error_code(bob_intro) == 0); + slice_ref_uint8_t intro_bytes = create_intro_result_bytes(bob_intro); + + /* Alice initiates a conversation with Bob */ + CreateConvoResult_t *alice_convo = client_create_conversation( + alice, intro_bytes, STR("hello bob")); + assert(create_convo_result_error_code(alice_convo) == 0); + create_intro_result_free(bob_intro); + + /* Route alice -> bob */ + PushInboundResult_t *recv = route(bob); + + assert(push_inbound_result_has_content(recv) && "expected content from alice"); + assert(push_inbound_result_is_new_convo(recv) && "expected new-conversation flag"); + + slice_ref_uint8_t content = push_inbound_result_content(recv); + assert(content.len == 9); + assert(memcmp(content.ptr, "hello bob", 9) == 0); + printf("Bob received: \"%.*s\"\n", (int)content.len, content.ptr); + + /* Copy Bob's convo_id before freeing recv */ + slice_ref_uint8_t cid_ref = push_inbound_result_convo_id(recv); + uint8_t bob_cid[256]; + size_t bob_cid_len = cid_ref.len; + if (bob_cid_len >= sizeof(bob_cid)) { + fprintf(stderr, "conversation id too long (%zu bytes)\n", bob_cid_len); + return 1; + } + memcpy(bob_cid, cid_ref.ptr, bob_cid_len); + push_inbound_result_free(recv); + + /* Bob replies */ + ErrorCode_t rc = client_send_message( + bob, SLICE(bob_cid, bob_cid_len), STR("hi alice")); + assert(rc == ERROR_CODE_NONE); + + recv = route(alice); + assert(push_inbound_result_has_content(recv) && "expected content from bob"); + assert(!push_inbound_result_is_new_convo(recv) && "unexpected new-convo flag"); + content = push_inbound_result_content(recv); + assert(content.len == 8); + assert(memcmp(content.ptr, "hi alice", 8) == 0); + printf("Alice received: \"%.*s\"\n", (int)content.len, content.ptr); + push_inbound_result_free(recv); + + /* Multiple back-and-forth rounds */ + slice_ref_uint8_t alice_cid = create_convo_result_id(alice_convo); + for (int i = 0; i < 3; i++) { + char msg[32]; + int mlen = snprintf(msg, sizeof(msg), "msg %d", i); + + rc = client_send_message(alice, alice_cid, SLICE(msg, (size_t)mlen)); + assert(rc == ERROR_CODE_NONE); + + recv = route(bob); + assert(push_inbound_result_has_content(recv)); + content = push_inbound_result_content(recv); + assert((int)content.len == mlen); + assert(memcmp(content.ptr, msg, (size_t)mlen) == 0); + push_inbound_result_free(recv); + + char reply[32]; + int rlen = snprintf(reply, sizeof(reply), "reply %d", i); + + rc = client_send_message( + bob, SLICE(bob_cid, bob_cid_len), SLICE(reply, (size_t)rlen)); + assert(rc == ERROR_CODE_NONE); + + recv = route(alice); + assert(push_inbound_result_has_content(recv)); + content = push_inbound_result_content(recv); + assert((int)content.len == rlen); + assert(memcmp(content.ptr, reply, (size_t)rlen) == 0); + push_inbound_result_free(recv); + } + + /* Cleanup */ + create_convo_result_free(alice_convo); + client_destroy(alice); + client_destroy(bob); + + printf("Message exchange complete.\n"); + return 0; +} diff --git a/examples/in-process/Cargo.toml b/examples/in-process/Cargo.toml new file mode 100644 index 0000000..0305491 --- /dev/null +++ b/examples/in-process/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "in-process" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "in-process" +path = "src/main.rs" + +[dependencies] +client = { path = "../../crates/client" } diff --git a/examples/in-process/README.md b/examples/in-process/README.md new file mode 100644 index 0000000..0cb5daa --- /dev/null +++ b/examples/in-process/README.md @@ -0,0 +1,16 @@ +# in-process + +An example Rust application built on top of [`crates/client`](../../crates/client). + +It demonstrates that creating a working chat client in pure Rust is trivial: depend on +`crates/client`, pick a `DeliveryService` implementation (here the in-memory +`InProcessDelivery` shipped with the crate), and wire up `ChatClient`. No boilerplate, no FFI. + +## Running + +``` +cargo run -p in-process +``` + +The binary performs an Alice-Bob message exchange entirely in-process and prints +the exchanged messages to stdout. diff --git a/examples/in-process/src/main.rs b/examples/in-process/src/main.rs new file mode 100644 index 0000000..3fab9ca --- /dev/null +++ b/examples/in-process/src/main.rs @@ -0,0 +1,34 @@ +use client::{ChatClient, ConversationIdOwned, InProcessDelivery}; +use std::sync::Arc; + +fn main() { + let (delivery, bus) = InProcessDelivery::new(); + let mut cursor = bus.subscribe_tail("delivery_address"); + + let mut alice = ChatClient::new("alice", delivery.clone()); + let mut bob = ChatClient::new("bob", delivery); + + let bob_bundle = bob.create_intro_bundle().unwrap(); + alice + .create_conversation(&bob_bundle, b"hello bob") + .unwrap(); + + let raw = cursor.next().unwrap(); + let content = bob.receive(&raw).unwrap().unwrap(); + println!( + "Bob received: {:?}", + std::str::from_utf8(&content.data).unwrap() + ); + + let bob_convo_id: ConversationIdOwned = Arc::from(content.conversation_id.as_str()); + bob.send_message(&bob_convo_id, b"hi alice").unwrap(); + + let raw = cursor.next().unwrap(); + let content = alice.receive(&raw).unwrap().unwrap(); + println!( + "Alice received: {:?}", + std::str::from_utf8(&content.data).unwrap() + ); + + println!("Message exchange complete."); +}