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:
+12
@@ -68,6 +68,12 @@ pub struct Cli {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
||||||
// ── global ────────────────────────────────────────────────────────
|
// ── 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.
|
/// Trace-level logging.
|
||||||
#[arg(long, short)]
|
#[arg(long, short)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
@@ -89,6 +95,12 @@ pub enum DisplayServerArg {
|
|||||||
X11,
|
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)
|
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
|
||||||
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
|
/// 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
|
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod bandwidth;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
|
pub mod output;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
|||||||
@@ -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
@@ -16,8 +16,8 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
use crate::cli::HostOpts;
|
use crate::cli::HostOpts;
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, signal,
|
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
|
||||||
tunnel,
|
signal, tunnel,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::pipeline::CaptureHandle;
|
use self::pipeline::CaptureHandle;
|
||||||
@@ -85,9 +85,25 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
|||||||
let relay_only =
|
let relay_only =
|
||||||
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
|
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
|
||||||
let ticket = EndpointTicket::new(relay_only);
|
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);
|
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 (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
|
||||||
let supervisor = tokio::spawn(supervise(
|
let supervisor = tokio::spawn(supervise(
|
||||||
opts.clone(),
|
opts.clone(),
|
||||||
@@ -215,16 +231,22 @@ async fn supervise(
|
|||||||
match msg {
|
match msg {
|
||||||
SupervisorMsg::AddViewer(reply) => {
|
SupervisorMsg::AddViewer(reply) => {
|
||||||
if count >= max_viewers {
|
if count >= max_viewers {
|
||||||
let _ = reply.send(Err(format!(
|
let reason =
|
||||||
"host is full ({count} of {max_viewers} viewers connected)"
|
format!("host is full ({count} of {max_viewers} viewers connected)");
|
||||||
)));
|
output::emit(output::Event::ViewerRefused { reason: &reason });
|
||||||
|
let _ = reply.send(Err(reason));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if handle.is_none() {
|
if handle.is_none() {
|
||||||
tracing::info!("first viewer arriving — spawning capture");
|
tracing::info!("first viewer arriving — spawning capture");
|
||||||
match capture::spawn(display, &opts, &quality).await {
|
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) => {
|
Err(e) => {
|
||||||
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
|
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
|
||||||
continue;
|
continue;
|
||||||
@@ -235,16 +257,21 @@ async fn supervise(
|
|||||||
let port = handle.as_ref().expect("handle was just set").local_port();
|
let port = handle.as_ref().expect("handle was just set").local_port();
|
||||||
count += 1;
|
count += 1;
|
||||||
let _ = reply.send(Ok(port));
|
let _ = reply.send(Ok(port));
|
||||||
|
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
|
||||||
tracing::info!(active = count, cap = max_viewers, "viewer joined");
|
tracing::info!(active = count, cap = max_viewers, "viewer joined");
|
||||||
}
|
}
|
||||||
SupervisorMsg::RemoveViewer => {
|
SupervisorMsg::RemoveViewer => {
|
||||||
count = count.saturating_sub(1);
|
count = count.saturating_sub(1);
|
||||||
|
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
|
||||||
tracing::info!(active = count, cap = max_viewers, "viewer left");
|
tracing::info!(active = count, cap = max_viewers, "viewer left");
|
||||||
if count == 0
|
if count == 0
|
||||||
&& let Some(h) = handle.take()
|
&& let Some(h) = handle.take()
|
||||||
{
|
{
|
||||||
tracing::info!("last viewer left — tearing down capture");
|
tracing::info!("last viewer left — tearing down capture");
|
||||||
h.shutdown().await;
|
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() {
|
if let Some(h) = handle.take() {
|
||||||
tracing::info!("host shutdown — tearing down capture");
|
tracing::info!("host shutdown — tearing down capture");
|
||||||
h.shutdown().await;
|
h.shutdown().await;
|
||||||
|
output::emit(output::Event::Capture {
|
||||||
|
state: output::CaptureState::Stopped,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ async fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
init_tracing(cli.verbose);
|
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;
|
// libpipewire requires global init before any pw_* call. Idempotent;
|
||||||
// safe to call even when the per-app audio thread never spawns.
|
// safe to call even when the per-app audio thread never spawns.
|
||||||
pipewire::init();
|
pipewire::init();
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,7 @@ use iroh_tickets::endpoint::EndpointTicket;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::cli::ViewerOpts;
|
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<()> {
|
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
||||||
let cancel = signal::install_ctrl_c();
|
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 listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
|
||||||
let port = listener.local_addr()?.port();
|
let port = listener.local_addr()?.port();
|
||||||
let url = format!("http://127.0.0.1:{port}");
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
output::emit(output::Event::Connected { url: &url });
|
||||||
|
|
||||||
if opts.interactive {
|
if opts.interactive {
|
||||||
let player = crate::interactive::prompt_player()?;
|
let player = crate::interactive::prompt_player()?;
|
||||||
|
|||||||
Reference in New Issue
Block a user