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:
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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).
|
- 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).
|
- `/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).
|
- 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).
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub struct Config {
|
|||||||
pub torrent: Torrent,
|
pub torrent: Torrent,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: Web,
|
pub web: Web,
|
||||||
|
#[serde(default)]
|
||||||
|
pub push: Push,
|
||||||
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
|
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feeds: BTreeMap<String, Feed>,
|
pub feeds: BTreeMap<String, Feed>,
|
||||||
@@ -73,6 +75,33 @@ pub enum Organize {
|
|||||||
Date,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Torrent {
|
pub struct Torrent {
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ pub(crate) async fn daemon(
|
|||||||
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||||
tokio::spawn(signals::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(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
|
// 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
|
// it was removed: Docker runs it with exec, which failed on Tower whenever root was logged
|
||||||
// out (#218).
|
// out (#218).
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||||
|
|
||||||
use crate::entity::{
|
use crate::entity::{
|
||||||
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings,
|
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, push_devices, sessions,
|
||||||
signal_verdicts, signals, subscriptions, users,
|
settings, signal_verdicts, signals, subscriptions, users,
|
||||||
};
|
};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use sea_orm::sea_query::{Expr, Func};
|
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(deadair_saved::Entity),
|
||||||
schema.create_table_from_entity(signals::Entity),
|
schema.create_table_from_entity(signals::Entity),
|
||||||
schema.create_table_from_entity(signal_verdicts::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")?;
|
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||||
}
|
}
|
||||||
@@ -2288,6 +2289,51 @@ impl Db {
|
|||||||
Ok(Some(User::from(user)))
|
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<()> {
|
pub async fn delete_session(&self, token: &str) -> Result<()> {
|
||||||
sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
|
sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3184,6 +3230,24 @@ mod tests {
|
|||||||
assert!(unread().await.is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn a_new_password_signs_the_account_out_everywhere_but_keeps_its_tokens() {
|
async fn a_new_password_signs_the_account_out_everywhere_but_keeps_its_tokens() {
|
||||||
let db = Db::memory().await.unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
|
|||||||
@@ -386,6 +386,27 @@ pub mod sessions {
|
|||||||
owned_by_user!();
|
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
|
/// 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.
|
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
|
||||||
pub mod api_tokens {
|
pub mod api_tokens {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ mod lists;
|
|||||||
mod logbuf;
|
mod logbuf;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
|
mod push;
|
||||||
mod retention;
|
mod retention;
|
||||||
mod scan;
|
mod scan;
|
||||||
mod signals;
|
mod signals;
|
||||||
|
|||||||
245
crates/ipx/src/push.rs
Normal file
245
crates/ipx/src/push.rs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/me", get(me).patch(patch_me))
|
.route("/api/me", get(me).patch(patch_me))
|
||||||
.route("/api/tokens", get(list_tokens).post(add_token))
|
.route("/api/tokens", get(list_tokens).post(add_token))
|
||||||
.route("/api/tokens/{id}", axum::routing::delete(remove_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/logout", post(logout))
|
||||||
.route("/api/feeds", get(feeds).post(add_feed))
|
.route("/api/feeds", get(feeds).post(add_feed))
|
||||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_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 })))
|
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(
|
async fn remove_token(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
|
|||||||
@@ -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
|
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.
|
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.<id>]`
|
## `[feeds.<id>]`
|
||||||
|
|
||||||
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||||
|
|||||||
@@ -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).
|
- 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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|||||||
@@ -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:"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:"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:"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:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
|
||||||
| `{t:"slept"}` | the timer ran out and playback paused |
|
| `{t:"slept"}` | the timer ran out and playback paused |
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
Offline.shared.backgroundCompletion = completionHandler
|
Offline.shared.backgroundCompletion = completionHandler
|
||||||
Offline.shared.resume()
|
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
|
/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ enum Badge {
|
|||||||
// `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page
|
// `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page
|
||||||
// has nobody to tap Allow, and the dialog covers it.
|
// has nobody to tap Allow, and the dialog covers it.
|
||||||
if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return }
|
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).
|
/// The page's own count, sent as its list changes (#421).
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
||||||
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) }
|
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.onChange = { [weak self] in self?.sendKept() }
|
||||||
Offline.shared.onFail = { [weak self] path, msg in
|
Offline.shared.onFail = { [weak self] path, msg in
|
||||||
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
||||||
@@ -55,6 +56,12 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
"keeping": Offline.shared.downloading.sorted()])
|
"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
|
// MARK: - page to host
|
||||||
|
|
||||||
func userContentController(_ controller: WKUserContentController,
|
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.
|
// 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)]) }
|
if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
|
||||||
sendKept()
|
sendKept()
|
||||||
|
sendPush()
|
||||||
case "load":
|
case "load":
|
||||||
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
||||||
let enc = m["enc"] as? Int,
|
let enc = m["enc"] as? Int,
|
||||||
|
|||||||
23
ios/Sources/Push.swift
Normal file
23
ios/Sources/Push.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: iPX
|
CFBundleDisplayName: iPX
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "10"
|
CFBundleVersion: "11"
|
||||||
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
|
# 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.
|
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
|
||||||
UIBackgroundModes: [audio, fetch]
|
UIBackgroundModes: [audio, fetch]
|
||||||
@@ -55,6 +55,12 @@ targets:
|
|||||||
- ipodderx.sdf1.net
|
- ipodderx.sdf1.net
|
||||||
- rays-sdf1.cloudflareaccess.com
|
- rays-sdf1.cloudflareaccess.com
|
||||||
- authentik.sdf1.net
|
- 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:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
|
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
|
||||||
|
|||||||
@@ -180,6 +180,18 @@ since();
|
|||||||
vm.runInContext('hostBadge(7); hostBadge(7); hostBadge(3)', ctx);
|
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');
|
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); }
|
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');
|
console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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, 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).
|
- 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).
|
- 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).
|
||||||
|
|||||||
@@ -847,7 +847,7 @@ $<HTMLButtonElement>('#prefs').onclick=prefsModal;
|
|||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// 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.
|
// 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).
|
// What was kept for offline goes too: the next person on this device is not to read it (#389).
|
||||||
$<HTMLButtonElement>('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); await clearOffline(); location.href=S.me?.sign_out||'/login'; };
|
$<HTMLButtonElement>('#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=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
offlineOwner(u.name);
|
offlineOwner(u.name);
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ function hostBadge(n: number){
|
|||||||
try{ if(n!==badgeSent){ badgeSent=n; ipxHost?.({t:'unread', n}); } }catch{}
|
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.
|
/// 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
|
/// `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.
|
/// 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.
|
// 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==='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==='sleep'||m.t==='slept'){ hostSleep(m); return; }
|
||||||
|
if(m.t==='push'){ hostPush(m); return; }
|
||||||
if(!N.on) return;
|
if(!N.on) return;
|
||||||
switch(m.t){
|
switch(m.t){
|
||||||
case 'time':
|
case 'time':
|
||||||
|
|||||||
Reference in New Issue
Block a user