/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use crate::storage::{RemoteTab, TabGroup, Window}; use serde_derive::{Deserialize, Serialize}; use std::collections::HashMap; use types::Timestamp; // copy/pasta... fn skip_if_default(v: &T) -> bool { *v == T::default() } #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct TabsRecordTab { pub title: String, pub url_history: Vec, pub icon: Option, pub last_used: i64, // Seconds since epoch! #[serde(default, skip_serializing_if = "skip_if_default")] pub inactive: bool, #[serde(default, skip_serializing_if = "skip_if_default")] pub pinned: bool, #[serde(default, skip_serializing_if = "skip_if_default")] pub index: u32, // the position #[serde(default, skip_serializing_if = "skip_if_default")] pub tab_group_id: String, #[serde(default, skip_serializing_if = "skip_if_default")] pub window_id: String, } impl From for TabsRecordTab { fn from(tab: RemoteTab) -> Self { Self { title: tab.title, url_history: tab.url_history, icon: tab.icon, last_used: tab.last_used.checked_div(1000).unwrap_or_default(), inactive: tab.inactive, pinned: tab.pinned, index: tab.index, tab_group_id: tab.tab_group_id, window_id: tab.window_id, } } } impl From for RemoteTab { fn from(tab: TabsRecordTab) -> Self { Self { title: tab.title, url_history: tab.url_history, icon: tab.icon, last_used: tab.last_used.checked_mul(1000).unwrap_or_default(), inactive: tab.inactive, pinned: tab.pinned, index: tab.index, tab_group_id: tab.tab_group_id, window_id: tab.window_id, } } } #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct TabsRecordWindow { pub id: String, pub last_used: Timestamp, pub index: u32, #[serde(default, skip_serializing_if = "skip_if_default")] pub window_type: u8, // The repr of a `crate::storage::WindowType` } impl From for TabsRecordWindow { fn from(w: Window) -> Self { Self { id: w.id, last_used: w.last_used, index: w.index, window_type: w.window_type as u8, } } } impl From for Window { fn from(w: TabsRecordWindow) -> Self { Self { id: w.id, last_used: w.last_used, index: w.index, window_type: w.window_type.into(), } } } #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct TabsRecordTabGroup { // Empty and closed tab groups are not included. pub id: String, pub name: String, pub color: String, pub collapsed: bool, } impl From for TabsRecordTabGroup { fn from(group: TabGroup) -> Self { Self { id: group.id, name: group.name, color: group.color, collapsed: group.collapsed, } } } impl From for TabGroup { fn from(group: TabsRecordTabGroup) -> Self { Self { id: group.id, name: group.name, color: group.color, collapsed: group.collapsed, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(test, derive(Default))] #[serde(rename_all = "camelCase")] /// This struct mirrors what is stored on the server as the top-level payload. pub struct TabsRecord { // `String` instead of `SyncGuid` because `SyncGuid` is optimized for short uids, // but we expect these to be long "xxx-xxx-xxx-xxx" FxA device uids. pub id: String, pub client_name: String, pub tabs: Vec, #[serde(default, skip_serializing_if = "skip_if_default")] pub tab_groups: HashMap, #[serde(default, skip_serializing_if = "skip_if_default")] pub windows: HashMap, } #[cfg(test)] pub mod test { use super::*; use serde_json::json; #[test] fn test_payload() { let payload = json!({ "id": "JkeBPC50ZI0m", "clientName": "client name", "tabs": [{ "title": "the title", "urlHistory": [ "https://mozilla.org/" ], "icon": "https://mozilla.org/icon", "lastUsed": 1643764207 }] }); let record: TabsRecord = serde_json::from_value(payload).expect("should work"); assert_eq!(record.id, "JkeBPC50ZI0m"); assert_eq!(record.client_name, "client name"); assert_eq!(record.tabs.len(), 1); assert_eq!(record.windows.len(), 0); assert_eq!(record.tab_groups.len(), 0); let tab = &record.tabs[0]; assert_eq!(tab.title, "the title"); assert_eq!(tab.icon, Some("https://mozilla.org/icon".to_string())); assert_eq!(tab.last_used, 1643764207); assert!(!tab.inactive); } #[test] fn test_roundtrip() { let tab = TabsRecord { id: "JkeBPC50ZI0m".into(), client_name: "client name".into(), tabs: vec![TabsRecordTab { title: "the title".into(), url_history: vec!["https://mozilla.org/".into()], icon: Some("https://mozilla.org/icon".into()), last_used: 1643764207, inactive: true, ..Default::default() }], tab_groups: HashMap::new(), windows: HashMap::new(), }; let round_tripped = serde_json::from_value(serde_json::to_value(tab.clone()).unwrap()).unwrap(); assert_eq!(tab, round_tripped); } #[test] fn test_extra_fields() { let payload = json!({ "id": "JkeBPC50ZI0m", // Let's say we agree on new tabs to record, we want old versions to // ignore them! "ignoredField": "??", "foo": [1, 2, 3], "bar": [{"id": 1}], "clientName": "client name", "tabs": [{ "title": "the title", "urlHistory": [ "https://mozilla.org/" ], "icon": "https://mozilla.org/icon", "lastUsed": 1643764207, // Ditto - make sure we ignore unexpected fields in each tab. "ignoredField": "??", }] }); let record: TabsRecord = serde_json::from_value(payload).unwrap(); // The point of this test is really just to ensure the deser worked, so // just check the ID. assert_eq!(record.id, "JkeBPC50ZI0m"); } #[test] fn test_windows_tab_groups() { let payload = json!({ "id": "JkeBPC50ZI0m", "clientName": "client name", "tabs": [{ "title": "the title", "urlHistory": [ "https://mozilla.org/" ], "icon": "https://mozilla.org/icon", "lastUsed": 1643764207 }], "windows" : { "window-1" : { "id": "window-1", "lastUsed": 1, "index": 0, "windowType": 1 } } }); let record: TabsRecord = serde_json::from_value(payload).expect("should work"); assert_eq!(record.windows.len(), 1); assert_eq!(record.windows.get("window-1").unwrap().id, "window-1"); assert_eq!( record.windows.get("window-1").unwrap().last_used, types::Timestamp(1) ); assert_eq!(record.tab_groups.len(), 0); } }