stage 2
This commit is contained in:
commit
ca1910de60
39 changed files with 6328 additions and 0 deletions
256
crates/headroom-cli/src/main.rs
Normal file
256
crates/headroom-cli/src/main.rs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue