Skip to main content

simploxide_ffi_core/
lib.rs

1//! A fully asynchrouns raw SimpleX client backed by the SimpleX FFI bindings(see
2//! [simploxide_sxcrt_sys] for setup instructions) that provides:
3//!
4//! 1. Multi-instance support: run many SimpleX-Chat instances from a single process. Each instance
5//!    is fully isolated and all are served by a single shared worker thread with fair round-robin
6//!    scheduling and per-instance execution caps to prevent starvation.
7//!
8//! 2. Complete asynchonisity: futures created by the same instance of a client are fully
9//!    independent from each other. The event queue receives events independently from client
10//!    actions.
11//!
12//! 3. Graceful shutdown with strong guarantees:
13//!     - All commands enqueued before [`RawClient::disconnect`] are guaranteed to execute and
14//!       return their responses.
15//!
16//!     - All commands enqueued after [`RawClient::disconnect`] are guaranteed to return
17//!       [`CallError::Failure`] without being executed.
18//!
19//!     - You will receive events for as long as the chat instance is active. After disconnect the
20//!       remaining buffered events are delivered and then the event queue closes.
21//!
22//! -----
23//!
24//! _Current implementation heavily depends on `tokio` runtime and won't work with other
25//! executors._
26
27mod worker;
28
29pub use simploxide_core::SimplexVersion;
30pub use simploxide_sxcrt_sys::{CallError, InitError, MigrationConfirmation};
31
32use serde::Deserialize;
33use simploxide_core::VersionInfo;
34
35use std::{path::Path, sync::Arc, time::Duration};
36
37pub type Command = String;
38pub type Event = String;
39pub type Response = String;
40
41pub type Result<T = (), E = Arc<CallError>> = ::std::result::Result<T, E>;
42
43type FfiResponder = tokio::sync::oneshot::Sender<Result<Response>>;
44
45type CmdTransmitter = std::sync::mpsc::Sender<ChatCommand>;
46type CmdReceiver = std::sync::mpsc::Receiver<ChatCommand>;
47
48type EventTransmitter = tokio::sync::mpsc::UnboundedSender<Result<Event>>;
49pub type EventReceiver = tokio::sync::mpsc::UnboundedReceiver<Result<Event>>;
50
51type ShutdownEmitter = tokio::sync::watch::Sender<bool>;
52type ShutdownSignal = tokio::sync::watch::Receiver<bool>;
53
54/// Configuration for the shared FFI worker thread.
55///
56/// Applies only on the first [`init`] or [`init_with_config`] call. All subsequent calls reuse
57/// the already running worker thread and ignore this parameter entirely.
58#[derive(Debug, Clone)]
59pub struct WorkerConfig {
60    /// Maximum permissible event latency. Controls how long the worker thread may sleep between
61    /// polling cycles when all chats are idle. The sleep interval grows linearly from zero up to
62    /// this value as idle time accumulates. Sending any command resets the interval immediately by
63    /// waking the thread. Default: 1sec
64    pub max_event_latency: Duration,
65
66    /// Maximum number of chat instances the worker thread will serve simultaneously. [`init`]
67    /// returns [`CallError::Failure`] when this limit is reached. Passing `0` is valid but
68    /// prevents any chat from ever being created. Default: 20
69    pub max_instances: usize,
70
71    /// Maximum number of commands executed per chat instance per scheduling iteration. Higher
72    /// values improve throughput under command bursts at the cost of increased latency for other
73    /// chat instances. Default: 3
74    pub max_cmds_per_iter: usize,
75
76    /// Maximum number of events drained per chat instance per scheduling iteration. Higher values
77    /// reduce event latency under event bursts at the cost of increased command latency for other
78    /// chat instances. Default 6
79    pub max_events_per_iter: usize,
80}
81
82impl Default for WorkerConfig {
83    fn default() -> Self {
84        Self {
85            max_event_latency: Duration::from_secs(1),
86            max_instances: 20,
87            max_cmds_per_iter: 3,
88            max_events_per_iter: 6,
89        }
90    }
91}
92
93impl WorkerConfig {
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    pub fn with_event_latency(mut self, duration: Duration) -> Self {
99        self.max_event_latency = duration;
100        self
101    }
102
103    pub fn max_instances(mut self, max_instances: usize) -> Self {
104        self.max_instances = max_instances;
105        self
106    }
107
108    pub fn max_cmds_per_iter(mut self, max_cmds: usize) -> Self {
109        self.max_cmds_per_iter = max_cmds;
110        self
111    }
112
113    pub fn max_events_per_iter(mut self, max_events: usize) -> Self {
114        self.max_events_per_iter = max_events;
115        self
116    }
117}
118
119/// Open a SimpleX database with default [`WorkerConfig`] and start receiving events.
120///
121/// See [`init_with_config`] for full documentation.
122pub async fn init(
123    default_user: DefaultUser,
124    db_opts: DbOpts,
125) -> Result<(RawClient, RawEventQueue), InitError> {
126    init_with_config(default_user, db_opts, WorkerConfig::default()).await
127}
128
129/// Open a SimpleX database and start receiving events.
130///
131/// Returns a [`RawClient`] for sending commands and a [`RawEventQueue`] that buffers incoming chat
132/// events independently of client activity. Each init call creates a fully isolated instance with
133/// its own client and event queue, shutting down one instance does not affect any other.
134///
135/// All FFI calls like event polling and command execution are running on a single shared OS
136/// thread. Creating a new instance blocks this thread for the full duration of the database
137/// initialisation(including migrations). All other currently active chat instances are frozen
138/// during the execution of this method.
139///
140/// # Memory
141///
142/// The [`RawEventQueue`] is backed by an unbounded channel. If events are not consumed they
143/// accumulate indefinitely. Either process events promptly or drop the queue immediately if your
144/// application does not need them.
145///
146///
147/// # Example
148///
149/// ```ignore
150/// let (client, mut events) = simploxide_ffi_core::init_with_config(
151///     DefaultUser::bot("MyBot"),
152///     DbOpts::unencrypted("./data/mybot"),
153///     WorkerConfig::new().max_instances(4),
154/// ).await?;
155///
156/// // (Optional) Drop the event queue if you're not planning to handle events
157/// drop(events)
158///
159/// // Get SimpleX runtime version
160/// let version = client.version().await?;
161/// ```
162pub async fn init_with_config(
163    default_user: DefaultUser,
164    db_opts: DbOpts,
165    config: WorkerConfig,
166) -> Result<(RawClient, RawEventQueue), InitError> {
167    worker::init(config).spawn_chat(default_user, db_opts).await
168}
169
170/// A lightweight cheaply clonable client for sending raw requests(SimpleX commands) and receiving
171/// raw responses(JSON objects).
172///
173/// You can use the client behind a shared reference, or you can clone it, in both cases the
174/// created futures will be indpenendent from each other.
175#[derive(Clone)]
176pub struct RawClient {
177    tx: CmdTransmitter,
178    worker: worker::Worker,
179    shutdown: ShutdownSignal,
180}
181
182impl RawClient {
183    /// Send a raw SimpleX command and await its response.
184    ///
185    /// The command is sent immediately and the returned future directly awaits the response from
186    /// the worker thread.
187    pub async fn send(&self, command: Command) -> Result<Response> {
188        let (responder, response) = tokio::sync::oneshot::channel();
189
190        self.tx
191            .send(ChatCommand::Execute(command, responder))
192            .map_err(|_| CallError::Failure)?;
193
194        self.worker.wake();
195
196        response.await.map_err(|_| CallError::Failure)?
197    }
198
199    /// Returns the version of the underlying SimpleX runtime.
200    pub async fn version(&self) -> Result<SimplexVersion, VersionError> {
201        #[derive(Deserialize)]
202        struct VersionResult<'a> {
203            #[serde(borrow)]
204            result: VersionInfo<'a>,
205        }
206
207        let output = self.send("/v".to_owned()).await?;
208
209        let response = serde_json::from_str::<VersionResult>(&output)
210            .map_err(|e| Arc::new(CallError::InvalidJson(e)))?
211            .result
212            .version_info
213            .version;
214
215        let version = response
216            .parse()
217            .map_err(|_| VersionError::ParseError(response.to_owned()))?;
218
219        Ok(version)
220    }
221
222    /// Initiates a graceful shutdown and waits until the database is fully closed.
223    ///
224    /// All futures that got scheduled before this call will still receive their responses. All
225    /// futures scheduled after this call(from cloned clients) will resolve immediately with
226    /// [`CallError::Failure`].
227    ///
228    /// If you don't care about waiting for the graceful shutdown to complete you can just drop the
229    /// future, the shutdown will still be triggered
230    ///
231    /// ```ignore
232    /// let _ = client.disconnect();
233    /// ```
234    ///
235    /// or use [`tokio::time::timeout`] to limit the wait time
236    ///
237    /// ```ignore
238    /// tokio::time::timeout(Duration::from_secs(5), client.disconnect())
239    ///     .await
240    ///     .unwrap_or_default();
241    /// ```
242    ///
243    /// # Racing with [`Self::send`]
244    ///
245    /// Commands and the disconnect signal share the same FIFO channel. Whichever call enqueues
246    /// first is processed first: If `disconnect` enqueues before a concurrent `send` the `send`
247    /// future returns [`CallError::Failure`] and the command is guaranteed not to have been
248    /// executed, if `send` enqueues before `disconnect` - the command executes normally.
249    ///
250    /// To guarantee ordering, await all `send` futures to completion before calling `disconnect`.
251    pub fn disconnect(mut self) -> impl Future<Output = ()> {
252        let _ = self.tx.send(ChatCommand::Disconnect);
253        self.worker.wake();
254
255        async move {
256            let _ = self.shutdown.wait_for(|b| *b).await;
257        }
258    }
259}
260
261/// An event queue that buffers incoming SimpleX events independently of client activity.
262///
263/// Backed by an unbounded channel. If events are not consumed they accumulate indefinitely. Drop
264/// the queue as soon as it is no longer needed. When dropped while a chat instance is active and
265/// producing events, the Haskell-side queue is still drained continuously - events are discarded
266/// in Rust and do not accumulate in the FFI layer.
267pub struct RawEventQueue {
268    receiver: EventReceiver,
269}
270
271impl RawEventQueue {
272    /// Returns the next event from the queue, or `None` if the chat has shut down.
273    pub async fn next_event(&mut self) -> Option<Result<Event>> {
274        self.receiver.recv().await
275    }
276
277    /// Unwraps the queue into the underlying tokio unbounded receiver for more advanced use cases.
278    pub fn into_receiver(self) -> EventReceiver {
279        self.receiver
280    }
281}
282
283/// The SimpleX user profile used to initialise the chat instance.
284///
285/// # Security
286///
287/// The `display_name` field is injected into a SimpleX CLI command of the form `/create {kind}
288/// '{display_name}'`. It is intended to be a short, fixed, ASCII identifier chosen by the
289/// application author, do not supply a user-input here to avoid command injections like:
290/// "User'Name"(creates a user named "User" with bio="Name")
291#[derive(Debug, Clone)]
292pub struct DefaultUser {
293    pub display_name: String,
294    pub is_bot: bool,
295}
296
297impl DefaultUser {
298    /// Creates a regular SimpleX user profile with the given display name.
299    ///
300    /// `name` is injected literally into `/create user '{name}'`. Use a fixed ASCII identifier;
301    /// do not pass user-supplied input here.
302    pub fn regular<S: Into<String>>(name: S) -> Self {
303        Self {
304            display_name: name.into(),
305            is_bot: false,
306        }
307    }
308
309    /// Creates a bot SimpleX user profile with the given display name.
310    ///
311    /// `name` is injected literally into `/create bot '{name}'`. Use a fixed ASCII identifier;
312    /// do not pass user-supplied input here.
313    pub fn bot<S: Into<String>>(name: S) -> Self {
314        Self {
315            display_name: name.into(),
316            is_bot: true,
317        }
318    }
319}
320
321/// Database options for a SimpleX chat instance.
322///
323/// # The `prefix` field
324///
325/// SimpleX stores each chat instance as a set of files sharing a common path prefix. The prefix
326/// is a directory path plus a filename stem: the directory part is created if absent, and the
327/// stem is prepended to every database filename.
328///
329/// ```text
330/// prefix: "data/bot" - creates ./data/bot_agent.db, ./data/bot_chat.db
331/// prefix: "bot" - creates ./bot_agent.db, ./bot_chat.db
332/// ```
333///
334/// # Warning: overlapping prefixes
335///
336/// Two instances whose prefixes share the same directory and stem will silently read and write the
337/// same files. This will produce DB errors and may cause DB corruptions
338#[derive(Debug, Clone)]
339pub struct DbOpts {
340    pub prefix: String,
341    pub key: Option<String>,
342    pub migration: MigrationConfirmation,
343}
344
345impl DbOpts {
346    /// Open an unencrypted SimpleX database at the given path prefix.
347    ///
348    /// See [`DbOpts`] for an explanation of what `prefix` means and the overlap warning.
349    pub fn unencrypted<P: AsRef<Path>>(db_path: P) -> Self {
350        Self {
351            prefix: db_path.as_ref().display().to_string(),
352            key: None,
353            migration: MigrationConfirmation::YesUp,
354        }
355    }
356
357    /// Open an encrypted SimpleX database at the given path prefix with the given passphrase.
358    ///
359    /// See [`DbOpts`] for an explanation of what `prefix` means and the overlap warning.
360    pub fn encrypted<P: AsRef<Path>, K: Into<String>>(prefix: P, key: K) -> Self {
361        Self {
362            prefix: prefix.as_ref().display().to_string(),
363            key: Some(key.into()),
364            migration: MigrationConfirmation::YesUp,
365        }
366    }
367}
368
369/// Error returned by [`RawClient::version`].
370#[derive(Debug)]
371pub enum VersionError {
372    Ffi(Arc<CallError>),
373    ParseError(String),
374}
375
376impl From<Arc<CallError>> for VersionError {
377    fn from(value: Arc<CallError>) -> Self {
378        Self::Ffi(value)
379    }
380}
381
382impl std::fmt::Display for VersionError {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        match self {
385            Self::Ffi(e) => e.fmt(f),
386            Self::ParseError(s) => {
387                write!(
388                    f,
389                    "Cannot parse version, expected format: '<major>.<minor>.<patch>.<hotfix>', got {s:?}"
390                )
391            }
392        }
393    }
394}
395
396impl std::error::Error for VersionError {
397    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
398        match self {
399            Self::Ffi(e) => Some(e),
400            Self::ParseError(_) => None,
401        }
402    }
403}
404
405enum ChatCommand {
406    Execute(Command, FfiResponder),
407    Disconnect,
408}