feat(output): --output json machine-readable event stream

Adds common/output.rs: a process-global JSON-lines emitter for
non-interactive front-ends. With --output json, host and viewer emit one
JSON object per line on stdout (ticket, host_info, viewer_count, capture
start/stop, viewer_refused, connected), flushed per line; the human banner
and tracing logs stay on stderr so the two never interleave. No-op when the
flag is absent, so call sites emit unconditionally.

This is the shell-out counterpart to an in-process event channel: the
upcoming --gui front-end re-execs this binary as `pixelpass --host
--output json` and parses these lines to drive its window. serde_json was
already in the tree from the bandwidth pre-flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 16:17:38 -04:00
parent 6619bc9b0f
commit e7ded10db8
6 changed files with 135 additions and 8 deletions
+12
View File
@@ -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<OutputFormat>,
/// 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
+1
View File
@@ -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;
+79
View File
@@ -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":"<tag>", ...}`.
#[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}"),
}
}
+37 -7
View File
@@ -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::<SupervisorMsg>(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,
});
}
}
+4
View File
@@ -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();
+2 -1
View File
@@ -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()?;