1use crate::{
2 crypto::Encryption,
3 error::{Error, Result},
4};
5use serde::Serialize;
6
7const DEFAULT_TITLE: &str = "Notification";
8const CATEGORY: &str = "myNotificationCategory";
9const ENCRYPTED_TITLE: &str = "Bark";
10const ENCRYPTED_BODY: &str = "Encrypted Message";
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
17pub enum InterruptionLevel {
18 #[serde(rename = "critical")]
20 Critical,
21 #[serde(rename = "timeSensitive")]
23 TimeSensitive,
24 #[serde(rename = "active")]
26 Active,
27 #[serde(rename = "passive")]
29 Passive,
30}
31
32impl InterruptionLevel {
33 pub const fn as_bark_str(self) -> &'static str {
35 match self {
36 Self::Critical => "critical",
37 Self::TimeSensitive => "timeSensitive",
38 Self::Active => "active",
39 Self::Passive => "passive",
40 }
41 }
42}
43
44#[derive(Clone, Debug)]
63pub struct Message {
64 title: String,
66 subtitle: Option<String>,
68 body: String,
70 markdown: Option<String>,
72 image: Option<String>,
74 level: InterruptionLevel,
76 volume: Option<u8>,
78 badge: Option<u64>,
80 auto_copy: Option<bool>,
82 copy: Option<String>,
84 sound: Option<String>,
86 call: bool,
88 icon: Option<String>,
90 group: Option<String>,
92 archive: Option<bool>,
94 ttl: Option<u64>,
96 url: Option<String>,
98 action: Option<String>,
100 id: Option<String>,
102 delete: bool,
104 encryption: Option<Encryption>,
106}
107
108impl Message {
109 pub fn new() -> Self {
116 Self {
117 title: DEFAULT_TITLE.to_owned(),
118 body: String::new(),
119 subtitle: None,
120 markdown: None,
121 image: None,
122 level: InterruptionLevel::Active,
123 volume: None,
124 badge: None,
125 auto_copy: None,
126 copy: None,
127 sound: None,
128 call: false,
129 icon: None,
130 group: None,
131 archive: None,
132 ttl: None,
133 url: None,
134 action: None,
135 id: None,
136 delete: false,
137 encryption: None,
138 }
139 }
140
141 pub fn title<T>(mut self, title: T) -> Self
145 where
146 T: Into<String>,
147 {
148 self.title = title.into();
149 self
150 }
151
152 pub fn subtitle<S>(mut self, subtitle: S) -> Self
157 where
158 S: Into<String>,
159 {
160 self.subtitle = non_empty(subtitle.into());
161 self
162 }
163
164 pub fn body<B>(mut self, body: B) -> Self
170 where
171 B: Into<String>,
172 {
173 self.body = body.into();
174 self
175 }
176
177 pub fn markdown<M>(mut self, markdown: M) -> Self
197 where
198 M: Into<String>,
199 {
200 self.markdown = non_empty(markdown.into());
201 self
202 }
203
204 pub fn image<I>(mut self, image: I) -> Self
210 where
211 I: Into<String>,
212 {
213 self.image = non_empty(image.into());
214 self
215 }
216
217 pub fn level(mut self, level: InterruptionLevel) -> Self {
222 self.level = level;
223 self
224 }
225
226 pub fn volume(mut self, volume: u8) -> Self {
231 self.volume = Some(volume.min(10));
232 self
233 }
234
235 pub fn badge(mut self, badge: u64) -> Self {
240 self.badge = Some(badge);
241 self
242 }
243
244 pub fn auto_copy(mut self, auto_copy: bool) -> Self {
249 self.auto_copy = Some(auto_copy);
250 self
251 }
252
253 pub fn copy<C>(mut self, copy: C) -> Self
258 where
259 C: Into<String>,
260 {
261 self.copy = non_empty(copy.into());
262 self
263 }
264
265 pub fn sound<S>(mut self, sound: S) -> Self
273 where
274 S: Into<String>,
275 {
276 self.sound = non_empty(sound.into());
277 self
278 }
279
280 pub fn call(mut self, call: bool) -> Self {
285 self.call = call;
286 self
287 }
288
289 pub fn icon<I>(mut self, icon: I) -> Self
295 where
296 I: Into<String>,
297 {
298 self.icon = non_empty(icon.into());
299 self
300 }
301
302 pub fn group<G>(mut self, group: G) -> Self
307 where
308 G: Into<String>,
309 {
310 self.group = non_empty(group.into());
311 self
312 }
313
314 pub fn archive(mut self, archive: bool) -> Self {
320 self.archive = Some(archive);
321 self
322 }
323
324 pub fn ttl(mut self, ttl: u64) -> Self {
328 self.ttl = Some(ttl);
329 self
330 }
331
332 pub fn url<U>(mut self, url: U) -> Self
337 where
338 U: Into<String>,
339 {
340 self.url = non_empty(url.into());
341 self
342 }
343
344 pub fn action<A>(mut self, action: A) -> Self
349 where
350 A: Into<String>,
351 {
352 self.action = non_empty(action.into());
353 self
354 }
355
356 pub fn id<I>(mut self, id: I) -> Self
362 where
363 I: Into<String>,
364 {
365 self.id = non_empty(id.into());
366 self
367 }
368
369 pub fn delete(mut self) -> Self {
375 self.delete = true;
376 self
377 }
378
379 pub fn encryption(mut self, encryption: Encryption) -> Self {
386 self.encryption = Some(encryption);
387 self
388 }
389
390 pub(crate) fn is_delete(&self) -> bool {
391 self.delete
392 }
393
394 pub(crate) fn id_value(&self) -> Option<&str> {
395 self.id.as_deref()
396 }
397
398 pub(crate) fn payload_bytes(&self) -> Result<Vec<u8>> {
399 Ok(serde_json::to_vec(&self.payload_value()?)?)
400 }
401
402 pub(crate) fn validate_headers(&self) -> Result<()> {
403 if let Some(id) = &self.id {
404 let actual = id.len();
405 if actual > 64 {
406 return Err(Error::InvalidCollapseId { actual });
407 }
408 }
409
410 if self.delete && self.id.is_none() {
411 return Err(Error::MissingMessageIdForDelete);
412 }
413
414 Ok(())
415 }
416
417 fn payload_value(&self) -> Result<serde_json::Value> {
418 self.validate_headers()?;
419
420 if self.delete {
421 return Ok(serde_json::to_value(DeletePayload {
422 aps: DeleteAps {
423 content_available: 1,
424 },
425 delete: "1",
426 id: self.id.as_deref().expect("validated delete id"),
427 })?);
428 }
429
430 self.validate_content()?;
431
432 if let Some(encryption) = &self.encryption {
433 let fields = BarkPlaintextFields::from_message(self);
434 let plaintext = serde_json::to_vec(&fields)?;
435 let ciphertext = encryption.encrypt_bark_json(&plaintext)?;
436 return Ok(serde_json::to_value(EncryptedNotificationPayload {
437 aps: ApsPayload::encrypted(),
438 ciphertext,
439 iv: encryption.apns_iv(),
440 })?);
441 }
442
443 Ok(serde_json::to_value(NotificationPayload {
444 aps: ApsPayload::plain(self),
445 fields: BarkFields::from_message(self),
446 })?)
447 }
448
449 fn validate_content(&self) -> Result<()> {
450 if self.body.trim().is_empty() && self.markdown.is_none() {
451 return Err(Error::EmptyMessage);
452 }
453
454 Ok(())
455 }
456}
457
458impl Default for Message {
459 fn default() -> Self {
460 Self::new()
461 }
462}
463
464#[derive(Debug, Serialize)]
465struct NotificationPayload {
466 aps: ApsPayload,
467 #[serde(flatten)]
468 fields: BarkFields,
469}
470
471#[derive(Debug, Serialize)]
472struct EncryptedNotificationPayload<'a> {
473 aps: ApsPayload,
474 ciphertext: String,
475 #[serde(skip_serializing_if = "Option::is_none")]
476 iv: Option<&'a str>,
477}
478
479#[derive(Debug, Serialize)]
480struct DeletePayload<'a> {
481 aps: DeleteAps,
482 delete: &'static str,
483 id: &'a str,
484}
485
486#[derive(Debug, Serialize)]
487struct DeleteAps {
488 #[serde(rename = "content-available")]
489 content_available: u8,
490}
491
492#[derive(Debug, Serialize)]
493struct ApsPayload {
494 #[serde(rename = "mutable-content")]
495 mutable_content: u8,
496 category: &'static str,
497 #[serde(rename = "interruption-level")]
498 interruption_level: InterruptionLevel,
499 #[serde(skip_serializing_if = "Option::is_none")]
500 badge: Option<u64>,
501 #[serde(skip_serializing_if = "Option::is_none")]
502 sound: Option<String>,
503 #[serde(rename = "thread-id", skip_serializing_if = "Option::is_none")]
504 thread_id: Option<String>,
505 alert: AlertPayload,
506}
507
508impl ApsPayload {
509 fn plain(message: &Message) -> Self {
510 Self {
511 mutable_content: 1,
512 category: CATEGORY,
513 interruption_level: message.level,
514 badge: message.badge,
515 sound: message.sound.as_deref().map(apns_sound_name),
516 thread_id: message.group.clone(),
517 alert: AlertPayload {
518 title: message.title.clone(),
519 subtitle: message.subtitle.clone(),
520 body: message.body.clone(),
521 },
522 }
523 }
524
525 fn encrypted() -> Self {
526 Self {
527 mutable_content: 1,
528 category: CATEGORY,
529 interruption_level: InterruptionLevel::Active,
530 badge: None,
531 sound: None,
532 thread_id: None,
533 alert: AlertPayload {
534 title: ENCRYPTED_TITLE.to_owned(),
535 subtitle: None,
536 body: ENCRYPTED_BODY.to_owned(),
537 },
538 }
539 }
540}
541
542#[derive(Debug, Serialize)]
543struct AlertPayload {
544 title: String,
545 #[serde(skip_serializing_if = "Option::is_none")]
546 subtitle: Option<String>,
547 body: String,
548}
549
550#[derive(Debug, Default, Serialize)]
551struct BarkFields {
552 #[serde(skip_serializing_if = "Option::is_none")]
553 level: Option<&'static str>,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 volume: Option<String>,
556 #[serde(skip_serializing_if = "Option::is_none")]
557 badge: Option<String>,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 call: Option<&'static str>,
560 #[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
561 auto_copy: Option<&'static str>,
562 #[serde(skip_serializing_if = "Option::is_none")]
563 copy: Option<String>,
564 #[serde(skip_serializing_if = "Option::is_none")]
565 sound: Option<String>,
566 #[serde(skip_serializing_if = "Option::is_none")]
567 icon: Option<String>,
568 #[serde(skip_serializing_if = "Option::is_none")]
569 image: Option<String>,
570 #[serde(skip_serializing_if = "Option::is_none")]
571 group: Option<String>,
572 #[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
573 archive: Option<&'static str>,
574 #[serde(skip_serializing_if = "Option::is_none")]
575 ttl: Option<String>,
576 #[serde(skip_serializing_if = "Option::is_none")]
577 url: Option<String>,
578 #[serde(skip_serializing_if = "Option::is_none")]
579 action: Option<String>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 markdown: Option<String>,
582 #[serde(skip_serializing_if = "Option::is_none")]
583 id: Option<String>,
584}
585
586impl BarkFields {
587 fn from_message(message: &Message) -> Self {
588 Self {
589 level: Some(message.level.as_bark_str()),
590 volume: message.volume.map(|volume| volume.to_string()),
591 badge: message.badge.map(|badge| badge.to_string()),
592 call: message.call.then_some("1"),
593 auto_copy: message
594 .auto_copy
595 .map(|auto_copy| if auto_copy { "1" } else { "0" }),
596 copy: message.copy.clone(),
597 sound: message.sound.clone(),
598 icon: message.icon.clone(),
599 image: message.image.clone(),
600 group: message.group.clone(),
601 archive: message
602 .archive
603 .map(|archive| if archive { "1" } else { "0" }),
604 ttl: message.ttl.map(|ttl| ttl.to_string()),
605 url: message.url.clone(),
606 action: message.action.clone(),
607 markdown: message.markdown.clone(),
608 id: message.id.clone(),
609 }
610 }
611}
612
613#[derive(Debug, Default, Serialize)]
614struct BarkPlaintextFields {
615 title: String,
616 #[serde(skip_serializing_if = "Option::is_none")]
617 subtitle: Option<String>,
618 body: String,
619 #[serde(skip_serializing_if = "Option::is_none")]
620 markdown: Option<String>,
621 #[serde(skip_serializing_if = "Option::is_none")]
622 level: Option<&'static str>,
623 #[serde(skip_serializing_if = "Option::is_none")]
624 volume: Option<String>,
625 #[serde(skip_serializing_if = "Option::is_none")]
626 badge: Option<String>,
627 #[serde(skip_serializing_if = "Option::is_none")]
628 call: Option<&'static str>,
629 #[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
630 auto_copy: Option<&'static str>,
631 #[serde(skip_serializing_if = "Option::is_none")]
632 copy: Option<String>,
633 #[serde(skip_serializing_if = "Option::is_none")]
634 sound: Option<String>,
635 #[serde(skip_serializing_if = "Option::is_none")]
636 icon: Option<String>,
637 #[serde(skip_serializing_if = "Option::is_none")]
638 image: Option<String>,
639 #[serde(skip_serializing_if = "Option::is_none")]
640 group: Option<String>,
641 #[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
642 archive: Option<&'static str>,
643 #[serde(skip_serializing_if = "Option::is_none")]
644 ttl: Option<String>,
645 #[serde(skip_serializing_if = "Option::is_none")]
646 url: Option<String>,
647 #[serde(skip_serializing_if = "Option::is_none")]
648 action: Option<String>,
649 #[serde(skip_serializing_if = "Option::is_none")]
650 id: Option<String>,
651}
652
653impl BarkPlaintextFields {
654 fn from_message(message: &Message) -> Self {
655 let fields = BarkFields::from_message(message);
656 Self {
657 title: message.title.clone(),
658 subtitle: message.subtitle.clone(),
659 body: message.body.clone(),
660 markdown: fields.markdown,
661 level: fields.level,
662 volume: fields.volume,
663 badge: fields.badge,
664 call: fields.call,
665 auto_copy: fields.auto_copy,
666 copy: fields.copy,
667 sound: fields.sound,
668 icon: fields.icon,
669 image: fields.image,
670 group: fields.group,
671 archive: fields.archive,
672 ttl: fields.ttl,
673 url: fields.url,
674 action: fields.action,
675 id: fields.id,
676 }
677 }
678}
679
680fn non_empty(value: String) -> Option<String> {
681 if value.trim().is_empty() {
682 None
683 } else {
684 Some(value)
685 }
686}
687
688fn apns_sound_name(sound: &str) -> String {
689 if sound.ends_with(".caf") {
690 sound.to_owned()
691 } else {
692 format!("{sound}.caf")
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use serde_json::json;
699
700 use super::*;
701 use crate::crypto::{EncryptionAlgorithm, EncryptionMode};
702
703 fn payload(message: Message) -> serde_json::Value {
704 message.payload_value().unwrap()
705 }
706
707 #[test]
708 fn serializes_markdown_without_manual_escaping() {
709 let payload = payload(
710 Message::new()
711 .title("build")
712 .body("fallback")
713 .markdown("## ok\nquoted: \"yes\"")
714 .group(String::from("ci")),
715 );
716
717 assert_eq!(payload["markdown"], "## ok\nquoted: \"yes\"");
718 assert_eq!(payload["group"], "ci");
719 assert_eq!(payload["aps"]["alert"]["body"], "fallback");
720 }
721
722 #[test]
723 fn sound_is_only_serialized_when_explicitly_set() {
724 let default_payload = payload(Message::new().body("quiet"));
725 let sound_payload = payload(Message::new().body("loud").sound("birdsong"));
726
727 assert!(default_payload["aps"].get("sound").is_none());
728 assert_eq!(sound_payload["aps"]["sound"], "birdsong.caf");
729 assert_eq!(sound_payload["sound"], "birdsong");
730 }
731
732 #[test]
733 fn serializes_delete_as_background_payload() {
734 let payload = payload(Message::new().id("deploy-42").delete());
735
736 assert_eq!(
737 payload,
738 json!({
739 "aps": { "content-available": 1 },
740 "delete": "1",
741 "id": "deploy-42"
742 })
743 );
744 }
745
746 #[test]
747 fn encrypted_payload_keeps_bark_fields_out_of_apns_user_info() {
748 let encryption = Encryption::with_iv(
749 EncryptionAlgorithm::AES128,
750 EncryptionMode::CBC,
751 "1234567890123456",
752 "1111111111111111",
753 )
754 .unwrap();
755
756 let payload = payload(
757 Message::new()
758 .title("secret title")
759 .body("secret body")
760 .markdown("**secret**")
761 .badge(7)
762 .group("ops")
763 .sound("birdsong")
764 .encryption(encryption),
765 );
766
767 assert_eq!(payload["aps"]["alert"]["title"], ENCRYPTED_TITLE);
768 assert_eq!(payload["aps"]["alert"]["body"], ENCRYPTED_BODY);
769 assert!(payload["aps"].get("sound").is_none());
770 assert_eq!(payload["iv"], "1111111111111111");
771 assert!(payload.get("ciphertext").is_some());
772 assert!(payload.get("group").is_none());
773 assert!(payload.get("markdown").is_none());
774 assert!(payload.get("badge").is_none());
775 }
776
777 #[test]
778 fn delete_requires_id() {
779 let err = Message::new().delete().payload_bytes().unwrap_err();
780
781 assert!(matches!(err, Error::MissingMessageIdForDelete));
782 }
783
784 #[test]
785 fn normal_message_requires_body_or_markdown() {
786 let err = Message::new().title("empty").payload_bytes().unwrap_err();
787
788 assert!(matches!(err, Error::EmptyMessage));
789 }
790}