feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
use std::sync::Arc;
|
2026-06-11 10:08:07 +02:00
|
|
|
use std::thread::{self, JoinHandle};
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
use components::{ThreadedWakeupService, WakeupEvent};
|
2026-06-11 10:08:07 +02:00
|
|
|
use crossbeam_channel::{Receiver, Sender, select};
|
2026-06-23 13:40:19 +08:00
|
|
|
use crypto::Ed25519VerifyingKey;
|
2026-04-08 23:15:48 +02:00
|
|
|
use libchat::{
|
2026-06-23 12:02:01 -07:00
|
|
|
AccountDirectory, ConversationId, ConvoOutcome, Core, DeliveryService, IdentId, IdentIdRef,
|
|
|
|
|
IdentityProvider, InboxOutcome, Introduction, PayloadOutcome, RegistrationService,
|
2026-04-08 23:15:48 +02:00
|
|
|
};
|
2026-06-11 10:08:07 +02:00
|
|
|
use parking_lot::Mutex;
|
2026-06-23 12:02:01 -07:00
|
|
|
use storage::ChatStore;
|
2026-05-19 11:54:54 -07:00
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
use crate::delegate::DelegateCredential;
|
2026-05-19 11:54:54 -07:00
|
|
|
use crate::errors::ClientError;
|
2026-06-23 14:31:23 +08:00
|
|
|
use crate::event::{Event, MessageSender};
|
2026-04-08 23:15:48 +02:00
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
type ClientCore<I, T, R, S> = Core<(I, T, R, ThreadedWakeupService, S)>;
|
2026-06-20 09:44:27 -07:00
|
|
|
type AccountAddressRef<'a> = &'a str;
|
|
|
|
|
type LocalSignerId = IdentId;
|
2026-06-11 10:08:07 +02:00
|
|
|
|
|
|
|
|
/// The transport as the client sees it: a [`DeliveryService`] for outbound
|
|
|
|
|
/// publishing plus the inbound payload stream the worker drains. One object owns
|
|
|
|
|
/// both directions of the boundary.
|
|
|
|
|
pub trait Transport: DeliveryService + Send + 'static {
|
|
|
|
|
/// Hand over the inbound payload stream. Called once, at client construction,
|
|
|
|
|
/// before the [`Core`] takes ownership of the service.
|
|
|
|
|
fn inbound(&mut self) -> Receiver<Vec<u8>>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// High-level chat client.
|
|
|
|
|
///
|
|
|
|
|
/// Owns the synchronous [`Core`] behind an `Arc<Mutex<…>>` and a background
|
|
|
|
|
/// worker that consumes inbound payloads off the transport's channel, drives
|
|
|
|
|
/// the core, and forwards observations as [`Event`]s. Construction returns the
|
|
|
|
|
/// handle together with the `Receiver<Event>` the application drains on its own
|
|
|
|
|
/// schedule.
|
|
|
|
|
///
|
|
|
|
|
/// Outbound calls (`send_message`, `create_conversation`, …) run on the
|
|
|
|
|
/// caller's thread: they briefly lock the core, invoke it, and return — no
|
|
|
|
|
/// message-passing round-trip. The `Arc`/`Mutex`/threads live entirely here;
|
|
|
|
|
/// the core never mentions threads.
|
2026-06-23 12:02:01 -07:00
|
|
|
pub struct ChatClient<I, T, R, S>
|
|
|
|
|
where
|
|
|
|
|
I: IdentityProvider + Send + 'static,
|
|
|
|
|
T: Transport + Send + 'static,
|
|
|
|
|
R: RegistrationService + Send + 'static,
|
|
|
|
|
S: ChatStore + Send + 'static,
|
|
|
|
|
{
|
2026-06-11 10:08:07 +02:00
|
|
|
/// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't
|
|
|
|
|
/// starve caller operations of the lock.
|
2026-06-23 12:02:01 -07:00
|
|
|
core: Arc<Mutex<ClientCore<I, T, R, S>>>,
|
2026-06-11 10:08:07 +02:00
|
|
|
/// Dropped on `Drop` to wake the worker's `select!` and shut it down.
|
|
|
|
|
shutdown: Option<Sender<()>>,
|
|
|
|
|
worker: Option<JoinHandle<()>>,
|
2026-06-23 12:02:01 -07:00
|
|
|
address: String,
|
2026-03-24 18:21:00 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
// -- GenericChatClient
|
|
|
|
|
impl<I, T, R, S> ChatClient<I, T, R, S>
|
2026-06-04 10:09:29 +08:00
|
|
|
where
|
2026-06-23 12:02:01 -07:00
|
|
|
I: IdentityProvider + Send + 'static,
|
2026-06-22 10:38:17 -07:00
|
|
|
T: Transport + Send + 'static,
|
2026-06-11 10:08:07 +02:00
|
|
|
R: RegistrationService + Send + 'static,
|
2026-06-23 12:02:01 -07:00
|
|
|
S: ChatStore + Send + 'static,
|
2026-06-04 10:09:29 +08:00
|
|
|
{
|
2026-06-23 12:02:01 -07:00
|
|
|
pub fn new(
|
|
|
|
|
ident: I,
|
2026-06-22 10:38:17 -07:00
|
|
|
mut transport: T,
|
|
|
|
|
reg: R,
|
2026-06-23 12:02:01 -07:00
|
|
|
storage: S,
|
2026-06-22 10:38:17 -07:00
|
|
|
) -> Result<(Self, Receiver<Event>), ClientError> {
|
|
|
|
|
let inbound = transport.inbound();
|
|
|
|
|
|
|
|
|
|
let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded();
|
|
|
|
|
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
|
2026-06-23 12:02:01 -07:00
|
|
|
let core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?;
|
2026-06-22 10:38:17 -07:00
|
|
|
Ok(Self::spawn(core, inbound, wakeup_rx))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 13:15:18 -07:00
|
|
|
fn spawn(
|
2026-06-23 12:02:01 -07:00
|
|
|
core: ClientCore<I, T, R, S>,
|
2026-06-15 13:15:18 -07:00
|
|
|
inbound: Receiver<Vec<u8>>,
|
|
|
|
|
wakeup_events: Receiver<WakeupEvent>,
|
|
|
|
|
) -> (Self, Receiver<Event>) {
|
2026-06-23 12:02:01 -07:00
|
|
|
let address = core.ident_id().to_string();
|
2026-06-11 10:08:07 +02:00
|
|
|
let core = Arc::new(Mutex::new(core));
|
|
|
|
|
let (event_tx, event_rx) = crossbeam_channel::unbounded();
|
|
|
|
|
let (shutdown_tx, shutdown_rx) = crossbeam_channel::bounded::<()>(0);
|
|
|
|
|
|
|
|
|
|
let worker = thread::spawn({
|
|
|
|
|
let core = Arc::clone(&core);
|
2026-06-15 13:15:18 -07:00
|
|
|
move || worker_loop(core, inbound, wakeup_events, shutdown_rx, event_tx)
|
2026-06-11 10:08:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
Self {
|
|
|
|
|
core,
|
|
|
|
|
shutdown: Some(shutdown_tx),
|
|
|
|
|
worker: Some(worker),
|
2026-06-23 12:02:01 -07:00
|
|
|
address,
|
2026-06-11 10:08:07 +02:00
|
|
|
},
|
|
|
|
|
event_rx,
|
|
|
|
|
)
|
2026-06-04 10:09:29 +08:00
|
|
|
}
|
2026-04-08 23:15:48 +02:00
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
pub fn addr(&self) -> AccountAddressRef<'_> {
|
|
|
|
|
&self.address
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 23:15:48 +02:00
|
|
|
/// Returns the installation name (identity label) of this client.
|
2026-06-11 10:08:07 +02:00
|
|
|
pub fn installation_name(&self) -> String {
|
|
|
|
|
self.core.lock().installation_name().to_string()
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Produce a serialised introduction bundle for sharing out-of-band.
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ClientError> {
|
2026-06-11 10:08:07 +02:00
|
|
|
self.core.lock().create_intro_bundle().map_err(Into::into)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 09:44:27 -07:00
|
|
|
// Creates a conversation between two Accounts.
|
|
|
|
|
pub fn create_direct_conversation(
|
|
|
|
|
&mut self,
|
|
|
|
|
account: AccountAddressRef,
|
|
|
|
|
) -> Result<ConversationId, ClientError> {
|
|
|
|
|
let signers = self.signers_from_account(account)?;
|
|
|
|
|
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
|
|
|
|
|
|
|
|
|
|
self.core
|
|
|
|
|
.lock()
|
|
|
|
|
.create_direct_convo(&signer_refs)
|
|
|
|
|
.map_err(Into::into)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// Parse intro bundle bytes and initiate a private conversation. Outbound
|
|
|
|
|
/// envelopes are published by the core. Returns this side's conversation ID.
|
2026-06-20 09:44:27 -07:00
|
|
|
///
|
|
|
|
|
/// This function will be deprecated in the future. Use `create_direct_conversation`
|
2026-04-08 23:15:48 +02:00
|
|
|
pub fn create_conversation(
|
|
|
|
|
&mut self,
|
|
|
|
|
intro_bundle: &[u8],
|
|
|
|
|
initial_content: &[u8],
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
) -> Result<ConversationId, ClientError> {
|
2026-04-08 23:15:48 +02:00
|
|
|
let intro = Introduction::try_from(intro_bundle)?;
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
self.core
|
2026-06-11 10:08:07 +02:00
|
|
|
.lock()
|
2026-06-19 12:01:17 -07:00
|
|
|
.create_private_convo_v1(&intro, initial_content)
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
.map_err(Into::into)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// List all conversation IDs known to this client.
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ClientError> {
|
2026-06-11 10:08:07 +02:00
|
|
|
self.core.lock().list_conversations().map_err(Into::into)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// Encrypt and send `content` to an existing conversation. The core
|
|
|
|
|
/// publishes the outbound envelope.
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
pub fn send_message(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ClientError> {
|
|
|
|
|
self.core
|
2026-06-11 10:08:07 +02:00
|
|
|
.lock()
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
.send_content(convo_id, content)
|
|
|
|
|
.map_err(Into::into)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
2026-06-20 09:44:27 -07:00
|
|
|
|
|
|
|
|
// Get signers for a given AccountAddress.
|
|
|
|
|
fn signers_from_account(
|
|
|
|
|
&self,
|
|
|
|
|
account: AccountAddressRef,
|
|
|
|
|
) -> Result<Vec<LocalSignerId>, ClientError> {
|
|
|
|
|
// Assume Account = LocalSigner until Account is ready
|
|
|
|
|
Ok(vec![IdentId::new(account.to_string())])
|
|
|
|
|
}
|
2026-06-11 10:08:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
impl<I, T, R, S> Drop for ChatClient<I, T, R, S>
|
|
|
|
|
where
|
|
|
|
|
I: IdentityProvider + Send + 'static,
|
|
|
|
|
T: Transport + Send + 'static,
|
|
|
|
|
R: RegistrationService + Send + 'static,
|
|
|
|
|
S: ChatStore + Send + 'static,
|
|
|
|
|
{
|
2026-06-11 10:08:07 +02:00
|
|
|
fn drop(&mut self) {
|
|
|
|
|
// Dropping the sender disconnects the worker's shutdown channel, waking
|
|
|
|
|
// its `select!` so it can exit; then we join it.
|
|
|
|
|
self.shutdown.take();
|
|
|
|
|
if let Some(handle) = self.worker.take() {
|
|
|
|
|
let _ = handle.join();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 23:15:48 +02:00
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// Background loop: block until an inbound payload or shutdown arrives, drive
|
|
|
|
|
/// the core on each payload, and forward events. No polling — `select!` parks
|
|
|
|
|
/// the thread until one of the channels is ready.
|
2026-06-23 12:02:01 -07:00
|
|
|
fn worker_loop<I: IdentityProvider + 'static, T, R, S: ChatStore + 'static>(
|
|
|
|
|
core: Arc<Mutex<ClientCore<I, T, R, S>>>,
|
2026-06-11 10:08:07 +02:00
|
|
|
inbound: Receiver<Vec<u8>>,
|
2026-06-15 13:15:18 -07:00
|
|
|
wakeup_events: Receiver<WakeupEvent>,
|
2026-06-11 10:08:07 +02:00
|
|
|
shutdown: Receiver<()>,
|
|
|
|
|
event_tx: Sender<Event>,
|
|
|
|
|
) where
|
|
|
|
|
T: DeliveryService + Send + 'static,
|
|
|
|
|
R: RegistrationService + Send + 'static,
|
|
|
|
|
{
|
|
|
|
|
loop {
|
|
|
|
|
select! {
|
|
|
|
|
recv(inbound) -> msg => {
|
|
|
|
|
let Ok(bytes) = msg else {
|
|
|
|
|
return; // transport's sender dropped
|
|
|
|
|
};
|
|
|
|
|
let events = {
|
|
|
|
|
let mut core = core.lock();
|
|
|
|
|
match core.handle_payload(&bytes) {
|
2026-06-23 13:40:19 +08:00
|
|
|
Ok(outcome) => events_from_inbound(outcome, core.account_directory()),
|
2026-06-11 10:08:07 +02:00
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("inbound handle_payload failed: {e:?}");
|
|
|
|
|
vec![Event::InboundError {
|
|
|
|
|
message: e.to_string(),
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
for event in events {
|
|
|
|
|
if event_tx.send(event).is_err() {
|
|
|
|
|
return; // application dropped the receiver
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 13:15:18 -07:00
|
|
|
recv(wakeup_events) -> msg => {
|
|
|
|
|
let Ok(WakeupEvent { convo_id }) = msg else {
|
|
|
|
|
return; // wakeup service's sender dropped
|
|
|
|
|
};
|
|
|
|
|
if let Err(e) = core.lock().wakeup(&convo_id) {
|
|
|
|
|
tracing::warn!("wakeup failed: {e:?}");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-11 10:08:07 +02:00
|
|
|
recv(shutdown) -> _ => return,
|
|
|
|
|
}
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
2026-03-24 18:21:00 -07:00
|
|
|
}
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// Walk a [`PayloadOutcome`] in causal order and emit one `Event` per
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
/// observation. For an `Inbox` outcome, [`Event::ConversationStarted`]
|
|
|
|
|
/// precedes the message event. The convo id is wrapped into `Arc<str>` once
|
|
|
|
|
/// per outcome and shared across the events it produces.
|
2026-06-23 13:40:19 +08:00
|
|
|
fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
match result {
|
|
|
|
|
PayloadOutcome::Empty => Vec::new(),
|
2026-06-23 13:40:19 +08:00
|
|
|
PayloadOutcome::Convo(co) => convo_events(co, directory),
|
|
|
|
|
PayloadOutcome::Inbox(io) => inbox_events(io, directory),
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08:00
|
|
|
/// Interpret a hex account address as an Ed25519 account verifying key.
|
|
|
|
|
fn account_key_from_hex(addr: &str) -> Option<Ed25519VerifyingKey> {
|
|
|
|
|
let bytes: [u8; 32] = hex::decode(addr).ok()?.try_into().ok()?;
|
|
|
|
|
Ed25519VerifyingKey::from_bytes(&bytes).ok()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 14:55:28 +08:00
|
|
|
/// Why a message's sender could not be accepted, so the message is dropped.
|
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
|
|
|
enum SenderError {
|
|
|
|
|
/// Credential bytes were not valid hex.
|
|
|
|
|
NotHex,
|
|
|
|
|
/// Credential bytes did not decode to a delegate credential.
|
|
|
|
|
Malformed,
|
|
|
|
|
/// The claimed account address is not an Ed25519 verifying key.
|
|
|
|
|
AccountNotAKey,
|
|
|
|
|
/// The account → device mapping is wrong or could not be confirmed: the
|
|
|
|
|
/// device is not in the account's published set, the account published none,
|
|
|
|
|
/// or the directory lookup failed.
|
|
|
|
|
Unverified,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 14:31:23 +08:00
|
|
|
/// Decode and verify a message's sender from its credential, checked against the
|
|
|
|
|
/// account → device directory (our account store).
|
2026-06-23 13:40:19 +08:00
|
|
|
///
|
2026-06-23 14:55:28 +08:00
|
|
|
/// `Ok(None)` — deliver, but the sender is unknown (no credential, e.g. a
|
|
|
|
|
/// PrivateV1 1:1 message). `Ok(Some(sender))` — deliver with the sender; its
|
|
|
|
|
/// `account` is set only when the directory confirmed the device, so it is
|
|
|
|
|
/// always verified. `Err` — drop the message.
|
2026-06-23 14:31:23 +08:00
|
|
|
fn decode_sender(
|
|
|
|
|
directory: &impl AccountDirectory,
|
|
|
|
|
encoded: &[u8],
|
2026-06-23 14:55:28 +08:00
|
|
|
) -> Result<Option<MessageSender>, SenderError> {
|
2026-06-23 13:40:19 +08:00
|
|
|
// No credential (e.g. the PrivateV1 placeholder) asserts no account mapping.
|
|
|
|
|
if encoded.is_empty() {
|
2026-06-23 14:31:23 +08:00
|
|
|
return Ok(None);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
let Ok(data) = hex::decode(encoded) else {
|
|
|
|
|
tracing::warn!("sender credential is not valid hex; dropping message");
|
2026-06-23 14:55:28 +08:00
|
|
|
return Err(SenderError::NotHex);
|
2026-06-23 13:40:19 +08:00
|
|
|
};
|
|
|
|
|
let cred = match DelegateCredential::try_from(data) {
|
|
|
|
|
Ok(cred) => cred,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
tracing::warn!("malformed sender credential; dropping message");
|
2026-06-23 14:55:28 +08:00
|
|
|
return Err(SenderError::Malformed);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let device = hex::encode(cred.delegate_id().as_ref());
|
|
|
|
|
// An unassociated delegate asserts no account → device mapping.
|
|
|
|
|
let Some(account_addr) = cred.account_addr() else {
|
2026-06-23 14:31:23 +08:00
|
|
|
return Ok(Some(MessageSender {
|
|
|
|
|
account: None,
|
|
|
|
|
local_identity: IdentId::new(device),
|
|
|
|
|
}));
|
2026-06-23 13:40:19 +08:00
|
|
|
};
|
|
|
|
|
let Some(account_key) = account_key_from_hex(account_addr) else {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
account_addr,
|
|
|
|
|
"sender account address is not a verifying key; dropping message"
|
|
|
|
|
);
|
2026-06-23 14:55:28 +08:00
|
|
|
return Err(SenderError::AccountNotAKey);
|
2026-06-23 13:40:19 +08:00
|
|
|
};
|
|
|
|
|
match directory.fetch(&account_key) {
|
2026-06-23 14:31:23 +08:00
|
|
|
Ok(Some(set)) if set.devices.iter().any(|d| d == &device) => Ok(Some(MessageSender {
|
|
|
|
|
account: Some(IdentId::new(account_addr.to_string())),
|
|
|
|
|
local_identity: IdentId::new(device),
|
|
|
|
|
})),
|
2026-06-23 13:40:19 +08:00
|
|
|
_ => {
|
|
|
|
|
tracing::warn!(account_addr, %device, "account → device mapping is wrong or unconfirmable; dropping message");
|
2026-06-23 14:55:28 +08:00
|
|
|
Err(SenderError::Unverified)
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
2026-06-22 10:38:17 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08:00
|
|
|
fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
let ConvoOutcome { convo_id, content } = outcome;
|
|
|
|
|
content
|
2026-06-23 14:31:23 +08:00
|
|
|
.and_then(|c| {
|
|
|
|
|
let sender = decode_sender(directory, &c.encoded_credential).ok()?;
|
|
|
|
|
Some(Event::MessageReceived {
|
|
|
|
|
convo_id: Arc::from(convo_id),
|
|
|
|
|
content: c.bytes,
|
|
|
|
|
sender,
|
|
|
|
|
})
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
})
|
|
|
|
|
.into_iter()
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08:00
|
|
|
fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
let InboxOutcome {
|
|
|
|
|
new_conversation,
|
|
|
|
|
initial,
|
|
|
|
|
} = outcome;
|
|
|
|
|
let id: Arc<str> = Arc::from(new_conversation.convo_id);
|
|
|
|
|
let mut events = Vec::with_capacity(2);
|
|
|
|
|
events.push(Event::ConversationStarted {
|
|
|
|
|
convo_id: Arc::clone(&id),
|
|
|
|
|
class: new_conversation.class,
|
|
|
|
|
});
|
2026-06-23 13:40:19 +08:00
|
|
|
if let Some(c) = initial.and_then(|co| co.content)
|
2026-06-23 14:31:23 +08:00
|
|
|
&& let Ok(sender) = decode_sender(directory, &c.encoded_credential)
|
2026-06-23 13:40:19 +08:00
|
|
|
{
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
events.push(Event::MessageReceived {
|
|
|
|
|
convo_id: Arc::clone(&id),
|
|
|
|
|
content: c.bytes,
|
2026-06-23 14:31:23 +08:00
|
|
|
sender,
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
events
|
|
|
|
|
}
|
2026-06-23 13:40:19 +08:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod sender_check_tests {
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
|
2026-06-23 14:31:23 +08:00
|
|
|
use libchat::{DeviceSet, IdentId, SignedDeviceBundle};
|
2026-06-23 13:40:19 +08:00
|
|
|
|
2026-06-23 14:55:28 +08:00
|
|
|
use super::{MessageSender, SenderError, decode_sender};
|
2026-06-23 13:40:19 +08:00
|
|
|
use crate::delegate::DelegateCredential;
|
|
|
|
|
|
|
|
|
|
/// In-test account → device directory. Holds device id sets keyed by the hex
|
|
|
|
|
/// account key, and can be made to fail to simulate a directory outage.
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
struct FakeDir {
|
|
|
|
|
bundles: HashMap<String, Vec<String>>,
|
|
|
|
|
fail: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl FakeDir {
|
|
|
|
|
/// Publish `devices` (verifying keys) as `account`'s device set.
|
|
|
|
|
fn with_devices(account: &Ed25519VerifyingKey, devices: &[&Ed25519VerifyingKey]) -> Self {
|
|
|
|
|
let mut bundles = HashMap::new();
|
|
|
|
|
bundles.insert(
|
|
|
|
|
hex::encode(account.as_ref()),
|
|
|
|
|
devices.iter().map(|d| hex::encode(d.as_ref())).collect(),
|
|
|
|
|
);
|
|
|
|
|
Self {
|
|
|
|
|
bundles,
|
|
|
|
|
fail: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl libchat::AccountDirectory for FakeDir {
|
|
|
|
|
type Error = &'static str;
|
|
|
|
|
|
|
|
|
|
fn publish(&mut self, _: &SignedDeviceBundle) -> Result<(), Self::Error> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, Self::Error> {
|
|
|
|
|
if self.fail {
|
|
|
|
|
return Err("directory unavailable");
|
|
|
|
|
}
|
|
|
|
|
Ok(self
|
|
|
|
|
.bundles
|
|
|
|
|
.get(&hex::encode(account.as_ref()))
|
|
|
|
|
.map(|devices| DeviceSet {
|
|
|
|
|
lamport: 1,
|
|
|
|
|
devices: devices.clone(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn key() -> Ed25519VerifyingKey {
|
|
|
|
|
Ed25519SigningKey::generate().verifying_key()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Encode a credential exactly as it travels on the wire: the hex of the
|
|
|
|
|
/// serialized TLV, matching the MLS leaf credential's content bytes.
|
|
|
|
|
fn encoded(cred: DelegateCredential) -> Vec<u8> {
|
|
|
|
|
hex::encode(cred.serialize()).into_bytes()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 14:31:23 +08:00
|
|
|
fn local_id(k: &Ed25519VerifyingKey) -> IdentId {
|
|
|
|
|
IdentId::new(hex::encode(k.as_ref()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08:00
|
|
|
/// The account published a device set that includes the sending device — the
|
2026-06-23 14:31:23 +08:00
|
|
|
/// claim checks out, so the message is delivered with a verified account.
|
2026-06-23 13:40:19 +08:00
|
|
|
#[test]
|
2026-06-23 14:31:23 +08:00
|
|
|
fn verified_sender_surfaces_account_and_device() {
|
2026-06-23 13:40:19 +08:00
|
|
|
let account = key();
|
|
|
|
|
let device = key();
|
|
|
|
|
let dir = FakeDir::with_devices(&account, &[&device]);
|
|
|
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
2026-06-23 14:31:23 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Ok(Some(MessageSender {
|
|
|
|
|
account: Some(local_id(&account)),
|
|
|
|
|
local_identity: local_id(&device),
|
|
|
|
|
}))
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The account published a device set that does NOT include the sending
|
|
|
|
|
/// device — a spoofed account claim, so the message is dropped.
|
|
|
|
|
#[test]
|
|
|
|
|
fn contradicted_claim_is_dropped() {
|
|
|
|
|
let account = key();
|
|
|
|
|
let endorsed = key();
|
|
|
|
|
let spoofer = key();
|
|
|
|
|
let dir = FakeDir::with_devices(&account, &[&endorsed]);
|
|
|
|
|
let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref()));
|
2026-06-23 14:56:29 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::Unverified)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 14:31:23 +08:00
|
|
|
/// A delegate that claims no account surfaces its device but no account.
|
2026-06-23 13:40:19 +08:00
|
|
|
#[test]
|
2026-06-23 14:31:23 +08:00
|
|
|
fn unassociated_sender_surfaces_device_only() {
|
2026-06-23 13:40:19 +08:00
|
|
|
let dir = FakeDir::default();
|
2026-06-23 14:31:23 +08:00
|
|
|
let device = key();
|
|
|
|
|
let cred = DelegateCredential::unassociated(&device);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Ok(Some(MessageSender {
|
|
|
|
|
account: None,
|
|
|
|
|
local_identity: local_id(&device),
|
|
|
|
|
}))
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The claimed account has never published a device set — the mapping is
|
|
|
|
|
/// missing, so the message is dropped.
|
|
|
|
|
#[test]
|
|
|
|
|
fn unpublished_account_is_dropped() {
|
|
|
|
|
let account = key();
|
|
|
|
|
let device = key();
|
|
|
|
|
let dir = FakeDir::default(); // nothing published
|
|
|
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
2026-06-23 14:56:29 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::Unverified)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A directory outage leaves the mapping unconfirmed, so the message is
|
|
|
|
|
/// dropped rather than delivered on an unverified claim.
|
|
|
|
|
#[test]
|
|
|
|
|
fn directory_error_is_dropped() {
|
|
|
|
|
let account = key();
|
|
|
|
|
let device = key();
|
|
|
|
|
let dir = FakeDir {
|
|
|
|
|
fail: true,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
2026-06-23 14:56:29 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::Unverified)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// No credential at all (e.g. the PrivateV1 placeholder) asserts no account
|
2026-06-23 14:31:23 +08:00
|
|
|
/// mapping and is delivered with no sender.
|
2026-06-23 13:40:19 +08:00
|
|
|
#[test]
|
2026-06-23 14:31:23 +08:00
|
|
|
fn empty_credential_has_no_sender() {
|
2026-06-23 13:40:19 +08:00
|
|
|
let dir = FakeDir::default();
|
2026-06-23 14:31:23 +08:00
|
|
|
assert_eq!(decode_sender(&dir, b""), Ok(None));
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bytes that aren't a well-formed credential leave the sender's mapping
|
|
|
|
|
/// undeterminable, so the message is dropped.
|
|
|
|
|
#[test]
|
|
|
|
|
fn malformed_credential_is_dropped() {
|
|
|
|
|
let dir = FakeDir::default();
|
2026-06-23 14:55:28 +08:00
|
|
|
assert_eq!(decode_sender(&dir, b"not hex"), Err(SenderError::NotHex));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, hex::encode([0u8; 4]).as_bytes()),
|
|
|
|
|
Err(SenderError::Malformed)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An account address that isn't a verifying key can't be looked up, so the
|
|
|
|
|
/// claim is unconfirmable and the message is dropped.
|
|
|
|
|
#[test]
|
|
|
|
|
fn non_key_account_address_is_dropped() {
|
|
|
|
|
let dir = FakeDir::default();
|
|
|
|
|
let cred = DelegateCredential::associated(&key(), "user@example.com");
|
2026-06-23 14:55:28 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::AccountNotAKey)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
}
|