imessage_database/message_types/
url.rs1use 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#[derive(Debug, PartialEq, Eq, Default)]
24pub struct URLMessage<'a> {
25 pub title: Option<&'a str>,
27 pub summary: Option<&'a str>,
29 pub url: Option<&'a str>,
31 pub original_url: Option<&'a str>,
33 pub item_type: Option<&'a str>,
35 pub images: Vec<&'a str>,
37 pub icons: Vec<&'a str>,
39 pub site_name: Option<&'a str>,
41 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 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 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 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 #[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}