Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
0

Configure Feed

Select the types of activity you want to include in your feed.

Embed systemd --user unit template + `zerod service` installer

Adds three subcommands so the Pi/SBC user flow is a copy-paste from
README rather than hand-writing a service file:

zerod service install # writes ~/.config/systemd/user/zerod.service
zerod service uninstall # removes it
zerod service path # prints where it would land

The unit template lives at assets/zerod.service.in and is embedded via
include_str!. `service install` substitutes {{exec_start}} with
current_exe() (canonicalised), writes atomically via tempfile + rename,
and prints the systemctl --user follow-up commands plus a loginctl
enable-linger hint so it survives logout.

Linux-only — non-Linux builds expose the subcommand but every action
returns "zerod service: linux only" rather than producing a unit file
no one can use.

+179
+28
assets/zerod.service.in
··· 1 + # zerod user service — generated by `zerod service install`. 2 + # To override any field without editing this file, drop a snippet at 3 + # ~/.config/systemd/user/zerod.service.d/override.conf 4 + 5 + [Unit] 6 + Description=zerod headless audio / Bluetooth / systemd control daemon 7 + Documentation=https://tangled.org/tsiry-sandratraina.com/zerod 8 + After=network-online.target pipewire.service wireplumber.service 9 + Wants=network-online.target 10 + 11 + [Service] 12 + Type=simple 13 + ExecStart={{exec_start}} 14 + Restart=on-failure 15 + RestartSec=5 16 + 17 + # Pin the bearer token via env so the random fallback isn't used at startup. 18 + # Either set it inline here: 19 + # Environment=ZEROD_BEARER_TOKEN=changeme 20 + # or — better — in a drop-in that's not regenerated by `service install`: 21 + # ~/.config/systemd/user/zerod.service.d/override.conf 22 + # 23 + # Configuration files (bind address, systemd unit allowlist, managed 24 + # config registry) live in ~/.config/zerod/zerod.toml — see 25 + # `zerod.toml.example` for the schema. 26 + 27 + [Install] 28 + WantedBy=default.target
+63
src/main.rs
··· 15 15 use tonic::Request; 16 16 use zerod_proto::v1alpha1 as pb; 17 17 18 + mod service; 19 + 18 20 #[derive(Parser)] 19 21 #[command(name = "zerod", version, about = "headless audio/bluetooth/systemd control daemon")] 20 22 struct Cli { ··· 60 62 /// System volume control via ALSA mixer (client). 61 63 #[command(subcommand)] 62 64 Volume(VolumeCmd), 65 + /// Manage zerod's own systemd --user unit (Linux only). 66 + #[command(subcommand)] 67 + Service(ServiceCmd), 68 + } 69 + 70 + #[derive(Subcommand)] 71 + enum ServiceCmd { 72 + /// Write ~/.config/systemd/user/zerod.service pointing at this binary. 73 + Install { 74 + /// Overwrite an existing unit file. 75 + #[arg(long)] 76 + force: bool, 77 + }, 78 + /// Remove ~/.config/systemd/user/zerod.service. 79 + Uninstall, 80 + /// Print where the unit file would be installed. 81 + Path, 63 82 } 64 83 65 84 #[derive(Subcommand)] ··· 206 225 Some(Command::Config(cmd)) => run_config(&cli.host, cli.port, cli.bearer_token, cmd).await, 207 226 Some(Command::System(cmd)) => run_system(&cli.host, cli.port, cli.bearer_token, cmd).await, 208 227 Some(Command::Volume(cmd)) => run_volume(&cli.host, cli.port, cli.bearer_token, cmd).await, 228 + Some(Command::Service(cmd)) => run_service(cmd), 229 + } 230 + } 231 + 232 + fn run_service(cmd: ServiceCmd) -> Result<()> { 233 + match cmd { 234 + ServiceCmd::Install { force } => { 235 + let path = service::install(force)?; 236 + println!("Wrote {}", path.display()); 237 + println!(); 238 + println!("Next steps:"); 239 + println!(" systemctl --user daemon-reload"); 240 + println!(" systemctl --user enable --now zerod.service"); 241 + println!(); 242 + println!("Then check it's healthy:"); 243 + println!(" systemctl --user status zerod.service"); 244 + println!(" journalctl --user -u zerod.service -f"); 245 + println!(); 246 + println!("So the service survives logout / starts on boot:"); 247 + println!(" sudo loginctl enable-linger \"$USER\""); 248 + println!(); 249 + println!("Pin the bearer token so the random one isn't used at startup:"); 250 + println!(" systemctl --user edit zerod.service"); 251 + println!(" # then add: [Service]\\n Environment=ZEROD_BEARER_TOKEN=…"); 252 + Ok(()) 253 + } 254 + ServiceCmd::Uninstall => match service::uninstall()? { 255 + Some(path) => { 256 + println!("Removed {}", path.display()); 257 + println!(); 258 + println!("Tell systemd to forget it:"); 259 + println!(" systemctl --user disable --now zerod.service"); 260 + println!(" systemctl --user daemon-reload"); 261 + Ok(()) 262 + } 263 + None => { 264 + println!("No unit file installed at {}", service::unit_path()?.display()); 265 + Ok(()) 266 + } 267 + }, 268 + ServiceCmd::Path => { 269 + println!("{}", service::unit_path()?.display()); 270 + Ok(()) 271 + } 209 272 } 210 273 } 211 274
+88
src/service.rs
··· 1 + //! `zerod service {install,uninstall,path}` — manage zerod's own systemd 2 + //! user-unit. Linux-only: on other platforms the CLI surfaces a clear error 3 + //! rather than producing a unit file no one can use. 4 + 5 + #[cfg(target_os = "linux")] 6 + mod imp { 7 + use anyhow::{anyhow, Context, Result}; 8 + use std::path::PathBuf; 9 + 10 + const TEMPLATE: &str = include_str!("../assets/zerod.service.in"); 11 + const UNIT_NAME: &str = "zerod.service"; 12 + 13 + /// `$XDG_CONFIG_HOME/systemd/user/zerod.service` or, when XDG isn't 14 + /// set, `~/.config/systemd/user/zerod.service`. 15 + pub fn unit_path() -> Result<PathBuf> { 16 + let base = match std::env::var_os("XDG_CONFIG_HOME") { 17 + Some(v) if !v.is_empty() => PathBuf::from(v), 18 + _ => { 19 + let home = std::env::var_os("HOME") 20 + .filter(|v| !v.is_empty()) 21 + .ok_or_else(|| anyhow!("HOME is not set"))?; 22 + PathBuf::from(home).join(".config") 23 + } 24 + }; 25 + Ok(base.join("systemd").join("user").join(UNIT_NAME)) 26 + } 27 + 28 + pub fn install(force: bool) -> Result<PathBuf> { 29 + let exe = std::env::current_exe().context("resolve current_exe()")?; 30 + let exe = exe 31 + .canonicalize() 32 + .unwrap_or(exe); // best-effort canonicalise; symlinks → real path 33 + let exe_str = exe 34 + .to_str() 35 + .ok_or_else(|| anyhow!("current_exe path is not UTF-8: {exe:?}"))?; 36 + 37 + let path = unit_path()?; 38 + if path.exists() && !force { 39 + return Err(anyhow!( 40 + "{} already exists — pass --force to overwrite", 41 + path.display() 42 + )); 43 + } 44 + 45 + let parent = path 46 + .parent() 47 + .ok_or_else(|| anyhow!("unit path has no parent: {}", path.display()))?; 48 + std::fs::create_dir_all(parent) 49 + .with_context(|| format!("mkdir -p {}", parent.display()))?; 50 + 51 + let rendered = TEMPLATE.replace("{{exec_start}}", exe_str); 52 + let tmp = path.with_extension("service.tmp"); 53 + std::fs::write(&tmp, rendered) 54 + .with_context(|| format!("write {}", tmp.display()))?; 55 + std::fs::rename(&tmp, &path) 56 + .with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?; 57 + 58 + Ok(path) 59 + } 60 + 61 + pub fn uninstall() -> Result<Option<PathBuf>> { 62 + let path = unit_path()?; 63 + if !path.exists() { 64 + return Ok(None); 65 + } 66 + std::fs::remove_file(&path) 67 + .with_context(|| format!("rm {}", path.display()))?; 68 + Ok(Some(path)) 69 + } 70 + } 71 + 72 + #[cfg(not(target_os = "linux"))] 73 + mod imp { 74 + use anyhow::{bail, Result}; 75 + use std::path::PathBuf; 76 + 77 + pub fn unit_path() -> Result<PathBuf> { 78 + bail!("zerod service: linux only") 79 + } 80 + pub fn install(_force: bool) -> Result<PathBuf> { 81 + bail!("zerod service: linux only") 82 + } 83 + pub fn uninstall() -> Result<Option<PathBuf>> { 84 + bail!("zerod service: linux only") 85 + } 86 + } 87 + 88 + pub use imp::{install, uninstall, unit_path};