diff --git a/src/cli.rs b/src/cli.rs index ca21ddf..be6b824 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -68,6 +68,12 @@ pub struct Cli { pub port: u16, // ── global ──────────────────────────────────────────────────────── + /// Emit machine-readable events on stdout (one JSON object per line) + /// alongside the human banner on stderr. For scripts and the --gui + /// front-end. Currently only `json` is supported. + #[arg(long, value_enum, value_name = "FORMAT")] + pub output: Option, + /// Trace-level logging. #[arg(long, short)] pub verbose: bool, @@ -89,6 +95,12 @@ pub enum DisplayServerArg { X11, } +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum OutputFormat { + /// One JSON object per line on stdout. + Json, +} + /// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps) /// tuple — resolution is a quality-per-bitrate knob, so the three only make /// sense together. `Auto` has no fixed tuple; it picks one of the others from diff --git a/src/common/mod.rs b/src/common/mod.rs index 99d45e7..af1fd15 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -3,6 +3,7 @@ pub mod bandwidth; pub mod config; pub mod deps; pub mod display; +pub mod output; pub mod process; pub mod signal; pub mod tunnel; diff --git a/src/common/output.rs b/src/common/output.rs new file mode 100644 index 0000000..58df24d --- /dev/null +++ b/src/common/output.rs @@ -0,0 +1,79 @@ +//! Machine-readable event stream for non-interactive front-ends. +//! +//! When enabled with `--output json`, the host and viewer emit one JSON +//! object per line on **stdout**. The human banner and `tracing` logs stay +//! on **stderr**, so the two streams never interleave and a parser reading +//! stdout sees only events. Each line is flushed immediately so a front-end +//! reading the pipe gets events live rather than in block-buffered chunks. +//! +//! This is the shell-out counterpart to an in-process event channel: the +//! `--gui` front-end re-execs this binary as `pixelpass --host --output json` +//! and parses these lines to drive its window. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::Serialize; + +static JSON_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Turn JSON event output on. Called once at startup from `--output json`. +pub fn set_json(enabled: bool) { + JSON_ENABLED.store(enabled, Ordering::Relaxed); +} + +fn json_enabled() -> bool { + JSON_ENABLED.load(Ordering::Relaxed) +} + +/// One event in the stdout stream. Serialized as `{"event":"", ...}`. +#[derive(Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event<'a> { + /// The relay-only ticket the viewer needs. Emitted once at host startup. + Ticket { value: &'a str }, + /// One-shot host configuration summary, mirroring the banner fields. + HostInfo { + display_server: &'a str, + capture: &'a str, + quality: &'a str, + dimensions: &'a str, + hw_encode: bool, + max_viewers: u32, + max_viewers_source: &'a str, + }, + /// Active viewer count changed. + ViewerCount { active: u32, max: u32 }, + /// Capture pipeline lifecycle (spawned on first viewer, torn down on last). + Capture { state: CaptureState }, + /// A viewer was turned away (host full, or capture spawn failed). + ViewerRefused { reason: &'a str }, + /// Viewer-side: the local player URL is ready to open. + Connected { url: &'a str }, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CaptureState { + Started, + Stopped, +} + +/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON +/// output was enabled with [`set_json`], so call sites can sprinkle these +/// unconditionally without branching. +pub fn emit(event: Event) { + if !json_enabled() { + return; + } + match serde_json::to_string(&event) { + Ok(line) => { + let mut out = std::io::stdout().lock(); + // Best-effort: a closed pipe (front-end gone) shouldn't crash the + // host — it keeps streaming to any viewers already connected. + let _ = writeln!(out, "{line}"); + let _ = out.flush(); + } + Err(e) => tracing::warn!("failed to serialize event: {e}"), + } +} diff --git a/src/host/mod.rs b/src/host/mod.rs index 4f42ac6..28cdbb4 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -16,8 +16,8 @@ use tokio_util::sync::CancellationToken; use crate::cli::HostOpts; use crate::common::{ - alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, signal, - tunnel, + alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output, + signal, tunnel, }; use self::pipeline::CaptureHandle; @@ -85,9 +85,25 @@ pub async fn run(opts: HostOpts) -> Result<()> { let relay_only = EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned()); let ticket = EndpointTicket::new(relay_only); - let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string()); + let ticket_str = ticket.to_string(); + let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket_str); print_host_banner(&ticket, display, &opts, &quality, &resolution, clipboard_ok); + output::emit(output::Event::Ticket { value: &ticket_str }); + let display_str = format!("{display:?}"); + let capture = capture_summary(&opts); + let dims = quality.dimensions_summary(); + let cap_source = resolution.source.label(); + output::emit(output::Event::HostInfo { + display_server: &display_str, + capture: &capture, + quality: &quality.label, + dimensions: &dims, + hw_encode: !opts.no_hwencode, + max_viewers: resolution.value, + max_viewers_source: &cap_source, + }); + let (sup_tx, sup_rx) = mpsc::channel::(16); let supervisor = tokio::spawn(supervise( opts.clone(), @@ -215,16 +231,22 @@ async fn supervise( match msg { SupervisorMsg::AddViewer(reply) => { if count >= max_viewers { - let _ = reply.send(Err(format!( - "host is full ({count} of {max_viewers} viewers connected)" - ))); + let reason = + format!("host is full ({count} of {max_viewers} viewers connected)"); + output::emit(output::Event::ViewerRefused { reason: &reason }); + let _ = reply.send(Err(reason)); continue; } if handle.is_none() { tracing::info!("first viewer arriving — spawning capture"); match capture::spawn(display, &opts, &quality).await { - Ok(h) => handle = Some(h), + Ok(h) => { + handle = Some(h); + output::emit(output::Event::Capture { + state: output::CaptureState::Started, + }); + } Err(e) => { let _ = reply.send(Err(format!("capture spawn failed: {e:#}"))); continue; @@ -235,16 +257,21 @@ async fn supervise( let port = handle.as_ref().expect("handle was just set").local_port(); count += 1; let _ = reply.send(Ok(port)); + output::emit(output::Event::ViewerCount { active: count, max: max_viewers }); tracing::info!(active = count, cap = max_viewers, "viewer joined"); } SupervisorMsg::RemoveViewer => { count = count.saturating_sub(1); + output::emit(output::Event::ViewerCount { active: count, max: max_viewers }); tracing::info!(active = count, cap = max_viewers, "viewer left"); if count == 0 && let Some(h) = handle.take() { tracing::info!("last viewer left — tearing down capture"); h.shutdown().await; + output::emit(output::Event::Capture { + state: output::CaptureState::Stopped, + }); } } } @@ -253,6 +280,9 @@ async fn supervise( if let Some(h) = handle.take() { tracing::info!("host shutdown — tearing down capture"); h.shutdown().await; + output::emit(output::Event::Capture { + state: output::CaptureState::Stopped, + }); } } diff --git a/src/main.rs b/src/main.rs index 35013bd..021c741 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,10 @@ async fn main() -> Result<()> { let cli = Cli::parse(); init_tracing(cli.verbose); + if matches!(cli.output, Some(cli::OutputFormat::Json)) { + common::output::set_json(true); + } + // libpipewire requires global init before any pw_* call. Idempotent; // safe to call even when the per-app audio thread never spawns. pipewire::init(); diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 011c6be..bba5720 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -5,7 +5,7 @@ use iroh_tickets::endpoint::EndpointTicket; use tokio::net::TcpListener; use crate::cli::ViewerOpts; -use crate::common::{alpn::ALPN, signal}; +use crate::common::{alpn::ALPN, output, signal}; pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> { let cancel = signal::install_ctrl_c(); @@ -23,6 +23,7 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> { let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?; let port = listener.local_addr()?.port(); let url = format!("http://127.0.0.1:{port}"); + output::emit(output::Event::Connected { url: &url }); if opts.interactive { let player = crate::interactive::prompt_player()?;