Skip to main content

imessage_database/message_types/
url.rs

1/*!
2 Link previews 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, HasUrl, 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, Default)]
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    /// URL that served the preview content.
30    pub url: Option<&'a str>,
31    /// Original URL before redirects.
32    pub original_url: Option<&'a str>,
33    /// Apple-provided item type.
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    /// Website icon URLs.
38    pub icons: Vec<&'a str>,
39    /// Website name.
40    pub site_name: Option<&'a str>,
41    /// `true` when Messages stored the link as an unloaded placeholder preview.
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            icons: URLMessage::get_array_from_nested_dict(url_metadata, "icons"),
56            site_name: get_string_from_dict(url_metadata, "siteName"),
57            placeholder: get_bool_from_dict(url_metadata, "richLinkIsPlaceholder").unwrap_or(false),
58        })
59    }
60}
61
62impl<'a> URLMessage<'a> {
63    /// Parse the concrete URL-balloon subtype from the payload.
64    pub fn get_url_message_override(
65        payload: &'a Value,
66    ) -> Result<URLOverride<'a>, PlistParseError> {
67        if let Ok(balloon) = CollaborationMessage::from_map(payload) {
68            return Ok(URLOverride::Collaboration(balloon));
69        }
70        if let Ok(balloon) = MusicMessage::from_map(payload) {
71            return Ok(URLOverride::AppleMusic(balloon));
72        }
73        if let Ok(balloon) = AppStoreMessage::from_map(payload) {
74            return Ok(URLOverride::AppStore(balloon));
75        }
76        if let Ok(balloon) = PlacemarkMessage::from_map(payload) {
77            return Ok(URLOverride::SharedPlacemark(balloon));
78        }
79        if let Ok(balloon) = URLMessage::from_map(payload) {
80            return Ok(URLOverride::Normal(balloon));
81        }
82        Err(PlistParseError::NoPayload)
83    }
84
85    /// Extract the main metadata dictionary from the payload.
86    ///
87    /// Messages stores this data under either `richLinkMetadata` or `metadata`.
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) -> Vec<&'a str> {
118        let Some(items) = payload
119            .as_dictionary()
120            .and_then(|root| root.get(key))
121            .and_then(Value::as_dictionary)
122            .and_then(|nested| nested.get(key))
123            .and_then(Value::as_array)
124        else {
125            return Vec::new();
126        };
127
128        items
129            .iter()
130            .filter_map(|item| get_string_from_nested_dict(item, "URL"))
131            .collect()
132    }
133
134    /// Resolve this message's URL via [`HasUrl::get_url`].
135    #[must_use]
136    pub fn get_url(&self) -> Option<&str> {
137        <Self as HasUrl>::get_url(self)
138    }
139}
140
141impl HasUrl for URLMessage<'_> {
142    fn url(&self) -> Option<&str> {
143        self.url
144    }
145
146    fn original_url(&self) -> Option<&str> {
147        self.original_url
148    }
149}
150
151#[cfg(test)]
152mod url_tests {
153    use crate::{
154        message_types::{url::URLMessage, variants::BalloonProvider},
155        util::plist::parse_ns_keyed_archiver,
156    };
157    use plist::{Dictionary, Value};
158    use std::env::current_dir;
159    use std::fs::File;
160
161    fn nested_url(url: &str) -> Value {
162        let inner = Dictionary::from_iter([("URL".to_string(), Value::String(url.to_string()))]);
163
164        let outer = Dictionary::from_iter([("URL".to_string(), Value::Dictionary(inner))]);
165
166        Value::Dictionary(outer)
167    }
168
169    fn nested_array_payload(key: &str, items: Vec<Value>) -> Value {
170        let inner = Dictionary::from_iter([(key.to_string(), Value::Array(items))]);
171
172        let outer = Dictionary::from_iter([(key.to_string(), Value::Dictionary(inner))]);
173
174        Value::Dictionary(outer)
175    }
176
177    #[test]
178    fn test_parse_url_me() {
179        let plist_path = current_dir()
180            .unwrap()
181            .as_path()
182            .join("test_data/url_message/URL.plist");
183        let plist_data = File::open(plist_path).unwrap();
184        let plist = Value::from_reader(plist_data).unwrap();
185        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
186
187        let balloon = URLMessage::from_map(&parsed).unwrap();
188        let expected = URLMessage {
189            title: Some("Christopher Sardegna"),
190            summary: None,
191            url: Some("https://chrissardegna.com/"),
192            original_url: Some("https://chrissardegna.com"),
193            item_type: None,
194            images: vec![],
195            icons: vec!["https://chrissardegna.com/favicon.ico"],
196            site_name: None,
197            placeholder: false,
198        };
199
200        assert_eq!(balloon, expected);
201    }
202
203    #[test]
204    fn test_parse_url_me_metadata() {
205        let plist_path = current_dir()
206            .unwrap()
207            .as_path()
208            .join("test_data/url_message/MetadataURL.plist");
209        let plist_data = File::open(plist_path).unwrap();
210        let plist = Value::from_reader(plist_data).unwrap();
211        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
212
213        let balloon = URLMessage::from_map(&parsed).unwrap();
214        let expected = URLMessage {
215            title: Some("Christopher Sardegna"),
216            summary: Some("Sample page description"),
217            url: Some("https://chrissardegna.com"),
218            original_url: Some("https://chrissardegna.com"),
219            item_type: Some("article"),
220            images: vec!["https://chrissardegna.com/ddc-facebook-icon.png"],
221            icons: vec![
222                "https://chrissardegna.com/apple-touch-icon-180x180.png",
223                "https://chrissardegna.com/ddc-icon-32x32.png",
224                "https://chrissardegna.com/ddc-icon-16x16.png",
225            ],
226            site_name: Some("Christopher Sardegna"),
227            placeholder: false,
228        };
229
230        assert_eq!(balloon, expected);
231    }
232
233    #[test]
234    fn test_parse_url_twitter() {
235        let plist_path = current_dir()
236            .unwrap()
237            .as_path()
238            .join("test_data/url_message/Twitter.plist");
239        let plist_data = File::open(plist_path).unwrap();
240        let plist = Value::from_reader(plist_data).unwrap();
241        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
242
243        let balloon = URLMessage::from_map(&parsed).unwrap();
244        let expected = URLMessage {
245            title: Some("Christopher Sardegna on Twitter"),
246            summary: Some("“Hello Twitter, meet Bella”"),
247            url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
248            original_url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
249            item_type: Some("article"),
250            images: vec![
251                "https://pbs.twimg.com/media/EFGLfR2X4AE8ItK.jpg:large",
252                "https://pbs.twimg.com/media/EFGLfRmX4AMnwqW.jpg:large",
253                "https://pbs.twimg.com/media/EFGLfRlXYAYn9Ce.jpg:large",
254            ],
255            icons: vec![
256                "https://abs.twimg.com/icons/apple-touch-icon-192x192.png",
257                "https://abs.twimg.com/favicons/favicon.ico",
258            ],
259            site_name: Some("Twitter"),
260            placeholder: false,
261        };
262
263        assert_eq!(balloon, expected);
264    }
265
266    #[test]
267    fn test_parse_url_reminder() {
268        let plist_path = current_dir()
269            .unwrap()
270            .as_path()
271            .join("test_data/url_message/Reminder.plist");
272        let plist_data = File::open(plist_path).unwrap();
273        let plist = Value::from_reader(plist_data).unwrap();
274        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
275
276        let balloon = URLMessage::from_map(&parsed).unwrap();
277        let expected = URLMessage {
278            title: None,
279            summary: None,
280            url: None,
281            original_url: Some(
282                "https://www.icloud.com/reminders/ZmFrZXVybF9mb3JfcmVtaW5kZXI#TestList",
283            ),
284            item_type: None,
285            images: vec![],
286            icons: vec![],
287            site_name: None,
288            placeholder: false,
289        };
290
291        assert_eq!(balloon, expected);
292    }
293
294    #[test]
295    fn test_get_array_from_nested_dict_skips_malformed_entries() {
296        let payload = nested_array_payload(
297            "images",
298            vec![
299                nested_url("https://example.com/first.png"),
300                Value::Dictionary(Dictionary::new()),
301                nested_url(""),
302                nested_url("https://example.com/second.png"),
303            ],
304        );
305
306        assert_eq!(
307            URLMessage::get_array_from_nested_dict(&payload, "images"),
308            vec![
309                "https://example.com/first.png",
310                "https://example.com/second.png"
311            ]
312        );
313    }
314
315    #[test]
316    fn test_get_array_from_nested_dict_returns_empty_for_missing_list() {
317        let payload = Value::Dictionary(Dictionary::new());
318
319        assert!(URLMessage::get_array_from_nested_dict(&payload, "icons").is_empty());
320    }
321
322    #[test]
323    fn test_get_url() {
324        let expected = URLMessage {
325            title: Some("Christopher Sardegna"),
326            summary: None,
327            url: Some("https://chrissardegna.com/"),
328            original_url: Some("https://chrissardegna.com"),
329            item_type: None,
330            images: vec![],
331            icons: vec!["https://chrissardegna.com/favicon.ico"],
332            site_name: None,
333            placeholder: false,
334        };
335        assert_eq!(expected.get_url(), Some("https://chrissardegna.com/"));
336    }
337
338    #[test]
339    fn test_get_original_url() {
340        let expected = URLMessage {
341            title: Some("Christopher Sardegna"),
342            summary: None,
343            url: None,
344            original_url: Some("https://chrissardegna.com"),
345            item_type: None,
346            images: vec![],
347            icons: vec!["https://chrissardegna.com/favicon.ico"],
348            site_name: None,
349            placeholder: false,
350        };
351        assert_eq!(expected.get_url(), Some("https://chrissardegna.com"));
352    }
353
354    #[test]
355    fn test_get_no_url() {
356        let expected = URLMessage {
357            title: Some("Christopher Sardegna"),
358            summary: None,
359            url: None,
360            original_url: None,
361            item_type: None,
362            images: vec![],
363            icons: vec!["https://chrissardegna.com/favicon.ico"],
364            site_name: None,
365            placeholder: false,
366        };
367        assert_eq!(expected.get_url(), None);
368    }
369}
370
371#[cfg(test)]
372mod url_override_tests {
373    use crate::{
374        message_types::{url::URLMessage, variants::URLOverride},
375        util::plist::parse_ns_keyed_archiver,
376    };
377    use plist::Value;
378    use std::env::current_dir;
379    use std::fs::File;
380
381    #[test]
382    fn can_parse_normal() {
383        let plist_path = current_dir()
384            .unwrap()
385            .as_path()
386            .join("test_data/url_message/URL.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        assert!(matches!(balloon, URLOverride::Normal(_)));
393    }
394
395    #[test]
396    fn can_parse_music() {
397        let plist_path = current_dir()
398            .unwrap()
399            .as_path()
400            .join("test_data/music_message/AppleMusic.plist");
401        let plist_data = File::open(plist_path).unwrap();
402        let plist = Value::from_reader(plist_data).unwrap();
403        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
404
405        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
406        assert!(matches!(balloon, URLOverride::AppleMusic(_)));
407    }
408
409    #[test]
410    fn can_parse_app_store() {
411        let plist_path = current_dir()
412            .unwrap()
413            .as_path()
414            .join("test_data/app_store/AppStoreLink.plist");
415        let plist_data = File::open(plist_path).unwrap();
416        let plist = Value::from_reader(plist_data).unwrap();
417        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
418
419        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
420        assert!(matches!(balloon, URLOverride::AppStore(_)));
421    }
422
423    #[test]
424    fn can_parse_collaboration() {
425        let plist_path = current_dir()
426            .unwrap()
427            .as_path()
428            .join("test_data/collaboration_message/Freeform.plist");
429        let plist_data = File::open(plist_path).unwrap();
430        let plist = Value::from_reader(plist_data).unwrap();
431        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
432
433        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
434        assert!(matches!(balloon, URLOverride::Collaboration(_)));
435    }
436
437    #[test]
438    fn can_parse_placemark() {
439        let plist_path = current_dir()
440            .unwrap()
441            .as_path()
442            .join("test_data/shared_placemark/SharedPlacemark.plist");
443        let plist_data = File::open(plist_path).unwrap();
444        let plist = Value::from_reader(plist_data).unwrap();
445        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
446
447        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
448        println!("{balloon:?}");
449        assert!(matches!(balloon, URLOverride::SharedPlacemark(_)));
450    }
451}