| 1 | //! `headroom` cli — connects to the daemon over its uds; `daemon` runs core in-process. |
| 2 | |
| 3 | #![forbid(unsafe_code)] |
| 4 | |
| 5 | #[cfg(feature = "tui")] |
| 6 | mod tui; |
| 7 | |
| 8 | use std::path::PathBuf; |
| 9 | use std::process::ExitCode; |
| 10 | |
| 11 | use clap::{Parser, Subcommand, ValueEnum}; |
| 12 | use headroom_client::{Client, ClientError, Route, Topic}; |
| 13 | |
| 14 | // debug-only: arms core's audio-thread `assert_no_alloc` blocks; no-op in release. |
| 15 | #[cfg(debug_assertions)] |
| 16 | #[global_allocator] |
| 17 | static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler; |
| 18 | |
| 19 | /// headroom cli. |
| 20 | #[derive(Debug, Parser)] |
| 21 | #[command(version, about, long_about = None)] |
| 22 | struct Cli { |
| 23 | /// override the daemon control socket path. |
| 24 | #[arg(long, global = true)] |
| 25 | socket: Option<PathBuf>, |
| 26 | |
| 27 | #[command(subcommand)] |
| 28 | cmd: Cmd, |
| 29 | } |
| 30 | |
| 31 | #[derive(Debug, Subcommand)] |
| 32 | enum Cmd { |
| 33 | /// run the daemon in the foreground. |
| 34 | Daemon { |
| 35 | /// extra `*.toml` profile dir (repeatable). precedes shipped/user |
| 36 | /// profiles; first dir is watched for live edits. |
| 37 | #[arg(long = "profile-dir", value_name = "DIR")] |
| 38 | profile_dir: Vec<PathBuf>, |
| 39 | }, |
| 40 | |
| 41 | /// show daemon status. |
| 42 | Status, |
| 43 | |
| 44 | /// profile management. |
| 45 | #[command(subcommand)] |
| 46 | Profile(ProfileCmd), |
| 47 | |
| 48 | /// routing rules and per-stream decisions. |
| 49 | #[command(subcommand)] |
| 50 | Route(RouteCmd), |
| 51 | |
| 52 | /// per-app level control (layer a). |
| 53 | #[command(subcommand)] |
| 54 | PerApp(PerAppCmd), |
| 55 | |
| 56 | /// get a setting value from the active profile. |
| 57 | Get { |
| 58 | /// dotted setting key. |
| 59 | key: String, |
| 60 | }, |
| 61 | |
| 62 | /// set a setting override (or clear with `--clear`). overrides persist |
| 63 | /// across profile switches; `reset` clears all. |
| 64 | Set { |
| 65 | /// dotted setting key. |
| 66 | key: String, |
| 67 | /// new value, json-encoded. omit with `--clear`. |
| 68 | // allow_hyphen_values so negatives aren't parsed as flags. |
| 69 | #[arg(allow_hyphen_values = true)] |
| 70 | value: Option<String>, |
| 71 | /// clear this key's override instead of setting one. |
| 72 | #[arg(long, conflicts_with = "value")] |
| 73 | clear: bool, |
| 74 | }, |
| 75 | |
| 76 | /// toggle the global bypass kill switch. |
| 77 | Bypass { |
| 78 | /// `on` or `off`. |
| 79 | #[arg(value_enum)] |
| 80 | state: BypassState, |
| 81 | }, |
| 82 | |
| 83 | /// clear all setting overrides. leaves route/per-app/bypass intact. |
| 84 | Reset, |
| 85 | |
| 86 | /// reload profile files from disk. |
| 87 | Reload, |
| 88 | |
| 89 | /// live monitor. defaults to a tui; `--json` is the line-delimited stream. |
| 90 | Monitor { |
| 91 | /// topics to subscribe to (comma-separated). `--json` only; the tui |
| 92 | /// subscribes to all. |
| 93 | #[arg(value_delimiter = ',', default_value = "meters")] |
| 94 | topics: Vec<MonitorTopic>, |
| 95 | |
| 96 | /// emit one json event per line instead of the tui. |
| 97 | #[arg(long)] |
| 98 | json: bool, |
| 99 | }, |
| 100 | } |
| 101 | |
| 102 | #[derive(Debug, Clone, Copy, ValueEnum)] |
| 103 | enum MonitorTopic { |
| 104 | Meters, |
| 105 | Profile, |
| 106 | Routing, |
| 107 | Daemon, |
| 108 | } |
| 109 | |
| 110 | impl From<MonitorTopic> for Topic { |
| 111 | fn from(t: MonitorTopic) -> Self { |
| 112 | match t { |
| 113 | MonitorTopic::Meters => Topic::Meters, |
| 114 | MonitorTopic::Profile => Topic::Profile, |
| 115 | MonitorTopic::Routing => Topic::Routing, |
| 116 | MonitorTopic::Daemon => Topic::Daemon, |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | #[derive(Debug, Subcommand)] |
| 122 | enum ProfileCmd { |
| 123 | /// list known profiles. |
| 124 | List, |
| 125 | /// activate the named profile. |
| 126 | Use { |
| 127 | /// profile name. |
| 128 | name: String, |
| 129 | }, |
| 130 | /// show a profile in full. |
| 131 | Show { |
| 132 | /// profile name (defaults to the active profile). |
| 133 | name: Option<String>, |
| 134 | }, |
| 135 | } |
| 136 | |
| 137 | #[derive(Debug, Subcommand)] |
| 138 | enum RouteCmd { |
| 139 | /// list routing rules and current per-stream decisions. |
| 140 | List, |
| 141 | /// add or replace a routing rule for an app. |
| 142 | Set { |
| 143 | /// app identifier (e.g. `application.process.binary`). |
| 144 | app: String, |
| 145 | /// where to route. |
| 146 | #[arg(value_enum)] |
| 147 | to: RouteArg, |
| 148 | }, |
| 149 | /// remove an app's user routing rule. |
| 150 | Unset { |
| 151 | /// app identifier. |
| 152 | app: String, |
| 153 | }, |
| 154 | /// reroute a specific live stream by node id. |
| 155 | Stream { |
| 156 | /// pipewire node id. |
| 157 | node_id: u32, |
| 158 | /// where to route. |
| 159 | #[arg(value_enum)] |
| 160 | to: RouteArg, |
| 161 | }, |
| 162 | } |
| 163 | |
| 164 | #[derive(Debug, Subcommand)] |
| 165 | enum PerAppCmd { |
| 166 | /// show per-app layer a state for managed streams. |
| 167 | Status { |
| 168 | /// emit the snapshot list as json instead of a table. |
| 169 | #[arg(long)] |
| 170 | json: bool, |
| 171 | }, |
| 172 | /// enable the layer a master switch (persisted). |
| 173 | On, |
| 174 | /// disable the layer a master switch (persisted). |
| 175 | Off, |
| 176 | /// enable or disable layer a for a specific app (persisted). |
| 177 | Set { |
| 178 | /// app identifier (process_binary or application_name). |
| 179 | app: String, |
| 180 | /// `on` or `off`. |
| 181 | #[arg(value_enum)] |
| 182 | state: BypassState, |
| 183 | }, |
| 184 | /// clear a managed stream's deference lock. |
| 185 | Reset { |
| 186 | /// pipewire node id of the managed stream. |
| 187 | node_id: u32, |
| 188 | }, |
| 189 | } |
| 190 | |
| 191 | #[derive(Debug, Clone, Copy, ValueEnum)] |
| 192 | enum BypassState { |
| 193 | On, |
| 194 | Off, |
| 195 | } |
| 196 | |
| 197 | #[derive(Debug, Clone, Copy, ValueEnum)] |
| 198 | enum RouteArg { |
| 199 | Processed, |
| 200 | Bypass, |
| 201 | } |
| 202 | |
| 203 | impl From<RouteArg> for Route { |
| 204 | fn from(r: RouteArg) -> Self { |
| 205 | match r { |
| 206 | RouteArg::Processed => Route::Processed, |
| 207 | RouteArg::Bypass => Route::Bypass, |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | fn init_tracing() { |
| 213 | let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() |
| 214 | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom=info")); |
| 215 | tracing_subscriber::fmt() |
| 216 | .with_env_filter(env_filter) |
| 217 | .with_target(false) |
| 218 | .compact() |
| 219 | .init(); |
| 220 | } |
| 221 | |
| 222 | fn run() -> Result<(), CliError> { |
| 223 | let cli = Cli::parse(); |
| 224 | |
| 225 | // tui owns the terminal; keep `tracing` off it. |
| 226 | let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. }); |
| 227 | if !tui_mode { |
| 228 | init_tracing(); |
| 229 | } |
| 230 | |
| 231 | match cli.cmd { |
| 232 | Cmd::Daemon { profile_dir } => { |
| 233 | headroom_core::run_with_profile_dirs(profile_dir) |
| 234 | .map_err(|e| CliError::Daemon(e.to_string()))?; |
| 235 | Ok(()) |
| 236 | } |
| 237 | Cmd::Monitor { json: false, .. } => { |
| 238 | #[cfg(feature = "tui")] |
| 239 | { |
| 240 | // connect on the main thread so initial rpcs run before raw mode. |
| 241 | let client = match cli.socket.as_deref() { |
| 242 | Some(p) => Client::connect_at(p)?, |
| 243 | None => Client::connect()?, |
| 244 | }; |
| 245 | tui::run(client).map_err(CliError::Tui) |
| 246 | } |
| 247 | // no `tui` feature: point at the json stream, not an opaque failure. |
| 248 | #[cfg(not(feature = "tui"))] |
| 249 | { |
| 250 | let _ = cli.socket.as_deref(); |
| 251 | Err(CliError::Other( |
| 252 | "this build was compiled without the TUI (`tui` feature disabled); \ |
| 253 | rerun `headroom monitor --json` for the line-delimited event stream" |
| 254 | .into(), |
| 255 | )) |
| 256 | } |
| 257 | } |
| 258 | cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)), |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | fn with_client<F>(socket: Option<&std::path::Path>, f: F) -> Result<(), CliError> |
| 263 | where |
| 264 | F: FnOnce(&mut Client) -> Result<(), CliError>, |
| 265 | { |
| 266 | let mut client = match socket { |
| 267 | Some(p) => Client::connect_at(p)?, |
| 268 | None => Client::connect()?, |
| 269 | }; |
| 270 | f(&mut client) |
| 271 | } |
| 272 | |
| 273 | fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> { |
| 274 | match cmd { |
| 275 | Cmd::Daemon { .. } => unreachable!("handled in `run`"), |
| 276 | |
| 277 | Cmd::Status => { |
| 278 | let status = client.status()?; |
| 279 | println!("{}", serde_json::to_string_pretty(&status)?); |
| 280 | } |
| 281 | |
| 282 | Cmd::Profile(ProfileCmd::List) => { |
| 283 | let profiles = client.profile_list()?; |
| 284 | for p in profiles { |
| 285 | let marker = if p.active { '*' } else { ' ' }; |
| 286 | println!("{marker} {:<16} {}", p.name, p.description); |
| 287 | } |
| 288 | } |
| 289 | Cmd::Profile(ProfileCmd::Use { name }) => { |
| 290 | let active = client.profile_use(&name)?; |
| 291 | println!("active profile: {active}"); |
| 292 | } |
| 293 | Cmd::Profile(ProfileCmd::Show { name }) => { |
| 294 | let body = client.profile_show(name.as_deref())?; |
| 295 | println!("{}", serde_json::to_string_pretty(&body)?); |
| 296 | } |
| 297 | |
| 298 | Cmd::Route(RouteCmd::List) => { |
| 299 | let list = client.route_list()?; |
| 300 | println!("{}", serde_json::to_string_pretty(&list)?); |
| 301 | } |
| 302 | Cmd::Route(RouteCmd::Set { app, to }) => { |
| 303 | client.route_set(&app, to.into())?; |
| 304 | } |
| 305 | Cmd::Route(RouteCmd::Unset { app }) => { |
| 306 | client.route_unset(&app)?; |
| 307 | } |
| 308 | Cmd::Route(RouteCmd::Stream { node_id, to }) => { |
| 309 | client.route_stream(node_id, to.into())?; |
| 310 | } |
| 311 | |
| 312 | Cmd::PerApp(PerAppCmd::Status { json }) => { |
| 313 | let list = client.layer_a_list()?; |
| 314 | if json { |
| 315 | println!("{}", serde_json::to_string_pretty(&list)?); |
| 316 | } else if list.is_empty() { |
| 317 | println!("no streams under Layer A management"); |
| 318 | } else { |
| 319 | println!( |
| 320 | "{:<8} {:<24} {:>10} {:>9} {:>9}", |
| 321 | "node", "app", "reduction", "ceiling", "deferred" |
| 322 | ); |
| 323 | for s in &list { |
| 324 | let app = if s.app.len() > 24 { |
| 325 | format!("{}…", &s.app[..23]) |
| 326 | } else { |
| 327 | s.app.clone() |
| 328 | }; |
| 329 | let ceiling = s |
| 330 | .user_ceiling_lin |
| 331 | .map(|c| format!("{c:.2}")) |
| 332 | .unwrap_or_else(|| "—".to_string()); |
| 333 | println!( |
| 334 | "{:<8} {:<24} {:>8.1}dB {:>9} {:>9}", |
| 335 | s.node_id, app, s.reduction_db, ceiling, s.deferred |
| 336 | ); |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | Cmd::PerApp(PerAppCmd::On) => { |
| 341 | client.per_app_master(true)?; |
| 342 | } |
| 343 | Cmd::PerApp(PerAppCmd::Off) => { |
| 344 | client.per_app_master(false)?; |
| 345 | } |
| 346 | Cmd::PerApp(PerAppCmd::Set { app, state }) => { |
| 347 | client.per_app_set(&app, matches!(state, BypassState::On))?; |
| 348 | } |
| 349 | Cmd::PerApp(PerAppCmd::Reset { node_id }) => { |
| 350 | client.layer_a_reset(node_id)?; |
| 351 | } |
| 352 | |
| 353 | Cmd::Get { key } => { |
| 354 | let v = client.setting_get(&key)?; |
| 355 | println!("{}", serde_json::to_string(&v)?); |
| 356 | } |
| 357 | Cmd::Set { key, value, clear } => { |
| 358 | if clear { |
| 359 | let existed = client.setting_clear(&key)?; |
| 360 | if existed { |
| 361 | println!("cleared override for '{key}'"); |
| 362 | } else { |
| 363 | println!("no override set for '{key}'"); |
| 364 | } |
| 365 | } else { |
| 366 | let value = value.ok_or_else(|| { |
| 367 | CliError::Other("provide a value, or use --clear to remove the override".into()) |
| 368 | })?; |
| 369 | let parsed: serde_json::Value = serde_json::from_str(&value) |
| 370 | .map_err(|e| CliError::Other(format!("value is not valid JSON: {e}")))?; |
| 371 | client.setting_set(&key, parsed)?; |
| 372 | } |
| 373 | } |
| 374 | Cmd::Bypass { state } => { |
| 375 | client.bypass_set(matches!(state, BypassState::On))?; |
| 376 | } |
| 377 | Cmd::Reset => { |
| 378 | let cleared = client.setting_reset()?; |
| 379 | println!("cleared {cleared} setting override(s)"); |
| 380 | } |
| 381 | Cmd::Reload => { |
| 382 | let reloaded = client.profile_reload()?; |
| 383 | println!("reloaded: {reloaded:?}"); |
| 384 | } |
| 385 | Cmd::Monitor { topics, json } => { |
| 386 | if json { |
| 387 | let pw_topics: Vec<Topic> = |
| 388 | topics.iter().copied().map(Topic::from).collect(); |
| 389 | client.subscribe(&pw_topics)?; |
| 390 | loop { |
| 391 | let ev = client.next_event()?; |
| 392 | println!( |
| 393 | "{} {}/{} {}", |
| 394 | chrono_like_now(), |
| 395 | ev.topic, |
| 396 | ev.event, |
| 397 | serde_json::to_string(&ev.data)?, |
| 398 | ); |
| 399 | } |
| 400 | } else { |
| 401 | unreachable!("TUI monitor is dispatched before `with_client`") |
| 402 | } |
| 403 | } |
| 404 | } |
| 405 | Ok(()) |
| 406 | } |
| 407 | |
| 408 | #[derive(Debug, thiserror::Error)] |
| 409 | enum CliError { |
| 410 | #[error("client: {0}")] |
| 411 | Client(#[from] ClientError), |
| 412 | |
| 413 | #[error("daemon: {0}")] |
| 414 | Daemon(String), |
| 415 | |
| 416 | #[error("json: {0}")] |
| 417 | Json(#[from] serde_json::Error), |
| 418 | |
| 419 | #[cfg(feature = "tui")] |
| 420 | #[error("tui: {0}")] |
| 421 | Tui(tui::TuiError), |
| 422 | |
| 423 | #[error("{0}")] |
| 424 | Other(String), |
| 425 | } |
| 426 | |
| 427 | /// timestamp label for monitor output; `SystemTime` to avoid chrono. |
| 428 | fn chrono_like_now() -> String { |
| 429 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 430 | let t = SystemTime::now() |
| 431 | .duration_since(UNIX_EPOCH) |
| 432 | .unwrap_or_default(); |
| 433 | format!("{}.{:03}", t.as_secs(), t.subsec_millis()) |
| 434 | } |
| 435 | |
| 436 | fn main() -> ExitCode { |
| 437 | if let Err(e) = run() { |
| 438 | eprintln!("headroom: {e}"); |
| 439 | return ExitCode::from(1); |
| 440 | } |
| 441 | ExitCode::SUCCESS |
| 442 | } |