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