Skip to main content

koan_server/
push.rs

1//! Push to kōan's iOS app, through kōan's push relay.
2//!
3//! A linked app is reached over its WebSocket. iOS suspends an app in the
4//! background that is not playing, and the socket dies with it; a push is the
5//! supported way back. Apple accepts pushes to the app only under the
6//! developer's key, so every server, this one included, sends them through
7//! the relay at `push.koan.rocks` (`docs/reference/protocol/push-relay.md`),
8//! which holds that key. The server holds handles the app gave it, never a
9//! device token.
10//!
11//! Three kinds: a background push that wakes the app for half a minute to
12//! link and take what waits for it in the outbox; a notification for
13//! playback, which iOS will not let a suspended app start on its own, carrying
14//! a claim the app redeems over the link once the person taps it; and Live
15//! Activity updates for a device the phone follows.
16
17use koan_relay_client::{ActivityState, Client, Error, Push as Relayed};
18
19/// What to send.
20#[derive(Debug, Clone, PartialEq)]
21pub enum Push {
22    Wake,
23    WakeNow,
24    /// "Someone wants to play here": tapping opens the app.
25    Summon,
26    /// A command to run once the person taps it: the claim it is kept under
27    /// (see `crate::clients::claims`).
28    Notify {
29        claim: String,
30    },
31    Activity(ActivityState),
32}
33
34/// What became of a push.
35#[derive(Debug, PartialEq)]
36pub enum Outcome {
37    Sent,
38    /// The handle no longer reaches the device: forget it.
39    Gone,
40    Failed(String),
41}
42
43pub struct Pusher {
44    client: Client,
45}
46
47impl Pusher {
48    /// Send `push` through `handle`. Blocks for the round trip, so never call
49    /// it from async code.
50    pub fn send(&self, handle: &str, push: &Push) -> Outcome {
51        let sent = match push {
52            Push::Wake => self.client.push(handle, Relayed::Wake),
53            Push::WakeNow => self.client.push(handle, Relayed::WakeNow),
54            Push::Summon => self.client.push(handle, Relayed::Summon { blob: None }),
55            Push::Notify { claim } => self.client.push(
56                handle,
57                Relayed::Notify {
58                    blob: Some(claim.as_str()),
59                },
60            ),
61            Push::Activity(state) => self.client.update_activity(handle, state),
62        };
63        match sent {
64            Ok(()) => Outcome::Sent,
65            Err(Error::Gone) => Outcome::Gone,
66            Err(e) => Outcome::Failed(e.to_string()),
67        }
68    }
69}
70
71/// What a Live Activity shows of the device it follows.
72pub fn activity_state(info: &crate::clients::ClientInfo) -> ActivityState {
73    ActivityState {
74        device: info.name.clone(),
75        linked: true,
76        title: info.state.title.clone(),
77        artist: info.state.artist.clone(),
78        album: info.state.album.clone(),
79        playing: info.state.playing,
80        position_ms: info.position_ms(),
81        duration_ms: info.state.duration_ms,
82        at: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
83        art: info
84            .state
85            .queue
86            .iter()
87            .find(|e| e.current)
88            .and_then(|e| e.track_id.as_deref())
89            .and_then(activity_art),
90    }
91}
92
93/// Whether the activity would show something different from `sent`. A
94/// playhead moving on time is drawn by the activity itself.
95pub fn differs(state: &ActivityState, sent: &ActivityState) -> bool {
96    let strip = |s: &ActivityState| ActivityState {
97        position_ms: 0,
98        at: 0.0,
99        ..s.clone()
100    };
101    if strip(state) != strip(sent) {
102        return true;
103    }
104    let expected = if sent.playing {
105        sent.position_ms + ((state.at - sent.at).max(0.0) * 1000.0) as u64
106    } else {
107        sent.position_ms
108    };
109    state.position_ms.abs_diff(expected) > 3000
110}
111
112/// The relay takes art of at most this many base64 characters, which keeps a
113/// Live Activity push within Apple's 4 KB.
114const ART_BUDGET: usize = 2600;
115
116/// The cover of `track` (a uid or a row id) small enough to ride in a Live
117/// Activity push, as base64. Worked out once per track: the state it goes in
118/// is reported far more often than the track changes.
119fn activity_art(track: &str) -> Option<String> {
120    use base64::Engine as _;
121    static LAST: parking_lot::Mutex<Option<(String, Option<String>)>> =
122        parking_lot::Mutex::new(None);
123    static COVERS: std::sync::LazyLock<crate::covers::Covers> =
124        std::sync::LazyLock::new(crate::covers::Covers::in_config_dir);
125    if cfg!(test) {
126        return None;
127    }
128    if let Some((held, art)) = LAST.lock().as_ref()
129        && held == track
130    {
131        return art.clone();
132    }
133    let art = (|| {
134        let db = koan_core::db::pool::shared().get().ok()?;
135        let id = koan_core::db::queries::resolve_id(
136            &db.conn,
137            koan_core::db::queries::UidKind::Track,
138            track,
139        )
140        .ok()??;
141        let row = koan_core::db::queries::get_track_row(&db.conn, id).ok()??;
142        let cover = COVERS.cover(std::slice::from_ref(&row), crate::covers::SIZES[0])?;
143        let img = image::load_from_memory(&cover).ok()?;
144        // Smaller and rougher until it fits.
145        [(72, 60), (60, 50), (48, 40)]
146            .into_iter()
147            .find_map(|(side, quality)| {
148                let mut out = Vec::new();
149                let small = img.thumbnail(side, side).to_rgb8();
150                image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality)
151                    .encode_image(&small)
152                    .ok()?;
153                let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
154                (b64.len() <= ART_BUDGET).then_some(b64)
155            })
156    })();
157    *LAST.lock() = Some((track.to_string(), art.clone()));
158    art
159}
160
161/// The server's pusher, from the config it started with; `None` when push is
162/// turned off, and pushes are simply not sent.
163pub fn pusher() -> Option<&'static Pusher> {
164    static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
165        let cfg = koan_core::config::Config::load().unwrap_or_default();
166        if !cfg.push.enabled {
167            log::info!("push: off");
168            return None;
169        }
170        let relay = koan_core::remote::relay::RELAY;
171        match Client::new(relay) {
172            Ok(client) => {
173                log::info!("push: through {relay}");
174                Some(Pusher { client })
175            }
176            Err(e) => {
177                log::warn!("push: the relay {relay} cannot be used: {e}");
178                None
179            }
180        }
181    });
182    PUSHER.as_ref()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn state(position_ms: u64, at: f64, playing: bool) -> ActivityState {
190        ActivityState {
191            device: "Desk".into(),
192            linked: true,
193            title: Some("Blue in Green".into()),
194            artist: None,
195            album: None,
196            playing,
197            position_ms,
198            duration_ms: 337_000,
199            at,
200            art: None,
201        }
202    }
203
204    #[test]
205    fn a_playhead_moving_on_time_is_no_change() {
206        let sent = state(10_000, 100.0, true);
207        assert!(!differs(&state(15_000, 105.0, true), &sent));
208        assert!(differs(&state(60_000, 105.0, true), &sent), "a seek");
209        assert!(differs(&state(15_000, 105.0, false), &sent), "a pause");
210    }
211
212    #[test]
213    fn a_paused_playhead_stays_put() {
214        let sent = state(10_000, 100.0, false);
215        assert!(!differs(&state(10_000, 500.0, false), &sent));
216    }
217}