//! Graphical front-end (`pixelpass --gui`), compiled only with the `gui` //! feature. //! //! Architecture: this window is a thin **shell-out** driver. It never touches //! the capture / portal / gst / iroh machinery directly — instead it re-execs //! this same binary in headless mode (`pixelpass --host --output json …` or //! `pixelpass --output json`) as a child process and parses the //! child's JSON event stream (see [`crate::common::output`]) to drive what it //! 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, 480.0]) .with_min_inner_size([460.0, 380.0]) .with_title("PixelPass"), ..Default::default() }; eframe::run_native( "PixelPass", options, Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))), ) .map_err(|e| anyhow::anyhow!("GUI failed to start: {e}")) } /// Best-effort clipboard write. Returns whether it succeeded so callers can /// show an honest "✓ Copied" / fallback hint (the clipboard can be flaky on /// Wayland, and a silent miss is what left users pasting stale tickets). fn set_clipboard(text: &str) -> bool { arboard::Clipboard::new() .and_then(|mut cb| cb.set_text(text.to_owned())) .is_ok() } /// Best-effort clipboard read, backing the viewer Paste button and the /// View-screen prefill. `None` on a flaky/empty clipboard — callers just leave /// the field untouched. fn get_clipboard() -> Option { arboard::Clipboard::new() .and_then(|mut cb| cb.get_text()) .ok() } /// Decode the host endpoint id from a ticket string, using the exact same /// parse the viewer does (`EndpointTicket::from_str`), so the GUI agrees with /// the child about what's a valid code. `None` means it isn't a pixelpass /// ticket at all. Used to surface a stale/garbage paste *before* the 15s /// connect timeout, and to show which host the viewer is dialing — the missing /// signal that let people repeatedly dial a long-dead host. fn ticket_endpoint_id(ticket: &str) -> Option { ticket .trim() .parse::() .ok() .map(|t| t.endpoint_addr().id.to_string()) } /// Eyeball-comparable short form of an endpoint id (full ids are long). Enough /// to spot that two ids differ; the host and viewer screens show the same /// truncation so a stale ticket reads as an obvious mismatch. fn short_id(id: &str) -> String { let prefix: String = id.chars().take(12).collect(); if prefix.len() < id.len() { format!("{prefix}…") } else { prefix } } /// Which screen the single window is currently showing. #[derive(Default, PartialEq)] enum Screen { #[default] Menu, Host, 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, /// Whether the current ticket made it onto the clipboard (auto-copy on /// arrival, or a manual Copy click). Drives the "✓ Copied" hint so the /// user isn't left guessing whether to click Copy — the trap that had /// people pasting a stale clipboard ticket. copied: 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, copied: 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, /// Short endpoint id we're dialing, decoded from the ticket at Connect. /// Shown in the "Connecting to …" line so a dead host is identifiable. connecting_to: Option, 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. 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), Screen::Viewer => self.viewer(ui), } } } impl PixelPassApp { fn menu(&mut self, ui: &mut egui::Ui) { ui.vertical_centered(|ui| { ui.add_space(24.0); ui.heading("PixelPass"); ui.label("P2P screen sharing"); ui.label( egui::RichText::new(concat!("v", env!("CARGO_PKG_VERSION"))) .small() .weak(), ); ui.add_space(32.0); if ui .add_sized([260.0, 40.0], egui::Button::new("Host — share my screen")) .clicked() { self.screen = Screen::Host; } ui.add_space(8.0); if ui .add_sized( [260.0, 40.0], egui::Button::new("View — watch someone's screen"), ) .clicked() { self.screen = Screen::Viewer; self.prefill_viewer_ticket(); } }); } // ── Host screen ────────────────────────────────────────────────────── fn host(&mut self, ui: &mut egui::Ui) { 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("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):"); if let Some(id) = ticket_endpoint_id(&ticket) { // The viewer shows "Connecting to …" with this same // truncation, so the two ends can be eyeballed for a match. ui.label( egui::RichText::new(format!("This host: endpoint {}", short_id(&id))) .small() .weak(), ); } 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() .selectable(true), ); }); ui.add_space(4.0); ui.horizontal(|ui| { if ui.button("📋 Copy code").clicked() { self.copy_to_clipboard(&ticket); } if self.host.copied { ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Copied to clipboard"); } }); if !self.host.copied { ui.label( egui::RichText::new( "Couldn't auto-copy — click Copy, or select the code above.", ) .small() .weak(), ); } } 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; self.host.copied = 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; self.host.copied = false; } /// 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 } => { // Auto-copy on arrival, mirroring the CLI/interactive host // (which copies the ticket and prints "copied to your // clipboard"). A failure here is non-fatal: the ticket stays // visible for manual copy, and `copied` stays false so the UI // doesn't falsely claim success. self.host.copied = set_clipboard(&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 } } /// Manual Copy-button handler: copy and reflect success in the UI, or /// surface a clear error if the clipboard rejected it. fn copy_to_clipboard(&mut self, text: &str) { if set_clipboard(text) { self.host.copied = true; self.host.error = None; } else { self.host.copied = false; self.host.error = Some( "Couldn't write to the clipboard. Select the code above and copy it manually." .to_string(), ); } } // ── 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); // A Paste button (read-side mirror of the host's Copy button — one // click grabs the code the host just put on the clipboard) with the // field filling the rest of the row. A single horizontal row, so it // doesn't grab the panel's full height. let ticket_resp = ui .horizontal(|ui| { if ui.button("📋 Paste").clicked() && let Some(text) = get_clipboard() { self.viewer.ticket_input = text.trim().to_string(); } ui.add( egui::TextEdit::singleline(&mut self.viewer.ticket_input) .desired_width(f32::INFINITY) .hint_text("endpoint…"), ) }) .inner; // Enter in the field connects (gated on a decodable code below). let enter_pressed = ticket_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); // Decode the pasted code live: confirms it's a real ticket and shows // which host it points at, so a stale clipboard paste is caught here // instead of after the 15s connect timeout. let trimmed = self.viewer.ticket_input.trim().to_string(); let decoded_id = ticket_endpoint_id(&trimmed); if !trimmed.is_empty() { ui.add_space(4.0); match &decoded_id { Some(id) => ui.colored_label( egui::Color32::LIGHT_GREEN, format!("→ endpoint {}", short_id(id)), ), None => ui.colored_label( egui::Color32::from_rgb(220, 160, 60), "⚠ This doesn't look like a share code.", ), }; } 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); // Only dial a code that actually decodes — no point spawning a child to // spend 15s timing out against garbage. Enter takes the same path. let connect_clicked = ui .add_enabled( decoded_id.is_some(), egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)), ) .on_disabled_hover_text("Paste a valid share code first.") .clicked(); if decoded_id.is_some() && (connect_clicked || enter_pressed) { 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 { let msg = match &self.viewer.connecting_to { Some(id) => format!("● Connecting to {id}…"), None => "● Connecting…".to_string(), }; ui.colored_label(egui::Color32::YELLOW, msg); } ui.add_space(16.0); if ui .add_sized([140.0, 36.0], egui::Button::new("Disconnect")) .clicked() { self.stop_viewer(); } } /// On entering the View screen, drop a clipboard ticket straight into the /// field — the host's Copy button (and our auto-copy) usually leaves the /// freshly-shared code right there. Guarded so it only fires when the field /// is empty and the clipboard holds a *decodable* ticket, so stray /// clipboard text never lands in the box; the live decode still shows the /// id for the user to verify. fn prefill_viewer_ticket(&mut self) { if self.viewer.ticket_input.trim().is_empty() && self.viewer.proc.is_none() && let Some(text) = get_clipboard() && ticket_endpoint_id(&text).is_some() { self.viewer.ticket_input = text.trim().to_string(); } } 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(); self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id)); 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; self.viewer.connecting_to = None; } 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; } } } #[cfg(test)] mod tests { use super::*; /// A real, parseable ticket built the same way the host builds one /// (`EndpointTicket::new`), from a deterministic key so the id is stable. fn sample_ticket() -> (String, String) { let sk = iroh::SecretKey::from_bytes(&[7u8; 32]); let id = sk.public().to_string(); let ticket = iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new( sk.public(), )) .to_string(); (ticket, id) } #[test] fn decodes_endpoint_id_from_a_valid_ticket() { let (ticket, id) = sample_ticket(); assert_eq!(ticket_endpoint_id(&ticket).as_deref(), Some(id.as_str())); } #[test] fn tolerates_surrounding_whitespace() { let (ticket, _) = sample_ticket(); assert!(ticket_endpoint_id(&format!(" \n{ticket}\t ")).is_some()); } #[test] fn rejects_non_ticket_input() { // The empty/whitespace/garbage cases that gate the Connect button off. assert_eq!(ticket_endpoint_id(""), None); assert_eq!(ticket_endpoint_id(" "), None); assert_eq!(ticket_endpoint_id("hello world"), None); assert_eq!(ticket_endpoint_id("endpointbutnotreally"), None); } #[test] fn short_id_truncates_long_ids() { assert_eq!(short_id("0123456789abcdefghij"), "0123456789ab…"); } #[test] fn short_id_leaves_short_or_exact_ids_intact() { assert_eq!(short_id("abc"), "abc"); assert_eq!(short_id("0123456789ab"), "0123456789ab"); // exactly 12, no ellipsis } }