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                    state: match near {
280                        Some(n) => n.state.clone(),
281                        None => d.state.clone(),
282                    },
283                    heard: near.map_or(*at, |n| n.at),
284                    problem: None,
285                }
286            })
287            .collect();
288        for n in &s.nearby {
289            if out.iter().any(|d| d.id == n.hello.id) {
290                continue;
291            }
292            out.push(Device {
293                id: n.hello.id.clone(),
294                name: n.hello.name.clone(),
295                platform: n.hello.platform.clone(),
296                account: false,
297                nearby: true,
298                awake: true,
299                same_library: ours.is_some() && n.hello.library == ours,
300                state: n.state.clone(),
301                heard: n.at,
302                problem: None,
303            });
304        }
305        // Announced on this network but not connected: listed with the reason.
306        for f in crate::remote::nearby::found() {
307            let id =
308                f.id.clone()
309                    .unwrap_or_else(|| format!("bonjour:{}", f.name));
310            if out.iter().any(|d| d.id == id) || f.id.is_some() && f.id == this_id() {
311                continue;
312            }
313            out.push(Device {
314                id,
315                name: f.name,
316                platform: f.platform.unwrap_or_default(),
317                account: false,
318                nearby: false,
319                awake: false,
320                same_library: false,
321                state: None,
322                heard: Instant::now(),
323                problem: Some(f.problem.unwrap_or_else(|| "Connecting…".into())),
324            });
325        }
326        // The device being controlled, out of every list for now.
327        if let Some(t) = &s.target
328            && !out.iter().any(|d| d.id == t.id)
329        {
330            out.push(Device {
331                id: t.id.clone(),
332                name: t.name.clone(),
333                platform: t.platform.clone(),
334                account: false,
335                nearby: false,
336                awake: false,
337                same_library: true,
338                state: None,
339                heard: Instant::now(),
340                problem: Some("Out of reach".into()),
341            });
342        }
343        out.sort_by_key(|d| {
344            (
345                !d.state.as_ref().is_some_and(|st| st.playing),
346                !d.awake,
347                d.name.to_lowercase(),
348            )
349        });
350        out
351    })
352}
353
354/// The device the app is controlling; `None` for this one.
355pub fn target() -> Option<String> {
356    with(|s| s.target.as_ref().map(|t| t.id.clone()))
357}
358
359pub fn set_target(id: Option<String>) {
360    let listed = id
361        .as_ref()
362        .and_then(|id| list().into_iter().find(|d| d.id == *id));
363    changed(|s| {
364        s.target = id.map(|id| match listed {
365            Some(d) => Remembered {
366                id,
367                name: d.name,
368                platform: d.platform,
369            },
370            None => Remembered {
371                name: id.clone(),
372                id,
373                platform: String::new(),
374            },
375        });
376        s.save();
377    });
378}
379
380/// The app is in front again after iOS may have suspended it: link now,
381/// prove every connection is still alive, and look at the network afresh.
382/// What makes the other devices appear the moment the app opens rather than
383/// when a dead socket finally times out.
384pub fn resume() {
385    link::nudge();
386    crate::remote::wire::probe_all();
387    crate::remote::nearby::refresh();
388}
389
390/// The target as `list` would give it.
391pub fn target_device() -> Option<Device> {
392    let id = target()?;
393    list().into_iter().find(|d| d.id == id)
394}
395
396/// Get `cmd` to the device `id`: over the local network if connected there,
397/// else up the link, else in one request to the server, which is what a Live
398/// Activity's button has while iOS keeps the app's link down.
399pub fn send(id: &str, cmd: LinkCommand) -> Result<(), String> {
400    let (nearby, account) = with(|s| {
401        (
402            s.nearby.iter().any(|n| n.hello.id == id),
403            s.account.iter().any(|(d, _)| d.id == id),
404        )
405    });
406    if nearby && crate::remote::nearby::send(id, cmd.clone()) {
407        return Ok(());
408    }
409    if link::report(LinkReport::Command {
410        to: id.to_string(),
411        command: cmd.clone(),
412    }) {
413        return Ok(());
414    }
415    if !account && !nearby {
416        // Not heard of here, but perhaps by the server: the target of a Live
417        // Activity outlives this process's list of devices.
418        log::info!("devices: {id} is not listed; asking the server");
419    }
420    let cfg = Config::load().unwrap_or_default();
421    let client = crate::helpers::subsonic_client(&cfg).ok_or("not signed in to a server")?;
422    let json = serde_json::to_string(&cmd).map_err(|e| e.to_string())?;
423    client.koan_command(id, &json).map_err(|e| e.to_string())
424}
425
426/// This device's id, as other devices know it.
427pub fn this_id() -> Option<String> {
428    local().map(|l| l.identity.device_id.clone())
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn device(id: &str, playing: bool) -> LinkDevice {
436        LinkDevice {
437            id: id.into(),
438            name: id.into(),
439            platform: "macos".into(),
440            linked: true,
441            state: Some(LinkState {
442                playing,
443                position_ms: 1000,
444                duration_ms: 2000,
445                ..Default::default()
446            }),
447        }
448    }
449
450    // The store is process-wide; one test walks it through its states rather
451    // than several racing each other over it.
452    #[test]
453    fn a_device_found_both_ways_is_one_and_a_lost_target_is_kept() {
454        crate::config::isolate_config_for_tests();
455        set_account(vec![device("mac", false), device("phone", true)]);
456        let listed = list();
457        assert_eq!(listed[0].id, "phone", "playing first");
458        assert_eq!(listed.len(), 2);
459
460        nearby_hello(
461            LinkHello {
462                id: "mac".into(),
463                name: "Mac".into(),
464                platform: "macos".into(),
465                library: None,
466            },
467            "mac.local:5626",
468        );
469        nearby_hello(
470            LinkHello {
471                id: "tv".into(),
472                name: "Living room".into(),
473                platform: "macos".into(),
474                library: Some("elsewhere".into()),
475            },
476            "10.0.0.9:5626",
477        );
478        let listed = list();
479        assert_eq!(listed.len(), 3);
480        let mac = listed.iter().find(|d| d.id == "mac").unwrap();
481        assert!(mac.account && mac.nearby && mac.same_library);
482        let tv = listed.iter().find(|d| d.id == "tv").unwrap();
483        assert!(!tv.account && !tv.same_library);
484
485        set_target(Some("tv".into()));
486        nearby_gone("tv");
487        assert_eq!(target(), Some("tv".into()), "kept while out of reach");
488        let tv = list().into_iter().find(|d| d.id == "tv").unwrap();
489        assert_eq!(tv.name, "Living room");
490        assert!(!tv.awake && tv.problem.is_some());
491
492        // What a relaunch finds.
493        with(|s| *s = Store::default());
494        restore();
495        assert_eq!(target(), Some("tv".into()));
496        let listed = list();
497        assert!(
498            listed.iter().any(|d| d.id == "mac" && !d.awake),
499            "last heard of, not linked"
500        );
501        let remembered = remembered_nearby();
502        assert!(
503            remembered
504                .iter()
505                .any(|n| n.id == "tv" && n.addr == "10.0.0.9:5626")
506        );
507
508        set_target(None);
509        set_account(Vec::new());
510        nearby_gone("mac");
511        assert!(list().is_empty());
512    }
513}