sync15/engine/
bridged_engine.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use crate::error::debug;
6use crate::{ServerTimestamp, telemetry};
7use anyhow::Result;
8
9use crate::Guid;
10use crate::bso::IncomingBso;
11
12use super::{CollSyncIds, EngineSyncAssociation, SyncEngine};
13
14/// Adapts a [`SyncEngine`] to the method set that Desktop Firefox's JS Sync
15/// framework drives (historically the `mozIBridgedSyncEngine` shape). Desktop
16/// owns the fetch loop, so unlike the native Rust sync client it reads and
17/// writes the engine's last-sync time explicitly and manages sync IDs as opaque
18/// strings; this wrapper translates those calls onto the `SyncEngine` trait, and
19/// handles the JSON `String` <-> BSO marshalling that crosses the UniFFI
20/// boundary.
21///
22/// Consuming crates expose a thin newtype around this via the
23/// [`uniffi_bridged_engine!`] macro rather than hand-writing the facade.
24///
25/// All methods return [`anyhow::Result`], which each crate maps onto its own
26/// UniFFI error type via an `impl From<anyhow::Error>`.
27pub struct BridgedEngineWrapper {
28    inner: Box<dyn SyncEngine + Send + Sync>,
29}
30
31impl BridgedEngineWrapper {
32    pub fn new(inner: Box<dyn SyncEngine + Send + Sync>) -> Self {
33        Self { inner }
34    }
35
36    /// The last sync time, in milliseconds. Desktop reads this to build the
37    /// collection URL for fetching incoming records. There is deliberately no
38    /// setter: the engine owns its last-sync time and advances it itself in
39    /// `apply`/`set_uploaded`.
40    pub fn last_sync(&self) -> Result<i64> {
41        Ok(self.inner.last_sync()?.unwrap_or_default().as_millis())
42    }
43
44    /// Force a full re-download next sync by resetting the engine-owned
45    /// `last_sync` timestamp - lighter than a full reset.
46    pub fn reset_last_sync(&self) -> Result<()> {
47        self.inner.reset_last_sync()
48    }
49
50    /// The per-collection sync ID, derived from the engine's sync association.
51    /// (Bridged engines never maintain the "global" guid - that's all managed by
52    /// the consumer, ie, Desktop. They only care about the per-collection one.)
53    pub fn sync_id(&self) -> Result<Option<String>> {
54        Ok(match self.inner.get_sync_assoc()? {
55            EngineSyncAssociation::Disconnected => None,
56            EngineSyncAssociation::Connected(c) => Some(c.coll.into()),
57        })
58    }
59
60    /// Resets the sync ID for this collection, returning the new ID. As a side
61    /// effect this resets all local Sync state, as in `reset`.
62    pub fn reset_sync_id(&self) -> Result<String> {
63        let global = Guid::empty();
64        let coll = Guid::random();
65        self.inner
66            .reset(&EngineSyncAssociation::Connected(CollSyncIds {
67                global,
68                coll: coll.clone(),
69            }))?;
70        Ok(coll.to_string())
71    }
72
73    /// Ensures the locally stored sync ID matches `sync_id`; resets local Sync
74    /// state on a mismatch. Returns the assigned sync ID.
75    pub fn ensure_current_sync_id(&self, sync_id: &str) -> Result<String> {
76        let assoc = self.inner.get_sync_assoc()?;
77        if matches!(assoc, EngineSyncAssociation::Connected(c) if c.coll == sync_id) {
78            debug!("ensure_current_sync_id is current");
79        } else {
80            let new_coll_ids = CollSyncIds {
81                global: Guid::empty(),
82                coll: sync_id.into(),
83            };
84            self.inner
85                .reset(&EngineSyncAssociation::Connected(new_coll_ids))?;
86        }
87        Ok(sync_id.to_string())
88    }
89
90    pub fn set_clients(&self, client_data: &str) -> Result<()> {
91        // unwrap here is unfortunate, but can hopefully go away if we can
92        // start using the ClientData type instead of the string.
93        self.inner
94            .set_clients(&|| serde_json::from_str::<crate::ClientData>(client_data).unwrap())
95    }
96
97    pub fn sync_started(&self) -> Result<()> {
98        self.inner.sync_started()
99    }
100
101    /// Decode the JSON-encoded `IncomingBso`s that UniFFI passes to us, then
102    /// hand them to the wrapped engine.
103    pub fn store_incoming(&self, incoming: Vec<String>) -> Result<()> {
104        let mut bsos = Vec::with_capacity(incoming.len());
105        for inc in incoming {
106            bsos.push(serde_json::from_str::<IncomingBso>(&inc)?);
107        }
108        let mut telem = telemetry::Engine::new(self.inner.collection_name());
109        self.inner.stage_incoming(bsos, &mut telem)
110    }
111
112    /// Apply staged records and encode the outgoing `OutgoingBso`s back into
113    /// JSON for UniFFI.
114    ///
115    /// `server_modified_millis` is the collection's server last-modified time,
116    /// passed explicitly by Desktop (which has just stored it as the last sync
117    /// time before calling us). It's forwarded to [`SyncEngine::apply`] exactly
118    /// as the native Rust client does, so reconciliation sees the real
119    /// timestamp.
120    pub fn apply(&self, server_modified_millis: i64) -> Result<Vec<String>> {
121        let mut telem = telemetry::Engine::new(self.inner.collection_name());
122        let records = self.inner.apply(
123            ServerTimestamp::from_millis(server_modified_millis),
124            &mut telem,
125        )?;
126        let mut outgoing = Vec::with_capacity(records.len());
127        for e in records {
128            outgoing.push(serde_json::to_string(&e)?);
129        }
130        Ok(outgoing)
131    }
132
133    /// The uploaded ids always cross the UniFFI boundary as plain strings; we
134    /// convert them to [`Guid`] for the engine here.
135    pub fn set_uploaded(&self, server_modified_millis: i64, ids: Vec<String>) -> Result<()> {
136        let guids: Vec<Guid> = ids.into_iter().map(Guid::from).collect();
137        self.inner
138            .set_uploaded(ServerTimestamp::from_millis(server_modified_millis), guids)
139    }
140
141    pub fn sync_finished(&self) -> Result<()> {
142        self.inner.sync_finished()
143    }
144
145    pub fn reset(&self) -> Result<()> {
146        self.inner.reset(&EngineSyncAssociation::Disconnected)
147    }
148
149    pub fn wipe(&self) -> Result<()> {
150        self.inner.wipe()
151    }
152}
153
154/// Generates a UniFFI-exposable bridged engine newtype around
155/// [`BridgedEngineWrapper`], removing the ~100 lines of identical facade
156/// boilerplate each consuming crate used to hand-write.
157///
158/// Usage (invoke in the module the crate's UDL `interface` resolves against):
159/// ```ignore
160/// sync15::uniffi_bridged_engine!(LoginsBridgedEngine);
161/// sync15::uniffi_bridged_engine!(TabsBridgedEngine);
162/// ```
163///
164/// All bridged engines expose the same interface; `set_uploaded` takes ids as a
165/// plain `sequence<string>` in every crate's UDL. The generated methods return
166/// `anyhow::Result`, which the crate's UDL `[Throws=...]` maps to its error type
167/// via the existing `impl From<anyhow::Error>`.
168///
169/// The macro always emits `set_clients`; a crate whose UDL doesn't declare it
170/// (logins, webext-storage) simply leaves that inherent method unbound, which is
171/// harmless.
172#[macro_export]
173macro_rules! uniffi_bridged_engine {
174    ($name:ident) => {
175        // This is what UniFFI exposes; it does nothing other than delegate to
176        // the shared `BridgedEngineWrapper`, which adapts our `SyncEngine`.
177        pub struct $name($crate::engine::BridgedEngineWrapper);
178
179        impl $name {
180            pub fn new(
181                inner: ::std::boxed::Box<dyn $crate::engine::SyncEngine + Send + Sync>,
182            ) -> Self {
183                Self($crate::engine::BridgedEngineWrapper::new(inner))
184            }
185
186            pub fn last_sync(&self) -> ::anyhow::Result<i64> {
187                self.0.last_sync()
188            }
189
190            pub fn reset_last_sync(&self) -> ::anyhow::Result<()> {
191                self.0.reset_last_sync()
192            }
193
194            pub fn sync_id(&self) -> ::anyhow::Result<Option<String>> {
195                self.0.sync_id()
196            }
197
198            pub fn reset_sync_id(&self) -> ::anyhow::Result<String> {
199                self.0.reset_sync_id()
200            }
201
202            pub fn ensure_current_sync_id(&self, sync_id: &str) -> ::anyhow::Result<String> {
203                self.0.ensure_current_sync_id(sync_id)
204            }
205
206            pub fn set_clients(&self, client_data: &str) -> ::anyhow::Result<()> {
207                self.0.set_clients(client_data)
208            }
209
210            pub fn sync_started(&self) -> ::anyhow::Result<()> {
211                self.0.sync_started()
212            }
213
214            pub fn store_incoming(&self, incoming: Vec<String>) -> ::anyhow::Result<()> {
215                self.0.store_incoming(incoming)
216            }
217
218            pub fn apply(&self, server_modified_millis: i64) -> ::anyhow::Result<Vec<String>> {
219                self.0.apply(server_modified_millis)
220            }
221
222            pub fn set_uploaded(
223                &self,
224                server_modified_millis: i64,
225                ids: Vec<String>,
226            ) -> ::anyhow::Result<()> {
227                self.0.set_uploaded(server_modified_millis, ids)
228            }
229
230            pub fn sync_finished(&self) -> ::anyhow::Result<()> {
231                self.0.sync_finished()
232            }
233
234            pub fn reset(&self) -> ::anyhow::Result<()> {
235                self.0.reset()
236            }
237
238            pub fn wipe(&self) -> ::anyhow::Result<()> {
239                self.0.wipe()
240            }
241        }
242    };
243}
244
245#[cfg(test)]
246mod wrapper_tests {
247    use super::*;
248    use crate::CollectionName;
249    use crate::bso::OutgoingBso;
250    use crate::engine::CollectionRequest;
251    use std::sync::Mutex;
252
253    // A minimal SyncEngine that records the guids passed to `set_uploaded`, so
254    // we can confirm the wrapper converts the incoming string ids to `Guid` and
255    // drives a `SyncEngine`.
256    #[derive(Default)]
257    struct RecordingEngine {
258        uploaded: Mutex<Vec<Guid>>,
259    }
260
261    impl SyncEngine for RecordingEngine {
262        fn collection_name(&self) -> CollectionName {
263            "test".into()
264        }
265        fn stage_incoming(
266            &self,
267            _inbound: Vec<IncomingBso>,
268            _telem: &mut telemetry::Engine,
269        ) -> Result<()> {
270            Ok(())
271        }
272        fn apply(
273            &self,
274            _timestamp: ServerTimestamp,
275            _telem: &mut telemetry::Engine,
276        ) -> Result<Vec<OutgoingBso>> {
277            Ok(vec![])
278        }
279        fn set_uploaded(&self, _new_timestamp: ServerTimestamp, ids: Vec<Guid>) -> Result<()> {
280            self.uploaded.lock().unwrap().extend(ids);
281            Ok(())
282        }
283        fn get_collection_request(
284            &self,
285            _server_timestamp: ServerTimestamp,
286        ) -> Result<Option<CollectionRequest>> {
287            Ok(None)
288        }
289        fn get_sync_assoc(&self) -> Result<EngineSyncAssociation> {
290            Ok(EngineSyncAssociation::Disconnected)
291        }
292        fn reset(&self, _assoc: &EngineSyncAssociation) -> Result<()> {
293            Ok(())
294        }
295        fn wipe(&self) -> Result<()> {
296            Ok(())
297        }
298    }
299
300    #[test]
301    fn set_uploaded_converts_string_ids() {
302        let wrapper = BridgedEngineWrapper::new(Box::new(RecordingEngine::default()));
303        // Every crate now hands us string ids; the wrapper turns them into `Guid`.
304        wrapper
305            .set_uploaded(1, vec!["aaaa".to_string(), "bbbb".to_string()])
306            .unwrap();
307    }
308}