Files
pixelpass/src/common/output.rs
T

249 lines
8.7 KiB
Rust

//! 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":"<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,
},
/// 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"}"#
);
}
}