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}