core_crypto_ffi/core_crypto/
epoch_observer.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use core_crypto::prelude::{ConversationId, Obfuscated};
5#[cfg(target_family = "wasm")]
6use js_sys::{Promise, Uint8Array};
7#[cfg(target_family = "wasm")]
8use log::kv;
9#[cfg(target_family = "wasm")]
10use wasm_bindgen::prelude::*;
11#[cfg(target_family = "wasm")]
12use wasm_bindgen_futures::JsFuture;
13
14use crate::{CoreCrypto, CoreCryptoError, CoreCryptoResult};
15
16#[cfg(not(target_family = "wasm"))]
17#[derive(Debug, thiserror::Error, uniffi::Error)]
18#[uniffi(flat_error)]
19pub enum EpochChangedReportingError {
20    #[error("panic or otherwise unexpected error from foreign code")]
21    Ffi(#[from] uniffi::UnexpectedUniFFICallbackError),
22}
23
24/// An `EpochObserver` is notified whenever a conversation's epoch changes.
25#[cfg(not(target_family = "wasm"))]
26#[uniffi::export(with_foreign)]
27#[async_trait]
28pub trait EpochObserver: Send + Sync {
29    /// This function will be called every time a conversation's epoch changes.
30    ///
31    /// The `epoch` parameter is the new epoch.
32    ///
33    /// <div class="warning">
34    /// This function must not block! Foreign implementors of this inteface can
35    /// spawn a task indirecting the notification, or (unblocking) send the notification
36    /// on some kind of channel, or anything else, as long as the operation completes
37    /// quickly.
38    /// </div>
39    ///
40    /// Though the signature includes an error type, that error is only present because
41    /// it is required by `uniffi` in order to handle panics. This function should suppress
42    /// and ignore internal errors instead of propagating them, to the maximum extent possible.
43    async fn epoch_changed(
44        &self,
45        conversation_id: ConversationId,
46        epoch: u64,
47    ) -> Result<(), EpochChangedReportingError>;
48}
49
50/// This shim bridges the public `EpochObserver` interface with the internal one defined by `core-crypto`.
51///
52/// This is slightly unfortunate, as it introduces an extra layer of indirection before a change notice can
53/// actually reach its foreign target. However, the orphan rule prevents us from just tying the two traits
54/// together directly, so this is the straightforward way to accomplish that.
55#[cfg(not(target_family = "wasm"))]
56struct ObserverShim(Arc<dyn EpochObserver>);
57
58#[cfg(not(target_family = "wasm"))]
59#[async_trait]
60impl core_crypto::mls::EpochObserver for ObserverShim {
61    async fn epoch_changed(&self, conversation_id: ConversationId, epoch: u64) {
62        if let Err(err) = self.0.epoch_changed(conversation_id.clone(), epoch).await {
63            // we don't _care_ if an error is thrown by the the notification function, per se,
64            // but this would probably be useful information for downstream debugging efforts
65            log::warn!(
66                conversation_id = Obfuscated::new(&conversation_id),
67                epoch,
68                err = log::kv::Value::from_dyn_error(&err);
69                "caught an error when attempting to notify the epoch observer of an epoch change"
70            );
71        }
72    }
73}
74
75#[cfg(not(target_family = "wasm"))]
76#[uniffi::export]
77impl CoreCrypto {
78    /// Add an epoch observer to this client.
79    ///
80    /// This function should be called 0 or 1 times in a client's lifetime.
81    /// If called when an epoch observer already exists, this will return an error.
82    pub async fn register_epoch_observer(&self, epoch_observer: Arc<dyn EpochObserver>) -> CoreCryptoResult<()> {
83        let shim = Arc::new(ObserverShim(epoch_observer));
84        self.inner
85            .register_epoch_observer(shim)
86            .await
87            .map_err(CoreCryptoError::generic())
88    }
89}
90
91/// An `EpochObserver` is notified whenever a conversation's epoch changes.
92#[cfg(target_family = "wasm")]
93#[wasm_bindgen]
94pub struct EpochObserver {
95    this_context: JsValue,
96    epoch_changed: js_sys::Function,
97}
98
99#[cfg(target_family = "wasm")]
100// SAFETY: we promise that we're only ever using this in a single-threaded context
101unsafe impl Send for EpochObserver {}
102#[cfg(target_family = "wasm")]
103// SAFETY: we promise that we're only ever using this in a single-threaded context
104unsafe impl Sync for EpochObserver {}
105
106#[cfg(target_family = "wasm")]
107#[wasm_bindgen]
108impl EpochObserver {
109    /// Create a new Epoch Observer.
110    ///
111    /// This function should be hidden on the JS side of things! The JS bindings should have an `interface EpochObserver`
112    /// which has the method defined, and the bindings themselves should destructure an instance implementing that
113    /// interface appropriately to construct this.
114    ///
115    /// - `this_context` is the instance itself, which will be bound to `this` within the function bodies
116    /// - `epoch_changed`: A function of the form `(conversation_id: Uint8Array, epoch: bigint) -> Promise<void>`.
117    ///   Called every time a conversation's epoch changes.
118    #[wasm_bindgen(constructor)]
119    pub fn new(this_context: JsValue, epoch_changed: js_sys::Function) -> CoreCryptoResult<Self> {
120        // we can't do much type-checking here unfortunately, but we can at least validate that the incoming functions have the right length
121        if epoch_changed.length() != 2 {
122            return Err(CoreCryptoError::ad_hoc(format!(
123                "`epoch_changed` must accept 2 arguments but accepts {}",
124                epoch_changed.length()
125            )));
126        }
127        Ok(Self {
128            this_context,
129            epoch_changed,
130        })
131    }
132}
133
134#[cfg(target_family = "wasm")]
135impl EpochObserver {
136    /// Call the JS `epoch_observed` function
137    ///
138    /// This blocks if the JS side of things blocks.
139    ///
140    /// This is extracted as its own function instead of being implemented inline within the
141    /// `impl EpochObserver for EpochObserver` block mostly to consolidate error-handling.
142    async fn epoch_changed(&self, conversation_id: ConversationId, epoch: u64) -> Result<(), JsValue> {
143        let converation_id = Uint8Array::from(conversation_id.as_slice());
144
145        let promise = self
146            .epoch_changed
147            .call2(&self.this_context, &converation_id.into(), &epoch.into())?
148            .dyn_into::<Promise>()?;
149        // we don't actually care what the result of executing the notification promise is; we'll ignore it if it exists
150        JsFuture::from(promise).await?;
151        Ok(())
152    }
153}
154
155#[cfg(target_family = "wasm")]
156#[async_trait(?Send)]
157impl core_crypto::mls::EpochObserver for EpochObserver {
158    async fn epoch_changed(&self, conversation_id: ConversationId, epoch: u64) {
159        if let Err(err) = self.epoch_changed(conversation_id.clone(), epoch).await {
160            // we don't _care_ if an error is thrown by the the notification function, per se,
161            // but this would probably be useful information for downstream debugging efforts
162            log::warn!(
163                conversation_id = Obfuscated::new(&conversation_id),
164                epoch,
165                err = LoggableJsValue(err);
166                "caught an error when attempting to notify the epoch observer of an epoch change"
167            );
168        }
169    }
170}
171
172#[cfg(target_family = "wasm")]
173#[wasm_bindgen]
174impl CoreCrypto {
175    /// Add an epoch observer to this client.
176    ///
177    /// This function should be called 0 or 1 times in a client's lifetime.
178    /// If called when an epoch observer already exists, this will return an error.
179    pub async fn register_epoch_observer(&self, epoch_observer: EpochObserver) -> CoreCryptoResult<()> {
180        self.inner
181            .register_epoch_observer(Arc::new(epoch_observer))
182            .await
183            .map_err(CoreCryptoError::generic())
184    }
185}
186
187#[cfg(target_family = "wasm")]
188struct LoggableJsValue(JsValue);
189
190#[cfg(target_family = "wasm")]
191impl kv::ToValue for LoggableJsValue {
192    fn to_value(&self) -> kv::Value<'_> {
193        // can't get a borrowed str from `JsValue`, so can't directly
194        // convert into a string; oh well; fallback should catch it
195        if let Some(f) = self.0.as_f64() {
196            return f.into();
197        }
198        if let Some(b) = self.0.as_bool() {
199            return b.into();
200        }
201        if self.0.is_null() || self.0.is_undefined() {
202            return kv::Value::null();
203        }
204        kv::Value::from_debug(&self.0)
205    }
206}