Skip to main content

bark_apns/
apns.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    sync::Mutex,
4    time::{Duration, SystemTime, UNIX_EPOCH},
5};
6
7use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
8use reqwest::{
9    StatusCode,
10    header::{AUTHORIZATION, HeaderMap, HeaderValue},
11};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15    error::{Error, Result},
16    message::Message,
17};
18
19const TOKEN_REFRESH_AFTER: u64 = 45 * 60;
20const DEFAULT_TEAM_ID: &str = "5U8LBRXG3A";
21const DEFAULT_AUTH_KEY_ID: &str = "LH4T9V5U4R";
22const DEFAULT_TOPIC: &str = "me.fin.bark";
23const DEFAULT_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
24MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg4vtC3g5L5HgKGJ2+\n\
25T1eA0tOivREvEAY2g+juRXJkYL2gCgYIKoZIzj0DAQehRANCAASmOs3JkSyoGEWZ\n\
26sUGxFs/4pw1rIlSV2IC19M8u3G5kq36upOwyFWj9Gi3Ejc9d3sC7+SHRqXrEAJow\n\
278/7tRpV+\n\
28-----END PRIVATE KEY-----\n";
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31enum Environment {
32    Production,
33    Sandbox,
34}
35
36impl Environment {
37    pub const fn host(self) -> &'static str {
38        match self {
39            Self::Production => "api.push.apple.com",
40            Self::Sandbox => "api.sandbox.push.apple.com",
41        }
42    }
43}
44
45#[derive(Clone)]
46struct Credentials {
47    team_id: String,
48    auth_key_id: String,
49    topic: String,
50    encoding_key: EncodingKey,
51}
52
53impl Credentials {
54    fn new<T, K, O, P>(team_id: T, auth_key_id: K, topic: O, private_key_pem: P) -> Result<Self>
55    where
56        T: Into<String>,
57        K: Into<String>,
58        O: Into<String>,
59        P: Into<String>,
60    {
61        let private_key_pem = private_key_pem.into();
62        let encoding_key = EncodingKey::from_ec_pem(private_key_pem.as_bytes())?;
63
64        Ok(Self {
65            team_id: team_id.into(),
66            auth_key_id: auth_key_id.into(),
67            topic: topic.into(),
68            encoding_key,
69        })
70    }
71
72    fn team_id(&self) -> &str {
73        &self.team_id
74    }
75
76    fn auth_key_id(&self) -> &str {
77        &self.auth_key_id
78    }
79
80    fn topic(&self) -> &str {
81        &self.topic
82    }
83
84    fn token(&self, issued_at: u64) -> Result<String> {
85        let mut header = Header::new(Algorithm::ES256);
86        header.kid = Some(self.auth_key_id.clone());
87
88        let claims = ApnsClaims {
89            iss: &self.team_id,
90            iat: issued_at,
91        };
92
93        Ok(encode(&header, &claims, &self.encoding_key)?)
94    }
95}
96
97/// Direct APNs client for Bark notifications.
98///
99/// `Bark` signs APNs provider tokens with Bark's default credentials by default
100/// and sends serialized [`Message`] payloads to iOS device tokens. It talks to
101/// APNs directly; no Bark server is contacted.
102///
103/// Device tokens are normalized before sending: surrounding angle brackets and
104/// ASCII whitespace are removed, and duplicate tokens are sent once.
105///
106/// [`Message`]: crate::Message
107pub struct Bark {
108    credentials: Credentials,
109    environment: Environment,
110    async_http: reqwest::Client,
111    blocking_http: reqwest::blocking::Client,
112    token: Mutex<Option<CachedToken>>,
113}
114
115impl Bark {
116    /// Creates a client using Bark's default APNs team id, key id, topic, and
117    /// private key.
118    ///
119    /// This matches the public Bark app topic `me.fin.bark`.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the built-in APNs key cannot be parsed or the HTTP
124    /// clients cannot be constructed.
125    pub fn new() -> Result<Self> {
126        Self::with_credentials(
127            DEFAULT_TEAM_ID,
128            DEFAULT_AUTH_KEY_ID,
129            DEFAULT_TOPIC,
130            DEFAULT_PRIVATE_KEY,
131        )
132    }
133
134    /// Creates a client with custom APNs credentials.
135    ///
136    /// Use this for a forked Bark app or a private APNs topic. The private key
137    /// must be an APNs Auth Key in PEM format for ES256 signing.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the private key cannot be parsed or the HTTP clients
142    /// cannot be constructed.
143    pub fn with_credentials<T, K, O, P>(
144        team_id: T,
145        auth_key_id: K,
146        topic: O,
147        private_key_pem: P,
148    ) -> Result<Self>
149    where
150        T: Into<String>,
151        K: Into<String>,
152        O: Into<String>,
153        P: Into<String>,
154    {
155        let credentials = Credentials::new(team_id, auth_key_id, topic, private_key_pem)?;
156        Ok(Self {
157            credentials,
158            environment: Environment::Production,
159            async_http: reqwest::ClientBuilder::new()
160                .http2_adaptive_window(true)
161                .build()
162                .map_err(Error::HttpClient)?,
163            blocking_http: reqwest::blocking::ClientBuilder::new()
164                .http2_adaptive_window(true)
165                .build()
166                .map_err(Error::HttpClient)?,
167            token: Mutex::new(None),
168        })
169    }
170
171    /// Selects APNs production.
172    ///
173    /// This is the default and sends to `api.push.apple.com`.
174    pub fn production(mut self) -> Self {
175        self.environment = Environment::Production;
176        self
177    }
178
179    /// Selects APNs sandbox.
180    ///
181    /// This sends to `api.sandbox.push.apple.com`, useful for development builds
182    /// whose device tokens belong to the sandbox environment.
183    pub fn sandbox(mut self) -> Self {
184        self.environment = Environment::Sandbox;
185        self
186    }
187
188    /// Returns the APNs team id used for provider-token signing.
189    pub fn team_id(&self) -> &str {
190        self.credentials.team_id()
191    }
192
193    /// Returns the APNs auth key id used in the JWT header.
194    pub fn auth_key_id(&self) -> &str {
195        self.credentials.auth_key_id()
196    }
197
198    /// Returns the APNs topic, normally Bark's bundle id.
199    pub fn topic(&self) -> &str {
200        self.credentials.topic()
201    }
202
203    /// Sends a message synchronously to one or more device tokens.
204    ///
205    /// The same serialized APNs payload is sent to each unique normalized token.
206    /// For delete messages this method uses APNs `background` push type and
207    /// priority `5`; otherwise it uses `alert` push type and priority `10`.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`Error::ApnsFailures`] if any device fails. The error message
212    /// includes the normalized device token and reason for each failed device.
213    /// Validation, serialization, encryption, token-signing, and HTTP client
214    /// errors are returned directly.
215    ///
216    /// [`Error::ApnsFailures`]: crate::Error::ApnsFailures
217    pub fn send<I, D>(&self, message: &Message, devices: I) -> Result<()>
218    where
219        I: IntoIterator<Item = D>,
220        D: Into<String>,
221    {
222        let body = message.payload_bytes()?;
223        let headers = self.headers(message)?;
224        let devices = collect_devices(devices);
225        let mut failures = BTreeMap::new();
226
227        for device in devices {
228            let response = self
229                .blocking_http
230                .post(self.device_url(&device))
231                .headers(headers.clone())
232                .body(body.clone())
233                .send();
234
235            match response {
236                Ok(response) => {
237                    if !response.status().is_success() {
238                        let status = response.status();
239                        let reason = blocking_apns_reason(response);
240                        failures.insert(device, format!("{status}: {reason}"));
241                    }
242                }
243                Err(error) => {
244                    failures.insert(device, error.to_string());
245                }
246            }
247        }
248
249        if failures.is_empty() {
250            Ok(())
251        } else {
252            Err(Error::ApnsFailures(format_apns_failures(&failures)))
253        }
254    }
255
256    /// Sends a message asynchronously to one or more device tokens.
257    ///
258    /// This has the same payload and error semantics as [`Bark::send`], but uses
259    /// the asynchronous `reqwest` client.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`Error::ApnsFailures`] if any device fails. The error message
264    /// includes the normalized device token and reason for each failed device.
265    /// Validation, serialization, encryption, token-signing, and HTTP client
266    /// errors are returned directly.
267    ///
268    /// [`Error::ApnsFailures`]: crate::Error::ApnsFailures
269    pub async fn send_async<I, D>(&self, message: &Message, devices: I) -> Result<()>
270    where
271        I: IntoIterator<Item = D>,
272        D: Into<String>,
273    {
274        let body = message.payload_bytes()?;
275        let headers = self.headers(message)?;
276        let devices = collect_devices(devices);
277        let mut failures = BTreeMap::new();
278
279        for device in devices {
280            let response = self
281                .async_http
282                .post(self.device_url(&device))
283                .headers(headers.clone())
284                .body(body.clone())
285                .send()
286                .await;
287
288            match response {
289                Ok(response) => {
290                    if !response.status().is_success() {
291                        let status = response.status();
292                        let reason = async_apns_reason(response).await;
293                        failures.insert(device, format!("{status}: {reason}"));
294                    }
295                }
296                Err(error) => {
297                    failures.insert(device, error.to_string());
298                }
299            }
300        }
301
302        if failures.is_empty() {
303            Ok(())
304        } else {
305            Err(Error::ApnsFailures(format_apns_failures(&failures)))
306        }
307    }
308
309    fn headers(&self, message: &Message) -> Result<HeaderMap> {
310        message.validate_headers()?;
311
312        let mut headers = HeaderMap::new();
313        let authorization = format!("bearer {}", self.apns_token()?);
314        headers.insert(
315            AUTHORIZATION,
316            HeaderValue::from_str(&authorization).map_err(|source| Error::InvalidHeaderValue {
317                name: "authorization",
318                source,
319            })?,
320        );
321        headers.insert(
322            "apns-topic",
323            HeaderValue::from_str(self.credentials.topic()).map_err(|source| {
324                Error::InvalidHeaderValue {
325                    name: "apns-topic",
326                    source,
327                }
328            })?,
329        );
330        headers.insert(
331            "apns-push-type",
332            HeaderValue::from_static(if message.is_delete() {
333                "background"
334            } else {
335                "alert"
336            }),
337        );
338        headers.insert(
339            "apns-priority",
340            HeaderValue::from_static(if message.is_delete() { "5" } else { "10" }),
341        );
342
343        if let Some(id) = message.id_value() {
344            headers.insert(
345                "apns-collapse-id",
346                HeaderValue::from_str(id).map_err(|source| Error::InvalidHeaderValue {
347                    name: "apns-collapse-id",
348                    source,
349                })?,
350            );
351        }
352
353        Ok(headers)
354    }
355
356    fn apns_token(&self) -> Result<String> {
357        let now = unix_timestamp();
358        let mut cached = self.token.lock().expect("APNs token cache poisoned");
359
360        if let Some(cached) = cached.as_ref()
361            && cached.issued_at + TOKEN_REFRESH_AFTER > now
362        {
363            return Ok(cached.value.clone());
364        }
365
366        let value = self.credentials.token(now)?;
367        *cached = Some(CachedToken {
368            issued_at: now,
369            value: value.clone(),
370        });
371        Ok(value)
372    }
373
374    fn device_url(&self, device: &str) -> String {
375        format!("https://{}/3/device/{device}", self.environment.host())
376    }
377}
378
379#[derive(Clone, Debug)]
380struct CachedToken {
381    issued_at: u64,
382    value: String,
383}
384
385#[derive(Debug, Serialize)]
386struct ApnsClaims<'a> {
387    iss: &'a str,
388    iat: u64,
389}
390
391#[derive(Debug, Deserialize)]
392struct ApnsErrorBody {
393    reason: Option<String>,
394}
395
396fn collect_devices<I, D>(devices: I) -> Vec<String>
397where
398    I: IntoIterator<Item = D>,
399    D: Into<String>,
400{
401    devices
402        .into_iter()
403        .filter_map(|device| {
404            let normalized = normalize_device_token(device.into());
405            (!normalized.is_empty()).then_some(normalized)
406        })
407        .collect::<BTreeSet<_>>()
408        .into_iter()
409        .collect()
410}
411
412fn normalize_device_token(device: String) -> String {
413    device
414        .trim()
415        .trim_start_matches('<')
416        .trim_end_matches('>')
417        .chars()
418        .filter(|ch| !ch.is_ascii_whitespace())
419        .collect()
420}
421
422async fn async_apns_reason(response: reqwest::Response) -> String {
423    let status = response.status();
424    match response.text().await {
425        Ok(text) => apns_reason_from_text(status, &text),
426        Err(error) => error.to_string(),
427    }
428}
429
430fn blocking_apns_reason(response: reqwest::blocking::Response) -> String {
431    let status = response.status();
432    match response.text() {
433        Ok(text) => apns_reason_from_text(status, &text),
434        Err(error) => error.to_string(),
435    }
436}
437
438fn apns_reason_from_text(status: StatusCode, text: &str) -> String {
439    if text.trim().is_empty() {
440        return status.to_string();
441    }
442
443    serde_json::from_str::<ApnsErrorBody>(text)
444        .ok()
445        .and_then(|body| body.reason)
446        .unwrap_or_else(|| text.to_owned())
447}
448
449fn format_apns_failures(failures: &BTreeMap<String, String>) -> String {
450    failures
451        .iter()
452        .map(|(device, reason)| format!("{device}: {reason}"))
453        .collect::<Vec<_>>()
454        .join("; ")
455}
456
457fn unix_timestamp() -> u64 {
458    SystemTime::now()
459        .duration_since(UNIX_EPOCH)
460        .unwrap_or_else(|_| Duration::from_secs(0))
461        .as_secs()
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn credentials_accept_str_and_string() {
470        let credentials = Credentials::new(
471            String::from(DEFAULT_TEAM_ID),
472            DEFAULT_AUTH_KEY_ID,
473            DEFAULT_TOPIC,
474            DEFAULT_PRIVATE_KEY,
475        )
476        .unwrap();
477
478        assert_eq!(credentials.team_id(), DEFAULT_TEAM_ID);
479        assert_eq!(credentials.auth_key_id(), DEFAULT_AUTH_KEY_ID);
480        assert_eq!(credentials.topic(), DEFAULT_TOPIC);
481    }
482
483    #[test]
484    fn device_tokens_are_normalized_and_deduped() {
485        let devices = collect_devices(["<aa bb>", "aabb", "  ", "cc"]);
486
487        assert_eq!(devices, vec!["aabb", "cc"]);
488    }
489}