use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use koan_core::config::PushConfig;
use parking_lot::Mutex;
use serde_json::{Value, json};
const TOKEN_LIFE: Duration = Duration::from_secs(50 * 60);
pub struct Pusher {
key: EncodingKey,
key_id: String,
team_id: String,
topic: String,
bearer: Mutex<Option<(String, SystemTime)>>,
http: std::sync::OnceLock<reqwest::blocking::Client>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
Sent,
Gone,
Failed(String),
}
pub enum Push {
Wake,
Notify {
title: String,
body: String,
command: Value,
},
}
impl Pusher {
pub fn from_config(cfg: &PushConfig) -> Option<Self> {
if cfg.key_id.is_empty() || cfg.team_id.is_empty() {
return None;
}
let pem = match (&cfg.key, &cfg.key_path) {
(Some(pem), _) if !pem.trim().is_empty() => pem.clone(),
(_, Some(path)) => read_key(path)?,
_ => return None,
};
let key = match EncodingKey::from_ec_pem(pem.as_bytes()) {
Ok(key) => key,
Err(e) => {
log::warn!("push: the APNs key is not an EC private key: {e}");
return None;
}
};
Some(Self {
key,
key_id: cfg.key_id.clone(),
team_id: cfg.team_id.clone(),
topic: cfg.topic.clone(),
bearer: Mutex::new(None),
http: std::sync::OnceLock::new(),
})
}
fn bearer(&self) -> Result<String, String> {
let mut cached = self.bearer.lock();
if let Some((token, at)) = cached.as_ref()
&& at.elapsed().is_ok_and(|age| age < TOKEN_LIFE)
{
return Ok(token.clone());
}
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(self.key_id.clone());
let iat = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs();
let token = jsonwebtoken::encode(
&header,
&json!({ "iss": self.team_id, "iat": iat }),
&self.key,
)
.map_err(|e| e.to_string())?;
*cached = Some((token.clone(), SystemTime::now()));
Ok(token)
}
pub fn send(&self, token: &str, sandbox: bool, push: &Push) -> Outcome {
let http = self.http.get_or_init(|| {
reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(20))
.build()
.unwrap_or_default()
});
let bearer = match self.bearer() {
Ok(b) => b,
Err(e) => return Outcome::Failed(format!("signing: {e}")),
};
let host = if sandbox {
"api.sandbox.push.apple.com"
} else {
"api.push.apple.com"
};
let (kind, priority, expires_in) = match push {
Push::Wake => ("background", "5", 60 * 60),
Push::Notify { .. } => ("alert", "10", 10 * 60),
};
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() + expires_in)
.unwrap_or_default();
let response = http
.post(format!("https://{host}/3/device/{token}"))
.bearer_auth(bearer)
.header("apns-topic", &self.topic)
.header("apns-push-type", kind)
.header("apns-priority", priority)
.header("apns-expiration", expiration.to_string())
.json(&payload(push))
.send();
match response {
Ok(r) if r.status().is_success() => Outcome::Sent,
Ok(r) => {
let status = r.status();
let reason = r
.json::<Value>()
.ok()
.and_then(|v| v["reason"].as_str().map(str::to_owned))
.unwrap_or_default();
if status == reqwest::StatusCode::GONE
|| matches!(
reason.as_str(),
"BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic"
)
{
Outcome::Gone
} else {
Outcome::Failed(format!("{status} {reason}"))
}
}
Err(e) => Outcome::Failed(e.to_string()),
}
}
}
pub fn payload(push: &Push) -> Value {
match push {
Push::Wake => json!({ "aps": { "content-available": 1 } }),
Push::Notify {
title,
body,
command,
} => json!({
"aps": {
"alert": { "title": title, "body": body },
"sound": "default",
"interruption-level": "time-sensitive",
},
"koan": command,
}),
}
}
fn read_key(path: &Path) -> Option<String> {
match std::fs::read_to_string(path) {
Ok(pem) => Some(pem),
Err(e) => {
log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
None
}
}
}
pub fn pusher() -> Option<&'static Pusher> {
static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
let cfg = koan_core::config::Config::load().unwrap_or_default();
let pusher = Pusher::from_config(&cfg.push);
if pusher.is_some() {
log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
}
pusher
});
PUSHER.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
-----END PRIVATE KEY-----";
fn config() -> PushConfig {
PushConfig {
key: Some(TEST_KEY.into()),
key_id: "ABC123DEFG".into(),
team_id: "TEAM123456".into(),
..PushConfig::default()
}
}
#[test]
fn no_key_no_pusher() {
assert!(Pusher::from_config(&PushConfig::default()).is_none());
}
#[test]
fn the_token_is_es256_with_the_key_id_and_reused() {
let pusher = Pusher::from_config(&config()).expect("pusher");
let token = pusher.bearer().unwrap();
let header = jsonwebtoken::decode_header(&token).unwrap();
assert_eq!(header.alg, Algorithm::ES256);
assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
assert_eq!(pusher.bearer().unwrap(), token);
}
#[test]
fn a_notification_carries_its_command() {
let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
let body = payload(&Push::Notify {
title: "Play on this iPhone".into(),
body: "Golden Standard".into(),
command: command.clone(),
});
assert_eq!(body["koan"], command);
assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
assert_eq!(body["aps"]["interruption-level"], "time-sensitive");
assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
}
}