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
+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}"),
}
}