//! 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); } /// Whether the JSON event stream is on — i.e. we're being driven by a /// machine front-end (the `--gui` shell-out) rather than a human terminal. /// Gates features that only make sense under that front-end, like the /// stdin command channel the host reads `kick` requests from. pub 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, }, /// A viewer joined. `id` is the viewer's endpoint id; `active` is the new /// total after the join. ViewerJoined { id: &'a str, active: u32, max: u32 }, /// A viewer left — disconnected on their own or kicked by the host. `id` /// is the viewer's endpoint id; `active` is the new total after. ViewerLeft { id: &'a str, 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 }, /// Per-app audio routing state (only emitted when `--app` is set). `routed` /// = the chosen app's audio is now reaching viewers; `lost` = its last /// stream went away. Under `--strict-audio`, `lost` means viewers currently /// hear silence; without it, viewers fall back to whole-desktop audio. AppAudio { state: AppAudioState }, } #[derive(Serialize)] #[serde(rename_all = "snake_case")] pub enum CaptureState { Started, Stopped, } #[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AppAudioState { Routed, Lost, } /// 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}"), } } #[cfg(test)] mod tests { use super::*; // The app_audio event is the wire contract peerspeak parses to drive its // echo warning; pin the exact shape so a rename here is caught here. #[test] fn app_audio_event_wire_shape() { let routed = serde_json::to_string(&Event::AppAudio { state: AppAudioState::Routed, }) .unwrap(); assert_eq!(routed, r#"{"event":"app_audio","state":"routed"}"#); let lost = serde_json::to_string(&Event::AppAudio { state: AppAudioState::Lost, }) .unwrap(); assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#); } }