1use std::collections::{HashMap, HashSet};
23use std::sync::OnceLock;
24use std::time::{Duration, Instant};
25
26use parking_lot::{Condvar, Mutex};
27
28use crate::config::Config;
29use crate::remote::acks;
30use crate::remote::link::{
31 self, CommandSource, LinkCommand, LinkDevice, LinkHello, LinkReport, LinkState, Local,
32};
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct Device {
37 pub id: String,
38 pub name: String,
39 pub platform: String,
40 pub account: bool,
42 pub owner: Option<String>,
45 pub nearby: bool,
47 pub awake: bool,
50 pub asleep: bool,
53 pub wakeable: bool,
56 pub last_seen: Option<i64>,
58 pub waking: Option<Waking>,
60 pub same_library: bool,
62 pub state: Option<LinkState>,
64 pub heard: Instant,
66 pub problem: Option<String>,
69}
70
71impl Device {
72 pub fn position_ms(&self) -> u64 {
74 let Some(state) = &self.state else { return 0 };
75 if !state.playing {
76 return state.position_ms;
77 }
78 let pos = state.position_ms + self.heard.elapsed().as_millis() as u64;
79 if state.duration_ms > 0 {
80 pos.min(state.duration_ms)
81 } else {
82 pos
83 }
84 }
85
86 pub fn reach(&self) -> Reach {
88 if let Some(problem) = &self.problem {
89 return Reach::OutOfReach((problem != OUT_OF_REACH).then(|| problem.clone()));
92 }
93 if self.awake {
94 return Reach::Connected;
95 }
96 match &self.waking {
97 Some(Waking::Failed(_)) | None => {}
98 Some(_) => return Reach::Waking,
99 }
100 if !self.asleep {
103 return Reach::Reconnecting;
104 }
105 if self.wakeable {
106 return Reach::Asleep;
107 }
108 Reach::OutOfReach(match &self.waking {
109 Some(Waking::Failed(why)) => Some(why.clone()),
110 _ => None,
111 })
112 }
113}
114
115const OUT_OF_REACH: &str = "Out of reach";
117
118#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Reach {
123 Connected,
125 Reconnecting,
128 Waking,
130 Asleep,
132 OutOfReach(Option<String>),
135}
136
137#[derive(Default)]
138struct Store {
139 linked: bool,
140 linked_at: Option<i64>,
142 account: Vec<(LinkDevice, Instant)>,
143 nearby: Vec<Nearby>,
144 seen: Vec<SeenNearby>,
145 target: Option<Remembered>,
146 version: u64,
147 live: HashMap<String, i64>,
149 departed: Vec<Departed>,
151 used: HashSet<String>,
153 fresh: bool,
156 waking: HashMap<String, Waking>,
158 lan_heard: HashMap<String, i64>,
161 attempt: Option<(u64, String)>,
163 wake_gen: u64,
165 shares: Vec<String>,
167 share_error: Option<String>,
169 accounts: Vec<String>,
171 notice: Option<Notice>,
173 tv_looked: HashMap<(String, String), Instant>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Notice {
183 pub seq: u64,
185 pub device: String,
187 pub outcome: acks::AckOutcome,
188 pub unreached: bool,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum Missed {
195 OutOfReach(String),
198 Said(String),
200}
201
202impl Missed {
203 pub fn into_message(self) -> String {
204 match self {
205 Self::OutOfReach(m) | Self::Said(m) => m,
206 }
207 }
208}
209
210impl From<String> for Missed {
211 fn from(message: String) -> Self {
212 Self::Said(message)
213 }
214}
215
216pub fn notice() -> Option<Notice> {
218 with(|s| s.notice.clone())
219}
220
221pub type Then = Box<dyn FnOnce(Option<acks::AckOutcome>) + Send>;
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum Waking {
228 Tv,
232 Network,
235 Push,
238 Notification,
240 Failed(String),
242}
243
244#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
246struct Departed {
247 id: String,
248 name: String,
249 platform: String,
250 account: bool,
251 same_library: bool,
252 shared: bool,
254}
255
256pub const STALE_SECS: i64 = super::wire::IDLE.as_secs() as i64;
260
261#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
263struct Saved {
264 target: Option<Remembered>,
265 account: Vec<LinkDevice>,
266 #[serde(default)]
267 nearby: Vec<SeenNearby>,
268 #[serde(default)]
271 live: HashMap<String, i64>,
272 #[serde(default)]
274 departed: Vec<Departed>,
275 #[serde(default)]
278 used: Option<Vec<String>>,
279}
280
281#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
284pub struct SeenNearby {
285 pub id: String,
286 pub name: String,
287 pub platform: String,
288 pub addr: String,
289 pub at: i64,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub tv: Option<crate::remote::airplay::Tv>,
294}
295
296const NEARBY_KEPT: i64 = 7 * 24 * 60 * 60;
298
299#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
302struct Remembered {
303 id: String,
304 name: String,
305 platform: String,
306}
307
308struct Nearby {
309 hello: LinkHello,
310 state: Option<LinkState>,
311 at: Instant,
312}
313
314static STORE: Mutex<Option<Store>> = Mutex::new(None);
315static LOCAL: OnceLock<Local> = OnceLock::new();
316
317fn with<R>(f: impl FnOnce(&mut Store) -> R) -> R {
318 let mut store = STORE.lock();
319 f(store.get_or_insert_with(Store::default))
320}
321
322fn changed<R>(f: impl FnOnce(&mut Store) -> R) -> R {
324 let out = with(|s| {
325 let out = f(s);
326 s.version += 1;
327 out
328 });
329 crate::signal::engine_changed().bump();
330 replan();
331 out
332}
333
334static CLOCK: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
337
338fn replan() {
342 static STARTED: OnceLock<()> = OnceLock::new();
343 *CLOCK.0.lock() = true;
344 CLOCK.1.notify_all();
345 STARTED.get_or_init(|| {
346 let _ = std::thread::Builder::new()
347 .name("koan-devices-clock".into())
348 .spawn(clock);
349 });
350}
351
352fn clock() {
353 loop {
354 let now = chrono::Utc::now().timestamp();
355 let wait = next_change(now).map(|at| Duration::from_secs((at - now).max(0) as u64 + 1));
356 let mut rung = CLOCK.0.lock();
357 if !*rung {
358 let timed_out = match wait {
359 Some(wait) => CLOCK.1.wait_for(&mut rung, wait).timed_out(),
360 None => {
361 CLOCK.1.wait(&mut rung);
362 false
363 }
364 };
365 if timed_out {
366 drop(rung);
367 with(|s| s.version += 1);
369 crate::signal::engine_changed().bump();
370 continue;
371 }
372 }
373 *rung = false;
374 }
375}
376
377impl Store {
378 fn save(&self) {
379 let saved = Saved {
380 target: self.target.clone(),
381 account: self.account.iter().map(|(d, _)| d.clone()).collect(),
382 nearby: self.seen.clone(),
383 live: self.live.clone(),
384 departed: self.departed.clone(),
385 used: Some({
386 let mut used: Vec<String> = self.used.iter().cloned().collect();
387 used.sort();
388 used
389 }),
390 };
391 if let Ok(json) = serde_json::to_string(&saved) {
392 let _ = std::fs::write(saved_path(), json);
393 }
394 }
395}
396
397fn saved_path() -> std::path::PathBuf {
398 let files = LOCAL.get().map_or("", |l| l.identity.files);
399 crate::config::config_dir().join(format!("{files}devices.json"))
400}
401
402fn restore() {
404 let Ok(text) = std::fs::read_to_string(saved_path()) else {
405 return;
406 };
407 let Ok(saved) = serde_json::from_str::<Saved>(&text) else {
408 return;
409 };
410 let now = Instant::now();
411 let cutoff = chrono::Utc::now().timestamp() - NEARBY_KEPT;
412 changed(|s| {
413 s.target = saved.target;
414 s.seen = saved
415 .nearby
416 .into_iter()
417 .filter(|n| n.at >= cutoff)
418 .collect();
419 s.live = saved.live;
420 s.used = match saved.used {
421 Some(used) => used.into_iter().collect(),
422 None => s.target.iter().map(|t| t.id.clone()).collect(),
423 };
424 let used = &s.used;
425 s.departed = saved
426 .departed
427 .into_iter()
428 .filter(|g| used.contains(&g.id))
429 .collect();
430 s.account = saved
433 .account
434 .into_iter()
435 .map(|d| {
436 (
437 LinkDevice {
438 linked: false,
439 state: None,
440 ..d
441 },
442 now,
443 )
444 })
445 .collect();
446 });
447}
448
449pub fn start(local: Local) {
452 if LOCAL.set(local.clone()).is_err() {
453 return;
454 }
455 restore();
456 link::spawn(local.clone());
457 crate::remote::nearby::start(local);
458}
459
460pub fn local() -> Option<&'static Local> {
462 LOCAL.get()
463}
464
465pub fn set_linked(linked: bool) {
474 crate::remote::offline::link_changed(linked);
475 changed(|s| {
476 if linked != s.linked {
477 s.linked_at = linked.then(|| chrono::Utc::now().timestamp());
478 }
479 s.linked = linked;
480 if !linked {
481 s.fresh = false;
482 }
483 });
484}
485
486pub fn linked_since() -> Option<i64> {
488 with(|s| s.linked.then_some(s.linked_at).flatten())
489}
490
491pub fn linked_controllers() -> Vec<LinkDevice> {
496 let me = this_id();
497 with(|s| {
498 s.account
499 .iter()
500 .map(|(d, _)| d)
501 .filter(|d| d.linked && d.owner.is_none() && Some(&d.id) != me.as_ref())
502 .cloned()
503 .collect()
504 })
505}
506
507pub fn name_of(id: &str) -> Option<String> {
509 with(|s| {
510 s.account
511 .iter()
512 .map(|(d, _)| (&d.id, &d.name))
513 .chain(s.seen.iter().map(|n| (&n.id, &n.name)))
514 .find(|(i, _)| *i == id)
515 .map(|(_, name)| name.clone())
516 })
517}
518
519pub fn set_account(devices: Vec<LinkDevice>) {
520 let now = Instant::now();
521 let unix = chrono::Utc::now().timestamp();
522 changed(|s| {
523 if s.fresh {
527 for (old, _) in &s.account {
528 if old.linked {
529 s.live.insert(old.id.clone(), unix);
530 }
531 }
532 }
533 for d in &devices {
534 if d.linked {
535 s.live.insert(d.id.clone(), unix);
536 s.waking.remove(&d.id);
537 } else if let Some(seen) = d.last_seen {
538 let live = s.live.entry(d.id.clone()).or_insert(seen);
541 *live = (*live).max(seen);
542 }
543 s.departed.retain(|g| g.id != d.id);
544 }
545 let leaving: Vec<Departed> = s
546 .account
547 .iter()
548 .filter(|(old, _)| !devices.iter().any(|d| d.id == old.id))
549 .map(|(old, _)| Departed {
550 id: old.id.clone(),
551 name: old.name.clone(),
552 platform: old.platform.clone(),
553 account: old.owner.is_none(),
554 shared: old.owner.is_some(),
555 same_library: true,
556 })
557 .collect();
558 for d in leaving {
559 if !s.live.contains_key(&d.id) || d.shared {
563 continue;
564 }
565 s.departed.retain(|g| g.id != d.id);
566 s.departed.push(d);
567 }
568 s.account = devices.into_iter().map(|d| (d, now)).collect();
569 s.fresh = s.linked;
570 s.save();
571 });
572}
573
574pub fn set_shares(grantees: Vec<String>, error: Option<String>, accounts: Vec<String>) {
577 changed(|s| {
578 s.shares = grantees;
579 s.share_error = error;
580 s.accounts = accounts;
581 });
582}
583
584pub fn accounts() -> Vec<String> {
586 with(|s| s.accounts.clone())
587}
588
589pub fn shares() -> Vec<String> {
590 with(|s| s.shares.clone())
591}
592
593pub fn share_error() -> Option<String> {
594 with(|s| s.share_error.clone())
595}
596
597pub fn forget_shares() {
599 set_shares(Vec::new(), None, Vec::new());
600}
601
602pub fn send_for(source: CommandSource, id: &str, cmd: LinkCommand) -> Result<(), String> {
611 send_for_then(source, id, cmd, None)
612}
613
614pub fn send_for_then(
616 source: CommandSource,
617 id: &str,
618 cmd: LinkCommand,
619 then: Option<Then>,
620) -> Result<(), String> {
621 match source {
622 CommandSource::Account => send_then(id, cmd, then),
623 CommandSource::Stranger => {
624 send_nearby(id, cmd)?;
625 if let Some(then) = then {
626 then(None);
627 }
628 Ok(())
629 }
630 CommandSource::Shared | CommandSource::Nearby => {
631 let (nearby, own) = with(|s| {
632 (
633 s.nearby.iter().any(|n| n.hello.id == id),
634 s.account
635 .iter()
636 .any(|(d, _)| d.id == id && d.owner.is_none()),
637 )
638 });
639 if own && !nearby {
640 return Err(format!("{id} is this account's, not the asker's"));
641 }
642 send_then(id, cmd, then)
643 }
644 }
645}
646
647pub fn send_nearby(id: &str, cmd: LinkCommand) -> Result<(), String> {
652 let nearby = with(|s| s.nearby.iter().any(|n| n.hello.id == id));
653 if nearby && cmd.allowed_nearby() && crate::remote::nearby::send(id, cmd) {
654 Ok(())
655 } else {
656 Err(format!("{id} is not on this network"))
657 }
658}
659
660pub fn share(grantee: &str, allow: bool) -> Result<(), String> {
663 let grantee = grantee.trim();
664 if grantee.is_empty() {
665 return Err("Name an account on your server.".into());
666 }
667 if link::report(LinkReport::Share {
668 grantee: grantee.to_string(),
669 allow,
670 }) {
671 Ok(())
672 } else {
673 Err("Not connected to your server.".into())
674 }
675}
676
677pub fn remembered_nearby() -> Vec<SeenNearby> {
679 with(|s| s.seen.clone())
680}
681
682pub fn nearby_hello(hello: LinkHello, addr: &str) {
683 let host = crate::remote::airplay::host_of(addr).to_string();
684 let look = changed(|s| {
685 let kept = s
686 .seen
687 .iter()
688 .find(|n| n.id == hello.id)
689 .and_then(|n| n.tv.clone());
690 let look = hello.platform == "tvos"
691 && !crate::remote::airplay::current(kept.as_ref(), &host)
692 && look_for_tv(&mut s.tv_looked, &hello.id, &host, Instant::now());
693 let tv = if look { None } else { kept };
696 s.seen.retain(|n| n.id != hello.id);
697 s.seen.push(SeenNearby {
698 id: hello.id.clone(),
699 name: hello.name.clone(),
700 platform: hello.platform.clone(),
701 addr: addr.to_string(),
702 at: chrono::Utc::now().timestamp(),
703 tv,
704 });
705 s.save();
706 s.live
707 .insert(hello.id.clone(), chrono::Utc::now().timestamp());
708 s.departed.retain(|g| g.id != hello.id);
709 s.waking.remove(&hello.id);
710 s.lan_heard
711 .insert(hello.id.clone(), chrono::Utc::now().timestamp());
712 s.nearby.retain(|n| n.hello.id != hello.id);
713 let found = look.then(|| (hello.id.clone(), hello.name.clone()));
714 s.nearby.push(Nearby {
715 hello,
716 state: None,
717 at: Instant::now(),
718 });
719 found
720 });
721 if let Some((id, name)) = look {
722 record_tv(id, name, addr.to_string());
723 }
724}
725
726const TV_LOOK_AGAIN: Duration = Duration::from_secs(10 * 60);
729
730fn look_for_tv(
733 looked: &mut HashMap<(String, String), Instant>,
734 id: &str,
735 host: &str,
736 now: Instant,
737) -> bool {
738 looked.retain(|_, at| now.saturating_duration_since(*at) < TV_LOOK_AGAIN);
739 let key = (id.to_string(), host.to_string());
740 if looked.contains_key(&key) {
741 return false;
742 }
743 looked.insert(key, now);
744 true
745}
746
747fn record_tv(id: String, name: String, addr: String) {
750 let _ = std::thread::Builder::new()
751 .name("koan-airplay".into())
752 .spawn(move || {
753 let Some(tv) = crate::remote::airplay::record(&addr, &name) else {
754 log::info!("airplay: {name}: no AirPlay announcement from {addr}");
755 return;
756 };
757 log::info!(
758 "airplay: {name} is {} on {} ({})",
759 tv.name,
760 tv.host,
761 tv.mac.as_deref().unwrap_or("no MAC")
762 );
763 with(|s| {
764 if let Some(n) = s.seen.iter_mut().find(|n| n.id == id) {
765 n.tv = Some(tv);
766 s.save();
767 }
768 });
769 });
770}
771
772fn tv_of(id: &str) -> Option<crate::remote::airplay::Tv> {
774 with(|s| {
775 s.seen
776 .iter()
777 .find(|n| n.id == id)
778 .and_then(|n| n.tv.clone())
779 })
780}
781
782pub fn nearby_state(id: &str, state: LinkState) {
783 changed(|s| {
784 if let Some(n) = s.nearby.iter_mut().find(|n| n.hello.id == id) {
785 n.state = Some(state);
786 n.at = Instant::now();
787 let now = chrono::Utc::now().timestamp();
788 s.live.insert(id.to_string(), now);
789 s.lan_heard.insert(id.to_string(), now);
790 }
791 });
792}
793
794pub fn nearby_gone(id: &str) {
795 let cfg = Config::load().unwrap_or_default();
796 let ours = link::library_fingerprint(&cfg);
797 changed(|s| {
798 let Some(at) = s.nearby.iter().position(|n| n.hello.id == id) else {
799 return;
800 };
801 let gone = s.nearby.remove(at);
802 let now = chrono::Utc::now().timestamp();
803 s.live.insert(id.to_string(), now);
804 s.lan_heard.insert(id.to_string(), now);
805 if s.account.iter().any(|(d, _)| d.id == id) {
808 return;
809 }
810 s.departed.retain(|g| g.id != id);
811 s.departed.push(Departed {
812 id: gone.hello.id.clone(),
813 name: gone.hello.name.clone(),
814 platform: gone.hello.platform.clone(),
815 account: false,
816 same_library: ours.is_some() && gone.hello.library == ours,
817 shared: false,
818 });
819 s.save();
820 });
821}
822
823pub fn forget(id: &str) -> Result<(), String> {
830 let device = list().into_iter().find(|d| d.id == id);
831 if device.as_ref().is_some_and(|d| d.awake) {
832 return Err("Only a device out of reach can be forgotten.".into());
833 }
834 let account = with(|s| s.account.iter().any(|(d, _)| d.id == id));
835 if account
836 && !link::report(LinkReport::Forget {
837 device: id.to_string(),
838 })
839 {
840 return Err("Not connected to your server, which remembers it too.".into());
841 }
842 forgotten(id);
843 Ok(())
844}
845
846fn used(id: &str) {
849 with(|s| {
850 if s.used.insert(id.to_string()) {
851 s.save();
852 }
853 });
854}
855
856pub fn forgotten(id: &str) {
859 changed(|s| {
860 s.used.remove(id);
861 s.account.retain(|(d, _)| d.id != id);
862 s.departed.retain(|g| g.id != id);
863 s.seen.retain(|n| n.id != id);
864 s.live.remove(id);
865 s.lan_heard.remove(id);
866 s.waking.remove(id);
867 if s.target.as_ref().is_some_and(|t| t.id == id) {
868 s.target = None;
869 }
870 s.save();
871 });
872 log::info!("devices: forgot {id}");
873}
874
875pub fn touch() {
878 changed(|_| ());
879}
880
881pub fn version() -> u64 {
883 with(|s| s.version)
884}
885
886pub fn linked() -> bool {
888 with(|s| s.linked)
889}
890
891pub fn list() -> Vec<Device> {
893 list_at(chrono::Utc::now().timestamp())
894}
895
896fn list_at(now: i64) -> Vec<Device> {
898 let cfg = Config::load().unwrap_or_default();
899 let ours = link::library_fingerprint(&cfg);
900 with(|s| {
901 let live = |id: &str| s.live.get(id).copied();
902 let fresh = |id: &str| live(id).is_some_and(|at| now - at <= STALE_SECS);
904 let kept = |id: &str| fresh(id) || s.used.contains(id);
906 let mut out: Vec<Device> = s
907 .account
908 .iter()
909 .filter_map(|(d, at)| {
910 let near = s.nearby.iter().find(|n| n.hello.id == d.id);
911 let awake = d.linked || near.is_some();
912 if !awake && !kept(&d.id) {
913 return None;
914 }
915 Some(Device {
916 id: d.id.clone(),
917 name: d.name.clone(),
918 platform: d.platform.clone(),
919 account: d.owner.is_none(),
920 owner: d.owner.clone(),
921 nearby: near.is_some(),
922 awake,
923 asleep: !awake && !fresh(&d.id),
924 wakeable: d.wakeable.unwrap_or(true),
927 last_seen: live(&d.id),
928 waking: None,
929 same_library: true,
930 state: match (near.and_then(|n| n.state.clone()), &d.state) {
933 (Some(mut fresh), Some(linked)) => {
934 fresh.outputs = linked.outputs.clone();
935 Some(fresh)
936 }
937 (Some(fresh), None) => Some(fresh),
938 (None, linked) => linked.clone(),
939 },
940 heard: near.map_or(*at, |n| n.at),
941 problem: None,
942 })
943 })
944 .collect();
945 for n in &s.nearby {
946 if out.iter().any(|d| d.id == n.hello.id) {
947 continue;
948 }
949 out.push(Device {
950 id: n.hello.id.clone(),
951 name: n.hello.name.clone(),
952 platform: n.hello.platform.clone(),
953 account: false,
954 owner: None,
955 nearby: true,
956 awake: true,
957 asleep: false,
958 wakeable: false,
959 last_seen: live(&n.hello.id),
960 waking: None,
961 same_library: ours.is_some() && n.hello.library == ours,
962 state: n.state.clone(),
963 heard: n.at,
964 problem: None,
965 });
966 }
967 for g in &s.departed {
968 if out.iter().any(|d| d.id == g.id) || !kept(&g.id) {
969 continue;
970 }
971 let last = live(&g.id);
972 out.push(Device {
975 id: g.id.clone(),
976 name: g.name.clone(),
977 platform: g.platform.clone(),
978 account: g.account,
979 owner: None,
980 nearby: false,
981 awake: false,
982 asleep: !fresh(&g.id),
983 wakeable: !g.account && s.linked,
987 last_seen: last,
988 waking: None,
989 same_library: g.same_library,
990 state: None,
992 heard: Instant::now(),
993 problem: None,
994 });
995 }
996 if let Some(t) = &s.target
1001 && !out.iter().any(|d| d.id == t.id)
1002 {
1003 out.push(Device {
1004 id: t.id.clone(),
1005 name: t.name.clone(),
1006 platform: t.platform.clone(),
1007 account: false,
1008 owner: None,
1009 nearby: false,
1010 awake: false,
1011 asleep: true,
1012 wakeable: false,
1013 last_seen: live(&t.id),
1014 waking: None,
1015 same_library: true,
1016 state: None,
1017 heard: Instant::now(),
1018 problem: Some(OUT_OF_REACH.into()),
1019 });
1020 }
1021 for d in &mut out {
1022 d.waking = s.waking.get(&d.id).cloned();
1023 }
1024 out.sort_by_key(|d| {
1025 (
1026 !d.state.as_ref().is_some_and(|st| st.playing),
1027 !d.awake,
1028 d.name.to_lowercase(),
1029 )
1030 });
1031 out
1032 })
1033}
1034
1035fn next_change(now: i64) -> Option<i64> {
1037 with(|s| {
1038 let reachable = |id: &str| {
1039 s.nearby.iter().any(|n| n.hello.id == id)
1040 || s.account.iter().any(|(d, _)| d.id == id && d.linked)
1041 };
1042 s.live
1043 .iter()
1044 .filter(|(id, _)| !reachable(id))
1045 .map(|(_, at)| at + STALE_SECS)
1046 .filter(|t| *t > now)
1047 .min()
1048 })
1049}
1050
1051pub fn choosable(id: &str) -> Result<(), String> {
1054 match list().into_iter().find(|d| d.id == id) {
1055 Some(d) if !d.awake && !d.wakeable && d.problem.is_none() => Err(if d.asleep {
1056 format!("{} is asleep and cannot be woken from here.", d.name)
1057 } else {
1058 format!("{} is reconnecting.", d.name)
1059 }),
1060 _ => Ok(()),
1061 }
1062}
1063
1064const NETWORK_RECENT: i64 = 60;
1066const NETWORK_WAIT: Duration = Duration::from_secs(3);
1068const PUSH_WAIT: Duration = Duration::from_secs(6);
1069const NOTIFICATION_WAIT: Duration = Duration::from_secs(45);
1070const TV_KNOCK: Duration = Duration::from_secs(20);
1072const TV_WAIT: Duration = Duration::from_secs(2);
1075
1076fn wake_plan(tv: bool, seen_nearby_recently: bool, wakeable: bool) -> Vec<(Waking, Duration)> {
1081 let mut plan = Vec::new();
1082 if tv {
1083 plan.push((Waking::Tv, TV_WAIT));
1084 }
1085 if seen_nearby_recently {
1086 plan.push((Waking::Network, NETWORK_WAIT));
1087 }
1088 if wakeable {
1089 plan.push((Waking::Push, PUSH_WAIT));
1090 plan.push((Waking::Notification, NOTIFICATION_WAIT));
1091 }
1092 plan
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq)]
1097enum Ended {
1098 Reached(Waking),
1099 Failed(String),
1100 Cancelled,
1102}
1103
1104fn run_wake(
1108 plan: &[(Waking, Duration)],
1109 current: impl Fn() -> bool,
1110 mut act: impl FnMut(&Waking) -> Result<(), String>,
1111 mut wait: impl FnMut(Duration) -> bool,
1112) -> Ended {
1113 let mut last = None;
1114 for (stage, time) in plan {
1115 if !current() {
1116 return Ended::Cancelled;
1117 }
1118 if let Err(why) = act(stage) {
1119 return Ended::Failed(why);
1120 }
1121 last = Some(stage);
1122 if wait(*time) {
1123 return Ended::Reached(stage.clone());
1124 }
1125 }
1126 if !current() {
1127 return Ended::Cancelled;
1128 }
1129 Ended::Failed(match last {
1130 Some(Waking::Notification) => {
1131 "Did not wake. A notification is waiting on it to be tapped.".into()
1132 }
1133 _ => "Did not wake.".into(),
1134 })
1135}
1136
1137fn reachable(id: &str) -> bool {
1138 with(|s| {
1139 s.nearby.iter().any(|n| n.hello.id == id)
1140 || s.account.iter().any(|(d, _)| d.id == id && d.linked)
1141 })
1142}
1143
1144fn recently_on_network(id: &str, now: i64) -> bool {
1146 with(|s| {
1147 s.lan_heard
1148 .get(id)
1149 .is_some_and(|at| now - at <= NETWORK_RECENT)
1150 })
1151}
1152
1153fn begin_wake(id: &str) -> Option<u64> {
1156 with(|s| {
1157 if s.attempt.as_ref().is_some_and(|(_, d)| d == id) {
1158 return None;
1159 }
1160 s.wake_gen += 1;
1161 s.attempt = Some((s.wake_gen, id.to_string()));
1162 Some(s.wake_gen)
1163 })
1164}
1165
1166fn wake_is_current(generation: u64) -> bool {
1167 with(|s| s.attempt.as_ref().is_some_and(|(g, _)| *g == generation))
1168}
1169
1170fn cancel_wake(keep: Option<&str>) {
1172 let cancelled = with(|s| {
1173 let other = s
1174 .attempt
1175 .as_ref()
1176 .is_some_and(|(_, d)| Some(d.as_str()) != keep);
1177 if other {
1178 let (_, id) = s.attempt.take().expect("checked");
1179 s.waking.remove(&id);
1180 s.wake_gen += 1;
1181 }
1182 other
1183 });
1184 if cancelled {
1185 crate::signal::engine_changed().bump();
1187 }
1188}
1189
1190pub fn wake(id: &str) {
1202 let id = id.to_string();
1203 if reachable(&id) {
1204 return;
1205 }
1206 let Some(device) = list().into_iter().find(|d| d.id == id) else {
1207 return;
1208 };
1209 let now = chrono::Utc::now().timestamp();
1210 let tv = (device.platform == "tvos").then(|| tv_of(&id)).flatten();
1212 let plan = wake_plan(tv.is_some(), recently_on_network(&id, now), device.wakeable);
1213 if plan.is_empty() {
1214 return;
1215 }
1216 let Some(generation) = begin_wake(&id) else {
1217 log::info!("wake: {}: already being woken", device.name);
1218 return;
1219 };
1220 let name = device.name;
1221 let _ = std::thread::Builder::new()
1222 .name("koan-wake".into())
1223 .spawn(move || {
1224 let started = Instant::now();
1225 let ms = || started.elapsed().as_millis();
1226 let current = || wake_is_current(generation);
1227 let mut box_silent = false;
1228 let ended = run_wake(
1229 &plan,
1230 current,
1231 |stage| {
1232 log::info!("wake: {name}: {stage:?} at +{}ms", ms());
1233 changed(|s| s.waking.insert(id.clone(), stage.clone()));
1234 match stage {
1235 Waking::Tv => {
1236 if let Some(tv) = &tv {
1237 let awake = crate::remote::airplay::wake(tv, TV_KNOCK, || {
1238 !current() || reachable(&id)
1239 });
1240 box_silent = !awake && current() && !reachable(&id);
1241 crate::remote::nearby::dial_now();
1244 }
1245 Ok(())
1246 }
1247 Waking::Network => {
1248 crate::remote::nearby::dial_now();
1249 Ok(())
1250 }
1251 Waking::Push | Waking::Notification => {
1252 let notify = *stage == Waking::Notification;
1253 if link::report(LinkReport::Wake {
1254 to: id.clone(),
1255 notify,
1256 }) {
1257 Ok(())
1258 } else {
1259 Err("Not connected to your server, which wakes it.".into())
1260 }
1261 }
1262 Waking::Failed(_) => Ok(()),
1263 }
1264 },
1265 |time| {
1266 let until = Instant::now() + time;
1267 let signal = crate::signal::engine_changed();
1268 let mut seen = signal.generation();
1269 while !reachable(&id) {
1270 let left = until.saturating_duration_since(Instant::now());
1271 if left.is_zero() || !current() {
1272 return false;
1273 }
1274 seen = signal.wait_until(seen, left);
1275 }
1276 true
1277 },
1278 );
1279 let ended = match ended {
1280 Ended::Failed(_) if box_silent => Ended::Failed(format!(
1281 "Couldn't wake {name}: the Apple TV did not answer on this network."
1282 )),
1283 ended => ended,
1284 };
1285 match &ended {
1286 Ended::Reached(stage) => {
1287 log::info!("wake: {name}: reached at +{}ms, after {stage:?}", ms());
1288 }
1289 Ended::Failed(why) => {
1290 log::warn!("wake: {name}: did not wake in {}ms: {why}", ms());
1291 }
1292 Ended::Cancelled => {
1293 log::info!("wake: {name}: abandoned at +{}ms for another device", ms());
1294 }
1295 }
1296 changed(|s| {
1297 if !s.attempt.as_ref().is_some_and(|(g, _)| *g == generation) {
1298 return;
1299 }
1300 s.attempt = None;
1301 match ended {
1302 Ended::Failed(why) => {
1303 s.waking.insert(id.clone(), Waking::Failed(why));
1304 }
1305 _ => {
1306 s.waking.remove(&id);
1307 }
1308 }
1309 });
1310 });
1311}
1312
1313pub fn target() -> Option<String> {
1315 with(|s| s.target.as_ref().map(|t| t.id.clone()))
1316}
1317
1318pub fn set_target(id: Option<String>) {
1319 cancel_wake(id.as_deref());
1320 if let Some(id) = &id {
1321 crate::remote::nearby::release(id);
1322 }
1323 let listed = id
1324 .as_ref()
1325 .and_then(|id| list().into_iter().find(|d| d.id == *id));
1326 changed(|s| {
1327 if let Some(id) = &id {
1328 s.used.insert(id.clone());
1329 }
1330 s.target = id.map(|id| match listed {
1331 Some(d) => Remembered {
1332 id,
1333 name: d.name,
1334 platform: d.platform,
1335 },
1336 None => Remembered {
1337 name: id.clone(),
1338 id,
1339 platform: String::new(),
1340 },
1341 });
1342 s.save();
1343 });
1344 crate::quiet::reapply();
1346}
1347
1348pub fn resume() {
1353 link::nudge();
1354 crate::remote::wire::probe_all();
1355 crate::remote::nearby::refresh();
1356}
1357
1358pub fn target_playhead() -> Option<(u64, bool)> {
1362 with(|s| {
1363 let (state, at) = reported(s, &s.target.as_ref()?.id)?;
1364 let mut position = state.position_ms;
1365 if state.playing {
1366 position += at.elapsed().as_millis() as u64;
1367 if state.duration_ms > 0 {
1368 position = position.min(state.duration_ms);
1369 }
1370 }
1371 Some((position, state.playing))
1372 })
1373}
1374
1375fn reported<'s>(s: &'s Store, id: &str) -> Option<(&'s LinkState, Instant)> {
1378 s.nearby
1379 .iter()
1380 .find(|n| n.hello.id == id)
1381 .and_then(|n| Some((n.state.as_ref()?, n.at)))
1382 .or_else(|| {
1383 s.account
1384 .iter()
1385 .find(|(d, _)| d.id == id)
1386 .and_then(|(d, at)| Some((d.state.as_ref()?, *at)))
1387 })
1388}
1389
1390pub fn current_track(id: &str) -> Option<String> {
1392 with(|s| {
1393 let (state, _) = reported(s, id)?;
1394 state.queue.iter().find(|e| e.current)?.track_id.clone()
1395 })
1396}
1397
1398pub fn last_report(id: &str) -> Option<LinkState> {
1400 with(|s| reported(s, id).map(|(state, _)| state.clone()))
1401}
1402
1403pub fn await_current(
1409 id: &str,
1410 track_id: &str,
1411 before: Option<&LinkState>,
1412 within: Duration,
1413) -> bool {
1414 let took = || with(|s| reported(s, id).is_some_and(|(now, _)| took(before, now, track_id)));
1415 await_until(took, within)
1416}
1417
1418fn took(before: Option<&LinkState>, now: &LinkState, track_id: &str) -> bool {
1424 let current = |state: &LinkState| state.queue.iter().find(|e| e.current).cloned();
1425 let Some(entry) = current(now) else {
1426 return false;
1427 };
1428 if entry.track_id.as_deref() != Some(track_id) {
1429 return false;
1430 }
1431 let was = before.and_then(current);
1432 match (&entry.id, was.as_ref().and_then(|e| e.id.as_ref())) {
1433 (Some(id), Some(was)) => id != was,
1434 (Some(_), None) => true,
1435 (None, _) => before != Some(now),
1436 }
1437}
1438
1439pub fn await_until(done: impl Fn() -> bool, within: Duration) -> bool {
1442 let until = Instant::now() + within;
1443 let signal = crate::signal::engine_changed();
1444 let mut seen = signal.generation();
1445 loop {
1446 if done() {
1447 return true;
1448 }
1449 let left = until.saturating_duration_since(Instant::now());
1450 if left.is_zero() {
1451 return false;
1452 }
1453 seen = signal.wait_until(seen, left);
1454 }
1455}
1456
1457pub fn target_device() -> Option<Device> {
1459 let id = target()?;
1460 list().into_iter().find(|d| d.id == id)
1461}
1462
1463pub fn reach_of(list: &[Device], id: &str) -> Reach {
1465 list.iter()
1466 .find(|d| d.id == id)
1467 .map_or(Reach::OutOfReach(None), Device::reach)
1468}
1469
1470pub fn send_live(id: &str, cmd: LinkCommand) -> bool {
1476 let everywhere = matches!(cmd, LinkCommand::WatchLevels { on: false });
1477 let near = cmd.allowed_nearby() && crate::remote::nearby::send(id, cmd.clone());
1478 if near && !everywhere {
1479 return true;
1480 }
1481 let linked = link::report(LinkReport::Command {
1482 to: id.to_string(),
1483 command: cmd,
1484 ack: None,
1485 });
1486 near || linked
1487}
1488
1489pub fn send(id: &str, cmd: LinkCommand) -> Result<(), String> {
1493 send_then(id, cmd, None)
1494}
1495
1496pub fn send_then(id: &str, cmd: LinkCommand, then: Option<Then>) -> Result<(), String> {
1499 reach(id, cmd, then).map_err(Missed::into_message)
1500}
1501
1502fn reach(id: &str, cmd: LinkCommand, then: Option<Then>) -> Result<(), Missed> {
1506 used(id);
1507 let (nearby, own, shared) = with(|s| {
1508 let listed = s.account.iter().find(|(d, _)| d.id == id);
1509 (
1510 s.nearby.iter().any(|n| n.hello.id == id),
1511 listed.is_some_and(|(d, _)| d.owner.is_none()),
1512 listed.is_some_and(|(d, _)| d.owner.is_some()),
1513 )
1514 });
1515 if !own && !cmd.allowed_playback() {
1519 return Err(Missed::Said(
1520 "Only your own devices can be asked that.".into(),
1521 ));
1522 }
1523 let routes = Routes {
1524 lan: nearby,
1525 lan_answers: nearby && answers_on_network(id),
1526 link_answers: answers_by_link(id),
1527 };
1528 let ack = acks::next_id();
1532 let answer = acks::expect(ack, id);
1533 if routes.lan {
1534 let sent = if routes.lan_answers {
1535 crate::remote::nearby::send_acked(id, cmd.clone(), ack)
1536 } else {
1537 crate::remote::nearby::send(id, cmd.clone())
1538 };
1539 if sent {
1540 follow(id, cmd, ack, answer, Route::Network, routes, then);
1541 return Ok(());
1542 }
1543 }
1544 let account = own || shared;
1545 if link::report(LinkReport::Command {
1546 to: id.to_string(),
1547 command: cmd.clone(),
1548 ack: routes.link_answers.then_some(ack),
1549 }) {
1550 follow(id, cmd, ack, answer, Route::Link, routes, then);
1551 return Ok(());
1552 }
1553 if !account && !nearby {
1554 log::info!("devices: {id} is not listed; asking the server");
1557 }
1558 let outcome = match ask_server(id, &cmd, Some(ack)) {
1559 Ok(outcome) => outcome,
1560 Err(Missed::OutOfReach(why)) if crate::remote::nearby::dials(id) => {
1564 crate::remote::nearby::dial_now();
1565 let back = await_until(
1566 || with(|s| s.nearby.iter().any(|n| n.hello.id == id)),
1567 NETWORK_WAIT,
1568 );
1569 let sent = back
1570 && if answers_on_network(id) {
1571 crate::remote::nearby::send_acked(id, cmd.clone(), ack)
1572 } else {
1573 crate::remote::nearby::send(id, cmd.clone())
1574 };
1575 if !sent {
1576 acks::forget(ack);
1577 return Err(Missed::OutOfReach(why));
1578 }
1579 let routes = Routes {
1580 lan: true,
1581 lan_answers: answers_on_network(id),
1582 ..routes
1583 };
1584 follow(id, cmd, ack, answer, Route::Network, routes, then);
1585 return Ok(());
1586 }
1587 Err(missed) => {
1588 acks::forget(ack);
1589 return Err(missed);
1590 }
1591 };
1592 acks::forget(ack);
1593 told(id, &cmd, outcome, then);
1594 Ok(())
1595}
1596
1597fn out_of_reach(id: &str) -> String {
1601 let name = list()
1602 .into_iter()
1603 .find(|d| d.id == id)
1604 .map_or_else(|| "That device".to_owned(), |d| d.name);
1605 format!("{name} is out of reach.")
1606}
1607
1608#[derive(Debug, Clone, Copy)]
1610struct Routes {
1611 lan: bool,
1612 lan_answers: bool,
1613 link_answers: bool,
1614}
1615
1616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1617enum Route {
1618 Network,
1619 Link,
1620}
1621
1622const NETWORK_ANSWER: Duration = Duration::from_secs(1);
1625const LINK_ANSWER: Duration = Duration::from_secs(3);
1626
1627fn follow(
1631 id: &str,
1632 cmd: LinkCommand,
1633 ack: u64,
1634 answer: crossbeam_channel::Receiver<acks::AckOutcome>,
1635 route: Route,
1636 routes: Routes,
1637 then: Option<Then>,
1638) {
1639 let answers = match route {
1640 Route::Network => routes.lan_answers,
1641 Route::Link => routes.link_answers,
1642 };
1643 if !answers {
1644 acks::forget(ack);
1645 told(id, &cmd, None, then);
1646 return;
1647 }
1648 let id = id.to_owned();
1649 let _ = std::thread::Builder::new()
1650 .name("koan-ack".into())
1651 .spawn(move || {
1652 let walked = walk(&id, &cmd, ack, &answer, route, routes);
1653 acks::forget(ack);
1654 match walked {
1655 Ok(outcome) => told(&id, &cmd, outcome, then),
1656 Err(missed) => unanswered(&id, &cmd, missed.into_message(), then),
1657 }
1658 });
1659}
1660
1661fn walk(
1665 id: &str,
1666 cmd: &LinkCommand,
1667 ack: u64,
1668 answer: &crossbeam_channel::Receiver<acks::AckOutcome>,
1669 first: Route,
1670 routes: Routes,
1671) -> Result<Option<acks::AckOutcome>, Missed> {
1672 if first == Route::Network {
1673 if let Ok(outcome) = answer.recv_timeout(NETWORK_ANSWER) {
1674 return Ok(Some(outcome));
1675 }
1676 log::info!("devices: no answer from {id} on the network; trying the link");
1677 if routes.link_answers
1678 && link::report(LinkReport::Command {
1679 to: id.to_string(),
1680 command: cmd.clone(),
1681 ack: Some(ack),
1682 })
1683 && let Ok(outcome) = answer.recv_timeout(LINK_ANSWER)
1684 {
1685 return Ok(Some(outcome));
1686 }
1687 } else if let Ok(outcome) = answer.recv_timeout(LINK_ANSWER) {
1688 return Ok(Some(outcome));
1689 }
1690 log::info!("devices: no answer from {id}; asking the server");
1691 ask_server(id, cmd, Some(ack))
1692}
1693
1694fn unanswered(id: &str, cmd: &LinkCommand, error: String, then: Option<Then>) {
1699 match then {
1700 Some(then) => {
1701 log::warn!("devices: no answer from {id} for {cmd:?}, and the server: {error}");
1702 then(None);
1703 }
1704 None => {
1705 log::warn!("devices: {cmd:?} did not reach {id}: {error}");
1706 notify(id, acks::AckOutcome::Failed { error }, true);
1707 }
1708 }
1709}
1710
1711fn told(id: &str, cmd: &LinkCommand, outcome: Option<acks::AckOutcome>, then: Option<Then>) {
1716 match &outcome {
1717 Some(acks::AckOutcome::Done) | None => {}
1718 Some(acks::AckOutcome::Queued) => {
1719 log::info!("devices: {id} is asleep; {cmd:?} waits for it")
1720 }
1721 Some(other) => log::warn!("devices: {cmd:?} did not reach {id}: {other:?}"),
1722 }
1723 if let Some(then) = then {
1724 then(outcome);
1725 return;
1726 }
1727 let Some(outcome) = outcome.filter(|o| *o != acks::AckOutcome::Done) else {
1728 return;
1729 };
1730 notify(id, outcome, false);
1731}
1732
1733fn notify(id: &str, outcome: acks::AckOutcome, unreached: bool) {
1735 let device = list()
1736 .into_iter()
1737 .find(|d| d.id == id)
1738 .map_or_else(|| id.to_owned(), |d| d.name);
1739 changed(|s| {
1740 let seq = s.notice.as_ref().map_or(1, |n| n.seq + 1);
1741 s.notice = Some(Notice {
1742 seq,
1743 device,
1744 outcome,
1745 unreached,
1746 });
1747 });
1748}
1749
1750fn answers_on_network(id: &str) -> bool {
1752 with(|s| s.nearby.iter().any(|n| n.hello.id == id && n.hello.acks))
1753}
1754
1755fn answers_by_link(id: &str) -> bool {
1758 crate::remote::profile::current().is_some_and(|p| p.offers(crate::remote::profile::ACK))
1759 && with(|s| s.account.iter().any(|(d, _)| d.id == id && d.acks))
1760}
1761
1762fn ask_server(
1766 id: &str,
1767 cmd: &LinkCommand,
1768 ack: Option<u64>,
1769) -> Result<Option<acks::AckOutcome>, Missed> {
1770 let cfg = Config::load().unwrap_or_default();
1771 let client = crate::helpers::subsonic_client(&cfg)
1773 .ok_or_else(|| Missed::OutOfReach(out_of_reach(id)))?;
1774 let json = serde_json::to_string(cmd).map_err(|e| e.to_string())?;
1775 client
1776 .koan_command(id, &json, ack)
1777 .map_err(|e| relay_error(id, e))
1778}
1779
1780fn relay_error(id: &str, e: crate::remote::client::SubsonicError) -> Missed {
1784 match e {
1785 crate::remote::client::SubsonicError::Api { code, message } if code != 70 => {
1786 Missed::Said(message)
1787 }
1788 e => {
1789 log::warn!("devices: the server cannot relay to {id}: {e}");
1790 Missed::OutOfReach(out_of_reach(id))
1791 }
1792 }
1793}
1794
1795pub fn send_by_server_first(id: &str, cmd: LinkCommand) -> Result<(), String> {
1802 server_first(
1803 || {
1804 ask_server(id, &cmd, Some(acks::next_id()))
1805 .map_err(Missed::into_message)
1806 .map(|outcome| match outcome {
1807 Some(acks::AckOutcome::Refused { reason }) => Err(reason),
1808 outcome => {
1809 told(id, &cmd, outcome, None);
1810 Ok(())
1811 }
1812 })?
1813 },
1814 || send(id, cmd.clone()),
1815 )
1816}
1817
1818fn server_first(
1819 server: impl FnOnce() -> Result<(), String>,
1820 otherwise: impl FnOnce() -> Result<(), String>,
1821) -> Result<(), String> {
1822 server().or_else(|why| {
1823 log::info!("devices: the server did not take it ({why}); sending it here");
1824 otherwise()
1825 })
1826}
1827
1828pub fn listed_owner(id: &str) -> Option<Option<String>> {
1831 with(|s| {
1832 s.account
1833 .iter()
1834 .find(|(d, _)| d.id == id)
1835 .map(|(d, _)| d.owner.clone())
1836 })
1837}
1838
1839pub fn this_id() -> Option<String> {
1841 local().map(|l| l.identity.device_id.clone())
1842}
1843
1844#[cfg(test)]
1845pub(crate) mod tests {
1846 use super::*;
1847
1848 #[test]
1852 fn reach_is_read_from_the_connections() {
1853 let device = |awake, asleep, wakeable| Device {
1854 id: "tv".into(),
1855 name: "TV".into(),
1856 platform: "tvos".into(),
1857 account: true,
1858 owner: None,
1859 nearby: false,
1860 awake,
1861 asleep,
1862 wakeable,
1863 last_seen: None,
1864 waking: None,
1865 same_library: true,
1866 state: None,
1867 heard: Instant::now(),
1868 problem: None,
1869 };
1870 assert_eq!(device(true, false, false).reach(), Reach::Connected);
1871 assert_eq!(device(false, false, true).reach(), Reach::Reconnecting);
1872 assert_eq!(device(false, false, false).reach(), Reach::Reconnecting);
1873 assert_eq!(device(false, true, true).reach(), Reach::Asleep);
1874 assert_eq!(device(false, true, false).reach(), Reach::OutOfReach(None));
1875 let waking = Device {
1876 waking: Some(Waking::Push),
1877 ..device(false, true, true)
1878 };
1879 assert_eq!(waking.reach(), Reach::Waking);
1880 let failed = Device {
1881 waking: Some(Waking::Failed("no answer".into())),
1882 ..device(false, true, false)
1883 };
1884 assert_eq!(failed.reach(), Reach::OutOfReach(Some("no answer".into())));
1885 let problem = Device {
1886 problem: Some("Not on this network".into()),
1887 ..device(true, false, false)
1888 };
1889 assert_eq!(
1890 problem.reach(),
1891 Reach::OutOfReach(Some("Not on this network".into()))
1892 );
1893 let unlisted = Device {
1894 problem: Some(OUT_OF_REACH.into()),
1895 ..device(false, true, false)
1896 };
1897 assert_eq!(
1898 unlisted.reach(),
1899 Reach::OutOfReach(None),
1900 "no reason beyond the state"
1901 );
1902 assert_eq!(reach_of(&[], "tv"), Reach::OutOfReach(None));
1903 }
1904
1905 #[test]
1909 fn a_command_to_a_device_out_of_reach_names_it() {
1910 use crate::remote::client::SubsonicError;
1911 let _held = STORE_LOCK.lock();
1912 crate::config::isolate_config_for_tests();
1913 with(|s| *s = Store::default());
1914 with(|s| {
1915 s.target = Some(Remembered {
1916 id: "mac".into(),
1917 name: "Studio Mac".into(),
1918 platform: "macos".into(),
1919 })
1920 });
1921
1922 let refused = reqwest::blocking::Client::new()
1923 .get("http://127.0.0.1:1/rest/koanCommand")
1924 .send()
1925 .unwrap_err();
1926 assert_eq!(
1927 relay_error("mac", SubsonicError::from(refused)),
1928 Missed::OutOfReach("Studio Mac is out of reach.".into())
1929 );
1930 let unknown = SubsonicError::Api {
1931 code: 70,
1932 message: "not found".into(),
1933 };
1934 assert_eq!(
1935 relay_error("mac", unknown),
1936 Missed::OutOfReach("Studio Mac is out of reach.".into())
1937 );
1938 let revoked = SubsonicError::Api {
1939 code: 44,
1940 message: "The key has been revoked.".into(),
1941 };
1942 assert_eq!(
1943 relay_error("mac", revoked),
1944 Missed::Said("The key has been revoked.".into())
1945 );
1946
1947 assert_eq!(
1948 send("mac", LinkCommand::Pause).unwrap_err(),
1949 "Studio Mac is out of reach.",
1950 "no server, and not on this network"
1951 );
1952 with(|s| *s = Store::default());
1953 }
1954
1955 #[test]
1960 fn an_unreachable_target_stays_controlled_and_a_refusal_is_said() {
1961 use crate::remote::client::SubsonicError;
1962 let _held = STORE_LOCK.lock();
1963 crate::config::isolate_config_for_tests();
1964 with(|s| *s = Store::default());
1965 with(|s| {
1966 s.target = Some(Remembered {
1967 id: "mac".into(),
1968 name: "Studio Mac".into(),
1969 platform: "macos".into(),
1970 })
1971 });
1972
1973 let missed = reach("mac", LinkCommand::Resume, None).unwrap_err();
1974 assert!(matches!(missed, Missed::OutOfReach(_)), "{missed:?}");
1975 assert_eq!(target().as_deref(), Some("mac"), "a miss keeps the target");
1976
1977 let refused = relay_error(
1978 "mac",
1979 SubsonicError::Api {
1980 code: 50,
1981 message: "Studio Mac is shared for playback only.".into(),
1982 },
1983 );
1984 assert_eq!(
1985 refused,
1986 Missed::Said("Studio Mac is shared for playback only.".into()),
1987 "the server answered"
1988 );
1989 with(|s| *s = Store::default());
1990 }
1991
1992 #[test]
1996 fn a_hello_that_does_not_look_keeps_the_tv_record() {
1997 let _held = STORE_LOCK.lock();
1998 crate::config::isolate_config_for_tests();
1999 with(|s| *s = Store::default());
2000 let tv = crate::remote::airplay::Tv {
2001 name: "Living Room".into(),
2002 host: "Living-Room.local.".into(),
2003 mac: Some("AA:BB:CC:DD:EE:FF".into()),
2004 ip: Some("10.0.0.9".into()),
2005 };
2006 with(|s| {
2007 s.seen.push(SeenNearby {
2008 id: "tv".into(),
2009 name: "Living Room".into(),
2010 platform: "tvos".into(),
2011 addr: "10.0.0.9:5626".into(),
2012 at: 0,
2013 tv: Some(tv.clone()),
2014 });
2015 s.tv_looked
2016 .insert(("tv".into(), "10.0.0.9".into()), Instant::now());
2017 });
2018 let mut hello = hello("tv");
2019 hello.platform = "tvos".into();
2020 nearby_hello(hello, "10.0.0.9:5626");
2021 assert_eq!(tv_of("tv").map(|t| t.host), Some(tv.host));
2022 }
2023
2024 #[test]
2025 fn an_apple_tv_is_looked_for_once_per_host_for_a_while() {
2026 let mut looked = HashMap::new();
2027 let t0 = Instant::now();
2028 assert!(look_for_tv(&mut looked, "tv", "living-room.local.", t0));
2029 assert!(!look_for_tv(
2030 &mut looked,
2031 "tv",
2032 "living-room.local.",
2033 t0 + Duration::from_secs(5)
2034 ));
2035 assert!(look_for_tv(
2037 &mut looked,
2038 "tv",
2039 "10.0.0.7",
2040 t0 + Duration::from_secs(5)
2041 ));
2042 assert!(look_for_tv(&mut looked, "other", "living-room.local.", t0));
2043 assert!(look_for_tv(
2044 &mut looked,
2045 "tv",
2046 "living-room.local.",
2047 t0 + TV_LOOK_AGAIN
2048 ));
2049 }
2050
2051 #[test]
2052 fn a_live_send_with_no_way_through_sends_nothing() {
2053 assert!(!send_live(
2055 "nowhere-at-all",
2056 LinkCommand::WatchLevels { on: true }
2057 ));
2058 }
2059
2060 fn device(id: &str, playing: bool) -> LinkDevice {
2061 LinkDevice {
2062 id: id.into(),
2063 name: id.into(),
2064 platform: "macos".into(),
2065 linked: true,
2066 state: Some(LinkState {
2067 playing,
2068 position_ms: 1000,
2069 duration_ms: 2000,
2070 ..Default::default()
2071 }),
2072 last_seen: None,
2073 wakeable: None,
2074 owner: None,
2075 acks: false,
2076 }
2077 }
2078
2079 pub(crate) static STORE_LOCK: Mutex<()> = Mutex::new(());
2082
2083 fn hello(id: &str) -> LinkHello {
2084 LinkHello {
2085 id: id.into(),
2086 name: id.into(),
2087 platform: "ios".into(),
2088 library: None,
2089 acks: false,
2090 nonce: None,
2091 }
2092 }
2093
2094 #[test]
2095 fn the_lock_screen_goes_by_the_server_and_falls_back_only_when_refused() {
2096 let local = std::cell::Cell::new(0);
2097 let here = || {
2098 local.set(local.get() + 1);
2099 Ok(())
2100 };
2101 assert_eq!(server_first(|| Ok(()), here), Ok(()));
2102 assert_eq!(local.get(), 0, "the server took it: nothing sent here");
2103
2104 assert_eq!(server_first(|| Err("no such device".into()), here), Ok(()));
2105 assert_eq!(local.get(), 1, "refused there: sent here instead");
2106
2107 assert_eq!(
2108 server_first(|| Err("offline".into()), || Err("not reachable".into())),
2109 Err("not reachable".to_string())
2110 );
2111 }
2112
2113 fn playing(track: &str, entry: &str) -> LinkState {
2115 LinkState {
2116 queue: vec![crate::remote::link::LinkQueueEntry {
2117 id: Some(entry.into()),
2118 track_id: Some(track.into()),
2119 current: true,
2120 ..Default::default()
2121 }],
2122 ..Default::default()
2123 }
2124 }
2125
2126 #[test]
2130 fn a_playing_devices_playhead_runs_on_from_its_report() {
2131 let _held = STORE_LOCK.lock();
2132 crate::config::isolate_config_for_tests();
2133 with(|s| *s = Store::default());
2134 nearby_hello(hello("speaker"), "10.0.0.6:5626");
2135 let at = |playing| LinkState {
2136 playing,
2137 position_ms: 1000,
2138 duration_ms: 60_000,
2139 ..Default::default()
2140 };
2141 let position = || {
2142 list()
2143 .into_iter()
2144 .find(|d| d.id == "speaker")
2145 .unwrap()
2146 .position_ms()
2147 };
2148
2149 nearby_state("speaker", at(true));
2150 std::thread::sleep(Duration::from_millis(50));
2151 assert!(position() >= 1050, "{}", position());
2152
2153 nearby_state("speaker", at(false));
2154 std::thread::sleep(Duration::from_millis(50));
2155 assert_eq!(position(), 1000, "paused, it stays put");
2156 }
2157
2158 #[test]
2159 fn a_command_queued_or_refused_becomes_the_notice_unless_its_sender_takes_it() {
2160 let _held = STORE_LOCK.lock();
2161 crate::config::isolate_config_for_tests();
2162 with(|s| *s = Store::default());
2163 nearby_hello(hello("phone"), "10.0.0.5:5626");
2164
2165 told(
2166 "phone",
2167 &LinkCommand::Pause,
2168 Some(acks::AckOutcome::Done),
2169 None,
2170 );
2171 told("phone", &LinkCommand::Pause, None, None);
2172 assert_eq!(notice(), None, "arrived, or sent as ever: nothing to say");
2173
2174 told(
2175 "phone",
2176 &LinkCommand::Pause,
2177 Some(acks::AckOutcome::Queued),
2178 None,
2179 );
2180 let queued = notice().unwrap();
2181 assert_eq!(
2182 (queued.device.as_str(), &queued.outcome),
2183 ("phone", &acks::AckOutcome::Queued)
2184 );
2185
2186 let failed = acks::AckOutcome::Failed {
2187 error: "gone".into(),
2188 };
2189 told("phone", &LinkCommand::Pause, Some(failed.clone()), None);
2190 assert_eq!(
2191 notice().unwrap().seq,
2192 queued.seq + 1,
2193 "the same twice is still news"
2194 );
2195
2196 let got = std::sync::Arc::new(parking_lot::Mutex::new(None));
2198 let into = got.clone();
2199 let before = notice();
2200 told(
2201 "phone",
2202 &LinkCommand::Pause,
2203 Some(failed.clone()),
2204 Some(Box::new(move |o| *into.lock() = Some(o))),
2205 );
2206 assert_eq!(*got.lock(), Some(Some(failed)));
2207 assert_eq!(notice(), before, "not said twice");
2208 }
2209
2210 #[test]
2215 fn failing_to_ask_the_server_is_no_answer_not_a_refusal() {
2216 let _held = STORE_LOCK.lock();
2217 crate::config::isolate_config_for_tests();
2218 with(|s| *s = Store::default());
2219 nearby_hello(hello("phone"), "10.0.0.5:5626");
2220
2221 let got = std::sync::Arc::new(parking_lot::Mutex::new(None));
2222 let into = got.clone();
2223 unanswered(
2224 "phone",
2225 &LinkCommand::Pause,
2226 "server unreachable".into(),
2227 Some(Box::new(move |o| *into.lock() = Some(o))),
2228 );
2229 assert_eq!(*got.lock(), Some(None), "no answer, not a refusal");
2230 assert_eq!(notice(), None);
2231
2232 unanswered(
2233 "phone",
2234 &LinkCommand::Pause,
2235 "server unreachable".into(),
2236 None,
2237 );
2238 assert!(matches!(
2239 notice().map(|n| n.outcome),
2240 Some(acks::AckOutcome::Failed { .. })
2241 ));
2242 }
2243
2244 #[test]
2245 fn a_hand_off_is_taken_only_when_the_device_reports_the_track() {
2246 let _held = STORE_LOCK.lock();
2247 crate::config::isolate_config_for_tests();
2248 with(|s| *s = Store::default());
2249 nearby_hello(hello("phone"), "10.0.0.5:5626");
2250 nearby_state("phone", playing("before", "e1"));
2251 let short = Duration::from_millis(50);
2252
2253 let before = last_report("phone");
2255 assert!(!await_current("phone", "handed", before.as_ref(), short));
2256
2257 nearby_state("phone", playing("handed", "e2"));
2260 let before = last_report("phone");
2261 let mut seeked = playing("handed", "e2");
2262 seeked.position_ms = 5000;
2263 nearby_state("phone", seeked);
2264 assert!(!await_current("phone", "handed", before.as_ref(), short));
2265
2266 let reporter = std::thread::spawn(|| {
2268 std::thread::sleep(Duration::from_millis(20));
2269 nearby_state("phone", playing("other", "e3"));
2270 });
2271 assert!(!await_current(
2272 "phone",
2273 "handed",
2274 before.as_ref(),
2275 Duration::from_millis(200)
2276 ));
2277 reporter.join().unwrap();
2278
2279 let before = last_report("phone");
2281 let reporter = std::thread::spawn(|| {
2282 std::thread::sleep(Duration::from_millis(20));
2283 nearby_state("phone", playing("handed", "e4"));
2284 });
2285 assert!(await_current(
2286 "phone",
2287 "handed",
2288 before.as_ref(),
2289 Duration::from_secs(5)
2290 ));
2291 reporter.join().unwrap();
2292 }
2293
2294 #[test]
2295 fn a_device_naming_no_entries_is_judged_on_its_whole_report() {
2296 let unnamed = |track: &str, position_ms| LinkState {
2297 position_ms,
2298 queue: vec![crate::remote::link::LinkQueueEntry {
2299 track_id: Some(track.into()),
2300 current: true,
2301 ..Default::default()
2302 }],
2303 ..Default::default()
2304 };
2305 let before = unnamed("handed", 0);
2306 assert!(!took(Some(&before), &before, "handed"));
2307 assert!(took(Some(&before), &unnamed("handed", 10), "handed"));
2308 assert!(took(None, &unnamed("handed", 0), "handed"));
2309 }
2310
2311 #[test]
2312 fn a_device_that_stops_answering_sleeps_after_a_heartbeat_and_stays() {
2313 let _held = STORE_LOCK.lock();
2314 crate::config::isolate_config_for_tests();
2315 with(|s| *s = Store::default());
2316 let week = 7 * 24 * 60 * 60;
2317
2318 nearby_hello(hello("stranger"), "10.0.0.5:5626");
2319 used("stranger");
2320 nearby_gone("stranger");
2321 let now = with(|s| s.live["stranger"]);
2322 let at = |t: i64| list_at(t).into_iter().find(|d| d.id == "stranger");
2323
2324 let missed_one = at(now + STALE_SECS - 1).expect("still listed");
2325 assert!(
2326 !missed_one.asleep,
2327 "one missed heartbeat does not make it asleep"
2328 );
2329 assert!(
2330 !missed_one.awake && !missed_one.nearby,
2331 "nor does it pretend to be reachable"
2332 );
2333 let asleep = at(now + STALE_SECS + 1).expect("listed asleep");
2334 assert!(asleep.asleep && !asleep.awake && !asleep.wakeable);
2335 assert_eq!(asleep.last_seen, Some(now));
2336 assert!(asleep.state.is_none(), "what it was doing is not news");
2337 assert!(
2338 at(now + week).is_some_and(|d| d.asleep),
2339 "kept long past the old grace, until forgotten"
2340 );
2341 assert_eq!(
2342 next_change(now),
2343 Some(now + STALE_SECS),
2344 "the clock wakes for the next crossing"
2345 );
2346 assert_eq!(next_change(now + STALE_SECS), None, "and then for nothing");
2347
2348 nearby_hello(hello("stranger"), "10.0.0.5:5626");
2350 let back = at(now + STALE_SECS + 5).unwrap();
2351 assert!(back.awake && back.nearby && !back.asleep);
2352
2353 nearby_gone("stranger");
2355 with(|s| *s.live.get_mut("stranger").unwrap() -= STALE_SECS + 10);
2356 assert!(choosable("stranger").is_err());
2357 assert!(send("stranger", LinkCommand::Pause).is_err());
2358
2359 let mut phone = device("phone", false);
2361 phone.linked = false;
2362 phone.last_seen = Some(now - week);
2363 phone.wakeable = Some(true);
2364 set_account(vec![phone]);
2365 used("phone");
2366 let phone = list_at(now).into_iter().find(|d| d.id == "phone").unwrap();
2367 assert!(phone.asleep && phone.wakeable);
2368 assert_eq!(phone.last_seen, Some(now - week));
2369 assert!(choosable("phone").is_ok());
2370
2371 set_linked(true);
2374 set_account(vec![device("mac", false)]);
2375 used("mac");
2376 set_account(Vec::new());
2377 let left = with(|s| s.live["mac"]);
2378 let mac = |t: i64| list_at(t).into_iter().find(|d| d.id == "mac");
2379 let reconnecting = mac(left + 1).unwrap();
2380 assert!(!reconnecting.awake && !reconnecting.asleep);
2381 let asleep = mac(left + STALE_SECS + 1).unwrap();
2382 assert!(asleep.asleep && !asleep.wakeable && asleep.account);
2383 assert!(mac(left + week).is_some());
2384 with(|s| *s = Store::default());
2385 }
2386
2387 #[test]
2390 fn only_a_device_used_here_is_kept_asleep() {
2391 let _held = STORE_LOCK.lock();
2392 crate::config::isolate_config_for_tests();
2393 with(|s| *s = Store::default());
2394
2395 nearby_hello(hello("passing"), "10.0.0.5:5626");
2396 nearby_hello(hello("played-on"), "10.0.0.6:5626");
2397 used("played-on");
2398 nearby_gone("passing");
2399 nearby_gone("played-on");
2400 let left = with(|s| s.live["passing"]);
2401 let listed = |t: i64, id: &str| list_at(t).into_iter().any(|d| d.id == id);
2402 assert!(listed(left + 1, "passing"), "reconnecting for a heartbeat");
2403 assert!(
2404 !listed(left + STALE_SECS + 1, "passing"),
2405 "never used: gone"
2406 );
2407 assert!(listed(left + STALE_SECS + 1, "played-on"), "used: asleep");
2408
2409 let mut phone = device("old-phone", false);
2411 phone.linked = false;
2412 phone.last_seen = Some(left - 3600);
2413 set_account(vec![phone]);
2414 assert!(!listed(left, "old-phone"));
2415
2416 with(|s| *s = Store::default());
2417 restore();
2418 let later = left + STALE_SECS + 1;
2419 assert!(
2420 list_at(later)
2421 .iter()
2422 .any(|d| d.id == "played-on" && d.asleep),
2423 "kept across a relaunch"
2424 );
2425 assert!(!listed(later, "passing"));
2426
2427 forget("played-on").unwrap();
2428 assert!(!list().iter().any(|d| d.id == "played-on"));
2429 with(|s| *s = Store::default());
2430 restore();
2431 assert!(
2432 !list().iter().any(|d| d.id == "played-on"),
2433 "forgotten on disk"
2434 );
2435 with(|s| *s = Store::default());
2436 }
2437
2438 #[test]
2441 fn the_controlled_device_is_kept_and_an_old_file_keeps_only_it() {
2442 let _held = STORE_LOCK.lock();
2443 crate::config::isolate_config_for_tests();
2444 with(|s| *s = Store::default());
2445 let gone = |id: &str| Departed {
2446 id: id.into(),
2447 name: id.into(),
2448 platform: "ios".into(),
2449 account: false,
2450 same_library: false,
2451 shared: false,
2452 };
2453 let old = serde_json::json!({
2454 "target": {"id": "tv", "name": "tv", "platform": "tvos"},
2455 "account": [],
2456 "live": {"tv": 1, "sim": 1},
2457 "departed": [gone("tv"), gone("sim")],
2458 });
2459 std::fs::write(saved_path(), old.to_string()).unwrap();
2460 restore();
2461 let ids: Vec<String> = list().into_iter().map(|d| d.id).collect();
2462 assert_eq!(
2463 ids,
2464 ["tv"],
2465 "the target stays; the simulator nobody used goes"
2466 );
2467
2468 set_target(None);
2469 nearby_hello(hello("mac"), "10.0.0.7:5626");
2470 set_target(Some("mac".into()));
2471 set_target(None);
2472 nearby_gone("mac");
2473 with(|s| *s.live.get_mut("mac").unwrap() -= STALE_SECS + 10);
2474 assert!(
2475 list().iter().any(|d| d.id == "mac" && d.asleep),
2476 "controlled once: kept"
2477 );
2478 with(|s| *s = Store::default());
2479 }
2480
2481 #[test]
2484 fn a_forgotten_device_is_gone_until_it_comes_back() {
2485 let _held = STORE_LOCK.lock();
2486 crate::config::isolate_config_for_tests();
2487 with(|s| *s = Store::default());
2488 nearby_hello(hello("stranger"), "10.0.0.5:5626");
2489 assert!(forget("stranger").is_err(), "not while it is reachable");
2490 used("stranger");
2491 nearby_gone("stranger");
2492 with(|s| *s = Store::default());
2494 restore();
2495 assert!(list().iter().any(|d| d.id == "stranger"));
2496 forget("stranger").unwrap();
2497 assert!(!list().iter().any(|d| d.id == "stranger"));
2498 assert!(remembered_nearby().iter().all(|n| n.id != "stranger"));
2499 with(|s| *s = Store::default());
2500 restore();
2501 assert!(
2502 !list().iter().any(|d| d.id == "stranger"),
2503 "forgotten on disk too"
2504 );
2505 nearby_hello(hello("stranger"), "10.0.0.5:5626");
2506 assert!(list().iter().any(|d| d.id == "stranger" && d.awake));
2507 with(|s| *s = Store::default());
2508 }
2509
2510 #[test]
2513 fn an_account_device_is_forgotten_through_the_server() {
2514 let _held = STORE_LOCK.lock();
2515 crate::config::isolate_config_for_tests();
2516 with(|s| *s = Store::default());
2517 let mut phone = device("phone", false);
2518 phone.linked = false;
2519 phone.wakeable = Some(true);
2520 phone.last_seen = Some(chrono::Utc::now().timestamp() - 3600);
2521 set_account(vec![phone]);
2522 used("phone");
2523 assert!(
2524 forget("phone").is_err(),
2525 "no link here: the server would still list it and wake it"
2526 );
2527 assert!(list().iter().any(|d| d.id == "phone"));
2528 forgotten("phone");
2530 assert!(!list().iter().any(|d| d.id == "phone"));
2531 with(|s| *s = Store::default());
2532 }
2533
2534 fn walk(recent: bool, answers_in: Option<Waking>) -> (Vec<Waking>, Ended) {
2537 let plan = wake_plan(false, recent, true);
2538 let mut started = Vec::new();
2539 let current = std::cell::RefCell::new(None);
2540 let ended = run_wake(
2541 &plan,
2542 || true,
2543 |stage| {
2544 started.push(stage.clone());
2545 *current.borrow_mut() = Some(stage.clone());
2546 Ok(())
2547 },
2548 |_| *current.borrow() == answers_in,
2549 );
2550 (started, ended)
2551 }
2552
2553 #[test]
2554 fn waking_tries_the_network_first_only_when_it_was_there_a_moment_ago() {
2555 let (tried, _) = walk(true, None);
2556 assert_eq!(tried, [Waking::Network, Waking::Push, Waking::Notification]);
2557 let (tried, _) = walk(false, None);
2558 assert_eq!(tried, [Waking::Push, Waking::Notification]);
2559 assert_eq!(
2560 wake_plan(false, true, false)
2561 .iter()
2562 .map(|(w, _)| w.clone())
2563 .collect::<Vec<_>>(),
2564 [Waking::Network],
2565 "a stranger is only ever tried on the network"
2566 );
2567 assert!(wake_plan(false, false, false).is_empty());
2568 }
2569
2570 #[test]
2571 fn an_apple_tv_on_record_is_woken_before_the_push() {
2572 let stages =
2573 |plan: Vec<(Waking, Duration)>| plan.into_iter().map(|(w, _)| w).collect::<Vec<_>>();
2574 assert_eq!(
2575 stages(wake_plan(true, false, true)),
2576 [Waking::Tv, Waking::Push, Waking::Notification]
2577 );
2578 assert_eq!(
2579 stages(wake_plan(true, true, true)),
2580 [
2581 Waking::Tv,
2582 Waking::Network,
2583 Waking::Push,
2584 Waking::Notification
2585 ]
2586 );
2587 }
2588
2589 #[test]
2590 fn an_apple_tv_met_on_the_network_keeps_its_airplay_record() {
2591 let _held = STORE_LOCK.lock();
2592 crate::config::isolate_config_for_tests();
2593 with(|s| *s = Store::default());
2594 let tv = crate::remote::airplay::Tv {
2596 name: "koan test TV".into(),
2597 host: "koan-test-tv.local.".into(),
2598 mac: Some("AA:BB:CC:DD:EE:FF".into()),
2599 ip: Some("10.0.0.4".into()),
2600 };
2601 let hello = |id: &str| LinkHello {
2602 name: "koan test TV".into(),
2603 platform: "tvos".into(),
2604 ..hello(id)
2605 };
2606 nearby_hello(hello("tv"), "koan-test-tv.local:50000");
2607 with(|s| s.seen[0].tv = Some(tv.clone()));
2608 nearby_hello(hello("tv"), "KOAN-test-tv.local:50001");
2610 assert_eq!(tv_of("tv"), Some(tv));
2611 nearby_hello(hello("tv"), "koan-test-other.local:50001");
2613 assert_eq!(tv_of("tv"), None);
2614 with(|s| *s = Store::default());
2615 }
2616
2617 #[test]
2618 fn a_wake_stops_at_the_stage_that_reached_it() {
2619 let (tried, ended) = walk(true, Some(Waking::Network));
2620 assert_eq!(tried, [Waking::Network], "no push for a phone still awake");
2621 assert_eq!(ended, Ended::Reached(Waking::Network));
2622 let (tried, ended) = walk(false, Some(Waking::Push));
2623 assert_eq!(
2624 tried,
2625 [Waking::Push],
2626 "no notification once the push worked"
2627 );
2628 assert_eq!(ended, Ended::Reached(Waking::Push));
2629 }
2630
2631 #[test]
2632 fn a_push_that_does_not_wake_it_falls_back_to_a_notification_then_says_so() {
2633 let (tried, ended) = walk(false, None);
2634 assert_eq!(tried.last(), Some(&Waking::Notification));
2635 assert!(matches!(ended, Ended::Failed(why) if why.contains("notification")));
2636 let plan = wake_plan(false, false, true);
2637 assert!(plan[0].1 <= Duration::from_secs(8) && plan[0].1 >= Duration::from_secs(5));
2638 }
2639
2640 #[test]
2641 fn a_stage_that_cannot_start_ends_the_wake_with_why() {
2642 let ended = run_wake(
2643 &wake_plan(false, false, true),
2644 || true,
2645 |_| Err("Not connected".into()),
2646 |_| true,
2647 );
2648 assert_eq!(ended, Ended::Failed("Not connected".into()));
2649 }
2650
2651 #[test]
2654 fn a_wake_abandoned_for_another_choice_sends_nothing_more() {
2655 let live = std::cell::Cell::new(true);
2656 let mut tried = Vec::new();
2657 let ended = run_wake(
2658 &wake_plan(false, true, true),
2659 || live.get(),
2660 |stage| {
2661 tried.push(stage.clone());
2662 Ok(())
2663 },
2664 |_| {
2665 live.set(false);
2666 false
2667 },
2668 );
2669 assert_eq!(tried, [Waking::Network]);
2670 assert_eq!(ended, Ended::Cancelled);
2671 }
2672
2673 #[test]
2674 fn one_wake_at_a_time_joined_by_the_same_choice_and_ended_by_another() {
2675 let _held = STORE_LOCK.lock();
2676 with(|s| *s = Store::default());
2677 let first = begin_wake("phone").expect("starts");
2678 assert!(begin_wake("phone").is_none(), "choosing it again joins it");
2679 assert!(wake_is_current(first));
2680 cancel_wake(Some("phone"));
2681 assert!(wake_is_current(first), "the same choice does not end it");
2682 cancel_wake(Some("mac"));
2683 assert!(!wake_is_current(first), "another choice does");
2684 let second = begin_wake("mac").unwrap();
2685 assert!(begin_wake("phone").is_some() && !wake_is_current(second));
2686 with(|s| *s = Store::default());
2687 }
2688
2689 #[test]
2692 fn a_device_chosen_seconds_after_it_dropped_is_woken() {
2693 let _held = STORE_LOCK.lock();
2694 crate::config::isolate_config_for_tests();
2695 with(|s| *s = Store::default());
2696 set_linked(true);
2697 set_account(vec![device("phone", false)]);
2698 let mut phone = device("phone", false);
2699 phone.linked = false;
2700 phone.wakeable = Some(true);
2701 phone.last_seen = Some(chrono::Utc::now().timestamp() - 10);
2702 set_account(vec![phone]);
2703 let listed = list().into_iter().find(|d| d.id == "phone").unwrap();
2704 assert!(!listed.asleep && !listed.awake, "reconnecting, not asleep");
2705 wake("phone");
2706 let ran = (0..100).any(|_| {
2709 let seen = with(|s| {
2710 s.attempt.as_ref().is_some_and(|(_, d)| d == "phone")
2711 || s.waking.contains_key("phone")
2712 });
2713 if !seen {
2714 std::thread::sleep(Duration::from_millis(10));
2715 }
2716 seen
2717 });
2718 assert!(ran, "the pipeline runs");
2719 cancel_wake(None);
2720 with(|s| *s = Store::default());
2721 }
2722
2723 #[test]
2727 fn last_heard_on_the_network_is_the_last_message_not_the_first() {
2728 let _held = STORE_LOCK.lock();
2729 crate::config::isolate_config_for_tests();
2730 with(|s| *s = Store::default());
2731 nearby_hello(hello("phone"), "10.0.0.7:5626");
2732 with(|s| {
2733 for seen in &mut s.seen {
2734 seen.at -= 10 * 60;
2735 }
2736 });
2737 nearby_state("phone", LinkState::default());
2738 nearby_gone("phone");
2739 let left = with(|s| s.lan_heard["phone"]);
2740 assert!(recently_on_network("phone", left + 30));
2741 assert!(!recently_on_network("phone", left + NETWORK_RECENT + 1));
2742 with(|s| *s = Store::default());
2743 }
2744
2745 #[test]
2749 fn a_stranger_asleep_on_the_network_is_woken_through_the_server_if_linked() {
2750 let _held = STORE_LOCK.lock();
2751 crate::config::isolate_config_for_tests();
2752 with(|s| *s = Store::default());
2753 nearby_hello(hello("stranger"), "10.0.0.5:5626");
2754 used("stranger");
2755 nearby_gone("stranger");
2756 with(|s| {
2757 *s.live.get_mut("stranger").unwrap() -= 10 * 60;
2758 *s.lan_heard.get_mut("stranger").unwrap() -= 10 * 60;
2759 });
2760 let listed = list().into_iter().find(|d| d.id == "stranger").unwrap();
2761 assert!(listed.asleep && !listed.wakeable, "no server to ask");
2762 wake("stranger");
2763 assert!(
2764 with(|s| s.attempt.is_none() && s.waking.is_empty()),
2765 "too long gone for the network, and no server"
2766 );
2767
2768 set_linked(true);
2769 let listed = list().into_iter().find(|d| d.id == "stranger").unwrap();
2770 assert!(listed.asleep && listed.wakeable);
2771 assert!(choosable("stranger").is_ok());
2772 let plan = wake_plan(false, false, listed.wakeable);
2773 assert_eq!(plan[0].0, Waking::Push, "asks the server");
2774 with(|s| *s = Store::default());
2775 }
2776
2777 #[test]
2781 fn a_hand_off_asked_by_a_stranger_never_goes_up_the_link() {
2782 let _held = STORE_LOCK.lock();
2783 crate::config::isolate_config_for_tests();
2784 with(|s| *s = Store::default());
2785 set_linked(true);
2786 set_account(vec![device("mac", false)]);
2787 let play = LinkCommand::Play {
2788 track_ids: vec!["t".into()],
2789 start_at: 0,
2790 position_ms: 0,
2791 paused: false,
2792 handoff: true,
2793 mode: crate::player::state::QueueMode::Keep,
2794 };
2795 assert!(
2796 send_nearby("mac", play).is_err(),
2797 "the account's Mac is reached only through the link"
2798 );
2799 with(|s| *s = Store::default());
2800 }
2801
2802 #[test]
2805 fn a_hand_off_for_someone_else_never_reaches_the_accounts_own_devices() {
2806 let _held = STORE_LOCK.lock();
2807 crate::config::isolate_config_for_tests();
2808 with(|s| *s = Store::default());
2809 set_linked(true);
2810 set_account(vec![device("mac", false)]);
2811 let play = || LinkCommand::Play {
2812 track_ids: vec!["t".into()],
2813 start_at: 0,
2814 position_ms: 0,
2815 paused: false,
2816 handoff: true,
2817 mode: crate::player::state::QueueMode::Keep,
2818 };
2819 for source in [
2820 CommandSource::Shared,
2821 CommandSource::Nearby,
2822 CommandSource::Stranger,
2823 ] {
2824 let refused = send_for(source, "mac", play()).unwrap_err();
2825 assert!(
2826 refused.contains("this account's") || refused.contains("not on this network"),
2827 "{source:?}: {refused}"
2828 );
2829 }
2830 with(|s| *s = Store::default());
2831 }
2832
2833 #[test]
2835 fn a_device_no_longer_shared_is_dropped_at_once() {
2836 let _held = STORE_LOCK.lock();
2837 crate::config::isolate_config_for_tests();
2838 with(|s| *s = Store::default());
2839 set_linked(true);
2840 let mut theirs = device("their-phone", false);
2841 theirs.owner = Some("k".into());
2842 set_account(vec![theirs]);
2843 let listed = list().into_iter().find(|d| d.id == "their-phone").unwrap();
2844 assert_eq!(listed.owner.as_deref(), Some("k"));
2845 assert!(!listed.account, "not this account's own");
2846 assert!(send("their-phone", LinkCommand::Sync { full: false }).is_err());
2847 set_account(Vec::new());
2848 assert!(!list().iter().any(|d| d.id == "their-phone"));
2849 with(|s| *s = Store::default());
2850 }
2851
2852 #[test]
2855 fn a_stranger_that_just_left_cannot_be_chosen() {
2856 let _held = STORE_LOCK.lock();
2857 crate::config::isolate_config_for_tests();
2858 with(|s| *s = Store::default());
2859 nearby_hello(hello("stranger"), "10.0.0.5:5626");
2860 nearby_gone("stranger");
2861 let listed = list().into_iter().find(|d| d.id == "stranger").unwrap();
2862 assert!(!listed.awake && !listed.asleep && !listed.nearby);
2863 assert!(choosable("stranger").is_err());
2864 assert!(send("stranger", LinkCommand::Pause).is_err());
2865 with(|s| *s = Store::default());
2866 }
2867
2868 #[test]
2871 fn a_list_held_across_a_gap_in_the_link_does_not_stamp_now() {
2872 let _held = STORE_LOCK.lock();
2873 crate::config::isolate_config_for_tests();
2874 with(|s| *s = Store::default());
2875 set_linked(true);
2876 set_account(vec![device("phone", true)]);
2877 used("phone");
2878 with(|s| *s.live.get_mut("phone").unwrap() -= 4 * 60 * 60);
2880 set_linked(false);
2881 let hours_ago = chrono::Utc::now().timestamp() - 3 * 60 * 60;
2882 set_linked(true);
2883 let mut phone = device("phone", false);
2884 phone.linked = false;
2885 phone.last_seen = Some(hours_ago);
2886 set_account(vec![phone]);
2887 let phone = list().into_iter().find(|d| d.id == "phone").unwrap();
2888 assert!(phone.asleep, "not reconnecting: it went hours ago");
2889 assert!(phone.last_seen.unwrap() >= hours_ago);
2892 assert!(
2893 phone.last_seen.unwrap() < chrono::Utc::now().timestamp() - STALE_SECS,
2894 "not seen just now"
2895 );
2896 with(|s| *s = Store::default());
2897 }
2898
2899 #[test]
2902 fn a_restored_device_the_server_drops_is_gone() {
2903 let _held = STORE_LOCK.lock();
2904 crate::config::isolate_config_for_tests();
2905 with(|s| *s = Store::default());
2906 let saved = Saved {
2908 target: None,
2909 account: vec![device("mac", true), device("phone", true)],
2910 nearby: Vec::new(),
2911 live: HashMap::new(),
2912 departed: Vec::new(),
2913 used: None,
2914 };
2915 std::fs::write(saved_path(), serde_json::to_string(&saved).unwrap()).unwrap();
2916 restore();
2917 let listed = list();
2918 assert!(listed.iter().all(|d| d.state.is_none() && !d.awake));
2919 set_linked(true);
2920 let mut phone = device("phone", false);
2921 phone.linked = false;
2922 phone.wakeable = Some(true);
2923 set_account(vec![phone]);
2924 assert!(
2925 !list().iter().any(|d| d.id == "mac"),
2926 "never seen here, and not listed: dropped"
2927 );
2928 with(|s| *s = Store::default());
2929 }
2930
2931 #[test]
2934 fn a_device_the_server_cannot_push_is_not_wakeable() {
2935 let _held = STORE_LOCK.lock();
2936 crate::config::isolate_config_for_tests();
2937 with(|s| *s = Store::default());
2938 let mut phone = device("phone", false);
2939 phone.linked = false;
2940 phone.wakeable = Some(false);
2941 phone.last_seen = Some(chrono::Utc::now().timestamp() - 3600);
2942 set_account(vec![phone]);
2943 used("phone");
2944 let phone = list().into_iter().find(|d| d.id == "phone").unwrap();
2945 assert!(phone.asleep && !phone.wakeable);
2946 assert!(choosable("phone").is_err());
2947 with(|s| *s = Store::default());
2948 }
2949
2950 #[test]
2951 fn a_device_found_both_ways_is_one_and_a_lost_target_is_kept() {
2952 let _held = STORE_LOCK.lock();
2953 crate::config::isolate_config_for_tests();
2954 with(|s| *s = Store::default());
2955 set_account(vec![device("mac", false), device("phone", true)]);
2956 let listed = list();
2957 assert_eq!(listed[0].id, "phone", "playing first");
2958 assert_eq!(listed.len(), 2);
2959
2960 nearby_hello(
2961 LinkHello {
2962 id: "mac".into(),
2963 name: "Mac".into(),
2964 platform: "macos".into(),
2965 library: None,
2966 acks: false,
2967 nonce: None,
2968 },
2969 "mac.local:5626",
2970 );
2971 nearby_hello(
2972 LinkHello {
2973 id: "tv".into(),
2974 name: "Living room".into(),
2975 platform: "macos".into(),
2976 library: Some("elsewhere".into()),
2977 acks: false,
2978 nonce: None,
2979 },
2980 "10.0.0.9:5626",
2981 );
2982 let listed = list();
2983 assert_eq!(listed.len(), 3);
2984 let mac = listed.iter().find(|d| d.id == "mac").unwrap();
2985 assert!(mac.account && mac.nearby && mac.same_library);
2986 let tv = listed.iter().find(|d| d.id == "tv").unwrap();
2987 assert!(!tv.account && !tv.same_library);
2988
2989 set_target(Some("tv".into()));
2990 nearby_gone("tv");
2991 with(|s| *s.live.get_mut("tv").unwrap() -= STALE_SECS + 31 * 60);
2993 assert_eq!(target(), Some("tv".into()), "kept while out of reach");
2994 let tv = list().into_iter().find(|d| d.id == "tv").unwrap();
2995 assert_eq!(tv.name, "Living room");
2996 assert!(!tv.awake && tv.asleep);
2997
2998 with(|s| *s = Store::default());
3000 restore();
3001 assert_eq!(target(), Some("tv".into()));
3002 let listed = list();
3003 assert!(
3004 listed.iter().any(|d| d.id == "mac" && !d.awake),
3005 "last heard of, not linked"
3006 );
3007 let remembered = remembered_nearby();
3008 assert!(
3009 remembered
3010 .iter()
3011 .any(|n| n.id == "tv" && n.addr == "10.0.0.9:5626")
3012 );
3013
3014 nearby_gone("mac");
3018 let play = LinkCommand::Play {
3019 track_ids: vec!["t".into()],
3020 start_at: 0,
3021 position_ms: 0,
3022 paused: false,
3023 handoff: true,
3024 mode: crate::player::state::QueueMode::Keep,
3025 };
3026 assert!(listed.iter().any(|d| d.id == "mac" && d.account));
3027 assert!(send_nearby("mac", play).is_err());
3028
3029 set_target(None);
3030 set_account(Vec::new());
3031 nearby_gone("mac");
3032 assert!(
3033 list().iter().all(|d| d.last_seen.is_some()),
3034 "only what has just left, kept for a heartbeat"
3035 );
3036 with(|s| *s = Store::default());
3037 }
3038}