#[cfg(target_vendor = "apple")]
use std::net::ToSocketAddrs;
use std::net::{SocketAddr, TcpStream};
use std::time::{Duration, Instant};
pub const SERVICES: &[&str] = &["_airplay._tcp", "_companion-link._tcp"];
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Tv {
pub name: String,
pub host: String,
#[serde(default)]
pub mac: Option<String>,
#[serde(default)]
pub ip: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announced {
pub name: String,
pub host: String,
pub mac: Option<String>,
}
pub fn host_of(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(h, _)| h)
}
fn same_host(a: &str, b: &str) -> bool {
let a = a.trim_end_matches('.');
let b = b.trim_end_matches('.');
!a.is_empty() && a.eq_ignore_ascii_case(b)
}
pub fn pick<'a>(announced: &'a [Announced], host: &str, name: &str) -> Option<&'a Announced> {
if let Some(a) = announced.iter().find(|a| same_host(&a.host, host)) {
return Some(a);
}
let mut named = announced
.iter()
.filter(|a| a.name.trim().eq_ignore_ascii_case(name.trim()));
match (named.next(), named.next()) {
(Some(a), None) => Some(a),
_ => None,
}
}
pub fn current(tv: Option<&Tv>, host: &str) -> bool {
tv.is_some_and(|t| same_host(&t.host, host))
}
pub fn knock_until_awake(
mut addrs: impl FnMut() -> Vec<SocketAddr>,
within: Duration,
mut give_up: impl FnMut() -> bool,
) -> bool {
let until = Instant::now() + within;
let mut first = true;
loop {
let round = addrs();
if first && round.is_empty() {
return false;
}
first = false;
for addr in round {
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() || give_up() {
return false;
}
if TcpStream::connect_timeout(&addr, KNOCK.min(left)).is_ok() {
return true;
}
}
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() || give_up() {
return false;
}
std::thread::sleep(ROUND.min(left));
}
}
const KNOCK: Duration = Duration::from_millis(1500);
const ROUND: Duration = Duration::from_millis(500);
#[cfg(target_vendor = "apple")]
const LOOK: Duration = Duration::from_secs(3);
#[cfg(target_vendor = "apple")]
pub fn record(addr: &str, name: &str) -> Option<Tv> {
use crate::remote::nearby::bonjour;
let instances = bonjour::instances(SERVICES[0], LOOK);
let announced: Vec<Announced> = instances
.iter()
.map(|i| Announced {
name: i.name.clone(),
host: i.host.clone(),
mac: i.txt("deviceid"),
})
.collect();
let a = pick(&announced, host_of(addr), name)?;
Some(Tv {
name: a.name.clone(),
host: a.host.clone(),
mac: a.mac.clone(),
ip: ipv4(&a.host),
})
}
#[cfg(not(target_vendor = "apple"))]
pub fn record(_addr: &str, _name: &str) -> Option<Tv> {
None
}
#[cfg(target_vendor = "apple")]
fn ipv4(host: &str) -> Option<String> {
(host.trim_end_matches('.'), 0)
.to_socket_addrs()
.ok()?
.find(|a| a.is_ipv4())
.map(|a| a.ip().to_string())
}
#[cfg(target_vendor = "apple")]
pub fn wake(tv: &Tv, within: Duration, give_up: impl FnMut() -> bool) -> bool {
use crate::remote::nearby::bonjour;
let started = Instant::now();
let ms = || started.elapsed().as_millis();
let mut asked = false;
let addrs = || {
let mut out = Vec::new();
for service in SERVICES {
let Some(i) = bonjour::resolve_named(&tv.name, service, 0, Duration::from_secs(1))
else {
continue;
};
if !asked && let (Some(mac), Some(ip)) = (&tv.mac, &tv.ip) {
bonjour::wake_on_resolve(mac, ip, service, i.interface);
asked = true;
}
if i.port == 0 {
continue;
}
let found = (i.host.trim_end_matches('.'), i.port)
.to_socket_addrs()
.map(|a| a.collect::<Vec<_>>())
.unwrap_or_default();
if found.is_empty() {
out.extend(
tv.ip
.as_deref()
.and_then(|ip| format!("{ip}:{}", i.port).parse::<SocketAddr>().ok()),
);
} else {
out.extend(found);
}
}
log::info!("airplay: {}: knocking on {out:?} at +{}ms", tv.name, ms());
out
};
let awake = knock_until_awake(addrs, within, give_up);
if awake {
log::info!("airplay: {}: awake at +{}ms", tv.name, ms());
} else {
log::warn!("airplay: {}: did not answer in {}ms", tv.name, ms());
}
awake
}
#[cfg(not(target_vendor = "apple"))]
pub fn wake(_tv: &Tv, _within: Duration, _give_up: impl FnMut() -> bool) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
fn announced(name: &str, host: &str) -> Announced {
Announced {
name: name.into(),
host: host.into(),
mac: Some("AA:BB:CC:DD:EE:FF".into()),
}
}
#[test]
fn the_same_host_is_the_tv() {
let all = [
announced("Bedroom", "Bedroom.local."),
announced("Living Room", "Living-Room.local."),
];
let tv = pick(&all, "living-room.local", "Somewhere else").unwrap();
assert_eq!(tv.name, "Living Room");
}
#[test]
fn the_name_decides_when_no_host_matches() {
let all = [
announced("Bedroom", "Bedroom-2.local."),
announced("Living Room", "Apple-TV.local."),
];
let tv = pick(&all, "10.0.0.4", "living room").unwrap();
assert_eq!(tv.host, "Apple-TV.local.");
}
#[test]
fn two_of_the_same_name_are_not_guessed_between() {
let all = [
announced("Apple TV", "Apple-TV.local."),
announced("Apple TV", "Apple-TV-2.local."),
];
assert_eq!(pick(&all, "10.0.0.4", "Apple TV"), None);
}
#[test]
fn nothing_on_the_network_is_nothing() {
assert_eq!(pick(&[], "Living-Room.local", "Living Room"), None);
}
#[test]
fn an_address_gives_its_host() {
assert_eq!(host_of("Living-Room.local:51234"), "Living-Room.local");
assert_eq!(host_of("Living-Room.local"), "Living-Room.local");
}
#[test]
fn a_record_on_the_same_host_is_kept() {
let tv = Tv {
name: "Living Room".into(),
host: "Living-Room.local.".into(),
mac: None,
ip: None,
};
assert!(current(Some(&tv), "living-room.local"));
assert!(!current(Some(&tv), "Bedroom.local"));
assert!(!current(None, "Living-Room.local"));
}
#[test]
fn a_record_reads_back_without_the_newer_fields() {
let tv: Tv = serde_json::from_str(r#"{"name":"TV","host":"TV.local."}"#).unwrap();
assert_eq!(tv.mac, None);
assert_eq!(tv.ip, None);
}
fn closed_port() -> SocketAddr {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap()
}
#[test]
fn a_host_that_answers_is_awake_at_once() {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
let started = Instant::now();
assert!(knock_until_awake(
|| vec![closed_port(), addr],
Duration::from_secs(5),
|| false
));
assert!(started.elapsed() < Duration::from_secs(2));
}
#[test]
fn a_host_that_wakes_part_way_is_found_awake() {
let addr = closed_port();
let woke = std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(1));
let l = TcpListener::bind(addr).unwrap();
let _ = l.accept();
});
assert!(knock_until_awake(
|| vec![addr],
Duration::from_secs(10),
|| false
));
woke.join().unwrap();
}
#[test]
fn a_host_that_never_answers_is_given_up_on_in_time() {
let addr = closed_port();
let started = Instant::now();
assert!(!knock_until_awake(
|| vec![addr],
Duration::from_millis(1200),
|| false
));
assert!(started.elapsed() < Duration::from_secs(3));
}
#[test]
fn nothing_to_knock_on_gives_up_at_once() {
let started = Instant::now();
let mut rounds = 0;
assert!(!knock_until_awake(
|| {
rounds += 1;
Vec::new()
},
Duration::from_secs(20),
|| false
));
assert_eq!(rounds, 1);
assert!(started.elapsed() < Duration::from_millis(200));
}
#[test]
fn a_wake_abandoned_stops_knocking() {
let addr = closed_port();
let started = Instant::now();
assert!(!knock_until_awake(
|| vec![addr],
Duration::from_secs(30),
|| true
));
assert!(started.elapsed() < Duration::from_secs(1));
}
}