mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-04-01 17:13:13 +00:00
feat: implement Client crate and C FFI bindings
Implement a `client` crate that wraps the `libchat` context behind a simple `ChatClient<D>` API. The delivery strategy is pluggable via a `DeliveryService` trait, with two implementations provided: - `InProcessDelivery` — shared `MessageBus` for single-process tests - `CDelivery` — C function-pointer callback for the FFI layer Add a `client-ffi` crate that exposes the client as a C API via `safer-ffi`. A `generate-headers` binary produces the companion C header. Include two runnable examples: - `examples/in-process` — Alice/Bob exchange using in-process delivery - `examples/c-ffi` — same exchange written entirely in C; smoketested under valgrind (to catch memory leaks) in CI iterates: #71
This commit is contained in:
parent
8cddd9ddcf
commit
0a6e833b53
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||
|
||||
17
Cargo.lock
generated
17
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -8,6 +8,8 @@ members = [
|
||||
"core/double-ratchets",
|
||||
"core/storage",
|
||||
"crates/client",
|
||||
"crates/client-ffi",
|
||||
"examples/in-process",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@ -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 {
|
||||
|
||||
19
crates/client-ffi/Cargo.toml
Normal file
19
crates/client-ffi/Cargo.toml
Normal file
@ -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"]
|
||||
285
crates/client-ffi/src/api.rs
Normal file
285
crates/client-ffi/src/api.rs
Normal file
@ -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<CDelivery>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive_ReprC]
|
||||
#[repr(opaque)]
|
||||
pub struct CreateConvoResult {
|
||||
error_code: i32,
|
||||
convo_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive_ReprC]
|
||||
#[repr(opaque)]
|
||||
pub struct PushInboundResult {
|
||||
error_code: i32,
|
||||
has_content: bool,
|
||||
is_new_convo: bool,
|
||||
convo_id: Option<String>,
|
||||
content: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<repr_c::Box<ClientHandle>> {
|
||||
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<ClientHandle>) {
|
||||
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<u8> {
|
||||
handle
|
||||
.0
|
||||
.installation_name()
|
||||
.as_bytes()
|
||||
.to_vec()
|
||||
.into_boxed_slice()
|
||||
.into()
|
||||
}
|
||||
|
||||
#[ffi_export]
|
||||
fn client_installation_name_free(name: c_slice::Box<u8>) {
|
||||
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<CreateIntroResult> {
|
||||
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<CreateIntroResult>) {
|
||||
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<CreateConvoResult> {
|
||||
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<CreateConvoResult>) {
|
||||
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<PushInboundResult> {
|
||||
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<PushInboundResult>) {
|
||||
drop(r)
|
||||
}
|
||||
6
crates/client-ffi/src/bin/generate-headers.rs
Normal file
6
crates/client-ffi/src/bin/generate-headers.rs
Normal file
@ -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)
|
||||
}
|
||||
31
crates/client-ffi/src/delivery.rs
Normal file
31
crates/client-ffi/src/delivery.rs
Normal file
@ -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(()) }
|
||||
}
|
||||
}
|
||||
7
crates/client-ffi/src/lib.rs
Normal file
7
crates/client-ffi/src/lib.rs
Normal file
@ -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()
|
||||
}
|
||||
@ -8,3 +8,6 @@ crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
libchat = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@ -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<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)
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
6
crates/client/src/delivery.rs
Normal file
6
crates/client/src/delivery.rs
Normal file
@ -0,0 +1,6 @@
|
||||
use libchat::AddressedEnvelope;
|
||||
|
||||
pub trait DeliveryService {
|
||||
type Error: std::fmt::Debug;
|
||||
fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error>;
|
||||
}
|
||||
109
crates/client/src/delivery_in_process.rs
Normal file
109
crates/client/src/delivery_in_process.rs
Normal file
@ -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<u8>;
|
||||
|
||||
/// 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<Mutex<HashMap<String, Vec<Message>>>>,
|
||||
}
|
||||
|
||||
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<Message> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
15
crates/client/src/errors.rs
Normal file
15
crates/client/src/errors.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
|
||||
71
crates/client/tests/alice_and_bob.rs
Normal file
71
crates/client/tests/alice_and_bob.rs
Normal file
@ -0,0 +1,71 @@
|
||||
use client::{
|
||||
ChatClient, ContentData, ConversationIdOwned, Cursor, InProcessDelivery, StorageConfig,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn receive(receiver: &mut ChatClient<InProcessDelivery>, 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"
|
||||
);
|
||||
}
|
||||
39
examples/c-ffi/Makefile
Normal file
39
examples/c-ffi/Makefile
Normal file
@ -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)
|
||||
21
examples/c-ffi/README.md
Normal file
21
examples/c-ffi/README.md
Normal file
@ -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
|
||||
```
|
||||
202
examples/c-ffi/src/main.c
Normal file
202
examples/c-ffi/src/main.c
Normal file
@ -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 <assert.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 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;
|
||||
}
|
||||
11
examples/in-process/Cargo.toml
Normal file
11
examples/in-process/Cargo.toml
Normal file
@ -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" }
|
||||
16
examples/in-process/README.md
Normal file
16
examples/in-process/README.md
Normal file
@ -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.
|
||||
34
examples/in-process/src/main.rs
Normal file
34
examples/in-process/src/main.rs
Normal file
@ -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.");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user