tabs/sync/
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 super::TabsRecord;
6use crate::schema;
7use crate::storage::{ClientRemoteTabs, TABS_CLIENT_TTL};
8use crate::store::TabsStore;
9use anyhow::Result;
10use error_support::{debug, info, trace, warn};
11
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex, RwLock, Weak};
14use sync15::bso::{IncomingBso, OutgoingBso, OutgoingEnvelope};
15use sync15::engine::{
16    CollSyncIds, CollectionRequest, EngineSyncAssociation, SyncEngine, SyncEngineId,
17};
18use sync15::{telemetry, ClientData, CollectionName, RemoteClient, ServerTimestamp};
19use sync_guid::Guid;
20
21// Our "sync manager" will use whatever is stashed here.
22lazy_static::lazy_static! {
23    // Mutex: just taken long enough to update the inner stuff
24    static ref STORE_FOR_MANAGER: Mutex<Weak<TabsStore>> = Mutex::new(Weak::new());
25}
26
27/// Called by the sync manager to get a sync engine via the store previously
28/// registered with the sync manager.
29pub fn get_registered_sync_engine(
30    engine_id: &SyncEngineId,
31) -> Option<Box<dyn sync15::engine::SyncEngine>> {
32    let weak = STORE_FOR_MANAGER.lock().unwrap();
33    match weak.upgrade() {
34        None => None,
35        Some(store) => match engine_id {
36            SyncEngineId::Tabs => Some(Box::new(TabsEngine::new(Arc::clone(&store)))),
37            // panicking here seems reasonable - it's a static error if this
38            // it hit, not something that runtime conditions can influence.
39            _ => unreachable!("can't provide unknown engine: {}", engine_id),
40        },
41    }
42}
43
44impl ClientRemoteTabs {
45    pub(crate) fn from_record(
46        client_id: String,
47        last_modified: ServerTimestamp,
48        remote_client: &RemoteClient,
49        record: TabsRecord,
50    ) -> Self {
51        Self {
52            client_id,
53            client_name: remote_client.device_name.clone(),
54            device_type: remote_client.device_type,
55            last_modified: last_modified.as_millis(),
56            remote_tabs: record.tabs.into_iter().map(Into::into).collect(),
57            tab_groups: record
58                .tab_groups
59                .into_iter()
60                .map(|(n, v)| (n, v.into()))
61                .collect(),
62            windows: record
63                .windows
64                .into_iter()
65                .map(|(n, v)| (n, v.into()))
66                .collect(),
67        }
68    }
69}
70
71// This is the implementation of syncing, which is used by the 2 different "sync engines"
72// (We hope to get these 2 engines even closer in the future, but for now, we suck this up)
73pub struct TabsEngine {
74    pub(super) store: Arc<TabsStore>,
75    // local_id is made public for use in examples/tabs-sync
76    pub local_id: RwLock<String>,
77}
78
79impl TabsEngine {
80    pub fn new(store: Arc<TabsStore>) -> Self {
81        Self {
82            store,
83            local_id: Default::default(),
84        }
85    }
86
87    // Internally owned last-sync - written internally from `apply`,
88    // `set_uploaded` and `reset` but otherwise private.
89    fn set_last_sync(&self, last_sync: ServerTimestamp) -> Result<()> {
90        let mut storage = self.store.storage.lock().unwrap();
91        debug!("Updating last sync to {}", last_sync);
92        let last_sync_millis = last_sync.as_millis();
93        Ok(storage.put_meta(schema::LAST_SYNC_META_KEY, &last_sync_millis)?)
94    }
95}
96
97impl SyncEngine for TabsEngine {
98    fn collection_name(&self) -> CollectionName {
99        "tabs".into()
100    }
101
102    fn last_sync(&self) -> Result<Option<ServerTimestamp>> {
103        let mut storage = self.store.storage.lock().unwrap();
104        let millis = storage.get_meta::<i64>(schema::LAST_SYNC_META_KEY)?;
105        Ok(millis.map(ServerTimestamp))
106    }
107
108    fn reset_last_sync(&self) -> Result<()> {
109        self.set_last_sync(ServerTimestamp(0))
110    }
111
112    fn set_clients(&self, get_client_data: &dyn Fn() -> ClientData) -> Result<()> {
113        let mut storage = self.store.storage.lock().unwrap();
114        // We only know the client list at sync time, but need to return tabs potentially
115        // at any time -- so we store the clients in the meta table to be able to properly
116        // return a ClientRemoteTab struct
117        let client_data = get_client_data();
118        storage.put_meta(
119            schema::REMOTE_CLIENTS_KEY,
120            &serde_json::to_string(&client_data.recent_clients)?,
121        )?;
122        *self.local_id.write().unwrap() = client_data.local_client_id;
123        Ok(())
124    }
125
126    fn stage_incoming(
127        &self,
128        inbound: Vec<IncomingBso>,
129        telem: &mut telemetry::Engine,
130    ) -> Result<()> {
131        // We don't really "stage" records, we just apply them.
132        let local_id = &*self.local_id.read().unwrap();
133        let mut remote_tabs = Vec::with_capacity(inbound.len());
134
135        let mut incoming_telemetry = telemetry::EngineIncoming::new();
136        for incoming in inbound {
137            if incoming.envelope.id == *local_id {
138                // That's our own record, ignore it.
139                continue;
140            }
141            let modified = incoming.envelope.modified;
142            let record = match incoming.into_content::<TabsRecord>().content() {
143                Some(record) => record,
144                None => {
145                    // Invalid record or a "tombstone" which tabs don't have.
146                    warn!("Ignoring incoming invalid tab");
147                    incoming_telemetry.failed(1);
148                    continue;
149                }
150            };
151            incoming_telemetry.applied(1);
152            remote_tabs.push((record, modified));
153        }
154        telem.incoming(incoming_telemetry);
155        let mut storage = self.store.storage.lock().unwrap();
156        // In desktop we might end up here with zero records when doing a quick-write, in
157        // which case we don't want to wipe the DB.
158        if !remote_tabs.is_empty() {
159            storage.replace_remote_tabs(&remote_tabs)?;
160        }
161        storage.remove_stale_clients()?;
162        storage.remove_old_pending_closures(&remote_tabs)?;
163        Ok(())
164    }
165
166    fn apply(
167        &self,
168        timestamp: ServerTimestamp,
169        _telem: &mut telemetry::Engine,
170    ) -> Result<Vec<OutgoingBso>> {
171        // We've already applied them - we just need to fetch outgoing.
172        let local_id = &*self.local_id.read().unwrap();
173        // Timestamp will be zero when used as a "bridged" engine.
174        if timestamp.0 != 0 {
175            self.set_last_sync(timestamp)?;
176        }
177
178        let mut storage = self.store.storage.lock().unwrap();
179        let remote_clients: HashMap<String, RemoteClient> = {
180            match storage.get_meta::<String>(schema::REMOTE_CLIENTS_KEY)? {
181                None => HashMap::default(),
182                Some(json) => serde_json::from_str(&json).unwrap(),
183            }
184        };
185
186        let Some(ref tabs_info) = *storage.local_tabs.borrow() else {
187            // It's a less than ideal outcome if at startup (or any time) we are asked to
188            // sync tabs before the app has told us what the tabs are, so make noise, but
189            // don't actually write that we have no tabs.
190            warn!("syncing without local tabs");
191            return Ok(vec![]);
192        };
193
194        let client_name = remote_clients
195            .get(local_id)
196            .map(|client| client.device_name.clone())
197            .unwrap_or_default();
198
199        let mut record = TabsRecord {
200            id: local_id.clone(),
201            client_name,
202            tabs: tabs_info
203                .tabs
204                .iter()
205                .map(Clone::clone)
206                .map(Into::into)
207                .collect(),
208            windows: tabs_info
209                .windows
210                .iter()
211                .map(|(n, v)| (n.clone(), v.clone().into()))
212                .collect(),
213            tab_groups: tabs_info
214                .tab_groups
215                .iter()
216                .map(|(n, v)| (n.clone(), v.clone().into()))
217                .collect(),
218        };
219        super::prepare_for_upload(&mut record);
220
221        trace!("outgoing {record}");
222        let envelope = OutgoingEnvelope {
223            id: local_id.as_str().into(),
224            ttl: Some(TABS_CLIENT_TTL),
225            ..Default::default()
226        };
227        // XXX - outgoing telem?
228        Ok(vec![OutgoingBso::from_content(envelope, record)?])
229    }
230
231    fn set_uploaded(&self, new_timestamp: ServerTimestamp, ids: Vec<Guid>) -> Result<()> {
232        info!("sync uploaded {} records", ids.len());
233        self.set_last_sync(new_timestamp)?;
234        Ok(())
235    }
236
237    fn get_collection_request(
238        &self,
239        server_timestamp: ServerTimestamp,
240    ) -> Result<Option<CollectionRequest>> {
241        let since = self.last_sync()?.unwrap_or_default();
242        Ok(if since == server_timestamp {
243            None
244        } else {
245            Some(
246                CollectionRequest::new("tabs".into())
247                    .full()
248                    .newer_than(since),
249            )
250        })
251    }
252
253    fn reset(&self, assoc: &EngineSyncAssociation) -> Result<()> {
254        self.set_last_sync(ServerTimestamp(0))?;
255        let mut storage = self.store.storage.lock().unwrap();
256        storage.delete_meta(schema::REMOTE_CLIENTS_KEY)?;
257        storage.wipe_remote_tabs()?;
258        match assoc {
259            EngineSyncAssociation::Disconnected => {
260                storage.delete_meta(schema::GLOBAL_SYNCID_META_KEY)?;
261                storage.delete_meta(schema::COLLECTION_SYNCID_META_KEY)?;
262            }
263            EngineSyncAssociation::Connected(ids) => {
264                storage.put_meta(schema::GLOBAL_SYNCID_META_KEY, &ids.global.to_string())?;
265                storage.put_meta(schema::COLLECTION_SYNCID_META_KEY, &ids.coll.to_string())?;
266            }
267        };
268        Ok(())
269    }
270
271    fn wipe(&self) -> Result<()> {
272        self.reset(&EngineSyncAssociation::Disconnected)?;
273        // not clear why we need to wipe the local tabs - the app is just going
274        // to re-add them?
275        self.store.storage.lock().unwrap().wipe_local_tabs();
276        Ok(())
277    }
278
279    fn get_sync_assoc(&self) -> Result<EngineSyncAssociation> {
280        let mut storage = self.store.storage.lock().unwrap();
281        let global = storage.get_meta::<String>(schema::GLOBAL_SYNCID_META_KEY)?;
282        let coll = storage.get_meta::<String>(schema::COLLECTION_SYNCID_META_KEY)?;
283        Ok(if let (Some(global), Some(coll)) = (global, coll) {
284            EngineSyncAssociation::Connected(CollSyncIds {
285                global: Guid::from_string(global),
286                coll: Guid::from_string(coll),
287            })
288        } else {
289            EngineSyncAssociation::Disconnected
290        })
291    }
292}
293
294impl crate::TabsStore {
295    // This allows the embedding app to say "make this instance available to
296    // the sync manager". The implementation is more like "offer to sync mgr"
297    // (thereby avoiding us needing to link with the sync manager) but
298    // `register_with_sync_manager()` is logically what's happening so that's
299    // the name it gets.
300    pub fn register_with_sync_manager(self: Arc<Self>) {
301        let mut state = STORE_FOR_MANAGER.lock().unwrap();
302        *state = Arc::downgrade(&self);
303    }
304}
305
306#[cfg(test)]
307pub mod test {
308    use super::*;
309    use crate::DeviceType;
310    use serde_json::json;
311    use sync15::bso::IncomingBso;
312
313    #[test]
314    fn test_incoming_tabs() {
315        error_support::init_for_tests();
316
317        let engine = TabsEngine::new(Arc::new(TabsStore::new_with_mem_path("test-incoming")));
318
319        let client_data = ClientData {
320            local_client_id: "my-device".to_string(),
321            recent_clients: HashMap::from([
322                (
323                    "my-device".to_string(),
324                    RemoteClient {
325                        fxa_device_id: None,
326                        device_name: "my device".to_string(),
327                        device_type: sync15::DeviceType::Unknown,
328                    },
329                ),
330                (
331                    "device-no-tabs".to_string(),
332                    RemoteClient {
333                        fxa_device_id: None,
334                        device_name: "device with no tabs".to_string(),
335                        device_type: DeviceType::Unknown,
336                    },
337                ),
338                (
339                    "device-with-a-tab".to_string(),
340                    RemoteClient {
341                        fxa_device_id: None,
342                        device_name: "device with an updated tab".to_string(),
343                        device_type: DeviceType::Unknown,
344                    },
345                ),
346            ]),
347        };
348        engine
349            .set_clients(&|| client_data.clone())
350            .expect("should work");
351
352        let records = vec![
353            json!({
354                "id": "device-no-tabs",
355                "clientName": "device with no tabs",
356                "tabs": [],
357            }),
358            json!({
359                "id": "device-with-a-tab",
360                "clientName": "device with a tab",
361                "tabs": [{
362                    "title": "the title",
363                    "urlHistory": [
364                        "https://mozilla.org/"
365                    ],
366                    "icon": "https://mozilla.org/icon",
367                    "lastUsed": 1643764207
368                }]
369            }),
370            json!({
371                "id": "device-with-a-tab",
372                "clientName": "device with an updated tab",
373                "tabs": [{
374                    "title": "the new title",
375                    "urlHistory": [
376                        "https://mozilla.org/"
377                    ],
378                    "icon": "https://mozilla.org/icon",
379                    "lastUsed": 1643764208
380                }]
381            }),
382            // This has the main payload as OK but the tabs part invalid.
383            json!({
384                "id": "device-with-invalid-tab",
385                "clientName": "device with a tab",
386                "tabs": [{
387                    "foo": "bar",
388                }]
389            }),
390            // We want this to be a valid payload but an invalid tab - so it needs an ID.
391            json!({
392                "id": "invalid-tab",
393                "foo": "bar"
394            }),
395        ];
396
397        let mut telem = telemetry::Engine::new("tabs");
398        let incoming = records
399            .into_iter()
400            .map(IncomingBso::from_test_content)
401            .collect();
402        engine
403            .stage_incoming(incoming, &mut telem)
404            .expect("Should apply incoming and stage outgoing records");
405        let outgoing = engine
406            .apply(ServerTimestamp(0), &mut telem)
407            .expect("should apply");
408
409        assert!(outgoing.is_empty());
410
411        // now check the store has what we think it has.
412        let mut storage = engine.store.storage.lock().unwrap();
413        let mut crts = storage.get_remote_tabs().expect("should work");
414        crts.sort_by(|a, b| a.client_name.partial_cmp(&b.client_name).unwrap());
415        assert_eq!(crts.len(), 2, "we currently include devices with no tabs");
416        let crt = &crts[0];
417        assert_eq!(crt.client_name, "device with an updated tab");
418        assert_eq!(crt.device_type, DeviceType::Unknown);
419        assert_eq!(crt.remote_tabs.len(), 1);
420        assert_eq!(crt.remote_tabs[0].title, "the new title");
421
422        let crt = &crts[1];
423        assert_eq!(crt.client_name, "device with no tabs");
424        assert_eq!(crt.device_type, DeviceType::Unknown);
425        assert_eq!(crt.remote_tabs.len(), 0);
426    }
427
428    #[test]
429    fn test_no_incoming_doesnt_write() {
430        error_support::init_for_tests();
431
432        let engine = TabsEngine::new(Arc::new(TabsStore::new_with_mem_path(
433            "test_no_incoming_doesnt_write",
434        )));
435
436        let client_data = ClientData {
437            local_client_id: "my-device".to_string(),
438            recent_clients: HashMap::from([(
439                "device-with-a-tab".to_string(),
440                RemoteClient {
441                    fxa_device_id: None,
442                    device_name: "device-with-a-tab".to_string(),
443                    device_type: DeviceType::Unknown,
444                },
445            )]),
446        };
447        engine
448            .set_clients(&|| client_data.clone())
449            .expect("should work");
450
451        let records = vec![json!({
452            "id": "device-with-a-tab",
453            "clientName": "device with a tab",
454            "tabs": [{
455                "title": "the title",
456                "urlHistory": [
457                    "https://mozilla.org/"
458                ],
459                "icon": "https://mozilla.org/icon",
460                "lastUsed": 1643764207
461            }]
462        })];
463
464        let mut telem = telemetry::Engine::new("tabs");
465        let incoming = records
466            .into_iter()
467            .map(IncomingBso::from_test_content)
468            .collect();
469        engine
470            .stage_incoming(incoming, &mut telem)
471            .expect("Should apply incoming and stage outgoing records");
472        engine
473            .apply(ServerTimestamp(0), &mut telem)
474            .expect("should apply");
475
476        // now check the store has what we think it has.
477        {
478            let mut storage = engine.store.storage.lock().unwrap();
479            assert_eq!(storage.get_remote_tabs().expect("should work").len(), 1);
480        }
481
482        // Now another sync with zero incoming records, should still be able to get back
483        // our one client.
484        engine
485            .stage_incoming(vec![], &mut telemetry::Engine::new("tabs"))
486            .expect("Should succeed applying zero records");
487
488        {
489            let mut storage = engine.store.storage.lock().unwrap();
490            assert_eq!(storage.get_remote_tabs().expect("should work").len(), 1);
491        }
492    }
493
494    #[test]
495    fn test_sync_manager_registration() {
496        let store = Arc::new(TabsStore::new_with_mem_path("test-registration"));
497        assert_eq!(Arc::strong_count(&store), 1);
498        assert_eq!(Arc::weak_count(&store), 0);
499        Arc::clone(&store).register_with_sync_manager();
500        assert_eq!(Arc::strong_count(&store), 1);
501        assert_eq!(Arc::weak_count(&store), 1);
502        let registered = STORE_FOR_MANAGER
503            .lock()
504            .unwrap()
505            .upgrade()
506            .expect("should upgrade");
507        assert!(Arc::ptr_eq(&store, &registered));
508        drop(registered);
509        // should be no new references
510        assert_eq!(Arc::strong_count(&store), 1);
511        assert_eq!(Arc::weak_count(&store), 1);
512        // dropping the registered object should drop the registration.
513        drop(store);
514        assert!(STORE_FOR_MANAGER.lock().unwrap().upgrade().is_none());
515    }
516
517    #[test]
518    fn test_apply_timestamp() {
519        error_support::init_for_tests();
520
521        let engine = TabsEngine::new(Arc::new(TabsStore::new_with_mem_path(
522            "test-apply-timestamp",
523        )));
524
525        let records = vec![json!({
526            "id": "device-no-tabs",
527            "clientName": "device with no tabs",
528            "tabs": [],
529        })];
530
531        let mut telem = telemetry::Engine::new("tabs");
532        engine
533            .set_last_sync(ServerTimestamp::from_millis(123))
534            .unwrap();
535        let incoming = records
536            .into_iter()
537            .map(IncomingBso::from_test_content)
538            .collect();
539        engine
540            .stage_incoming(incoming, &mut telem)
541            .expect("Should apply incoming and stage outgoing records");
542        engine
543            .apply(ServerTimestamp(0), &mut telem)
544            .expect("should apply");
545
546        assert_eq!(
547            engine
548                .last_sync()
549                .expect("should work")
550                .expect("should have a value"),
551            ServerTimestamp::from_millis(123),
552            "didn't set a zero timestamp"
553        )
554    }
555}