//! 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); /// First wire version for the desktop-audio-exclusion status family. /// /// Existing event tags predate explicit versioning. These events are consumed /// across the PixelPass/PeerSpeak process boundary and are landing before the /// PeerSpeak parser, so their version is carried on every record rather than /// inferred from either binary's package version. pub(crate) const AUDIO_EXCLUSION_EVENT_VERSION: u8 = 1; /// 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 }, /// One otherwise-eligible playback stream could not be linked safely. StreamUnsupported { version: u8, stream_serial: u64, reason: &'a str, }, /// The configured AEC identity never appeared before its validation /// deadline. Fan-out remains fail-closed. AecFailed { version: u8, module_index: u64 }, /// A previously validated AEC identity disappeared. Every owned fan-out /// link is revoked before this transition is reported. AecRevoked { version: u8, module_index: u64 }, /// An echo-cancel group other than the configured PeerSpeak instance is /// present and excluded from fan-out. ForeignAecWarning { version: u8, link_group: &'a str }, } #[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, } /// Owned form of the audio-exclusion status family. The PipeWire observer can /// enqueue this through an unbounded sender without borrowing its snapshot; /// a Tokio-side forwarder then converts it to the public JSON [`Event`]. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AudioExclusionEvent { StreamUnsupported { stream_serial: u64, reason: &'static str, }, AecFailed { module_index: u64, }, AecRevoked { module_index: u64, }, ForeignAecWarning { link_group: String, }, } impl AudioExclusionEvent { fn as_event(&self) -> Event<'_> { match self { Self::StreamUnsupported { stream_serial, reason, } => Event::StreamUnsupported { version: AUDIO_EXCLUSION_EVENT_VERSION, stream_serial: *stream_serial, reason, }, Self::AecFailed { module_index } => Event::AecFailed { version: AUDIO_EXCLUSION_EVENT_VERSION, module_index: *module_index, }, Self::AecRevoked { module_index } => Event::AecRevoked { version: AUDIO_EXCLUSION_EVENT_VERSION, module_index: *module_index, }, Self::ForeignAecWarning { link_group } => Event::ForeignAecWarning { version: AUDIO_EXCLUSION_EVENT_VERSION, link_group, }, } } /// Emit this owned status record through the stable stdout protocol. pub(crate) fn emit(&self) { emit(self.as_event()); } } /// 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"}"#); } #[test] fn audio_exclusion_event_wire_shapes_are_versioned_and_exact() { let unsupported = serde_json::to_string( &AudioExclusionEvent::StreamUnsupported { stream_serial: 4_294_967_297, reason: "link-creation-failed", } .as_event(), ) .unwrap(); assert_eq!( unsupported, r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967297,"reason":"link-creation-failed"}"# ); let failed = serde_json::to_string( &AudioExclusionEvent::AecFailed { module_index: 536_870_919, } .as_event(), ) .unwrap(); assert_eq!( failed, r#"{"event":"aec_failed","version":1,"module_index":536870919}"# ); let revoked = serde_json::to_string( &AudioExclusionEvent::AecRevoked { module_index: 536_870_919, } .as_event(), ) .unwrap(); assert_eq!( revoked, r#"{"event":"aec_revoked","version":1,"module_index":536870919}"# ); let warning = serde_json::to_string( &AudioExclusionEvent::ForeignAecWarning { link_group: "echo-cancel-9999-13".to_string(), } .as_event(), ) .unwrap(); assert_eq!( warning, r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"# ); } }