646f35d3eb
In strict per-app mode the default-sink loopback is suppressed, so until the chosen app's first stream routes the viewer hears silence. Previously no event fired for an app that never routed (`lost` only fires on an N→0 transition after a prior route), so peerspeak couldn't warn — the share looked normal but was silent. Emit a `lost` at capture start (lazy, on first viewer) when, and only when, `--app` + `--strict-audio` are both set; whole-desktop and best-effort modes keep audio flowing via the loopback and emit nothing. Factored the emit decision into the pure, unit-tested `initial_app_audio_state`; derive Debug/PartialEq/Eq on AppAudioState so it can be asserted on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
122 lines
4.5 KiB
Rust
122 lines
4.5 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);
|
|
|
|
/// 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 },
|
|
}
|
|
|
|
#[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"}"#);
|
|
}
|
|
}
|