mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-05-12 21:19:44 +00:00
feat: implement Client crate and C FFI bindings (#73)
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
8e8b0c0dd7
commit
d68c0cb275
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@ -36,3 +36,21 @@ jobs:
|
|||||||
- run: rustup update stable && rustup default stable
|
- run: rustup update stable && rustup default stable
|
||||||
- run: rustup component add rustfmt
|
- run: rustup component add rustfmt
|
||||||
- run: cargo fmt --all -- --check
|
- run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
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: crates/client-ffi/examples/message-exchange
|
||||||
|
- name: Run C FFI smoketest
|
||||||
|
run: ./c-client
|
||||||
|
working-directory: crates/client-ffi/examples/message-exchange
|
||||||
|
- name: Run C FFI smoketest under valgrind
|
||||||
|
run: make valgrind
|
||||||
|
working-directory: crates/client-ffi/examples/message-exchange
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -31,3 +31,9 @@ target
|
|||||||
tmp
|
tmp
|
||||||
|
|
||||||
.DS_Store
|
.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
|
||||||
|
|||||||
11
Cargo.lock
generated
11
Cargo.lock
generated
@ -137,6 +137,17 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chat-sqlite",
|
"chat-sqlite",
|
||||||
"libchat",
|
"libchat",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "client-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client",
|
||||||
|
"libchat",
|
||||||
|
"safer-ffi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -9,6 +9,7 @@ members = [
|
|||||||
"core/double-ratchets",
|
"core/double-ratchets",
|
||||||
"core/storage",
|
"core/storage",
|
||||||
"crates/client",
|
"crates/client",
|
||||||
|
"crates/client-ffi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
@ -7,6 +7,8 @@ mod proto;
|
|||||||
mod types;
|
mod types;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use context::{Context, Introduction};
|
pub use context::{Context, ConversationIdOwned, Introduction};
|
||||||
pub use errors::ChatError;
|
pub use errors::ChatError;
|
||||||
pub use sqlite::ChatStorage;
|
pub use sqlite::ChatStorage;
|
||||||
|
pub use sqlite::StorageConfig;
|
||||||
|
pub use types::{AddressedEnvelope, ContentData};
|
||||||
|
|||||||
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"]
|
||||||
39
crates/client-ffi/examples/message-exchange/Makefile
Normal file
39
crates/client-ffi/examples/message-exchange/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
crates/client-ffi/examples/message-exchange/README.md
Normal file
21
crates/client-ffi/examples/message-exchange/README.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# message-exchange
|
||||||
|
|
||||||
|
An example C application built on top of [`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
crates/client-ffi/examples/message-exchange/src/main.c
Normal file
202
crates/client-ffi/examples/message-exchange/src/main.c
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
/*
|
||||||
|
* message-exchange: Saro-Raya 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 *saro = client_create(STR("saro"), deliver_cb);
|
||||||
|
ClientHandle_t *raya = client_create(STR("raya"), deliver_cb);
|
||||||
|
|
||||||
|
assert(saro && "client_create returned NULL for saro");
|
||||||
|
assert(raya && "client_create returned NULL for raya");
|
||||||
|
|
||||||
|
/* Raya generates an intro bundle */
|
||||||
|
CreateIntroResult_t *raya_intro = client_create_intro_bundle(raya);
|
||||||
|
assert(create_intro_result_error_code(raya_intro) == 0);
|
||||||
|
slice_ref_uint8_t intro_bytes = create_intro_result_bytes(raya_intro);
|
||||||
|
|
||||||
|
/* Saro initiates a conversation with Raya */
|
||||||
|
CreateConvoResult_t *saro_convo = client_create_conversation(
|
||||||
|
saro, intro_bytes, STR("hello raya"));
|
||||||
|
assert(create_convo_result_error_code(saro_convo) == 0);
|
||||||
|
create_intro_result_free(raya_intro);
|
||||||
|
|
||||||
|
/* Route saro -> raya */
|
||||||
|
PushInboundResult_t *recv = route(raya);
|
||||||
|
|
||||||
|
assert(push_inbound_result_has_content(recv) && "expected content from saro");
|
||||||
|
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 == 10);
|
||||||
|
assert(memcmp(content.ptr, "hello raya", 10) == 0);
|
||||||
|
printf("Raya received: \"%.*s\"\n", (int)content.len, content.ptr);
|
||||||
|
|
||||||
|
/* Copy Raya's convo_id before freeing recv */
|
||||||
|
slice_ref_uint8_t cid_ref = push_inbound_result_convo_id(recv);
|
||||||
|
uint8_t raya_cid[256];
|
||||||
|
size_t raya_cid_len = cid_ref.len;
|
||||||
|
if (raya_cid_len >= sizeof(raya_cid)) {
|
||||||
|
fprintf(stderr, "conversation id too long (%zu bytes)\n", raya_cid_len);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
memcpy(raya_cid, cid_ref.ptr, raya_cid_len);
|
||||||
|
push_inbound_result_free(recv);
|
||||||
|
|
||||||
|
/* Raya replies */
|
||||||
|
ErrorCode_t rc = client_send_message(
|
||||||
|
raya, SLICE(raya_cid, raya_cid_len), STR("hi saro"));
|
||||||
|
assert(rc == ERROR_CODE_NONE);
|
||||||
|
|
||||||
|
recv = route(saro);
|
||||||
|
assert(push_inbound_result_has_content(recv) && "expected content from raya");
|
||||||
|
assert(!push_inbound_result_is_new_convo(recv) && "unexpected new-convo flag");
|
||||||
|
content = push_inbound_result_content(recv);
|
||||||
|
assert(content.len == 7);
|
||||||
|
assert(memcmp(content.ptr, "hi saro", 7) == 0);
|
||||||
|
printf("Saro received: \"%.*s\"\n", (int)content.len, content.ptr);
|
||||||
|
push_inbound_result_free(recv);
|
||||||
|
|
||||||
|
/* Multiple back-and-forth rounds */
|
||||||
|
slice_ref_uint8_t saro_cid = create_convo_result_id(saro_convo);
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
char msg[32];
|
||||||
|
int mlen = snprintf(msg, sizeof(msg), "msg %d", i);
|
||||||
|
|
||||||
|
rc = client_send_message(saro, saro_cid, SLICE(msg, (size_t)mlen));
|
||||||
|
assert(rc == ERROR_CODE_NONE);
|
||||||
|
|
||||||
|
recv = route(raya);
|
||||||
|
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(
|
||||||
|
raya, SLICE(raya_cid, raya_cid_len), SLICE(reply, (size_t)rlen));
|
||||||
|
assert(rc == ERROR_CODE_NONE);
|
||||||
|
|
||||||
|
recv = route(saro);
|
||||||
|
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(saro_convo);
|
||||||
|
client_destroy(saro);
|
||||||
|
client_destroy(raya);
|
||||||
|
|
||||||
|
printf("Message exchange complete.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
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 publish(&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()
|
||||||
|
}
|
||||||
@ -9,3 +9,7 @@ crate-type = ["rlib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
libchat = { workspace = true }
|
libchat = { workspace = true }
|
||||||
chat-sqlite = { path = "../../core/sqlite" }
|
chat-sqlite = { path = "../../core/sqlite" }
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
16
crates/client/examples/message-exchange/README.md
Normal file
16
crates/client/examples/message-exchange/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# message-exchange
|
||||||
|
|
||||||
|
An example Rust application built on top of [`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 --example message-exchange
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary performs a message exchange entirely in-process and prints
|
||||||
|
the exchanged messages to stdout.
|
||||||
33
crates/client/examples/message-exchange/main.rs
Normal file
33
crates/client/examples/message-exchange/main.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use client::{ChatClient, ConversationIdOwned, InProcessDelivery};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let delivery = InProcessDelivery::new(Default::default());
|
||||||
|
let mut cursor = delivery.cursor_at_tail("delivery_address");
|
||||||
|
|
||||||
|
let mut saro = ChatClient::new("saro", delivery.clone());
|
||||||
|
let mut raya = ChatClient::new("raya", delivery);
|
||||||
|
|
||||||
|
let raya_bundle = raya.create_intro_bundle().unwrap();
|
||||||
|
saro.create_conversation(&raya_bundle, b"hello raya")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let raw = cursor.next().unwrap();
|
||||||
|
let content = raya.receive(&raw).unwrap().unwrap();
|
||||||
|
println!(
|
||||||
|
"Raya received: {:?}",
|
||||||
|
std::str::from_utf8(&content.data).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let raya_convo_id: ConversationIdOwned = Arc::from(content.conversation_id.as_str());
|
||||||
|
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
|
||||||
|
|
||||||
|
let raw = cursor.next().unwrap();
|
||||||
|
let content = saro.receive(&raw).unwrap().unwrap();
|
||||||
|
println!(
|
||||||
|
"Saro received: {:?}",
|
||||||
|
std::str::from_utf8(&content.data).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("Message exchange complete.");
|
||||||
|
}
|
||||||
@ -1,22 +1,93 @@
|
|||||||
use chat_sqlite::StorageConfig;
|
use libchat::{
|
||||||
use libchat::ChatError;
|
AddressedEnvelope, ChatError, ChatStorage, ContentData, Context, ConversationIdOwned,
|
||||||
use libchat::ChatStorage;
|
Introduction, StorageConfig,
|
||||||
use libchat::Context;
|
};
|
||||||
|
|
||||||
pub struct ChatClient {
|
use crate::{delivery::DeliveryService, errors::ClientError};
|
||||||
|
|
||||||
|
pub struct ChatClient<D: DeliveryService> {
|
||||||
ctx: Context<ChatStorage>,
|
ctx: Context<ChatStorage>,
|
||||||
|
delivery: D,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatClient {
|
impl<D: DeliveryService> ChatClient<D> {
|
||||||
pub fn new(name: impl Into<String>) -> Self {
|
/// Create an in-memory, ephemeral client. Identity is lost on drop.
|
||||||
let store =
|
pub fn new(name: impl Into<String>, delivery: D) -> Self {
|
||||||
ChatStorage::new(StorageConfig::InMemory).expect("in-memory storage should not fail");
|
let store = ChatStorage::in_memory();
|
||||||
Self {
|
Self {
|
||||||
ctx: Context::new_with_name(name, store),
|
ctx: Context::new_with_name(name, store),
|
||||||
|
delivery,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
|
/// Open or create a persistent client backed by `StorageConfig`.
|
||||||
self.ctx.create_intro_bundle()
|
///
|
||||||
|
/// 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 store = ChatStorage::new(config).map_err(ChatError::from)?;
|
||||||
|
let ctx = Context::new_from_store(name, store)?;
|
||||||
|
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.publish(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 publish(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error>;
|
||||||
|
}
|
||||||
111
crates/client/src/delivery_in_process.rs
Normal file
111
crates/client/src/delivery_in_process.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
use crate::{AddressedEnvelope, delivery::DeliveryService};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
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<RwLock<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 cursor(&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 cursor_at_tail(&self, address: &str) -> Cursor {
|
||||||
|
let pos = self.log.read().unwrap().get(address).map_or(0, |v| v.len());
|
||||||
|
Cursor {
|
||||||
|
bus: self.clone(),
|
||||||
|
address: address.to_string(),
|
||||||
|
pos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, address: &str, pos: usize) -> Option<Message> {
|
||||||
|
// Unwrap produces a panic when the lock is poisoned.
|
||||||
|
// It would most likely indicate log corruption (e.g. incomplete write from another thread),
|
||||||
|
// so panic propagation seems appropriate.
|
||||||
|
self.log.read().unwrap().get(address)?.get(pos).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, address: String, data: Message) {
|
||||||
|
self.log
|
||||||
|
.write()
|
||||||
|
.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 msg = self.bus.get(&self.address, self.pos)?;
|
||||||
|
self.pos += 1;
|
||||||
|
Some(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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. Construct with a
|
||||||
|
/// [`MessageBus`] and use [`cursor`](InProcessDelivery::cursor) /
|
||||||
|
/// [`cursor_at_tail`](InProcessDelivery::cursor_at_tail) to read messages.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct InProcessDelivery(MessageBus);
|
||||||
|
|
||||||
|
impl InProcessDelivery {
|
||||||
|
/// Create a delivery service backed by `bus`.
|
||||||
|
pub fn new(bus: MessageBus) -> Self {
|
||||||
|
Self(bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a cursor positioned at the beginning of `address`.
|
||||||
|
pub fn cursor(&self, address: &str) -> Cursor {
|
||||||
|
self.0.cursor(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a cursor positioned at the current tail of `address`.
|
||||||
|
/// The cursor will only see messages delivered after this call.
|
||||||
|
pub fn cursor_at_tail(&self, address: &str) -> Cursor {
|
||||||
|
self.0.cursor_at_tail(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliveryService for InProcessDelivery {
|
||||||
|
type Error = Infallible;
|
||||||
|
|
||||||
|
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), Infallible> {
|
||||||
|
self.0.push(envelope.delivery_address, envelope.data);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/client/src/errors.rs
Normal file
11
crates/client/src/errors.rs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
use libchat::ChatError;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ClientError<D: std::fmt::Debug> {
|
||||||
|
#[error(transparent)]
|
||||||
|
Chat(#[from] ChatError),
|
||||||
|
/// Crypto state advanced but at least one envelope failed delivery.
|
||||||
|
/// Caller decides whether to retry.
|
||||||
|
#[error("delivery failed: {0:?}")]
|
||||||
|
Delivery(D),
|
||||||
|
}
|
||||||
@ -1,3 +1,12 @@
|
|||||||
mod client;
|
mod client;
|
||||||
|
mod delivery;
|
||||||
|
mod delivery_in_process;
|
||||||
|
mod errors;
|
||||||
|
|
||||||
pub use client::ChatClient;
|
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/saro_and_raya.rs
Normal file
71
crates/client/tests/saro_and_raya.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 saro_raya_message_exchange() {
|
||||||
|
let delivery = InProcessDelivery::new(Default::default());
|
||||||
|
let mut cursor = delivery.cursor_at_tail("delivery_address");
|
||||||
|
|
||||||
|
let mut saro = ChatClient::new("saro", delivery.clone());
|
||||||
|
let mut raya = ChatClient::new("raya", delivery);
|
||||||
|
|
||||||
|
let raya_bundle = raya.create_intro_bundle().unwrap();
|
||||||
|
let saro_convo_id = saro
|
||||||
|
.create_conversation(&raya_bundle, b"hello raya")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = receive(&mut raya, &mut cursor);
|
||||||
|
assert_eq!(content.data, b"hello raya");
|
||||||
|
assert!(content.is_new_convo);
|
||||||
|
|
||||||
|
let raya_convo_id: ConversationIdOwned = Arc::from(content.conversation_id.as_str());
|
||||||
|
|
||||||
|
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
|
||||||
|
let content = receive(&mut saro, &mut cursor);
|
||||||
|
assert_eq!(content.data, b"hi saro");
|
||||||
|
assert!(!content.is_new_convo);
|
||||||
|
|
||||||
|
for i in 0u8..5 {
|
||||||
|
let msg = format!("msg {i}");
|
||||||
|
saro.send_message(&saro_convo_id, msg.as_bytes()).unwrap();
|
||||||
|
let content = receive(&mut raya, &mut cursor);
|
||||||
|
assert_eq!(content.data, msg.as_bytes());
|
||||||
|
|
||||||
|
let reply = format!("reply {i}");
|
||||||
|
raya.send_message(&raya_convo_id, reply.as_bytes()).unwrap();
|
||||||
|
let content = receive(&mut saro, &mut cursor);
|
||||||
|
assert_eq!(content.data, reply.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(saro.list_conversations().unwrap().len(), 1);
|
||||||
|
assert_eq!(raya.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("saro", config.clone(), InProcessDelivery::default()).unwrap();
|
||||||
|
let name1 = client1.installation_name().to_string();
|
||||||
|
drop(client1);
|
||||||
|
|
||||||
|
let client2 = ChatClient::open("saro", config, InProcessDelivery::default()).unwrap();
|
||||||
|
let name2 = client2.installation_name().to_string();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
name1, name2,
|
||||||
|
"installation name should persist across restarts"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user