The iOS app's icon count, pushed by ipx (#433)

Background refresh runs when iOS chooses, and for this app that has
been rarely, so the icon's unread count lagged until the app was
opened (#421). Now ipx sends the count:
- ipx: a push_devices table and POST/DELETE /api/push/devices. With
  [push] set to an APNs key, a task compares each phone's All
  Subscriptions unread count with the last sent, once a minute, and
  sends a changed one to Apple as a badge-only push: no alert, no
  sound. A token Apple says is gone is forgotten. The signing token is
  ES256 over the team and time, made again every forty minutes, inside
  Apple's limits.
- iOS: the aps-environment entitlement. The app registers for push
  once badge permission is given, each launch, and hands its token to
  the page.
- Page: registers the token with ipx as the person signed in, since
  the player's address (ipxapi) lets through only the player's paths,
  and removes it on signing out.

jsonwebtoken here is built without PEM support, so the .p8 key's
base64 is decoded in push.rs rather than turning on a feature and
changing Cargo.lock for one file.

cargo test -p ipx on macOS: 182 pass; the 3 that fail read /proc and
fail there without this change too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 17:47:28 -05:00
parent e6414941ab
commit bd535851b0
20 changed files with 506 additions and 5 deletions

View File

@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- The iOS app's icon count stays right with the app closed: with `[push]` set to an APNs key, ipx sends each registered phone (`POST /api/push/devices`, removed with `DELETE`) its All Subscriptions unread count through Apple's push service, within a minute of a change (#433).
- A place in an episode saved from DeadAir's shortened version names its file (`deadair` in `POST .../position`), so it is read in that version's clock even when ipx never handed out its playlist, as for a copy kept on the device or after a restart (#412).
- `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389).
- The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384).

View File

@@ -15,6 +15,8 @@ pub struct Config {
pub torrent: Torrent,
#[serde(default)]
pub web: Web,
#[serde(default)]
pub push: Push,
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
#[serde(default)]
pub feeds: BTreeMap<String, Feed>,
@@ -73,6 +75,33 @@ pub enum Organize {
Date,
}
/// Apple's push service (#433), which keeps the iOS app's icon count exact. Nothing is sent
/// unless the key, its id and the team are all set.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Push {
/// The APNs key Apple gives once as a `.p8` file (developer account, Keys).
pub key_file: PathBuf,
/// That key's id, ten characters, shown beside it.
pub key_id: String,
/// The Apple developer team the app is signed by, as `8743L8D729`.
pub team_id: String,
/// The app's bundle id, which Apple checks each device token against.
pub topic: String,
}
impl Default for Push {
fn default() -> Self {
Push { key_file: PathBuf::new(), key_id: String::new(), team_id: String::new(), topic: "net.sdf1.ipx".into() }
}
}
impl Push {
pub fn is_set(&self) -> bool {
!self.key_file.as_os_str().is_empty() && !self.key_id.is_empty() && !self.team_id.is_empty()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Torrent {

View File

@@ -161,6 +161,7 @@ pub(crate) async fn daemon(
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
tokio::spawn(signals::run(ctx.clone(), rx_stop.clone()));
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
tokio::spawn(crate::push::run(ctx.clone(), rx_stop.clone()));
// The library's counts for /metrics. The Docker healthcheck asked for them every 30 s until
// it was removed: Docker runs it with exec, which failed on Tower whenever root was logged
// out (#218).

View File

@@ -2,8 +2,8 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat.
use crate::entity::{
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings,
signal_verdicts, signals, subscriptions, users,
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, push_devices, sessions,
settings, signal_verdicts, signals, subscriptions, users,
};
use anyhow::{Context, Result};
use sea_orm::sea_query::{Expr, Func};
@@ -72,6 +72,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(deadair_saved::Entity),
schema.create_table_from_entity(signals::Entity),
schema.create_table_from_entity(signal_verdicts::Entity),
schema.create_table_from_entity(push_devices::Entity),
] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
}
@@ -2288,6 +2289,51 @@ impl Db {
Ok(Some(User::from(user)))
}
/// Keeps a phone's push token for this person (#433). A token already kept for someone else
/// is theirs no longer: the phone has signed in as this person since.
pub async fn add_push_device(&self, user_id: i64, token: &str, sandbox: bool) -> Result<()> {
push_devices::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
push_devices::ActiveModel {
token: Set(token.to_owned()),
user_id: Set(user_id),
sandbox: Set(sandbox),
created: Set(now()),
badge: Set(None),
}
.insert(&self.orm)
.await?;
Ok(())
}
/// Signing out on that phone: someone else's token does nothing. Whether it was theirs.
pub async fn remove_push_device(&self, user_id: i64, token: &str) -> Result<bool> {
let gone = push_devices::Entity::delete_many()
.filter(push_devices::Column::Token.eq(token))
.filter(push_devices::Column::UserId.eq(user_id))
.exec(&self.orm)
.await?;
Ok(gone.rows_affected > 0)
}
/// One Apple says is no longer the app's, removed or reinstalled.
pub async fn forget_push_device(&self, token: &str) -> Result<()> {
push_devices::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
Ok(())
}
pub async fn push_devices(&self) -> Result<Vec<push_devices::Model>> {
Ok(push_devices::Entity::find().all(&self.orm).await?)
}
pub async fn set_push_badge(&self, token: &str, n: i64) -> Result<()> {
push_devices::Entity::update_many()
.col_expr(push_devices::Column::Badge, Expr::val(n))
.filter(push_devices::Column::Token.eq(token))
.exec(&self.orm)
.await?;
Ok(())
}
pub async fn delete_session(&self, token: &str) -> Result<()> {
sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
Ok(())
@@ -3184,6 +3230,24 @@ mod tests {
assert!(unread().await.is_empty());
}
#[tokio::test]
async fn a_phone_counts_for_whoever_last_signed_in_on_it() {
let db = Db::memory().await.unwrap();
let (me, you) =
(db.create_user("me", None, false).await.unwrap(), db.create_user("you", None, false).await.unwrap());
let phone = "ab".repeat(32);
db.add_push_device(me, &phone, false).await.unwrap();
db.set_push_badge(&phone, 4).await.unwrap();
db.add_push_device(you, &phone, true).await.unwrap();
let all = db.push_devices().await.unwrap();
assert_eq!(all.len(), 1, "one phone, one row (#433)");
assert_eq!((all[0].user_id, all[0].sandbox, all[0].badge), (you, true, None), "the new person's, sent afresh");
assert!(!db.remove_push_device(me, &phone).await.unwrap(), "not mine to remove any more");
assert!(db.remove_push_device(you, &phone).await.unwrap());
assert!(db.push_devices().await.unwrap().is_empty());
}
#[tokio::test]
async fn a_new_password_signs_the_account_out_everywhere_but_keeps_its_tokens() {
let db = Db::memory().await.unwrap();

View File

@@ -386,6 +386,27 @@ pub mod sessions {
owned_by_user!();
}
/// A phone with the iOS app, signed in as someone, to send the icon's unread count to (#433).
pub mod push_devices {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "push_devices")]
pub struct Model {
/// The token Apple gave the app on that phone, in hex.
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub token: String,
pub user_id: i64,
/// From a debug build, which Apple's sandbox serves rather than production.
pub sandbox: bool,
pub created: i64,
/// The count last sent, so a minute in which it did not change sends nothing.
pub badge: Option<i64>,
}
owned_by_user!();
}
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
pub mod api_tokens {

View File

@@ -20,6 +20,7 @@ mod lists;
mod logbuf;
mod logging;
mod metrics;
mod push;
mod retention;
mod scan;
mod signals;

245
crates/ipx/src/push.rs Normal file
View File

@@ -0,0 +1,245 @@
//! The iOS app's icon count, through Apple's push service (#433).
//!
//! The app shows All Subscriptions' unread count on its icon. While it is open the page keeps it
//! right; while it is closed only iOS's background refresh could, which runs when iOS chooses and
//! for this app had been rarely. So ipx sends it: once a minute, to each phone whose person's count
//! has changed since the last one sent, a push carrying the badge and nothing else, which shows no
//! alert and makes no sound.
//!
//! The app registers its phone through the page (`POST /api/push/devices`), signed in as the page
//! is: the address the app's player uses lets through only the player's own paths.
use crate::ctx::Ctx;
use crate::entity::push_devices;
use anyhow::{Context, Result};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::Instrument;
/// How often counts are compared. A minute behind is near enough for a number on an icon.
// ponytail: one count query per person with a phone, every minute; fine for a household, move to
// sending from where read state and new items are written if the people with phones grow many.
const EVERY: Duration = Duration::from_secs(60);
/// Apple refuses a signing token older than an hour, and one made more often than every twenty
/// minutes; so each is used for forty.
const TOKEN_LIFE: Duration = Duration::from_secs(40 * 60);
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
let mut signer = Signer::default();
loop {
if ctx.cfg().push.is_set() {
let pass = pass(&ctx, &mut signer).instrument(tracing::info_span!("push"));
tokio::select! {
_ = stop.changed() => return,
done = pass => if let Err(e) = done {
tracing::warn!(error = %format!("{e:#}"), "push: the icon counts could not be sent");
},
}
}
tokio::select! {
_ = stop.changed() => return,
_ = tokio::time::sleep(EVERY) => {}
}
}
}
/// Sends each phone its person's count, if it changed.
async fn pass(ctx: &Ctx, signer: &mut Signer) -> Result<()> {
let devices = ctx.db.push_devices().instrument(crate::db::step("push_devices")).await?;
let mut counts: HashMap<i64, i64> = HashMap::new();
for d in devices {
let n = match counts.get(&d.user_id) {
Some(n) => *n,
None => {
let n = ctx
.db
.count_of(d.user_id, None, crate::db::Filter::Unread, None)
.instrument(crate::db::step("unread_in_all"))
.await?;
counts.insert(d.user_id, n);
n
}
};
if d.badge == Some(n) {
continue;
}
match send(ctx, signer, &d, n).await? {
Sent::Yes => ctx.db.set_push_badge(&d.token, n).await?,
Sent::Gone => {
tracing::info!(user = d.user_id, "push: a phone's app is gone, so its token is forgotten");
ctx.db.forget_push_device(&d.token).await?;
}
// Tried again next minute: the badge kept is still the old one.
Sent::NotNow => {}
}
}
Ok(())
}
enum Sent {
Yes,
/// Apple says the token is not the app's: uninstalled, or for another app.
Gone,
NotNow,
}
#[tracing::instrument(name = "push.send", skip_all, fields(user = d.user_id, badge = n, status))]
async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64) -> Result<Sent> {
let cfg = ctx.cfg();
let host = if d.sandbox { "api.sandbox.push.apple.com" } else { "api.push.apple.com" };
let r = ctx
.client
.post(format!("https://{host}/3/device/{}", d.token))
.bearer_auth(signer.token(&cfg.push)?)
.header("apns-topic", &cfg.push.topic)
// A badge alone is still an alert to Apple: background pushes cannot set one.
.header("apns-push-type", "alert")
// Not urgent, so iOS may hold it to save the battery.
.header("apns-priority", "5")
.json(&serde_json::json!({ "aps": { "badge": n } }))
.send()
.await
.context("reaching Apple's push service")?;
let status = r.status().as_u16();
tracing::Span::current().record("status", status);
if status == 200 {
return Ok(Sent::Yes);
}
let reason = r.json::<serde_json::Value>().await.ok().and_then(|b| b["reason"].as_str().map(str::to_owned));
let reason = reason.unwrap_or_default();
Ok(match (status, reason.as_str()) {
(410, _) | (400, "BadDeviceToken" | "DeviceTokenNotForTopic") => Sent::Gone,
(403, "ExpiredProviderToken" | "InvalidProviderToken") => {
signer.forget();
tracing::warn!(status, reason, "push: Apple refused the signing token; check [push] in config.toml");
Sent::NotNow
}
_ => {
tracing::warn!(status, reason, "push: Apple did not take an icon count");
Sent::NotNow
}
})
}
/// The token each request is signed with: the `.p8` key's ES256 signature over the team and the
/// time, made again as Apple's limits ask.
#[derive(Default)]
struct Signer {
key: Option<EncodingKey>,
made: Option<(Instant, String)>,
}
impl Signer {
fn token(&mut self, cfg: &crate::config::Push) -> Result<String> {
if let Some((at, t)) = &self.made
&& at.elapsed() < TOKEN_LIFE
{
return Ok(t.clone());
}
if self.key.is_none() {
let pem = std::fs::read(&cfg.key_file)
.with_context(|| format!("reading the APNs key {}", cfg.key_file.display()))?;
self.key = Some(p8_key(&pem)?);
}
let token = sign(self.key.as_ref().context("loaded above")?, &cfg.key_id, &cfg.team_id, crate::db::now())?;
self.made = Some((Instant::now(), token.clone()));
Ok(token)
}
/// Made again, key and all, on the next send: the key file or its id may have changed.
fn forget(&mut self) {
*self = Signer::default();
}
}
/// The key in a `.p8` file: PKCS#8 in PEM, which this build of jsonwebtoken does not read, so
/// the base64 between its two lines is decoded here rather than a crate added for one file.
fn p8_key(pem: &[u8]) -> Result<EncodingKey> {
let text = std::str::from_utf8(pem).context("the APNs key is not a .p8 key")?;
let body: String = text.lines().filter(|l| !l.starts_with("-----")).collect::<String>();
let der = base64_decode(body.trim()).context("the APNs key is not a .p8 key")?;
Ok(EncodingKey::from_ec_der(&der))
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let value = |c: u8| match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
};
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0);
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
acc = (acc << 6) | u32::from(value(c)?);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Some(out)
}
fn sign(key: &EncodingKey, key_id: &str, team_id: &str, now: i64) -> Result<String> {
let mut h = Header::new(Algorithm::ES256);
h.kid = Some(key_id.to_owned());
Ok(jsonwebtoken::encode(&h, &serde_json::json!({ "iss": team_id, "iat": now }), key)?)
}
/// A device token as the app sends it: Apple's bytes in hex. Checked before it goes into a URL.
pub fn valid_token(t: &str) -> bool {
(64..=200).contains(&t.len()) && t.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{DecodingKey, Validation};
// A P-256 key made for this test alone, in the PKCS#8 form Apple's .p8 files are.
const KEY: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9x/aoouMrdbCsEAr
hLPm+4G3w8qNIHc9V7CMphVZL6WhRANCAATTwL52tAoDzAH+RcFS4cd9azD7xvGT
MnDzi5bfjGbabd5hc7sDHiTremhTWJzPs1TdGgtqd9RDURqCHr8jfP+B
-----END PRIVATE KEY-----";
// Its public half, as the point a JWK names.
const PUB_X: &str = "08C-drQKA8wB_kXBUuHHfWsw-8bxkzJw84uW34xm2m0";
const PUB_Y: &str = "3mFzuwMeJOt6aFNYnM-zVN0aC2p31ENRGoIevyN8_4E";
#[test]
fn the_signing_token_is_what_apple_reads() {
let key = p8_key(KEY.as_bytes()).unwrap();
let t = sign(&key, "ABC123DEFG", "8743L8D729", 1_800_000_000).unwrap();
let h = jsonwebtoken::decode_header(&t).unwrap();
assert_eq!(h.alg, Algorithm::ES256);
assert_eq!(h.kid.as_deref(), Some("ABC123DEFG"));
let mut v = Validation::new(Algorithm::ES256);
v.validate_exp = false;
v.required_spec_claims.clear();
let c =
jsonwebtoken::decode::<serde_json::Value>(&t, &DecodingKey::from_ec_components(PUB_X, PUB_Y).unwrap(), &v)
.unwrap()
.claims;
assert_eq!(c, serde_json::json!({ "iss": "8743L8D729", "iat": 1_800_000_000 }));
}
#[test]
fn base64_as_pem_has_it() {
assert_eq!(base64_decode("aXB4").unwrap(), b"ipx");
assert_eq!(base64_decode("aXB4IQ==").unwrap(), b"ipx!");
assert!(base64_decode("a!b").is_none());
}
#[test]
fn only_hex_of_a_tokens_length_is_a_device() {
assert!(valid_token(&"ab12".repeat(16)));
assert!(!valid_token("ab12"));
assert!(!valid_token(&format!("{}/../x", "a".repeat(64))));
assert!(!valid_token(&"g".repeat(64)));
}
}

View File

@@ -53,6 +53,8 @@ pub fn router(state: WebState) -> Router {
.route("/api/me", get(me).patch(patch_me))
.route("/api/tokens", get(list_tokens).post(add_token))
.route("/api/tokens/{id}", axum::routing::delete(remove_token))
.route("/api/push/devices", post(add_push_device))
.route("/api/push/devices/{token}", delete(remove_push_device))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
@@ -712,6 +714,39 @@ async fn add_token(
Ok(Json(serde_json::json!({ "id": id, "name": name, "token": token })))
}
#[derive(Deserialize)]
struct PushDevice {
token: String,
#[serde(default)]
sandbox: bool,
}
/// The iOS app's phone, to be sent this person's unread count for its icon (#433). Sent by the
/// page on the app's behalf, as the person signed in there.
async fn add_push_device(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<PushDevice>,
) -> Result<StatusCode, ApiError> {
let token = body.token.to_ascii_lowercase();
if !crate::push::valid_token(&token) {
return Err(ApiError::bad_request("that is not a device token from Apple"));
}
state.ctx.db.add_push_device(user.id, &token, body.sandbox).await?;
tracing::info!(user = %user.name, sandbox = body.sandbox, "push: a phone was registered");
Ok(StatusCode::NO_CONTENT)
}
/// Signing out in the app: that phone's icon is no longer this person's to count.
async fn remove_push_device(
State(state): State<WebState>,
user: crate::db::User,
Path(token): Path<String>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.remove_push_device(user.id, &token.to_ascii_lowercase()).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn remove_token(
State(state): State<WebState>,
user: crate::db::User,