Skip to main content

koan_core/remote/
devices.rs

1//! The devices this one can play on, and which of them it is controlling.
2//!
3//! Two sources: the account's other devices, which the server sends down the
4//! link, and whatever answers on the local network (`remote::nearby`), which
5//! may belong to anyone. A device found both ways is one device, reached over
6//! the local network while that connection is up: it is the shorter path.
7//!
8//! The device being controlled and the account's devices as last heard of are
9//! kept on disk, so an app iOS suspended or killed opens still controlling the
10//! same device, with the list drawn at once rather than after the link is back.
11
12use std::sync::OnceLock;
13use std::time::Instant;
14
15use parking_lot::Mutex;
16
17use crate::config::Config;
18use crate::remote::link::{self, LinkCommand, LinkDevice, LinkHello, LinkReport, LinkState, Local};
19
20/// Another device, as the app shows it.
21#[derive(Debug, Clone, PartialEq)]
22pub struct Device {
23    pub id: String,
24    pub name: String,
25    pub platform: String,
26    /// Signed in to the same account on the same server.
27    pub account: bool,
28    /// Connected to over the local network now.
29    pub nearby: bool,
30    /// Reachable at once. An account device that is not is one iOS has
31    /// suspended: a command wakes it, and music reaches it as a notification.
32    pub awake: bool,
33    /// Plays from the same library, so music can be handed between the two.
34    pub same_library: bool,
35    /// What it last reported.
36    pub state: Option<LinkState>,
37    /// When `state` was heard.
38    pub heard: Instant,
39    /// Why it cannot be reached, when found but not connected: shown rather
40    /// than leaving the device out, so the picker says what is wrong.
41    pub problem: Option<String>,
42}
43
44impl Device {
45    /// Where its playhead is now, from where it was reported to be.
46    pub fn position_ms(&self) -> u64 {
47        let Some(state) = &self.state else { return 0 };
48        if !state.playing {
49            return state.position_ms;
50        }
51        let pos = state.position_ms + self.heard.elapsed().as_millis() as u64;
52        if state.duration_ms > 0 {
53            pos.min(state.duration_ms)
54        } else {
55            pos
56        }
57    }
58}
59
60#[derive(Default)]
61struct Store {
62    linked: bool,
63    account: Vec<(LinkDevice, Instant)>,
64    nearby: Vec<Nearby>,
65    seen: Vec<SeenNearby>,
66    target: Option<Remembered>,
67    version: u64,
68}
69
70/// What is kept on disk. See the module note.
71#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
72struct Saved {
73    target: Option<Remembered>,
74    account: Vec<LinkDevice>,
75    #[serde(default)]
76    nearby: Vec<SeenNearby>,
77}
78
79/// A device reached on the local network, and where: dialled there at once
80/// on the next run, before Bonjour has announced anything.
81#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
82pub struct SeenNearby {
83    pub id: String,
84    pub name: String,
85    pub platform: String,
86    pub addr: String,
87    /// Unix seconds.
88    pub at: i64,
89}
90
91/// A device not reached on the network in this long is forgotten.
92const NEARBY_KEPT: i64 = 7 * 24 * 60 * 60;
93
94/// The device being controlled, named so that it can be shown while it is
95/// out of reach.
96#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
97struct Remembered {
98    id: String,
99    name: String,
100    platform: String,
101}
102
103struct Nearby {
104    hello: LinkHello,
105    state: Option<LinkState>,
106    at: Instant,
107}
108
109static STORE: Mutex<Option<Store>> = Mutex::new(None);
110static LOCAL: OnceLock<Local> = OnceLock::new();
111
112fn with<R>(f: impl FnOnce(&mut Store) -> R) -> R {
113    let mut store = STORE.lock();
114    f(store.get_or_insert_with(Store::default))
115}
116
117/// `f` changed something the app shows.
118fn changed<R>(f: impl FnOnce(&mut Store) -> R) -> R {
119    let out = with(|s| {
120        let out = f(s);
121        s.version += 1;
122        out
123    });
124    crate::signal::engine_changed().bump();
125    out
126}
127
128impl Store {
129    fn save(&self) {
130        let saved = Saved {
131            target: self.target.clone(),
132            account: self.account.iter().map(|(d, _)| d.clone()).collect(),
133            nearby: self.seen.clone(),
134        };
135        if let Ok(json) = serde_json::to_string(&saved) {
136            let _ = std::fs::write(saved_path(), json);
137        }
138    }
139}
140
141fn saved_path() -> std::path::PathBuf {
142    crate::config::config_dir().join("devices.json")
143}
144
145/// The target and account devices as the last run left them.
146fn restore() {
147    let Ok(text) = std::fs::read_to_string(saved_path()) else {
148        return;
149    };
150    let Ok(saved) = serde_json::from_str::<Saved>(&text) else {
151        return;
152    };
153    let now = Instant::now();
154    let cutoff = chrono::Utc::now().timestamp() - NEARBY_KEPT;
155    changed(|s| {
156        s.target = saved.target;
157        s.seen = saved
158            .nearby
159            .into_iter()
160            .filter(|n| n.at >= cutoff)
161            .collect();
162        // Not linked until the server says so: shown as last heard of.
163        s.account = saved
164            .account
165            .into_iter()
166            .map(|d| (LinkDevice { linked: false, ..d }, now))
167            .collect();
168    });
169}
170
171/// Link to the server and open this device to the local network, serving
172/// `local` to both. Once per process.
173pub fn start(local: Local) {
174    if LOCAL.set(local.clone()).is_err() {
175        return;
176    }
177    restore();
178    link::spawn(local.clone());
179    crate::remote::nearby::start(local);
180}
181
182/// This device, once started.
183pub fn local() -> Option<&'static Local> {
184    LOCAL.get()
185}
186
187/// The account's devices stay listed while the link is down, as they were
188/// last heard of: iOS drops the link each time it suspends the app, and a
189/// phone controlling a Mac should still be when it wakes. The server sends
190/// them afresh when the link is back.
191///
192/// Nothing here lets go of the target. A device that drops out of every list
193/// (a Mac asleep, a server restarting) is still the one the person picked,
194/// and the app shows it as out of reach until it is back or another is.
195pub fn set_linked(linked: bool) {
196    changed(|s| s.linked = linked);
197}
198
199pub fn set_account(devices: Vec<LinkDevice>) {
200    let now = Instant::now();
201    changed(|s| {
202        s.account = devices.into_iter().map(|d| (d, now)).collect();
203        s.save();
204    });
205}
206
207/// The devices reached on this network before, to dial first.
208pub fn remembered_nearby() -> Vec<SeenNearby> {
209    with(|s| s.seen.clone())
210}
211
212pub fn nearby_hello(hello: LinkHello, addr: &str) {
213    changed(|s| {
214        s.seen.retain(|n| n.id != hello.id);
215        s.seen.push(SeenNearby {
216            id: hello.id.clone(),
217            name: hello.name.clone(),
218            platform: hello.platform.clone(),
219            addr: addr.to_string(),
220            at: chrono::Utc::now().timestamp(),
221        });
222        s.save();
223        s.nearby.retain(|n| n.hello.id != hello.id);
224        s.nearby.push(Nearby {
225            hello,
226            state: None,
227            at: Instant::now(),
228        });
229    });
230}
231
232pub fn nearby_state(id: &str, state: LinkState) {
233    changed(|s| {
234        if let Some(n) = s.nearby.iter_mut().find(|n| n.hello.id == id) {
235            n.state = Some(state);
236            n.at = Instant::now();
237        }
238    });
239}
240
241pub fn nearby_gone(id: &str) {
242    changed(|s| s.nearby.retain(|n| n.hello.id != id));
243}
244
245/// Something beside the list that the app shows with it has changed: the
246/// server's profile, whether this device is listening.
247pub fn touch() {
248    changed(|_| ());
249}
250
251/// Bumped by every change to what `list` returns, and by `touch`.
252pub fn version() -> u64 {
253    with(|s| s.version)
254}
255
256/// Whether the link to the server is up.
257pub fn linked() -> bool {
258    with(|s| s.linked)
259}
260
261/// Every other device, those playing first.
262pub fn list() -> Vec<Device> {
263    let cfg = Config::load().unwrap_or_default();
264    let ours = link::library_fingerprint(&cfg);
265    with(|s| {
266        let mut out: Vec<Device> = s
267            .account
268            .iter()
269            .map(|(d, at)| {
270                let near = s.nearby.iter().find(|n| n.hello.id == d.id);
271                Device {
272                    id: d.id.clone(),
273                    name: d.name.clone(),
274                    platform: d.platform.clone(),
275                    account: true,
276                    nearby: near.is_some(),
277                    awake: d.linked || near.is_some(),
278                    same_library: true,
279                    // The network's state is fresher, but only the server's
280                    // carries the outputs: the network is told none.
281                    state: match (near.and_then(|n| n.state.clone()), &d.state) {
282                        (Some(mut fresh), Some(linked)) => {
283                            fresh.outputs = linked.outputs.clone();
284                            Some(fresh)
285                        }
286                        (Some(fresh), None) => Some(fresh),
287                        (None, linked) => linked.clone(),
288                    },
289                    heard: near.map_or(*at, |n| n.at),
290                    problem: None,
291                }
292            })
293            .collect();
294        for n in &s.nearby {
295            if out.iter().any(|d| d.id == n.hello.id) {
296                continue;
297            }
298            out.push(Device {
299                id: n.hello.id.clone(),
300                name: n.hello.name.clone(),
301                platform: n.hello.platform.clone(),
302                account: false,
303                nearby: true,
304                awake: true,
305                same_library: ours.is_some() && n.hello.library == ours,
306                state: n.state.clone(),
307                heard: n.at,
308                problem: None,
309            });
310        }
311        // A device announced on this network but not connected is left out:
312        // it cannot be played on, and a phone that left or a stranger's app
313        // that is not discoverable would sit in the list as a reason only.
314        // The device being controlled, out of every list for now.
315        if let Some(t) = &s.target
316            && !out.iter().any(|d| d.id == t.id)
317        {
318            out.push(Device {
319                id: t.id.clone(),
320                name: t.name.clone(),
321                platform: t.platform.clone(),
322                account: false,
323                nearby: false,
324                awake: false,
325                same_library: true,
326                state: None,
327                heard: Instant::now(),
328                problem: Some("Out of reach".into()),
329            });
330        }
331        out.sort_by_key(|d| {
332            (
333                !d.state.as_ref().is_some_and(|st| st.playing),
334                !d.awake,
335                d.name.to_lowercase(),
336            )
337        });
338        out
339    })
340}
341
342/// The device the app is controlling; `None` for this one.
343pub fn target() -> Option<String> {
344    with(|s| s.target.as_ref().map(|t| t.id.clone()))
345}
346
347pub fn set_target(id: Option<String>) {
348    let listed = id
349        .as_ref()
350        .and_then(|id| list().into_iter().find(|d| d.id == *id));
351    changed(|s| {
352        s.target = id.map(|id| match listed {
353            Some(d) => Remembered {
354                id,
355                name: d.name,
356                platform: d.platform,
357            },
358            None => Remembered {
359                name: id.clone(),
360                id,
361                platform: String::new(),
362            },
363        });
364        s.save();
365    });
366    // Controlling another device keeps a backgrounded app awake.
367    crate::quiet::reapply();
368}
369
370/// The app is in front again after iOS may have suspended it: link now,
371/// prove every connection is still alive, and look at the network afresh.
372/// What makes the other devices appear the moment the app opens rather than
373/// when a dead socket finally times out.
374pub fn resume() {
375    link::nudge();
376    crate::remote::wire::probe_all();
377    crate::remote::nearby::refresh();
378}
379
380/// The target as `list` would give it.
381pub fn target_device() -> Option<Device> {
382    let id = target()?;
383    list().into_iter().find(|d| d.id == id)
384}
385
386/// Get `cmd` to the device `id`: over the local network if connected there,
387/// else up the link, else in one request to the server, which is what a Live
388/// Activity's button has while iOS keeps the app's link down.
389pub fn send(id: &str, cmd: LinkCommand) -> Result<(), String> {
390    let (nearby, account) = with(|s| {
391        (
392            s.nearby.iter().any(|n| n.hello.id == id),
393            s.account.iter().any(|(d, _)| d.id == id),
394        )
395    });
396    // The network path proves nothing about who is asking, so a device on it
397    // refuses what only the account may send: that goes through the server,
398    // and only to the account's own devices.
399    if !cmd.allowed_nearby() && !account {
400        return Err("Only your own devices can be asked that.".into());
401    }
402    if nearby && cmd.allowed_nearby() && crate::remote::nearby::send(id, cmd.clone()) {
403        return Ok(());
404    }
405    if link::report(LinkReport::Command {
406        to: id.to_string(),
407        command: cmd.clone(),
408    }) {
409        return Ok(());
410    }
411    if !account && !nearby {
412        // Not heard of here, but perhaps by the server: the target of a Live
413        // Activity outlives this process's list of devices.
414        log::info!("devices: {id} is not listed; asking the server");
415    }
416    let cfg = Config::load().unwrap_or_default();
417    let client = crate::helpers::subsonic_client(&cfg).ok_or("not signed in to a server")?;
418    let json = serde_json::to_string(&cmd).map_err(|e| e.to_string())?;
419    client.koan_command(id, &json).map_err(|e| e.to_string())
420}
421
422/// This device's id, as other devices know it.
423pub fn this_id() -> Option<String> {
424    local().map(|l| l.identity.device_id.clone())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn device(id: &str, playing: bool) -> LinkDevice {
432        LinkDevice {
433            id: id.into(),
434            name: id.into(),
435            platform: "macos".into(),
436            linked: true,
437            state: Some(LinkState {
438                playing,
439                position_ms: 1000,
440                duration_ms: 2000,
441                ..Default::default()
442            }),
443        }
444    }
445
446    // The store is process-wide; one test walks it through its states rather
447    // than several racing each other over it.
448    #[test]
449    fn a_device_found_both_ways_is_one_and_a_lost_target_is_kept() {
450        crate::config::isolate_config_for_tests();
451        set_account(vec![device("mac", false), device("phone", true)]);
452        let listed = list();
453        assert_eq!(listed[0].id, "phone", "playing first");
454        assert_eq!(listed.len(), 2);
455
456        nearby_hello(
457            LinkHello {
458                id: "mac".into(),
459                name: "Mac".into(),
460                platform: "macos".into(),
461                library: None,
462            },
463            "mac.local:5626",
464        );
465        nearby_hello(
466            LinkHello {
467                id: "tv".into(),
468                name: "Living room".into(),
469                platform: "macos".into(),
470                library: Some("elsewhere".into()),
471            },
472            "10.0.0.9:5626",
473        );
474        let listed = list();
475        assert_eq!(listed.len(), 3);
476        let mac = listed.iter().find(|d| d.id == "mac").unwrap();
477        assert!(mac.account && mac.nearby && mac.same_library);
478        let tv = listed.iter().find(|d| d.id == "tv").unwrap();
479        assert!(!tv.account && !tv.same_library);
480
481        set_target(Some("tv".into()));
482        nearby_gone("tv");
483        assert_eq!(target(), Some("tv".into()), "kept while out of reach");
484        let tv = list().into_iter().find(|d| d.id == "tv").unwrap();
485        assert_eq!(tv.name, "Living room");
486        assert!(!tv.awake && tv.problem.is_some());
487
488        // What a relaunch finds.
489        with(|s| *s = Store::default());
490        restore();
491        assert_eq!(target(), Some("tv".into()));
492        let listed = list();
493        assert!(
494            listed.iter().any(|d| d.id == "mac" && !d.awake),
495            "last heard of, not linked"
496        );
497        let remembered = remembered_nearby();
498        assert!(
499            remembered
500                .iter()
501                .any(|n| n.id == "tv" && n.addr == "10.0.0.9:5626")
502        );
503
504        set_target(None);
505        set_account(Vec::new());
506        nearby_gone("mac");
507        assert!(list().is_empty());
508    }
509}