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 mut inner = Dictionary::new();
163        inner.insert("URL".to_string(), Value::String(url.to_string()));
164
165        let mut outer = Dictionary::new();
166        outer.insert("URL".to_string(), Value::Dictionary(inner));
167
168        Value::Dictionary(outer)
169    }
170
171    fn nested_array_payload(key: &str, items: Vec<Value>) -> Value {
172        let mut inner = Dictionary::new();
173        inner.insert(key.to_string(), Value::Array(items));
174
175        let mut outer = Dictionary::new();
176        outer.insert(key.to_string(), Value::Dictionary(inner));
177
178        Value::Dictionary(outer)
179    }
180
181    #[test]
182    fn test_parse_url_me() {
183        let plist_path = current_dir()
184            .unwrap()
185            .as_path()
186            .join("test_data/url_message/URL.plist");
187        let plist_data = File::open(plist_path).unwrap();
188        let plist = Value::from_reader(plist_data).unwrap();
189        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
190
191        let balloon = URLMessage::from_map(&parsed).unwrap();
192        let expected = URLMessage {
193            title: Some("Christopher Sardegna"),
194            summary: None,
195            url: Some("https://chrissardegna.com/"),
196            original_url: Some("https://chrissardegna.com"),
197            item_type: None,
198            images: vec![],
199            icons: vec!["https://chrissardegna.com/favicon.ico"],
200            site_name: None,
201            placeholder: false,
202        };
203
204        assert_eq!(balloon, expected);
205    }
206
207    #[test]
208    fn test_parse_url_me_metadata() {
209        let plist_path = current_dir()
210            .unwrap()
211            .as_path()
212            .join("test_data/url_message/MetadataURL.plist");
213        let plist_data = File::open(plist_path).unwrap();
214        let plist = Value::from_reader(plist_data).unwrap();
215        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
216
217        let balloon = URLMessage::from_map(&parsed).unwrap();
218        let expected = URLMessage {
219            title: Some("Christopher Sardegna"),
220            summary: Some("Sample page description"),
221            url: Some("https://chrissardegna.com"),
222            original_url: Some("https://chrissardegna.com"),
223            item_type: Some("article"),
224            images: vec!["https://chrissardegna.com/ddc-facebook-icon.png"],
225            icons: vec![
226                "https://chrissardegna.com/apple-touch-icon-180x180.png",
227                "https://chrissardegna.com/ddc-icon-32x32.png",
228                "https://chrissardegna.com/ddc-icon-16x16.png",
229            ],
230            site_name: Some("Christopher Sardegna"),
231            placeholder: false,
232        };
233
234        assert_eq!(balloon, expected);
235    }
236
237    #[test]
238    fn test_parse_url_twitter() {
239        let plist_path = current_dir()
240            .unwrap()
241            .as_path()
242            .join("test_data/url_message/Twitter.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: Some("Christopher Sardegna on Twitter"),
250            summary: Some("“Hello Twitter, meet Bella”"),
251            url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
252            original_url: Some("https://twitter.com/rxcs/status/1175874352946077696"),
253            item_type: Some("article"),
254            images: vec![
255                "https://pbs.twimg.com/media/EFGLfR2X4AE8ItK.jpg:large",
256                "https://pbs.twimg.com/media/EFGLfRmX4AMnwqW.jpg:large",
257                "https://pbs.twimg.com/media/EFGLfRlXYAYn9Ce.jpg:large",
258            ],
259            icons: vec![
260                "https://abs.twimg.com/icons/apple-touch-icon-192x192.png",
261                "https://abs.twimg.com/favicons/favicon.ico",
262            ],
263            site_name: Some("Twitter"),
264            placeholder: false,
265        };
266
267        assert_eq!(balloon, expected);
268    }
269
270    #[test]
271    fn test_parse_url_reminder() {
272        let plist_path = current_dir()
273            .unwrap()
274            .as_path()
275            .join("test_data/url_message/Reminder.plist");
276        let plist_data = File::open(plist_path).unwrap();
277        let plist = Value::from_reader(plist_data).unwrap();
278        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
279
280        let balloon = URLMessage::from_map(&parsed).unwrap();
281        let expected = URLMessage {
282            title: None,
283            summary: None,
284            url: None,
285            original_url: Some(
286                "https://www.icloud.com/reminders/ZmFrZXVybF9mb3JfcmVtaW5kZXI#TestList",
287            ),
288            item_type: None,
289            images: vec![],
290            icons: vec![],
291            site_name: None,
292            placeholder: false,
293        };
294
295        assert_eq!(balloon, expected);
296    }
297
298    #[test]
299    fn test_get_array_from_nested_dict_skips_malformed_entries() {
300        let payload = nested_array_payload(
301            "images",
302            vec![
303                nested_url("https://example.com/first.png"),
304                Value::Dictionary(Dictionary::new()),
305                nested_url(""),
306                nested_url("https://example.com/second.png"),
307            ],
308        );
309
310        assert_eq!(
311            URLMessage::get_array_from_nested_dict(&payload, "images"),
312            vec![
313                "https://example.com/first.png",
314                "https://example.com/second.png"
315            ]
316        );
317    }
318
319    #[test]
320    fn test_get_array_from_nested_dict_returns_empty_for_missing_list() {
321        let payload = Value::Dictionary(Dictionary::new());
322
323        assert!(URLMessage::get_array_from_nested_dict(&payload, "icons").is_empty());
324    }
325
326    #[test]
327    fn test_get_url() {
328        let expected = URLMessage {
329            title: Some("Christopher Sardegna"),
330            summary: None,
331            url: Some("https://chrissardegna.com/"),
332            original_url: Some("https://chrissardegna.com"),
333            item_type: None,
334            images: vec![],
335            icons: vec!["https://chrissardegna.com/favicon.ico"],
336            site_name: None,
337            placeholder: false,
338        };
339        assert_eq!(expected.get_url(), Some("https://chrissardegna.com/"));
340    }
341
342    #[test]
343    fn test_get_original_url() {
344        let expected = URLMessage {
345            title: Some("Christopher Sardegna"),
346            summary: None,
347            url: None,
348            original_url: Some("https://chrissardegna.com"),
349            item_type: None,
350            images: vec![],
351            icons: vec!["https://chrissardegna.com/favicon.ico"],
352            site_name: None,
353            placeholder: false,
354        };
355        assert_eq!(expected.get_url(), Some("https://chrissardegna.com"));
356    }
357
358    #[test]
359    fn test_get_no_url() {
360        let expected = URLMessage {
361            title: Some("Christopher Sardegna"),
362            summary: None,
363            url: None,
364            original_url: None,
365            item_type: None,
366            images: vec![],
367            icons: vec!["https://chrissardegna.com/favicon.ico"],
368            site_name: None,
369            placeholder: false,
370        };
371        assert_eq!(expected.get_url(), None);
372    }
373}
374
375#[cfg(test)]
376mod url_override_tests {
377    use crate::{
378        message_types::{url::URLMessage, variants::URLOverride},
379        util::plist::parse_ns_keyed_archiver,
380    };
381    use plist::Value;
382    use std::env::current_dir;
383    use std::fs::File;
384
385    #[test]
386    fn can_parse_normal() {
387        let plist_path = current_dir()
388            .unwrap()
389            .as_path()
390            .join("test_data/url_message/URL.plist");
391        let plist_data = File::open(plist_path).unwrap();
392        let plist = Value::from_reader(plist_data).unwrap();
393        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
394
395        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
396        assert!(matches!(balloon, URLOverride::Normal(_)));
397    }
398
399    #[test]
400    fn can_parse_music() {
401        let plist_path = current_dir()
402            .unwrap()
403            .as_path()
404            .join("test_data/music_message/AppleMusic.plist");
405        let plist_data = File::open(plist_path).unwrap();
406        let plist = Value::from_reader(plist_data).unwrap();
407        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
408
409        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
410        assert!(matches!(balloon, URLOverride::AppleMusic(_)));
411    }
412
413    #[test]
414    fn can_parse_app_store() {
415        let plist_path = current_dir()
416            .unwrap()
417            .as_path()
418            .join("test_data/app_store/AppStoreLink.plist");
419        let plist_data = File::open(plist_path).unwrap();
420        let plist = Value::from_reader(plist_data).unwrap();
421        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
422
423        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
424        assert!(matches!(balloon, URLOverride::AppStore(_)));
425    }
426
427    #[test]
428    fn can_parse_collaboration() {
429        let plist_path = current_dir()
430            .unwrap()
431            .as_path()
432            .join("test_data/collaboration_message/Freeform.plist");
433        let plist_data = File::open(plist_path).unwrap();
434        let plist = Value::from_reader(plist_data).unwrap();
435        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
436
437        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
438        assert!(matches!(balloon, URLOverride::Collaboration(_)));
439    }
440
441    #[test]
442    fn can_parse_placemark() {
443        let plist_path = current_dir()
444            .unwrap()
445            .as_path()
446            .join("test_data/shared_placemark/SharedPlacemark.plist");
447        let plist_data = File::open(plist_path).unwrap();
448        let plist = Value::from_reader(plist_data).unwrap();
449        let parsed = parse_ns_keyed_archiver(&plist).unwrap();
450
451        let balloon = URLMessage::get_url_message_override(&parsed).unwrap();
452        println!("{balloon:?}");
453        assert!(matches!(balloon, URLOverride::SharedPlacemark(_)));
454    }
455}