Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions fpush-apns/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ use std::collections::HashMap;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppleApnsConfig {
cert_file_path: String,
cert_password: String,
topic: String,
additional_data: Option<HashMap<String, Value>>,
#[serde(default = "ApnsEndpoint::production")]
Expand All @@ -15,15 +13,38 @@ pub struct AppleApnsConfig {
pool_idle_timeout: u64,
#[serde(default = "AppleApnsConfig::default_request_timeout")]
request_timeout: u64,

// Certificate-based auth (p12)
cert_file_path: Option<String>,
cert_password: Option<String>,

// Token-based auth (p8)
key_path: Option<String>,
key_id: Option<String>,
team_id: Option<String>,
}

impl AppleApnsConfig {
pub fn cert_file_path(&self) -> &str {
&self.cert_file_path
}
pub enum ApnsAuth<'a> {
Certificate { path: &'a str, password: &'a str },
Token { key_path: &'a str, key_id: &'a str, team_id: &'a str },
}

pub fn cert_password(&self) -> &str {
&self.cert_password
impl AppleApnsConfig {
pub fn auth(&self) -> Option<ApnsAuth<'_>> {
if let (Some(path), Some(password)) = (
self.cert_file_path.as_deref(),
self.cert_password.as_deref(),
) {
return Some(ApnsAuth::Certificate { path, password });
}
if let (Some(key_path), Some(key_id), Some(team_id)) = (
self.key_path.as_deref(),
self.key_id.as_deref(),
self.team_id.as_deref(),
) {
return Some(ApnsAuth::Token { key_path, key_id, team_id });
}
None
}

pub fn topic(&self) -> &str {
Expand Down
81 changes: 51 additions & 30 deletions fpush-apns/src/push.rs
Original file line number Diff line number Diff line change
@@ -1,78 +1,99 @@
use std::collections::HashMap;
use std::time::SystemTime;
use std::time::{SystemTime, UNIX_EPOCH};

use a2::{
request::payload::PayloadLike, Client, ClientConfig, DefaultNotificationBuilder,
NotificationBuilder, NotificationOptions, Priority, PushType,
request::notification::CollapseId, request::payload::PayloadLike, Client, ClientConfig,
DefaultNotificationBuilder, NotificationBuilder, NotificationOptions, Priority, PushType,
};
use fpush_traits::push::{PushError, PushResult, PushTrait};

use async_trait::async_trait;
use log::{debug, error};
use serde_json::Value;

use crate::AppleApnsConfig;
use crate::config::{ApnsAuth, AppleApnsConfig};

pub struct FpushApns {
apns: a2::client::Client,
topic: String,
additional_data: Option<HashMap<String, Value>>,
}

impl FpushApns {
fn open_cert(filename: &str) -> PushResult<std::fs::File> {
if let Ok(file) = std::fs::File::open(filename) {
Ok(file)
} else {
Err(PushError::CertLoading)
}
fn open_file(filename: &str) -> PushResult<std::fs::File> {
std::fs::File::open(filename).map_err(|e| {
error!("Could not open file {}: {}", filename, e);
PushError::CertLoading
})
}

pub fn init(apns_config: &AppleApnsConfig) -> PushResult<Self> {
let mut certificate = FpushApns::open_cert(apns_config.cert_file_path())?;

let mut client_config = ClientConfig::new(apns_config.endpoint());
client_config.pool_idle_timeout_secs = Some(apns_config.pool_idle_timeout());
client_config.request_timeout_secs = Some(apns_config.request_timeout());

match Client::certificate(&mut certificate, apns_config.cert_password(), client_config) {
Ok(apns_conn) => {
let wrapped_conn = Self {
apns: apns_conn,
topic: apns_config.topic().to_string(),
additional_data: apns_config.additional_data().clone(),
};
Ok(wrapped_conn)
let apns_conn = match apns_config.auth() {
Some(ApnsAuth::Certificate { path, password }) => {
let mut cert_file = Self::open_file(path)?;
Client::certificate(&mut cert_file, password, client_config).map_err(|e| {
error!("Problem initializing apple certificate config: {}", e);
PushError::PushEndpointTmp
})?
}
Err(a2::error::Error::ReadError(e)) => {
error!("Could not read apns: {}", e);
Err(PushError::PushEndpointPersistent)
Some(ApnsAuth::Token { key_path, key_id, team_id }) => {
let key_file = Self::open_file(key_path)?;
Client::token(key_file, key_id, team_id, client_config).map_err(|e| {
error!("Problem initializing apple token config: {}", e);
PushError::PushEndpointTmp
})?
}
Err(e) => {
error!("Problem initializing apple config: {}", e);
Err(PushError::PushEndpointTmp)
None => {
error!("APNs config requires either (certFilePath + certPassword) or (keyPath + keyId + teamId)");
return Err(PushError::CertLoading);
}
}
};

Ok(Self {
apns: apns_conn,
topic: apns_config.topic().to_string(),
additional_data: apns_config.additional_data().clone(),
})
}
}

#[async_trait]
impl PushTrait for FpushApns {
#[inline(always)]
async fn send(&self, token: String) -> PushResult<()> {
// APNs payload structure matches Signal-Server APNSender's APN_NSE_NOTIFICATION_PAYLOAD:
// - mutable-content: 1 (wakes the NSE)
// - alert title/body present so iOS shows something if NSE fails to replace it
// - NO sound: the NSE-posted notification carries the user's chosen sound;
// when NSE fails (and the fallback alert is what shows), match Signal's
// silent-failure UX rather than inconsistent audio cues.
let notification_builder = DefaultNotificationBuilder::new()
.set_title("New Message")
.set_body("New Message?")
.set_mutable_content()
.set_sound("default");
.set_mutable_content();
// Signal-Server uses apns-collapse-id "incoming-message" on every push.
// Effect: if NSE fails to post a user-visible notification (rare after
// recent NSE minimization work) AND multiple pushes arrive while the
// device is locked, the fallback alerts collapse to one entry on the
// lock screen instead of stacking N copies of "New Message". The
// per-conversation notifications NSE posts via UNUserNotificationCenter
// use their own identifiers and are unaffected.
let collapse_id = CollapseId::new("incoming-message")
.expect("collapse id literal fits the 64-byte limit");
let mut payload = notification_builder.build(
&token,
NotificationOptions {
apns_priority: Some(Priority::High),
apns_topic: Some(&self.topic),
apns_expiration: Some(
SystemTime::now().elapsed().unwrap().as_secs() + 4 * 7 * 24 * 3600,
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 4 * 7 * 24 * 3600,
),
apns_push_type: Some(PushType::Alert),
apns_collapse_id: Some(collapse_id),
..Default::default()
},
);
Expand Down