imessage_database/message_types/
url.rs

1/*!
2 These are the link previews that iMessage generates when sending links.
3
4 They may contain metadata, even if the page the link points to no longer exists on the internet.
5*/
6
7use plist::Value;
8
9use crate::{
10    error::plist::PlistParseError,
11    message_types::{
12        app_store::AppStoreMessage,
13        collaboration::CollaborationMessage,
14        music::MusicMessage,
15        placemark::PlacemarkMessage,
16        variants::{BalloonProvider, URLOverride},
17    },
18    util::plist::{get_bool_from_dict, get_string_from_dict, get_string_from_nested_dict},
19};
20
21/// This struct is not documented by Apple, but represents messages created by
22/// `com.apple.messages.URLBalloonProvider`.
23#[derive(Debug, PartialEq, Eq)]
24pub struct URLMessage<'a> {
25    /// The webpage's `<og:title>` attribute
26    pub title: Option<&'a str>,
27    /// The webpage's `<og:description>` attribute
28    pub summary: Option<&'a str>,
29    /// The URL that ended up serving content, after all redirects
30    pub url: Option<&'a str>,
31    /// The original url, before any redirects
32    pub original_url: Option<&'a str>,
33    /// The type of webpage Apple thinks the link represents
34    pub item_type: Option<&'a str>,
35    /// Up to 4 image previews displayed in the background of the bubble
36    pub images: Vec<&'a str>,
37    /// Icons that represent the website, generally the favicon or apple-touch-icon
38    pub icons: Vec<&'a str>,
39    /// The name of a website
40    pub site_name: Option<&'a str>,
41    pub placeholder: bool,
42}
43
44impl<'a> BalloonProvider<'a> for URLMessage<'a> {
45    fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
46        let url_metadata = URLMessage::get_body(payload)?;
47        Ok(URLMessage {
48            title: get_string_from_dict(url_metadata, "title"),
49            summary: get_string_from_dict(url_metadata, "summary"),
50            url: get_string_from_nested_dict(url_metadata, "URL"),
51            original_url: get_string_from_nested_dict(url_metadata, "originalURL"),
52            item_type: get_string_from_dict(url_metadata, "itemType"),
53            images: URLMessage::get_array_from_nested_dict(url_metadata, "images")
54                .unwrap_or_default(),
55            icons: URLMessage::get_array_from_nested_dict(url_metadata, "icons")
56                .unwrap_or_default(),
57            site_name: get_string_from_dict(url_metadata, "siteName"),
58            placeholder: get_bool_from_dict(url_metadata, "richLinkIsPlaceholder").unwrap_or(false),
59        })
60    }
61}
62
63impl<'a> URLMessage<'a> {
64    /// Gets the subtype of the URL message based on the payload
65    pub fn get_url_message_override(payload: &'a Value) -> Result<URLOverride<'a>, PlistParseError> {
66        if let Ok(balloon) = CollaborationMessage::from_map(payload) {
67            return Ok(URLOverride::Collaboration(balloon));
68        }
69        if let Ok(balloon) = MusicMessage::from_map(payload) {
70            return Ok(URLOverride::AppleMusic(balloon));
71        }
72        if let Ok(balloon) = AppStoreMessage::from_map(payload) {
73            return Ok(URLOverride::AppStore(balloon));
74        }
75        if let Ok(balloon) = PlacemarkMessage::from_map(payload) {
76            return Ok(URLOverride::SharedPlacemark(balloon));
77        }
78        if let Ok(balloon) = URLMessage::from_map(payload) {
79            return Ok(URLOverride::Normal(balloon));
80        }
81        Err(PlistParseError::NoPayload)
82    }
83
84    /// Extract the main dictionary of data from the body of the payload
85    ///
86    /// There are two known ways this data is stored: the more recent `richLinkMetadata` style,
87    /// or some kind of social integration stored under a `metadata` key
88    fn get_body(payload: &'a Value) -> Result<&'a Value, PlistParseError> {
89        let root_dict = payload.as_dictionary().ok_or_else(|| {
90            PlistParseError::InvalidType("root".to_string(), "dictionary".to_string())
91        })?;
92
93        if let Some(meta) = root_dict.get("richLinkMetadata") {
94            return Ok(meta);
95        };
96        if let Some(meta) = root_dict.get("metadata") {
97            return Ok(meta);
98        };
99        Err(PlistParseError::NoPayload)
100    }
101
102    /// Extract the array of image URLs from a URL message payload.
103    ///
104    /// The array consists of dictionaries that look like this:
105    /// ```json
106    /// [
107    ///     {
108    ///         "size": String("{0, 0}"),
109    ///         "URL": {
110    ///              "URL": String("https://chrissardegna.com/example.png")
111    ///          },
112    ///         "images": Integer(1)
113    ///     },
114    ///     ...
115    /// ]
116    /// ```
117    fn get_array_from_nested_dict(payload: &'a Value, key: &str) -> Option<Vec<&'a str>> {
118        payload
119            .as_dictionary()?
120            .get(key)?
121            .as_dictionary()?
122            .get(key)?
123            .as_array()?
124            .iter()
125            .map(|item| get_string_from_nested_dict(item, "URL"))
126            .collect()
127    }
128
129    /// Get the redirected URL from a URL message, falling back to the original URL, if it exists
130    pub fn get_url(&self) -> Option<&str> {
131        self.url.or(self.original_url)
132    }
133}
134
135#[cfg(test)]
136mod url_tests {
137    use crate::{
138        message_types::{url::URLMessage, variants::BalloonProvider},
139        util::plist::parse_ns_keyed_archiver,
140    };
141    use plist::Value;
142    use std::env::current_dir;
143    use std::fs::File;
144
145    #[test]
146    fn test_parse_url_me() {
147        let plist_path = current_dir()
148            .unwrap()
149            .as_path()
150            .join("test_data/url_message/URL.plist");
151        let plist_data = File::open(plist_path).unwrap();
152        let plist = Value::from_reader(plist_data).unwrap();
153        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
154
155        let balloon = URLMessage::from_map(&parsed).unwrap();
156        let expected = URLMessage {
157            title: Some("Christopher Sardegna"),
158            summary: None,
159            url: Some("https://chrissardegna.com/"),
160            original_url: Some("https://chrissardegna.com"),
161            item_type: None,
162            images: vec![],
163            icons: vec!["https://chrissardegna.com/favicon.ico"],
164            site_name: None,
165            placeholder: false,
166        };
167
168        assert_eq!(balloon, expected);
169    }
170
171    #[test]
172    fn test_parse_url_me_metadata() {
173        let plist_path = current_dir()
174            .unwrap()
175            .as_path()
176            .join("test_data/url_message/MetadataURL.plist");
177        let plist_data = File::open(plist_path).unwrap();
178        let plist = Value::from_reader(plist_data).unwrap();
179        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
180
181        let balloon = URLMessage::from_map(&parsed).unwrap();
182        let expected = URLMessage {
183            title: Some("Christopher Sardegna"),
184            summary: Some("Sample page description"),
185            url: Some("https://chrissardegna.com"),
186            original_url: Some("https://chrissardegna.com"),
187            item_type: Some("article"),
188            images: vec!["https://chrissardegna.com/ddc-facebook-icon.png"],
189            icons: vec![
190                "https://chrissardegna.com/apple-touch-icon-180x180.png",
191                "https://chrissardegna.com/ddc-icon-32x32.png",
192                "https://chrissardegna.com/ddc-icon-16x16.png",
193            ],
194            site_name: Some("Christopher Sardegna"),
195            placeholder: false,
196        };
197
198        assert_eq!(balloon, expected);
199    }
200
201    #[test]
202    fn test_parse_url_twitter() {
203        let plist_path = current_dir()
204            .unwrap()
205            .as_path()
206            .join("test_data/url_message/Twitter.plist");
207        let plist_data = File::open(plist_path).unwrap();
208        let plist = Value::from_reader(plist_data).unwrap();
209        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
210
211        let balloon = URLMessage::from_map(&parsed).unwrap();
212        let expected = URLMessage {
213            title: Some("Christopher Sardegna on Twitter"),
214            summary: Some("“Hello Twitter, meet Bella”"),
215            url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
216            original_url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
217            item_type: Some("article"),
218            images: vec![
219                "https://pbs.twimg.com/media/EFGLfR2X4AE8ItK.jpg:large",
220                "https://pbs.twimg.com/media/EFGLfRmX4AMnwqW.jpg:large",
221                "https://pbs.twimg.com/media/EFGLfRlXYAYn9Ce.jpg:large",
222            ],
223            icons: vec![
224                "https://abs.twimg.com/icons/apple-touch-icon-192x192.png",
225                "https://abs.twimg.com/favicons/favicon.ico",
226            ],
227            site_name: Some("Twitter"),
228            placeholder: false,
229        };
230
231        assert_eq!(balloon, expected);
232    }
233
234    #[test]
235    fn test_parse_url_reminder() {
236        let plist_path = current_dir()
237            .unwrap()
238            .as_path()
239            .join("test_data/url_message/Reminder.plist");
240        let plist_data = File::open(plist_path).unwrap();
241        let plist = Value::from_reader(plist_data).unwrap();
242        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
243
244        let balloon = URLMessage::from_map(&parsed).unwrap();
245        let expected = URLMessage {
246            title: None,
247            summary: None,
248            url: None,
249            original_url: Some(
250                "https://www.icloud.com/reminders/ZmFrZXVybF9mb3JfcmVtaW5kZXI#TestList",
251            ),
252            item_type: None,
253            images: vec![],
254            icons: vec![],
255            site_name: None,
256            placeholder: false,
257        };
258
259        assert_eq!(balloon, expected);
260    }
261
262    #[test]
263    fn test_get_url() {
264        let expected = URLMessage {
265            title: Some("Christopher Sardegna"),
266            summary: None,
267            url: Some("https://chrissardegna.com/"),
268            original_url: Some("https://chrissardegna.com"),
269            item_type: None,
270            images: vec![],
271            icons: vec!["https://chrissardegna.com/favicon.ico"],
272            site_name: None,
273            placeholder: false,
274        };
275        assert_eq!(expected.get_url(), Some("https://chrissardegna.com/"));
276    }
277
278    #[test]
279    fn test_get_original_url() {
280        let expected = URLMessage {
281            title: Some("Christopher Sardegna"),
282            summary: None,
283            url: None,
284            original_url: Some("https://chrissardegna.com"),
285            item_type: None,
286            images: vec![],
287            icons: vec!["https://chrissardegna.com/favicon.ico"],
288            site_name: None,
289            placeholder: false,
290        };
291        assert_eq!(expected.get_url(), Some("https://chrissardegna.com"));
292    }
293
294    #[test]
295    fn test_get_no_url() {
296        let expected = URLMessage {
297            title: Some("Christopher Sardegna"),
298            summary: None,
299            url: None,
300            original_url: None,
301            item_type: None,
302            images: vec![],
303            icons: vec!["https://chrissardegna.com/favicon.ico"],
304            site_name: None,
305            placeholder: false,
306        };
307        assert_eq!(expected.get_url(), None);
308    }
309}
310
311#[cfg(test)]
312mod url_override_tests {
313    use crate::{
314        message_types::{url::URLMessage, variants::URLOverride},
315        util::plist::parse_ns_keyed_archiver,
316    };
317    use plist::Value;
318    use std::env::current_dir;
319    use std::fs::File;
320
321    #[test]
322    fn can_parse_normal() {
323        let plist_path = current_dir()
324            .unwrap()
325            .as_path()
326            .join("test_data/url_message/URL.plist");
327        let plist_data = File::open(plist_path).unwrap();
328        let plist = Value::from_reader(plist_data).unwrap();
329        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
330
331        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
332        assert!(matches!(balloon, URLOverride::Normal(_)));
333    }
334
335    #[test]
336    fn can_parse_music() {
337        let plist_path = current_dir()
338            .unwrap()
339            .as_path()
340            .join("test_data/music_message/AppleMusic.plist");
341        let plist_data = File::open(plist_path).unwrap();
342        let plist = Value::from_reader(plist_data).unwrap();
343        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
344
345        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
346        assert!(matches!(balloon, URLOverride::AppleMusic(_)));
347    }
348
349    #[test]
350    fn can_parse_app_store() {
351        let plist_path = current_dir()
352            .unwrap()
353            .as_path()
354            .join("test_data/app_store/AppStoreLink.plist");
355        let plist_data = File::open(plist_path).unwrap();
356        let plist = Value::from_reader(plist_data).unwrap();
357        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
358
359        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
360        assert!(matches!(balloon, URLOverride::AppStore(_)));
361    }
362
363    #[test]
364    fn can_parse_collaboration() {
365        let plist_path = current_dir()
366            .unwrap()
367            .as_path()
368            .join("test_data/collaboration_message/Freeform.plist");
369        let plist_data = File::open(plist_path).unwrap();
370        let plist = Value::from_reader(plist_data).unwrap();
371        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
372
373        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
374        assert!(matches!(balloon, URLOverride::Collaboration(_)));
375    }
376
377    #[test]
378    fn can_parse_placemark() {
379        let plist_path = current_dir()
380            .unwrap()
381            .as_path()
382            .join("test_data/shared_placemark/SharedPlacemark.plist");
383        let plist_data = File::open(plist_path).unwrap();
384        let plist = Value::from_reader(plist_data).unwrap();
385        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
386
387        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
388        println!("{balloon:?}");
389        assert!(matches!(balloon, URLOverride::SharedPlacemark(_)));
390    }
391}