feat(gui): list connected viewers and let the host kick them
Track viewers by endpoint id instead of a bare count. The JSON event stream gains viewer_joined / viewer_left (each carrying the id), replacing viewer_count; active/max still ride along so the count display is unchanged. The host screen now renders one row per connected viewer with a Kick button. Clicking it sends `kick <id>` to the headless child over a new stdin command channel, which the host turns into a per-viewer CancellationToken cancel; the existing teardown path then emits the leave, so a kick and a self-disconnect look identical downstream. The stdin channel only runs under --output json (the GUI shell-out) and on a detached OS thread, so a read parked on stdin can't hold up the host's Ctrl+C shutdown. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+37
-7
@@ -6,8 +6,8 @@
|
||||
//! egui app drains each frame. stderr is captured into a small ring so a
|
||||
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -35,7 +35,13 @@ pub enum ChildEvent {
|
||||
max_viewers: u32,
|
||||
max_viewers_source: String,
|
||||
},
|
||||
ViewerCount {
|
||||
ViewerJoined {
|
||||
id: String,
|
||||
active: u32,
|
||||
max: u32,
|
||||
},
|
||||
ViewerLeft {
|
||||
id: String,
|
||||
active: u32,
|
||||
max: u32,
|
||||
},
|
||||
@@ -63,6 +69,9 @@ pub struct ChildProc {
|
||||
child: Child,
|
||||
pub rx: Receiver<ChildEvent>,
|
||||
stderr_tail: Arc<Mutex<Vec<String>>>,
|
||||
/// Write end of the child's stdin, for the line-based command channel
|
||||
/// (see [`ChildProc::send_command`]). `None` once it's been closed.
|
||||
stdin: Option<ChildStdin>,
|
||||
}
|
||||
|
||||
impl ChildProc {
|
||||
@@ -72,11 +81,14 @@ impl ChildProc {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut child = Command::new(exe)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
// Piped so we can send line commands (e.g. `kick <id>`); the host
|
||||
// only reads it when driven this way (`--output json`).
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
std::thread::spawn(move || {
|
||||
@@ -114,9 +126,23 @@ impl ChildProc {
|
||||
child,
|
||||
rx,
|
||||
stderr_tail,
|
||||
stdin,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one newline-terminated command to the child over its stdin (the
|
||||
/// host parses these as `kick <endpoint-id>`). Best-effort: a closed pipe
|
||||
/// (child already gone) just drops the command.
|
||||
pub fn send_command(&mut self, cmd: &str) {
|
||||
let Some(stdin) = self.stdin.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = writeln!(stdin, "{cmd}") {
|
||||
tracing::warn!("failed to send command to host child: {e}");
|
||||
self.stdin = None; // pipe is dead; stop trying
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the child is still running.
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
@@ -205,10 +231,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_count_round_trips() {
|
||||
fn viewer_join_leave_round_trip() {
|
||||
assert!(matches!(
|
||||
parse(Event::ViewerCount { active: 2, max: 4 }),
|
||||
ChildEvent::ViewerCount { active: 2, max: 4 }
|
||||
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
|
||||
ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(Event::ViewerLeft { id: "nodeXYZ", active: 1, max: 4 }),
|
||||
ChildEvent::ViewerLeft { id, active: 1, max: 4 } if id == "nodeXYZ"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+32
-1
@@ -168,6 +168,9 @@ struct HostState {
|
||||
copied: bool,
|
||||
last_refusal: Option<String>,
|
||||
error: Option<String>,
|
||||
/// Endpoint ids of the currently-connected viewers, in arrival order.
|
||||
/// Drives the per-viewer list and its Kick buttons.
|
||||
viewers: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for HostState {
|
||||
@@ -186,6 +189,7 @@ impl Default for HostState {
|
||||
copied: false,
|
||||
last_refusal: None,
|
||||
error: None,
|
||||
viewers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +366,23 @@ impl PixelPassApp {
|
||||
ui.add_space(6.0);
|
||||
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
|
||||
|
||||
// Per-viewer list with a Kick button each. Collect the click first so
|
||||
// we're not borrowing self.host.viewers while we reach for the child.
|
||||
let mut kick: Option<String> = None;
|
||||
for id in &self.host.viewers {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("• endpoint {}", short_id(id)));
|
||||
if ui.small_button("Kick").clicked() {
|
||||
kick = Some(id.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(id) = kick
|
||||
&& let Some(p) = &mut self.host.proc
|
||||
{
|
||||
p.send_command(&format!("kick {id}"));
|
||||
}
|
||||
|
||||
if let Some(info) = &self.host.info {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} · {}", info.display, info.capture))
|
||||
@@ -452,6 +473,7 @@ impl PixelPassApp {
|
||||
self.host.max = 0;
|
||||
self.host.capturing = false;
|
||||
self.host.copied = false;
|
||||
self.host.viewers.clear();
|
||||
|
||||
let mut args = vec![
|
||||
"--host".to_string(),
|
||||
@@ -483,6 +505,7 @@ impl PixelPassApp {
|
||||
self.host.capturing = false;
|
||||
self.host.ticket = None;
|
||||
self.host.copied = false;
|
||||
self.host.viewers.clear();
|
||||
}
|
||||
|
||||
/// Drain the host child's event channel into state, and detect an
|
||||
@@ -543,9 +566,17 @@ impl PixelPassApp {
|
||||
cap_source: max_viewers_source,
|
||||
});
|
||||
}
|
||||
ChildEvent::ViewerCount { active, max } => {
|
||||
ChildEvent::ViewerJoined { id, active, max } => {
|
||||
self.host.active = active;
|
||||
self.host.max = max;
|
||||
if !self.host.viewers.contains(&id) {
|
||||
self.host.viewers.push(id);
|
||||
}
|
||||
}
|
||||
ChildEvent::ViewerLeft { id, active, max } => {
|
||||
self.host.active = active;
|
||||
self.host.max = max;
|
||||
self.host.viewers.retain(|v| v != &id);
|
||||
}
|
||||
ChildEvent::Capture { state } => {
|
||||
self.host.capturing = matches!(state, child::CaptureState::Started);
|
||||
|
||||
Reference in New Issue
Block a user