use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub name: String,
pub kind: String,
pub selected: bool,
}
impl Target {
pub fn is_this_mac(&self) -> bool {
self.kind.eq_ignore_ascii_case("computer")
}
}
fn applescript_string(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn osascript(script: &str) -> Result<String, String> {
let out = Command::new("osascript")
.args(["-e", script])
.output()
.map_err(|e| format!("osascript: {e}"))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if err.is_empty() {
"osascript failed".to_string()
} else {
err
});
}
Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
}
pub fn music_running() -> bool {
Command::new("pgrep")
.args(["-x", "Music"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn targets() -> Result<Vec<Target>, String> {
let script = "tell application \"Music\"\n\
set out to \"\"\n\
repeat with d in AirPlay devices\n\
set out to out & (name of d) & tab & (kind of d as text) & tab & (selected of d as text) & linefeed\n\
end repeat\n\
return out\n\
end tell";
Ok(parse_targets(&osascript(script)?))
}
fn parse_targets(out: &str) -> Vec<Target> {
out.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let name = parts.next()?.trim();
if name.is_empty() {
return None;
}
let kind = parts.next().unwrap_or("").trim();
let selected = parts.next().unwrap_or("").trim();
Some(Target {
name: name.to_string(),
kind: kind.to_string(),
selected: selected.eq_ignore_ascii_case("true"),
})
})
.collect()
}
pub fn send_to(name: &str) -> Result<(), String> {
let script = format!(
"tell application \"Music\" to set current AirPlay devices to {{AirPlay device {}}}",
applescript_string(name)
);
osascript(&script).map(|_| ())
}
pub fn send_to_this_mac() -> Result<(), String> {
let this_mac = targets()?
.into_iter()
.find(Target::is_this_mac)
.ok_or("Music.app does not list this computer as a destination")?;
send_to(&this_mac.name)
}
pub fn current() -> Result<Vec<String>, String> {
let script = "tell application \"Music\"\n\
set out to \"\"\n\
repeat with d in current AirPlay devices\n\
set out to out & (name of d) & linefeed\n\
end repeat\n\
return out\n\
end tell";
Ok(osascript(script)?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_tab_separated_listing() {
let out = "Chris's MacBook Pro (2)\tcomputer\ttrue\n\
Living Room\tAirPlay device\tfalse\n\
50in TCL Roku TV\tTV\tfalse\n";
let targets = parse_targets(out);
assert_eq!(targets.len(), 3);
assert!(targets[0].is_this_mac());
assert!(targets[0].selected);
assert_eq!(targets[1].name, "Living Room");
assert_eq!(targets[1].kind, "AirPlay device");
assert!(!targets[1].selected);
}
#[test]
fn names_with_commas_survive() {
let targets = parse_targets("Kitchen, Upstairs\tAirPlay device\tfalse");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].name, "Kitchen, Upstairs");
}
#[test]
fn blank_lines_are_dropped() {
assert!(parse_targets("\n\n").is_empty());
}
#[test]
fn escapes_quotes_in_room_names() {
assert_eq!(applescript_string("Nora\"s Room"), "\"Nora\\\"s Room\"");
assert_eq!(applescript_string("plain"), "\"plain\"");
}
}