This commit is contained in:
atagen 2026-05-19 16:33:09 +10:00
commit ca1910de60
39 changed files with 6328 additions and 0 deletions

View file

@ -0,0 +1,25 @@
[package]
name = "headroom-cli"
description = "Headroom CLI binary."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
[[bin]]
name = "headroom"
path = "src/main.rs"
[dependencies]
headroom-client = { workspace = true }
headroom-core = { workspace = true }
headroom-ipc = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View file

@ -0,0 +1,256 @@
//! `headroom` — the user-facing CLI binary.
//!
//! For every subcommand other than `daemon`, this binary connects to
//! the running daemon over its Unix-domain socket and issues the
//! corresponding op. `daemon` enters [`headroom_core::run`] directly.
#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use headroom_client::{Client, ClientError, Route, Topic};
/// Headroom CLI.
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Cli {
/// Override the daemon control socket path.
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Debug, Subcommand)]
enum Cmd {
/// Run the headroom daemon in the foreground.
Daemon,
/// Show daemon status (active profile, sinks, current streams).
Status,
/// Profile management.
#[command(subcommand)]
Profile(ProfileCmd),
/// Routing rules and per-stream decisions.
#[command(subcommand)]
Route(RouteCmd),
/// Get a setting value from the active profile.
Get {
/// Dotted setting key.
key: String,
},
/// Set a setting value in the active profile.
Set {
/// Dotted setting key.
key: String,
/// New value, JSON-encoded.
value: String,
},
/// Toggle the global bypass kill switch.
Bypass {
/// `on` or `off`.
#[arg(value_enum)]
state: BypassState,
},
/// Reload profile files from disk.
Reload,
/// Subscribe to meter ticks and print as line-delimited JSON.
Monitor,
}
#[derive(Debug, Subcommand)]
enum ProfileCmd {
/// List known profiles.
List,
/// Activate the named profile.
Use {
/// Profile name.
name: String,
},
/// Show a profile in full.
Show {
/// Profile name (defaults to the active profile).
name: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum RouteCmd {
/// List routing rules and current per-stream decisions.
List,
/// Add or replace a routing rule for an app.
Set {
/// Application identifier (e.g. `application.process.binary`).
app: String,
/// Where to route.
#[arg(value_enum)]
to: RouteArg,
},
/// Remove an app's user routing rule.
Unset {
/// Application identifier.
app: String,
},
/// Reroute a specific live stream by node id.
Stream {
/// PipeWire node id.
node_id: u32,
/// Where to route.
#[arg(value_enum)]
to: RouteArg,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum BypassState {
On,
Off,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RouteArg {
Processed,
Bypass,
}
impl From<RouteArg> for Route {
fn from(r: RouteArg) -> Self {
match r {
RouteArg::Processed => Route::Processed,
RouteArg::Bypass => Route::Bypass,
}
}
}
fn init_tracing() {
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom=info"));
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(false)
.compact()
.init();
}
fn run() -> Result<(), CliError> {
let cli = Cli::parse();
init_tracing();
match cli.cmd {
Cmd::Daemon => {
headroom_core::run().map_err(|e| CliError::Daemon(e.to_string()))?;
Ok(())
}
cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
}
}
fn with_client<F>(socket: Option<&std::path::Path>, f: F) -> Result<(), CliError>
where
F: FnOnce(&mut Client) -> Result<(), CliError>,
{
let mut client = match socket {
Some(p) => Client::connect_at(p)?,
None => Client::connect()?,
};
f(&mut client)
}
fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
match cmd {
Cmd::Daemon => unreachable!("handled in `run`"),
Cmd::Status => {
let status = client.status()?;
println!("{}", serde_json::to_string_pretty(&status)?);
}
Cmd::Profile(ProfileCmd::List) => {
let profiles = client.profile_list()?;
for p in profiles {
let marker = if p.active { '*' } else { ' ' };
println!("{marker} {:<16} {}", p.name, p.description);
}
}
Cmd::Profile(ProfileCmd::Use { name }) => {
let active = client.profile_use(&name)?;
println!("active profile: {active}");
}
Cmd::Profile(ProfileCmd::Show { name }) => {
let body = client.profile_show(name.as_deref())?;
println!("{}", serde_json::to_string_pretty(&body)?);
}
Cmd::Route(RouteCmd::List) => {
let list = client.route_list()?;
println!("{}", serde_json::to_string_pretty(&list)?);
}
Cmd::Route(RouteCmd::Set { app, to }) => {
client.route_set(&app, to.into())?;
}
Cmd::Route(RouteCmd::Unset { app }) => {
client.route_unset(&app)?;
}
Cmd::Route(RouteCmd::Stream { node_id, to }) => {
client.route_stream(node_id, to.into())?;
}
Cmd::Get { key } => {
let v = client.setting_get(&key)?;
println!("{}", serde_json::to_string(&v)?);
}
Cmd::Set { key, value } => {
let parsed: serde_json::Value = serde_json::from_str(&value)
.map_err(|e| CliError::Other(format!("value is not valid JSON: {e}")))?;
client.setting_set(&key, parsed)?;
}
Cmd::Bypass { state } => {
client.bypass_set(matches!(state, BypassState::On))?;
}
Cmd::Reload => {
let reloaded = client.profile_reload()?;
println!("reloaded: {reloaded:?}");
}
Cmd::Monitor => {
client.subscribe(&[Topic::Meters])?;
loop {
let ev = client.next_event()?;
println!("{}", serde_json::to_string(&ev.data)?);
}
}
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error("client: {0}")]
Client(#[from] ClientError),
#[error("daemon: {0}")]
Daemon(String),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Other(String),
}
fn main() -> ExitCode {
if let Err(e) = run() {
eprintln!("headroom: {e}");
return ExitCode::from(1);
}
ExitCode::SUCCESS
}