1use std::path::Path;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use jsonwebtoken::{Algorithm, EncodingKey, Header};
17use koan_core::config::PushConfig;
18use parking_lot::Mutex;
19use serde_json::{Value, json};
20
21const TOKEN_LIFE: Duration = Duration::from_secs(50 * 60);
24
25pub struct Pusher {
26 key: EncodingKey,
27 key_id: String,
28 team_id: String,
29 topic: String,
30 bearer: Mutex<Option<(String, SystemTime)>>,
31 http: std::sync::OnceLock<reqwest::blocking::Client>,
35}
36
37#[derive(Debug, PartialEq, Eq)]
39pub enum Outcome {
40 Sent,
41 Gone,
44 Failed(String),
45}
46
47pub enum Push {
49 Wake,
51 Notify {
53 title: String,
54 body: String,
55 command: Value,
56 },
57}
58
59impl Pusher {
60 pub fn from_config(cfg: &PushConfig) -> Option<Self> {
62 if cfg.key_id.is_empty() || cfg.team_id.is_empty() {
63 return None;
64 }
65 let pem = match (&cfg.key, &cfg.key_path) {
66 (Some(pem), _) if !pem.trim().is_empty() => pem.clone(),
67 (_, Some(path)) => read_key(path)?,
68 _ => return None,
69 };
70 let key = match EncodingKey::from_ec_pem(pem.as_bytes()) {
71 Ok(key) => key,
72 Err(e) => {
73 log::warn!("push: the APNs key is not an EC private key: {e}");
74 return None;
75 }
76 };
77 Some(Self {
78 key,
79 key_id: cfg.key_id.clone(),
80 team_id: cfg.team_id.clone(),
81 topic: cfg.topic.clone(),
82 bearer: Mutex::new(None),
83 http: std::sync::OnceLock::new(),
84 })
85 }
86
87 fn bearer(&self) -> Result<String, String> {
88 let mut cached = self.bearer.lock();
89 if let Some((token, at)) = cached.as_ref()
90 && at.elapsed().is_ok_and(|age| age < TOKEN_LIFE)
91 {
92 return Ok(token.clone());
93 }
94 let mut header = Header::new(Algorithm::ES256);
95 header.kid = Some(self.key_id.clone());
96 let iat = SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .map_err(|e| e.to_string())?
99 .as_secs();
100 let token = jsonwebtoken::encode(
101 &header,
102 &json!({ "iss": self.team_id, "iat": iat }),
103 &self.key,
104 )
105 .map_err(|e| e.to_string())?;
106 *cached = Some((token.clone(), SystemTime::now()));
107 Ok(token)
108 }
109
110 pub fn send(&self, token: &str, sandbox: bool, push: &Push) -> Outcome {
113 let http = self.http.get_or_init(|| {
114 reqwest::blocking::Client::builder()
115 .connect_timeout(Duration::from_secs(10))
116 .timeout(Duration::from_secs(20))
117 .build()
118 .unwrap_or_default()
119 });
120 let bearer = match self.bearer() {
121 Ok(b) => b,
122 Err(e) => return Outcome::Failed(format!("signing: {e}")),
123 };
124 let host = if sandbox {
125 "api.sandbox.push.apple.com"
126 } else {
127 "api.push.apple.com"
128 };
129 let (kind, priority, expires_in) = match push {
130 Push::Wake => ("background", "5", 60 * 60),
132 Push::Notify { .. } => ("alert", "10", 10 * 60),
134 };
135 let expiration = SystemTime::now()
136 .duration_since(UNIX_EPOCH)
137 .map(|d| d.as_secs() + expires_in)
138 .unwrap_or_default();
139 let response = http
140 .post(format!("https://{host}/3/device/{token}"))
141 .bearer_auth(bearer)
142 .header("apns-topic", &self.topic)
143 .header("apns-push-type", kind)
144 .header("apns-priority", priority)
145 .header("apns-expiration", expiration.to_string())
146 .json(&payload(push))
147 .send();
148 match response {
149 Ok(r) if r.status().is_success() => Outcome::Sent,
150 Ok(r) => {
151 let status = r.status();
152 let reason = r
153 .json::<Value>()
154 .ok()
155 .and_then(|v| v["reason"].as_str().map(str::to_owned))
156 .unwrap_or_default();
157 if status == reqwest::StatusCode::GONE
158 || matches!(
159 reason.as_str(),
160 "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic"
161 )
162 {
163 Outcome::Gone
164 } else {
165 Outcome::Failed(format!("{status} {reason}"))
166 }
167 }
168 Err(e) => Outcome::Failed(e.to_string()),
169 }
170 }
171}
172
173pub fn payload(push: &Push) -> Value {
176 match push {
177 Push::Wake => json!({ "aps": { "content-available": 1 } }),
178 Push::Notify {
179 title,
180 body,
181 command,
182 } => json!({
183 "aps": {
184 "alert": { "title": title, "body": body },
185 "sound": "default",
186 },
187 "koan": command,
188 }),
189 }
190}
191
192fn read_key(path: &Path) -> Option<String> {
193 match std::fs::read_to_string(path) {
194 Ok(pem) => Some(pem),
195 Err(e) => {
196 log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
197 None
198 }
199 }
200}
201
202pub fn pusher() -> Option<&'static Pusher> {
205 static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
206 let cfg = koan_core::config::Config::load().unwrap_or_default();
207 let pusher = Pusher::from_config(&cfg.push);
208 if pusher.is_some() {
209 log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
210 }
211 pusher
212 });
213 PUSHER.as_ref()
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
222MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
223rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
224AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
225-----END PRIVATE KEY-----";
226
227 fn config() -> PushConfig {
228 PushConfig {
229 key: Some(TEST_KEY.into()),
230 key_id: "ABC123DEFG".into(),
231 team_id: "TEAM123456".into(),
232 ..PushConfig::default()
233 }
234 }
235
236 #[test]
237 fn no_key_no_pusher() {
238 assert!(Pusher::from_config(&PushConfig::default()).is_none());
239 }
240
241 #[test]
242 fn the_token_is_es256_with_the_key_id_and_reused() {
243 let pusher = Pusher::from_config(&config()).expect("pusher");
244 let token = pusher.bearer().unwrap();
245 let header = jsonwebtoken::decode_header(&token).unwrap();
246 assert_eq!(header.alg, Algorithm::ES256);
247 assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
248 assert_eq!(pusher.bearer().unwrap(), token);
249 }
250
251 #[test]
252 fn a_notification_carries_its_command() {
253 let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
254 let body = payload(&Push::Notify {
255 title: "Play on this iPhone".into(),
256 body: "Golden Standard".into(),
257 command: command.clone(),
258 });
259 assert_eq!(body["koan"], command);
260 assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
261 assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
262 }
263}