core_crypto_ffi/core_crypto/
epoch_observer.rs

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