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:
2026-05-25 15:27:49 -04:00
parent e8f86b0ac2
commit 24e0d0e799
4 changed files with 177 additions and 31 deletions
+32 -1
View File
@@ -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);