From 0be92f36a51510c8c38f44942438eb467dc82744 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 24 May 2026 16:32:26 -0400 Subject: [PATCH] feat(gui): host + viewer tabs driving the headless child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GUI now does real work. Host tab: a config form (quality combo, max-viewers, software-encode + single-window toggles) spawns `pixelpass --host --output json …` via re-exec, then a background thread parses the child's JSON events and the window shows live status — ticket with a copy button, viewer count, streaming/waiting state, host_info summary, and host-full refusals. Viewer tab: paste a code, pick mpv/VLC, Connect spawns `pixelpass --output json`, and on the connected event the GUI launches the player (reusing interactive::Player). ChildProc (gui/child.rs) owns the child: reads stdout events over a channel, rings the last 60 stderr lines for failure display, and stops via SIGINT (graceful host teardown) with a 2s grace before SIGKILL — Drop ensures closing the window never orphans a live host. Five round-trip tests lock the common::output::Event ↔ ChildEvent wire contract. Co-Authored-By: Claude Opus 4.7 --- src/gui/child.rs | 238 +++++++++++++++++++++ src/gui/mod.rs | 532 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 750 insertions(+), 20 deletions(-) create mode 100644 src/gui/child.rs diff --git a/src/gui/child.rs b/src/gui/child.rs new file mode 100644 index 0000000..2a5a50a --- /dev/null +++ b/src/gui/child.rs @@ -0,0 +1,238 @@ +//! Drives a headless `pixelpass` child process for the GUI. +//! +//! The GUI re-execs this same binary (via [`std::env::current_exe`]) in +//! headless mode with `--output json`, then reads the child's JSON event +//! stream on a background thread and forwards parsed events over a channel the +//! 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::sync::mpsc::Receiver; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use eframe::egui; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use serde::Deserialize; + +/// One parsed event from the child's stdout. Owned mirror of +/// [`crate::common::output::Event`] (which borrows for emit); kept separate so +/// the wire format and the parser can evolve independently. +#[derive(Deserialize, Debug)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum ChildEvent { + Ticket { + value: String, + }, + HostInfo { + display_server: String, + capture: String, + quality: String, + dimensions: String, + hw_encode: bool, + max_viewers: u32, + max_viewers_source: String, + }, + ViewerCount { + active: u32, + max: u32, + }, + Capture { + state: CaptureState, + }, + ViewerRefused { + reason: String, + }, + Connected { + url: String, + }, +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CaptureState { + Started, + Stopped, +} + +const STDERR_TAIL_MAX: usize = 60; + +pub struct ChildProc { + child: Child, + pub rx: Receiver, + stderr_tail: Arc>>, +} + +impl ChildProc { + /// Spawn `pixelpass ` as a child, wiring up the event reader. `ctx` + /// is repainted whenever an event arrives so the UI updates live. + pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result { + let exe = std::env::current_exe()?; + let mut child = Command::new(exe) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let (tx, rx) = std::sync::mpsc::channel(); + let stdout = child.stdout.take().expect("stdout piped"); + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if line.trim().is_empty() { + continue; + } + // Ignore any non-event line rather than dropping the stream. + if let Ok(ev) = serde_json::from_str::(&line) { + if tx.send(ev).is_err() { + break; // app gone + } + ctx.request_repaint(); + } + } + }); + + let stderr_tail = Arc::new(Mutex::new(Vec::::new())); + let stderr = child.stderr.take().expect("stderr piped"); + let tail = stderr_tail.clone(); + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + let mut t = tail.lock().unwrap(); + t.push(line); + let overflow = t.len().saturating_sub(STDERR_TAIL_MAX); + if overflow > 0 { + t.drain(0..overflow); + } + } + }); + + Ok(Self { + child, + rx, + stderr_tail, + }) + } + + /// Whether the child is still running. + pub fn is_alive(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + /// The last captured stderr lines, joined — for error display. + pub fn stderr_tail(&self) -> String { + self.stderr_tail.lock().unwrap().join("\n") + } + + /// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown + /// — tears down capture, closes the endpoint), with a ~2 s grace period + /// before a hard kill. Idempotent. + pub fn stop(&mut self) { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return; // already exited + } + let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT); + for _ in 0..40 { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for ChildProc { + fn drop(&mut self) { + // Closing the window (dropping the app, hence the session) must not + // orphan a live host child streaming to viewers. + self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::output::{CaptureState as EmitState, Event}; + + // The GUI parses what the headless child emits. These round-trip the + // emitter's own types (`common::output::Event`) through the parser + // (`ChildEvent`) so a rename on either side of the wire fails here rather + // than silently breaking the GUI at runtime. + fn parse(emit: Event) -> ChildEvent { + let line = serde_json::to_string(&emit).unwrap(); + serde_json::from_str::(&line).unwrap() + } + + #[test] + fn ticket_round_trips() { + assert!(matches!( + parse(Event::Ticket { value: "endpointXYZ" }), + ChildEvent::Ticket { value } if value == "endpointXYZ" + )); + } + + #[test] + fn host_info_round_trips() { + let ev = parse(Event::HostInfo { + display_server: "Wayland", + capture: "fullscreen + system-audio", + quality: "Medium", + dimensions: "≤720p / 2500 kbps / 30 fps", + hw_encode: true, + max_viewers: 3, + max_viewers_source: "user-specified", + }); + match ev { + ChildEvent::HostInfo { + display_server, + quality, + hw_encode, + max_viewers, + .. + } => { + assert_eq!(display_server, "Wayland"); + assert_eq!(quality, "Medium"); + assert!(hw_encode); + assert_eq!(max_viewers, 3); + } + other => panic!("expected HostInfo, got {other:?}"), + } + } + + #[test] + fn viewer_count_round_trips() { + assert!(matches!( + parse(Event::ViewerCount { active: 2, max: 4 }), + ChildEvent::ViewerCount { active: 2, max: 4 } + )); + } + + #[test] + fn capture_state_round_trips() { + assert!(matches!( + parse(Event::Capture { state: EmitState::Started }), + ChildEvent::Capture { state: CaptureState::Started } + )); + assert!(matches!( + parse(Event::Capture { state: EmitState::Stopped }), + ChildEvent::Capture { state: CaptureState::Stopped } + )); + } + + #[test] + fn refused_and_connected_round_trip() { + assert!(matches!( + parse(Event::ViewerRefused { reason: "host is full" }), + ChildEvent::ViewerRefused { reason } if reason == "host is full" + )); + assert!(matches!( + parse(Event::Connected { url: "http://127.0.0.1:5000" }), + ChildEvent::Connected { url } if url == "http://127.0.0.1:5000" + )); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 5739ba3..fdfc7dd 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -9,15 +9,19 @@ //! shows. That keeps the fragile capture stack sealed in a separate process: //! the GUI can be closed or crash without taking a live stream down. +mod child; + use eframe::egui; +use self::child::{ChildEvent, ChildProc}; + /// Launch the GUI event loop. Blocks until the window is closed. Runs on the /// main thread (a winit requirement), which is where `main` calls it from. pub fn run() -> anyhow::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([520.0, 420.0]) - .with_min_inner_size([420.0, 320.0]) + .with_inner_size([520.0, 480.0]) + .with_min_inner_size([460.0, 380.0]) .with_title("PixelPass"), ..Default::default() }; @@ -39,15 +43,138 @@ enum Screen { Viewer, } +/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality` +/// argument value passed to the child. +#[derive(Default, PartialEq, Clone, Copy)] +enum QualitySel { + #[default] + Auto, + Source, + High, + Medium, + Low, +} + +impl QualitySel { + fn as_arg(self) -> &'static str { + match self { + QualitySel::Auto => "auto", + QualitySel::Source => "source", + QualitySel::High => "high", + QualitySel::Medium => "medium", + QualitySel::Low => "low", + } + } + + fn label(self) -> &'static str { + match self { + QualitySel::Auto => "Auto — pick from my upload speed", + QualitySel::Source => "Source — native resolution", + QualitySel::High => "High — up to 1080p", + QualitySel::Medium => "Medium — up to 720p", + QualitySel::Low => "Low — up to 480p", + } + } + + const ALL: [QualitySel; 5] = [ + QualitySel::Auto, + QualitySel::Source, + QualitySel::High, + QualitySel::Medium, + QualitySel::Low, + ]; +} + +/// Player choices for the viewer screen. +#[derive(Default, PartialEq, Clone, Copy)] +enum PlayerSel { + #[default] + Mpv, + Vlc, +} + +impl PlayerSel { + fn to_player(self) -> crate::interactive::Player { + match self { + PlayerSel::Mpv => crate::interactive::Player::Mpv, + PlayerSel::Vlc => crate::interactive::Player::Vlc, + } + } +} + +/// Host-screen state: the config form fields plus, once started, the running +/// child and the latest values parsed from its event stream. +struct HostState { + // form + quality: QualitySel, + max_viewers: u32, // 0 = let the host auto-size from the bandwidth preflight + no_hwencode: bool, + window: bool, + // running session + accumulated live state + proc: Option, + ticket: Option, + info: Option, + active: u32, + max: u32, + capturing: bool, + last_refusal: Option, + error: Option, +} + +impl Default for HostState { + fn default() -> Self { + Self { + quality: QualitySel::default(), + max_viewers: 0, + no_hwencode: false, + window: false, + proc: None, + ticket: None, + info: None, + active: 0, + max: 0, + capturing: false, + last_refusal: None, + error: None, + } + } +} + +/// The host config summary echoed back by the child's `host_info` event. +struct HostInfo { + display: String, + capture: String, + quality: String, + dimensions: String, + hw_encode: bool, + cap_source: String, +} + +/// Viewer-screen state. +#[derive(Default)] +struct ViewerState { + ticket_input: String, + player: PlayerSel, + proc: Option, + url: Option, + launched: bool, + error: Option, +} + #[derive(Default)] struct PixelPassApp { screen: Screen, + host: HostState, + viewer: ViewerState, } impl eframe::App for PixelPassApp { - // eframe 0.34 hands us the central-panel `ui` directly (it wraps this in a - // CentralPanel for us). + // eframe 0.34 hands us the central-panel `ui` directly. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + // Drain any pending child events before drawing this frame. + self.pump_host_events(); + self.pump_viewer_events(); + match self.screen { Screen::Menu => self.menu(ui), Screen::Host => self.host(ui), @@ -64,7 +191,7 @@ impl PixelPassApp { ui.label("P2P screen sharing"); ui.add_space(32.0); if ui - .add_sized([240.0, 40.0], egui::Button::new("Host — share my screen")) + .add_sized([260.0, 40.0], egui::Button::new("Host — share my screen")) .clicked() { self.screen = Screen::Host; @@ -72,7 +199,7 @@ impl PixelPassApp { ui.add_space(8.0); if ui .add_sized( - [240.0, 40.0], + [260.0, 40.0], egui::Button::new("View — watch someone's screen"), ) .clicked() @@ -82,25 +209,390 @@ impl PixelPassApp { }); } + // ── Host screen ────────────────────────────────────────────────────── + fn host(&mut self, ui: &mut egui::Ui) { - self.back_bar(ui, "Host"); - ui.separator(); - ui.label("Host controls land in the next step."); - } - - fn viewer(&mut self, ui: &mut egui::Ui) { - self.back_bar(ui, "View"); - ui.separator(); - ui.label("Viewer controls land in a later step."); - } - - /// Title row with a back-to-menu button, shared by the host/viewer screens. - fn back_bar(&mut self, ui: &mut egui::Ui, title: &str) { + let running = self.host.proc.is_some(); ui.horizontal(|ui| { + // Leaving the host screen stops the session (Drop on the child). if ui.button("← Menu").clicked() { + self.stop_host(); self.screen = Screen::Menu; } - ui.heading(title); + ui.heading("Host"); }); + ui.separator(); + + if running { + self.host_running(ui); + } else { + self.host_form(ui); + } + } + + fn host_form(&mut self, ui: &mut egui::Ui) { + if let Some(err) = &self.host.error { + ui.colored_label(egui::Color32::LIGHT_RED, err); + ui.add_space(8.0); + } + + egui::Grid::new("host_form") + .num_columns(2) + .spacing([12.0, 10.0]) + .show(ui, |ui| { + ui.label("Quality"); + egui::ComboBox::from_id_salt("quality") + .selected_text(self.host.quality.label()) + .show_ui(ui, |ui| { + for q in QualitySel::ALL { + ui.selectable_value(&mut self.host.quality, q, q.label()); + } + }); + ui.end_row(); + + ui.label("Max viewers"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut self.host.max_viewers).range(0..=16)); + if self.host.max_viewers == 0 { + ui.label("(auto from upload speed)"); + } + }); + ui.end_row(); + + ui.label("Options"); + ui.vertical(|ui| { + ui.checkbox(&mut self.host.window, "Share a single window"); + ui.checkbox( + &mut self.host.no_hwencode, + "Software encoding (no GPU / VAAPI)", + ); + }); + ui.end_row(); + }); + + ui.add_space(16.0); + if ui + .add_sized([160.0, 36.0], egui::Button::new("Start hosting")) + .clicked() + { + self.start_host(ui.ctx().clone()); + } + ui.add_space(8.0); + ui.label( + egui::RichText::new( + "On Wayland a \"Share Screen?\" dialog appears when the first \ + viewer connects.", + ) + .small() + .weak(), + ); + } + + fn host_running(&mut self, ui: &mut egui::Ui) { + if self.host.capturing { + ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming"); + } else if self.host.ticket.is_some() { + ui.colored_label(egui::Color32::YELLOW, "● Waiting for viewers…"); + } else { + ui.label("Starting…"); + } + + ui.add_space(6.0); + ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max)); + + if let Some(info) = &self.host.info { + ui.label( + egui::RichText::new(format!("{} · {}", info.display, info.capture)) + .small() + .weak(), + ); + ui.label( + egui::RichText::new(format!( + "{} · {} · {} · cap {}", + info.quality, + info.dimensions, + if info.hw_encode { + "HW encode" + } else { + "software encode" + }, + info.cap_source + )) + .small() + .weak(), + ); + } + + ui.add_space(12.0); + + if let Some(ticket) = self.host.ticket.clone() { + ui.label("Share this code with your viewer(s):"); + ui.add_space(4.0); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(&ticket).monospace().small()).wrap()); + }); + ui.add_space(4.0); + if ui.button("📋 Copy code").clicked() { + self.copy_to_clipboard(&ticket); + } + } + + if let Some(reason) = &self.host.last_refusal { + ui.add_space(8.0); + ui.colored_label( + egui::Color32::from_rgb(220, 160, 60), + format!("⚠ {reason}"), + ); + } + + ui.add_space(16.0); + if ui + .add_sized([140.0, 36.0], egui::Button::new("Stop hosting")) + .clicked() + { + self.stop_host(); + } + } + + fn start_host(&mut self, ctx: egui::Context) { + self.host.error = None; + self.host.last_refusal = None; + self.host.ticket = None; + self.host.info = None; + self.host.active = 0; + self.host.max = 0; + self.host.capturing = false; + + let mut args = vec![ + "--host".to_string(), + "--output".to_string(), + "json".to_string(), + "--quality".to_string(), + self.host.quality.as_arg().to_string(), + ]; + if self.host.max_viewers > 0 { + args.push("--max-viewers".to_string()); + args.push(self.host.max_viewers.to_string()); + } + if self.host.no_hwencode { + args.push("--no-hwencode".to_string()); + } + if self.host.window { + args.push("--window".to_string()); + } + + match ChildProc::spawn(&args, ctx) { + Ok(p) => self.host.proc = Some(p), + Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")), + } + } + + fn stop_host(&mut self) { + // Dropping the ChildProc SIGINTs the child and reaps it. + self.host.proc = None; + self.host.capturing = false; + self.host.ticket = None; + } + + /// Drain the host child's event channel into state, and detect an + /// unexpected child exit (e.g. a failed dependency check) so it surfaces + /// in the form instead of leaving a dead "running" view. + fn pump_host_events(&mut self) { + let events: Vec = match &self.host.proc { + Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(), + None => return, + }; + for ev in events { + self.apply_host_event(ev); + } + + if let Some(p) = &mut self.host.proc + && !p.is_alive() + { + if self.host.ticket.is_none() { + let tail = p.stderr_tail(); + self.host.error = Some(if tail.trim().is_empty() { + "Host exited before it could start.".to_string() + } else { + format!("Host exited before it could start:\n{tail}") + }); + } + self.host.proc = None; + self.host.capturing = false; + } + } + + fn apply_host_event(&mut self, ev: ChildEvent) { + match ev { + ChildEvent::Ticket { value } => self.host.ticket = Some(value), + ChildEvent::HostInfo { + display_server, + capture, + quality, + dimensions, + hw_encode, + max_viewers, + max_viewers_source, + } => { + self.host.max = max_viewers; + self.host.info = Some(HostInfo { + display: display_server, + capture, + quality, + dimensions, + hw_encode, + cap_source: max_viewers_source, + }); + } + ChildEvent::ViewerCount { active, max } => { + self.host.active = active; + self.host.max = max; + } + ChildEvent::Capture { state } => { + self.host.capturing = matches!(state, child::CaptureState::Started); + } + ChildEvent::ViewerRefused { reason } => self.host.last_refusal = Some(reason), + ChildEvent::Connected { .. } => {} // viewer-side; not used on the host screen + } + } + + fn copy_to_clipboard(&mut self, text: &str) { + if let Err(e) = arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text.to_owned())) { + self.host.error = Some(format!("Clipboard copy failed: {e}")); + } + } + + // ── Viewer screen ───────────────────────────────────────────────────── + + fn viewer(&mut self, ui: &mut egui::Ui) { + let running = self.viewer.proc.is_some(); + ui.horizontal(|ui| { + if ui.button("← Menu").clicked() { + self.stop_viewer(); + self.screen = Screen::Menu; + } + ui.heading("View"); + }); + ui.separator(); + + if running { + self.viewer_running(ui); + } else { + self.viewer_form(ui); + } + } + + fn viewer_form(&mut self, ui: &mut egui::Ui) { + if let Some(err) = &self.viewer.error { + ui.colored_label(egui::Color32::LIGHT_RED, err); + ui.add_space(8.0); + } + + ui.label("Paste the share code you received:"); + ui.add_space(4.0); + ui.add( + egui::TextEdit::singleline(&mut self.viewer.ticket_input) + .desired_width(f32::INFINITY) + .hint_text("endpoint…"), + ); + + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Player"); + egui::ComboBox::from_id_salt("player") + .selected_text(match self.viewer.player { + PlayerSel::Mpv => "mpv", + PlayerSel::Vlc => "VLC", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.viewer.player, PlayerSel::Mpv, "mpv"); + ui.selectable_value(&mut self.viewer.player, PlayerSel::Vlc, "VLC"); + }); + }); + + ui.add_space(16.0); + let can_connect = !self.viewer.ticket_input.trim().is_empty(); + if ui + .add_enabled( + can_connect, + egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)), + ) + .clicked() + { + self.start_viewer(ui.ctx().clone()); + } + } + + fn viewer_running(&mut self, ui: &mut egui::Ui) { + if self.viewer.launched { + ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming"); + ui.label("Player launched. Close it or disconnect to stop."); + } else if self.viewer.url.is_some() { + ui.label("Connected — launching player…"); + } else { + ui.colored_label(egui::Color32::YELLOW, "● Connecting…"); + } + + ui.add_space(16.0); + if ui + .add_sized([140.0, 36.0], egui::Button::new("Disconnect")) + .clicked() + { + self.stop_viewer(); + } + } + + fn start_viewer(&mut self, ctx: egui::Context) { + self.viewer.error = None; + self.viewer.url = None; + self.viewer.launched = false; + + let ticket = self.viewer.ticket_input.trim().to_string(); + let args = vec![ticket, "--output".to_string(), "json".to_string()]; + match ChildProc::spawn(&args, ctx) { + Ok(p) => self.viewer.proc = Some(p), + Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")), + } + } + + fn stop_viewer(&mut self) { + self.viewer.proc = None; + self.viewer.url = None; + self.viewer.launched = false; + } + + fn pump_viewer_events(&mut self) { + let events: Vec = match &self.viewer.proc { + Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(), + None => return, + }; + for ev in events { + if let ChildEvent::Connected { url } = ev { + self.viewer.url = Some(url.clone()); + if !self.viewer.launched { + match self.viewer.player.to_player().spawn(&url) { + Ok(()) => self.viewer.launched = true, + Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")), + } + } + } + } + + if let Some(p) = &mut self.viewer.proc + && !p.is_alive() + { + // Bridge ended (player closed) or the connection failed. + if self.viewer.url.is_none() { + let tail = p.stderr_tail(); + self.viewer.error = Some(if tail.trim().is_empty() { + "Couldn't connect to the host (check the code).".to_string() + } else { + format!("Connection ended:\n{tail}") + }); + } + self.viewer.proc = None; + self.viewer.launched = false; + self.viewer.url = None; + } } }