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