diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 6ba4c7c..57b4c77 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -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). diff --git a/crates/ipx/src/config.rs b/crates/ipx/src/config.rs index f5101a6..33dcea8 100644 --- a/crates/ipx/src/config.rs +++ b/crates/ipx/src/config.rs @@ -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, @@ -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 { diff --git a/crates/ipx/src/daemon.rs b/crates/ipx/src/daemon.rs index cbc795c..f46486b 100644 --- a/crates/ipx/src/daemon.rs +++ b/crates/ipx/src/daemon.rs @@ -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). diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index 2f5c384..9cf88ed 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -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 { + 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> { + 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(); diff --git a/crates/ipx/src/entity.rs b/crates/ipx/src/entity.rs index 4b4a6cc..45e1c74 100644 --- a/crates/ipx/src/entity.rs +++ b/crates/ipx/src/entity.rs @@ -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, + } + + 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 { diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index a66cff2..bfe44a2 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -20,6 +20,7 @@ mod lists; mod logbuf; mod logging; mod metrics; +mod push; mod retention; mod scan; mod signals; diff --git a/crates/ipx/src/push.rs b/crates/ipx/src/push.rs new file mode 100644 index 0000000..cbeda71 --- /dev/null +++ b/crates/ipx/src/push.rs @@ -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, mut stop: tokio::sync::watch::Receiver) { + 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 = 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 { + 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::().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, + made: Option<(Instant, String)>, +} + +impl Signer { + fn token(&mut self, cfg: &crate::config::Push) -> Result { + 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 { + 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::(); + 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> { + 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 { + 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::(&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))); + } +} diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 93f50e2..831add2 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -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, + user: crate::db::User, + Json(body): Json, +) -> Result { + 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, + user: crate::db::User, + Path(token): Path, +) -> Result { + state.ctx.db.remove_push_device(user.id, &token.to_ascii_lowercase()).await?; + Ok(StatusCode::NO_CONTENT) +} + async fn remove_token( State(state): State, user: crate::db::User, diff --git a/docs/configuration.md b/docs/configuration.md index bc0fc24..c2a76c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -137,6 +137,30 @@ metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can itself carry a credential. Put TLS in front of it if that matters. +## `[push]` + +```toml +[push] +key_file = "/config/AuthKey_ABC123DEFG.p8" # the APNs key Apple gives once +key_id = "ABC123DEFG" # shown beside it in the developer account +team_id = "8743L8D729" # the team the iOS app is signed by +topic = "net.sdf1.ipx" # the app's bundle id, the default +``` + +The iOS app's icon shows All Subscriptions' unread count. With this set, ipx keeps it right while +the app is closed (#433): once a minute it compares each phone's count with the last one sent, and +sends a changed one through Apple's push service, a badge alone, with no alert or sound. Without +it, the count waits on the background refresh iOS runs when it chooses. + +The key is made in the Apple developer account under Certificates, Identifiers & Profiles, Keys, +with Apple Push Notifications service enabled, and can be downloaded once. One key serves every +app of the team, the TestFlight and debug builds both. Keep it as private as the database: it lets +anyone send to the team's apps. A key, id or team that is wrong shows as a warning in the log, +`push: Apple refused the signing token`, and nothing is sent until it is right. + +The app registers each phone through the page as the person signed in there +(`POST /api/push/devices`), and signing out removes it. + ## `[feeds.]` Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 5e4db68..afb3a2a 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -12,4 +12,5 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1. - Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425). - A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429). - iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435). +- The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433). - Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430). diff --git a/ios/README.md b/ios/README.md index 5f20539..fc58bfc 100644 --- a/ios/README.md +++ b/ios/README.md @@ -106,6 +106,7 @@ App to page, as `window.ipxNative.on(...)`: | `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays | | `{t:"keepFailed", url, message}` | one that could not be kept, and why | | `{t:"host", can}` | at `ready`: what this build does itself (`sleep`, `route`), so a page newer than the app offers only those | +| `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) | | `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded | | `{t:"slept"}` | the timer ran out and playback paused | diff --git a/ios/Sources/App.swift b/ios/Sources/App.swift index bd1ed4e..2dc6cf7 100644 --- a/ios/Sources/App.swift +++ b/ios/Sources/App.swift @@ -17,6 +17,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { Offline.shared.backgroundCompletion = completionHandler Offline.shared.resume() } + + /// Apple's address for this phone's copy of the app (#433), handed to the page to register. + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Push.token = deviceToken.map { String(format: "%02x", $0) }.joined() + } + + func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + NSLog("ipx: push registration: %@", error.localizedDescription) + } } /// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would diff --git a/ios/Sources/Badge.swift b/ios/Sources/Badge.swift index d38069f..9cf0e3b 100644 --- a/ios/Sources/Badge.swift +++ b/ios/Sources/Badge.swift @@ -35,7 +35,11 @@ enum Badge { // `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page // has nobody to tap Allow, and the dialog covers it. if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return } - UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { granted, _ in + // Each launch, not once: Apple can change the token, and says to ask every time. + guard granted else { return } + Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() } + } } /// The page's own count, sent as its list changes (#421). diff --git a/ios/Sources/Bridge.swift b/ios/Sources/Bridge.swift index 210de61..6c87198 100644 --- a/ios/Sources/Bridge.swift +++ b/ios/Sources/Bridge.swift @@ -43,6 +43,7 @@ final class Bridge: NSObject, WKScriptMessageHandler { playback.onEnded = { [weak self] in self?.send(["t": "ended"]) } playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) } playback.onSlept = { [weak self] in self?.send(["t": "slept"]) } + Push.onToken = { [weak self] _ in self?.sendPush() } Offline.shared.onChange = { [weak self] in self?.sendKept() } Offline.shared.onFail = { [weak self] path, msg in self?.send(["t": "keepFailed", "url": path, "message": msg]) @@ -55,6 +56,12 @@ final class Bridge: NSObject, WKScriptMessageHandler { "keeping": Offline.shared.downloading.sorted()]) } + /// For the page to register with ipx (#433), at ready and whenever Apple gives a new one. + private func sendPush() { + guard let token = Push.token else { return } + send(["t": "push", "token": token, "sandbox": Push.sandbox]) + } + // MARK: - page to host func userContentController(_ controller: WKUserContentController, @@ -69,6 +76,7 @@ final class Bridge: NSObject, WKScriptMessageHandler { // A page reloaded while the timer runs is told how long is left. if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) } sendKept() + sendPush() case "load": guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path), let enc = m["enc"] as? Int, diff --git a/ios/Sources/Push.swift b/ios/Sources/Push.swift new file mode 100644 index 0000000..9d19325 --- /dev/null +++ b/ios/Sources/Push.swift @@ -0,0 +1,23 @@ +import Foundation + +/// This phone's push token (#433). ipx sends the icon's count to it while the app is closed, +/// which background refresh only guessed at. The page registers it with ipx, signed in as the +/// page is, since the player's address lets through only the player's own paths. +@MainActor +enum Push { + /// Told when the token arrives or changes, to pass it to the page. + static var onToken: ((String) -> Void)? + + static var token: String? { + didSet { if let token, token != oldValue { onToken?(token) } } + } + + /// A debug build is served by Apple's sandbox, which ipx has to send to instead. + static var sandbox: Bool { + #if DEBUG + true + #else + false + #endif + } +} diff --git a/ios/project.yml b/ios/project.yml index e6b7407..d639e97 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -25,7 +25,7 @@ targets: properties: CFBundleDisplayName: iPX CFBundleShortVersionString: $(MARKETING_VERSION) - CFBundleVersion: "10" + CFBundleVersion: "11" # Playing on with the screen locked. A plist key, not a signed entitlement, so it works on # a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant. UIBackgroundModes: [audio, fetch] @@ -55,6 +55,12 @@ targets: - ipodderx.sdf1.net - rays-sdf1.cloudflareaccess.com - authentik.sdf1.net + # The icon's count pushed by ipx (#433). Development here; Xcode makes it production when it + # exports for TestFlight. + entitlements: + path: Resources/iPX.entitlements + properties: + aps-environment: development settings: base: PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx diff --git a/tests/native-bridge.ts b/tests/native-bridge.ts index bbdc691..44eef7b 100644 --- a/tests/native-bridge.ts +++ b/tests/native-bridge.ts @@ -180,6 +180,18 @@ since(); vm.runInContext('hostBadge(7); hostBadge(7); hostBadge(3)', ctx); ok(posted.filter(m => m.t === 'unread').map(m => m.n).join() === '7,3', 'the app was not told the unread count once per change'); +// Push (#433): the app's token is registered with ipx by the page, and dropped on signing out. +const fetched: string[] = []; +ctx.fetch = (url: unknown, init?: { method?: string, body?: string }) => { + fetched.push(`${init?.method || 'GET'} ${url} ${init?.body || ''}`); + return Promise.resolve({ ok: true, status: 204, headers: { get: () => null }, text: () => Promise.resolve(''), json: () => Promise.resolve(null) }); +}; +ctx.window.ipxNative.on({ t: 'push', token: 'ab'.repeat(32), sandbox: false }); +await new Promise(r => setTimeout(r, 0)); +ok(fetched.some(f => f.startsWith('POST /api/push/devices') && f.includes('"sandbox":false')), 'the phone was not registered with ipx'); +await vm.runInContext('forgetPush()', ctx); +ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone'); + if (failed) { console.error(`\n${failed} failed`); process.exit(1); } console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it'); process.exit(0); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index d05ea29..1350f46 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433). - In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421). - In the iOS app, presses are felt: play, pause, flag, pull to refresh, marking read and errors each give the phone's haptic for it (#430). - A sleep timer in the player: 5 minutes to an hour, then playback fades out and pauses. In the iOS app the phone keeps the time, so it works with the screen locked; the app and Safari also get an AirPlay button. On a phone the title has the player's top line to itself, with the controls either side of play below it (#429). diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 4b2b4c4..68816c8 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -847,7 +847,7 @@ $('#prefs').onclick=prefsModal; // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // the proxy still vouches for them. /api/me says where, when that is the case. // What was kept for offline goes too: the next person on this device is not to read it (#389). -$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); await clearOffline(); location.href=S.me?.sign_out||'/login'; }; +$('#signout').onclick=async()=>{ await forgetPush(); await api('/api/logout',{method:'POST'}); await clearOffline(); location.href=S.me?.sign_out||'/login'; }; api('/api/me').then(u=>{ S.me=u; offlineOwner(u.name); diff --git a/web/src/native.ts b/web/src/native.ts index f1aad32..1d6db95 100644 --- a/web/src/native.ts +++ b/web/src/native.ts @@ -34,6 +34,18 @@ function hostBadge(n: number){ try{ if(n!==badgeSent){ badgeSent=n; ipxHost?.({t:'unread', n}); } }catch{} } +/// This phone's push token (#433), which the app hands over at ready: registered with ipx here, +/// signed in as the page is, so ipx can keep the icon's count right while the app is closed. +let pushToken=''; +function hostPush(m: {token: string, sandbox?: boolean}){ + pushToken=m.token; + api('/api/push/devices',{method:'POST',body:JSON.stringify({token:m.token,sandbox:!!m.sandbox})}).catch(()=>{}); +} +/// Signing out: the phone's icon is no longer this person's to count. +async function forgetPush(){ + if(pushToken) await api(`/api/push/devices/${encodeURIComponent(pushToken)}`,{method:'DELETE'}).catch(()=>{}); +} + /// A press felt as well as seen, in the app (#430): the phone's haptics, which a page cannot reach. /// `tap` for play, pause and the like, `done` for something finished (marked read), `warn` for an /// error. An app without them ignores the message; a browser has no host to send it to. @@ -166,6 +178,7 @@ function installNativePlayback(){ // Sent at ready, so a page newer than the app offers only what the app can do. if(m.t==='host'){ hostCan.clear(); for(const c of m.can||[]) hostCan.add(c); paintRoute(); return; } if(m.t==='sleep'||m.t==='slept'){ hostSleep(m); return; } + if(m.t==='push'){ hostPush(m); return; } if(!N.on) return; switch(m.t){ case 'time':