//! 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. //! //! ## Windowing: why we hand-roll the loop instead of using `eframe` //! //! The "keep running in the tray" feature needs to truly *hide* the window //! while a stream keeps running in the child. On Wayland there is no //! unmap-but-keep-alive request in xdg-shell, so winit's `set_visible(false)` //! is a deliberate no-op there — the only way to make a toplevel vanish is to //! **destroy its surface** and recreate it later. `eframe::run_native` owns the //! one window it will never let you drop, so we replace it with a hand-rolled //! [`winit::application::ApplicationHandler`] + `glutin` + [`egui_glow`] loop: //! //! * **Hide to tray** ([`Gfx::hide`]) parks the GL context as *not-current* //! and drops the [`winit::window::Window`] + its GL surface — the Wayland //! surface is genuinely gone, natively, on both Wayland and X11. //! * **Show** ([`Gfx::show`], on a tray click) recreates the window + surface //! and makes the parked context current again. //! //! The GL context, `glutin` display/config, and the `egui_glow` painter (with //! its uploaded font/texture atlas) and `egui-winit` state (with its clipboard //! connection) are **kept** across the cycle — only the OS window and its //! surface churn. That preserves the hard-won Wayland clipboard integration and //! avoids re-uploading textures on every show. //! //! Wakeups are routed through winit's [`winit::event_loop::EventLoopProxy`] //! (see [`Waker`] and [`tray`]) rather than egui's repaint callback, because a //! child event or tray click must wake the loop even while the window is //! dropped and no egui frame is running. mod child; mod theme; mod tray; use std::num::NonZeroU32; use std::sync::Arc; use std::time::{Duration, Instant}; use eframe::egui; use egui_glow::EguiGlow; use egui_glow::egui_winit; use egui_glow::glow::{self, HasContext as _}; use glutin::config::{Config, ConfigTemplateBuilder}; use glutin::context::{ ContextApi, ContextAttributesBuilder, NotCurrentContext, NotCurrentGlContext as _, PossiblyCurrentContext, PossiblyCurrentGlContext as _, }; use glutin::display::{Display, GetGlDisplay as _, GlDisplay as _}; use glutin::surface::{ GlSurface as _, Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface, }; use glutin_winit::{ApiPreference, DisplayBuilder}; use winit::application::ApplicationHandler; use winit::event::WindowEvent; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; use winit::raw_window_handle::HasWindowHandle as _; use winit::window::{Window, WindowAttributes, WindowId}; use self::child::{ChildEvent, ChildProc}; use self::tray::{TrayAction, TrayHandle, TrayStatus}; /// Initial / minimum window size, in logical points. Initial height fits the /// host screen (ticket + Copy + QR + Stop) without needing to scroll on a 1080p /// display; the host screen still wraps in a ScrollArea for the case where the /// user shrinks the window below MIN_INNER_SIZE proportions. const INNER_SIZE: [f64; 2] = [520.0, 640.0]; const MIN_INNER_SIZE: [f64; 2] = [460.0, 380.0]; /// Events delivered to the winit loop from off the UI thread (or from egui). pub enum UserEvent { /// Something changed off the UI thread — a headless child emitted a JSON /// event or exited (see [`Waker`]). Drain it on the next tick, and repaint /// if a window is currently shown. Wake, /// A system-tray icon/menu action (see [`tray`]). Tray(TrayAction), } /// A cheap, cloneable handle that wakes the winit event loop from any thread. /// /// Used by the headless-child reader threads: pinging this wakes the loop /// **even when the window has been dropped to the tray** (no egui frame is /// running then, so egui's own repaint callback would go quiet after the first /// request — see the egui repaint bookkeeping). That's what keeps join/leave /// desktop notifications and the tray tooltip live while hidden. #[derive(Clone)] pub struct Waker { proxy: EventLoopProxy, } impl Waker { /// Wake the loop. A send error only means the loop has already exited, in /// which case there is nothing left to wake. pub fn wake(&self) { let _ = self.proxy.send_event(UserEvent::Wake); } } /// Window attributes used for both the first window and every recreated one, so /// a window restored from the tray is identical to the original. fn window_attributes() -> WindowAttributes { use winit::dpi::LogicalSize; // Wayland sources the titlebar/taskbar icon from the .desktop file matched // by this app_id (NOT from `with_window_icon`, which Wayland ignores) — it // must equal the installed pixelpass.desktop. `with_name(general, instance)` // sets the Wayland app_id to `general`. use winit::platform::wayland::WindowAttributesExtWayland as _; let mut attrs = WindowAttributes::default() .with_title("PixelPass") .with_inner_size(LogicalSize::new(INNER_SIZE[0], INNER_SIZE[1])) .with_min_inner_size(LogicalSize::new(MIN_INNER_SIZE[0], MIN_INNER_SIZE[1])) // Stay unmapped until the first frame is painted, to avoid a flash of an // empty window (on Wayland the surface only maps on the first swap // anyway; this matters mostly for X11). .with_visible(false) .with_name("pixelpass", "pixelpass"); // X11 titlebar/taskbar icon (`_NET_WM_ICON`), embedded at compile time so // the binary stays self-contained. Wayland ignores it (uses app_id above); // X11 falls back to it. Non-fatal on failure. match eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")) { Ok(icon) => match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { Ok(winit_icon) => attrs = attrs.with_window_icon(Some(winit_icon)), Err(e) => tracing::warn!("could not build X11 window icon: {e}"), }, Err(e) => tracing::warn!("could not load embedded window icon: {e}"), } attrs } /// Build a GL surface for `window` using the established display + config. fn create_surface( display: &Display, config: &Config, window: &Window, ) -> anyhow::Result> { let (w, h): (u32, u32) = window.inner_size().into(); let attrs = SurfaceAttributesBuilder::::new().build( window.window_handle()?.as_raw(), NonZeroU32::new(w).unwrap_or(NonZeroU32::MIN), NonZeroU32::new(h).unwrap_or(NonZeroU32::MIN), ); // SAFETY: `window` outlives the surface — both live in the same `WinState` // and are dropped together — so the raw handle stays valid for the // surface's lifetime. let surface = unsafe { display.create_window_surface(config, &attrs)? }; Ok(surface) } /// GL + egui state that persists for the whole GUI session. Only the OS window /// and its surface are dropped/recreated on hide/show (see the module docs); /// everything here outlives the cycle. struct Gfx { gl: Arc, gl_display: Display, gl_config: Config, egui_glow: EguiGlow, /// egui→window command state (title changes etc.), threaded through /// [`egui_winit::process_viewport_commands`] each frame. viewport_info: egui::ViewportInfo, win: WinState, } /// Whether the window is currently mapped. The GL *context* is preserved in /// both states; `Between` is only a momentary placeholder during a transition. // Exactly one `WinState` exists (it's a field of the single `Gfx`), never a // collection of them, so the inter-variant size gap costs ~250 idle bytes once // — not worth boxing the window/surface that every frame touches. #[allow(clippy::large_enum_variant)] enum WinState { Shown { window: Window, surface: Surface, context: PossiblyCurrentContext, }, Hidden { context: NotCurrentContext, }, /// Transient placeholder held only inside [`Gfx::hide`] / [`Gfx::show`]. Between, } impl Gfx { /// Full first-time initialization: pick a GL config, create the window, /// context, and surface, make the context current, and build the egui glow /// integration. Runs once, from `resumed`. fn create(event_loop: &ActiveEventLoop) -> anyhow::Result { let template = ConfigTemplateBuilder::new() .prefer_hardware_accelerated(None) .with_depth_size(0) .with_stencil_size(0) .with_transparency(false); let (maybe_window, gl_config) = DisplayBuilder::new() .with_preference(ApiPreference::FallbackEgl) .with_window_attributes(Some(window_attributes())) .build(event_loop, template, |mut configs| { configs.next().expect("no GL config matched") }) .map_err(|e| anyhow::anyhow!("failed to choose a GL config: {e}"))?; let gl_display = gl_config.display(); // On EGL/Wayland the window is built during `build`; on GLX it is // deferred until the visual is known, so finalize it here. let window = match maybe_window { Some(w) => w, None => glutin_winit::finalize_window(event_loop, window_attributes(), &gl_config)?, }; let raw = window.window_handle()?.as_raw(); let ctx_attrs = ContextAttributesBuilder::new().build(Some(raw)); // Fall back to a GLES context if a core GL context can't be created. let gles_attrs = ContextAttributesBuilder::new() .with_context_api(ContextApi::Gles(None)) .build(Some(raw)); // SAFETY: `raw` comes from `window`, which lives at least as long as the // context (both are owned by this `Gfx`). let not_current = unsafe { gl_display .create_context(&gl_config, &ctx_attrs) .or_else(|_| gl_display.create_context(&gl_config, &gles_attrs)) } .map_err(|e| anyhow::anyhow!("failed to create a GL context: {e}"))?; let surface = create_surface(&gl_display, &gl_config, &window)?; let context = not_current .make_current(&surface) .map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?; // Vsync, so the frame loop self-throttles and idles cheaply. let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN)); // SAFETY: the loader is only called while `gl_display` is alive, which // outlives the returned context. let gl = Arc::new(unsafe { glow::Context::from_loader_function(|s| match std::ffi::CString::new(s) { Ok(name) => gl_display.get_proc_address(name.as_c_str()), Err(_) => std::ptr::null(), }) }); let egui_glow = EguiGlow::new(event_loop, gl.clone(), None, None, true); let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "noto_sans".to_owned(), egui::FontData::from_static(include_bytes!("../../assets/NotoSans-Regular.ttf")).into(), ); fonts .families .get_mut(&egui::FontFamily::Proportional) .unwrap() .insert(0, "noto_sans".to_owned()); egui_glow.egui_ctx.set_fonts(fonts); let mut style = (*egui_glow.egui_ctx.global_style()).clone(); for font_id in style.text_styles.values_mut() { font_id.size *= 1.25; } egui_glow.egui_ctx.set_global_style(style); window.set_visible(true); window.request_redraw(); Ok(Self { gl, gl_display, gl_config, egui_glow, viewport_info: egui::ViewportInfo::default(), win: WinState::Shown { window, surface, context, }, }) } fn shown_window(&self) -> Option<&Window> { match &self.win { WinState::Shown { window, .. } => Some(window), _ => None, } } /// Hide to tray: park the context as not-current and drop the window + /// surface. The Wayland surface is genuinely destroyed (the only way to /// hide a toplevel there). fn hide(&mut self) { match std::mem::replace(&mut self.win, WinState::Between) { WinState::Shown { window, surface, context, } => match context.make_not_current() { Ok(not_current) => { // Order matters: the context must be made not-current before // its surface is dropped. drop(surface); drop(window); self.win = WinState::Hidden { context: not_current, }; } Err(e) => { tracing::error!("hide-to-tray: make_not_current failed: {e}"); // Leave `Between` (window already gone); a later Show will // log that there's no parked context and the user can quit // from the tray. This effectively never happens. } }, other => self.win = other, // already hidden / transient } } /// Restore from the tray: recreate the window + surface and make the parked /// context current again. fn show(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> { match std::mem::replace(&mut self.win, WinState::Between) { WinState::Hidden { context } => { let window = glutin_winit::finalize_window( event_loop, window_attributes(), &self.gl_config, )?; let surface = create_surface(&self.gl_display, &self.gl_config, &window)?; let context = context .make_current(&surface) .map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?; let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN)); window.set_visible(true); window.request_redraw(); self.win = WinState::Shown { window, surface, context, }; Ok(()) } WinState::Shown { window, surface, context, } => { // Already shown — just nudge a repaint and restore. window.request_redraw(); self.win = WinState::Shown { window, surface, context, }; Ok(()) } WinState::Between => { anyhow::bail!("no parked GL context to restore the window from") } } } /// Run one egui frame and paint it. Returns how long egui wants to wait /// before the next repaint (`Duration::MAX` == idle, sleep until an event). /// No-op returning `MAX` if the window isn't currently shown. fn paint_frame(&mut self, run_ui: impl FnMut(&mut egui::Ui)) -> Duration { let WinState::Shown { window, surface, context, } = &self.win else { return Duration::MAX; }; let eg = &mut self.egui_glow; let raw_input = eg.egui_winit.take_egui_input(window); let egui::FullOutput { platform_output, textures_delta, shapes, pixels_per_point, viewport_output, } = eg.egui_ctx.run_ui(raw_input, run_ui); eg.egui_winit .handle_platform_output(window, platform_output); // Apply any window commands egui emitted (we issue none directly, but // egui may request e.g. IME changes) and read the next repaint delay. let (repaint_delay, commands) = match viewport_output.get(&egui::ViewportId::ROOT) { Some(out) => (out.repaint_delay, out.commands.clone()), None => (Duration::MAX, Vec::new()), }; if !commands.is_empty() { let mut actions = Vec::new(); egui_winit::process_viewport_commands( &eg.egui_ctx, &mut self.viewport_info, commands, window, &mut actions, ); } let clipped = eg.egui_ctx.tessellate(shapes, pixels_per_point); for (id, image_delta) in &textures_delta.set { eg.painter.set_texture(*id, image_delta); } let dimensions: [u32; 2] = window.inner_size().into(); // SAFETY: the context is current (we are in the `Shown` arm) and `gl` // belongs to it. unsafe { self.gl.clear_color(0.08, 0.08, 0.08, 1.0); self.gl.clear(glow::COLOR_BUFFER_BIT); } eg.painter .paint_primitives(dimensions, pixels_per_point, &clipped); for id in &textures_delta.free { eg.painter.free_texture(*id); } if let Err(e) = surface.swap_buffers(context) { tracing::warn!("GL swap_buffers failed: {e}"); } repaint_delay } } /// The winit application: owns the persistent GL/egui state ([`Gfx`]) and the /// PixelPass UI logic ([`PixelPassApp`]), and routes events between them. struct App { state: PixelPassApp, /// `None` until the first `resumed`; `Some` for the rest of the session /// (the window inside may be Shown or Hidden). gfx: Option, /// When the next repaint is due, derived from egui's per-frame delay and /// external wakes. `None` == idle (sleep until an event). repaint_at: Option, } impl App { fn shown_window(&self) -> Option<&Window> { self.gfx.as_ref().and_then(Gfx::shown_window) } fn request_redraw(&self) { if let Some(w) = self.shown_window() { w.request_redraw(); } } /// Run an egui frame now (if shown) and record the next repaint deadline. fn redraw(&mut self) { let App { gfx, state, repaint_at, .. } = self; let Some(gfx) = gfx.as_mut() else { return }; let delay = gfx.paint_frame(|ui| state.draw(ui)); *repaint_at = (delay < Duration::MAX).then(|| Instant::now() + delay); } /// The window's close button: hide to the tray if enabled and a tray is /// actually present, otherwise quit. fn on_close(&mut self, event_loop: &ActiveEventLoop) { let hide = self.state.close_to_tray && self.state.tray.as_ref().is_some_and(TrayHandle::registered); if hide { if let Some(gfx) = self.gfx.as_mut() { gfx.hide(); } self.repaint_at = None; } else { event_loop.exit(); } } } impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.gfx.is_some() { return; // already initialized } match Gfx::create(event_loop) { Ok(gfx) => self.gfx = Some(gfx), Err(e) => { tracing::error!("GUI: could not create the window/GL context: {e}"); event_loop.exit(); } } } fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { if matches!(event, WindowEvent::CloseRequested) { self.on_close(event_loop); return; } if matches!(event, WindowEvent::RedrawRequested) { self.redraw(); return; } if let WindowEvent::Resized(size) = &event && let Some(gfx) = self.gfx.as_ref() && let WinState::Shown { surface, context, .. } = &gfx.win && let (Some(w), Some(h)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height)) { surface.resize(context, w, h); } // Feed the event to egui and repaint if it wants one. let Some(gfx) = self.gfx.as_mut() else { return }; let WinState::Shown { window, .. } = &gfx.win else { return; }; let response = gfx.egui_glow.egui_winit.on_window_event(window, &event); if response.repaint { window.request_redraw(); } } fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { match event { UserEvent::Wake => { // A child emitted/closed: update state (and fire notifications / // tray tooltip) even while hidden, then repaint if shown. self.state.tick(); self.request_redraw(); } UserEvent::Tray(TrayAction::Show) => { if let Some(gfx) = self.gfx.as_mut() && let Err(e) = gfx.show(event_loop) { tracing::error!("could not restore the window from the tray: {e}"); } self.state.tick(); } UserEvent::Tray(TrayAction::Quit) => { event_loop.exit(); } } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { let flow = match self.repaint_at { None => ControlFlow::Wait, Some(at) if at > Instant::now() => ControlFlow::WaitUntil(at), Some(_) => { // Due now: ask for a frame if there's a window to paint into; // otherwise (hidden) drop the pending repaint and sleep. if let Some(w) = self.shown_window() { w.request_redraw(); } else { self.repaint_at = None; } ControlFlow::Wait } }; event_loop.set_control_flow(flow); } fn exiting(&mut self, _event_loop: &ActiveEventLoop) { // Release GL resources only if the context is current (window shown); // when hidden the context isn't current, and the process is exiting // anyway so the driver reclaims everything. if let Some(gfx) = self.gfx.as_mut() && matches!(gfx.win, WinState::Shown { .. }) { gfx.egui_glow.painter.destroy(); } } } /// Launch the GUI. Blocks until the window is closed (or the tray Quit is /// chosen). Runs on the main thread, a winit requirement, which is where `main` /// calls it from. /// `relay` is the parent's `--relay` flag (if any), forwarded to every child /// process so a relay chosen on the GUI command line reaches the headless /// host/viewer. (The `PIXELPASS_RELAY` env var is inherited regardless; this /// covers the flag form.) pub fn run(relay: Option) -> anyhow::Result<()> { let event_loop = EventLoop::::with_user_event() .build() .map_err(|e| anyhow::anyhow!("failed to build the event loop: {e}"))?; event_loop.set_control_flow(ControlFlow::Wait); let proxy = event_loop.create_proxy(); let waker = Waker { proxy: proxy.clone(), }; // The tray runs on its own thread and wakes us via the proxy. let tray = tray::start(proxy.clone()); let gui_settings = crate::common::config::load() .map(|c| c.gui) .unwrap_or_default(); let active = theme::load_named(&gui_settings.theme); let names = theme::all_themes().into_iter().map(|t| t.name).collect(); let draft = active.clone(); let state = PixelPassApp { screen: Screen::default(), host: HostState::default(), viewer: ViewerState::default(), tray, close_to_tray: gui_settings.close_to_tray, show_qr: gui_settings.show_qr, relay, theme: ThemeState { active, names, dirty: true, // apply on the first frame editing: false, draft, status: None, }, waker, }; let mut app = App { state, gfx: None, repaint_at: None, }; event_loop .run_app(&mut app) .map_err(|e| anyhow::anyhow!("GUI event loop error: {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 } } /// Fire a desktop notification, on a detached thread so the D-Bus round-trip /// can't stall the egui frame. Best-effort: with no notification daemon it /// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this /// needs no system libdbus and no GTK event loop.) fn notify(summary: &'static str, body: String) { std::thread::spawn(move || { if let Err(e) = notify_rust::Notification::new() .appname("PixelPass") .summary(summary) .body(&body) .show() { tracing::warn!("desktop notification failed: {e}"); } }); } /// Persist just the close-to-tray preference, preserving the rest of the /// on-disk config (e.g. the bandwidth section the headless child may have /// written). Best-effort: a write failure is logged, not surfaced. fn persist_close_to_tray(value: bool) { let mut cfg = crate::common::config::load().unwrap_or_default(); cfg.gui.close_to_tray = value; if let Err(e) = crate::common::config::save(&cfg) { tracing::warn!("failed to save settings: {e}"); } } fn persist_show_qr(value: bool) { let mut cfg = crate::common::config::load().unwrap_or_default(); cfg.gui.show_qr = value; if let Err(e) = crate::common::config::save(&cfg) { tracing::warn!("failed to save settings: {e}"); } } fn persist_theme(name: &str) { let mut cfg = crate::common::config::load().unwrap_or_default(); cfg.gui.theme = name.to_string(); if let Err(e) = crate::common::config::save(&cfg) { tracing::warn!("failed to save settings: {e}"); } } /// Which screen the single window is currently showing. #[derive(Default, PartialEq)] enum Screen { #[default] Menu, Host, Viewer, Settings, } /// 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. #[derive(Default)] 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, /// Endpoint ids of the currently-connected viewers, in arrival order. /// Drives the per-viewer list and its Kick buttons. viewers: Vec, /// QR-code rendering of the current ticket, lazily built on the first draw /// after a Ticket event lands. Cleared on session start/stop so the next /// session's ticket regenerates it. qr_texture: Option, } /// 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, /// Set when the View screen opens so the code field grabs focus once /// (cleared on use, so it doesn't steal focus every frame). focus_ticket: bool, error: Option, } /// The PixelPass UI logic — screens, the running children, and the tray. Knows /// nothing about windowing; [`App`] drives its [`PixelPassApp::draw`] each frame /// and [`PixelPassApp::tick`] on each wake. struct PixelPassApp { screen: Screen, host: HostState, viewer: ViewerState, /// System-tray handle; `None` if the tray couldn't start, in which case the /// close button always quits (never hides). tray: Option, /// Persisted preference: when true (and a tray is present), the close button /// hides to the tray instead of quitting. Loaded at startup, written on /// toggle in Settings. close_to_tray: bool, /// Persisted preference: render the QR-code panel on the host screen. /// Defaults to on; toggled in Settings. show_qr: bool, /// The parent's `--relay` flag, forwarded to host/viewer children so the /// flag form reaches them (env-var form is inherited automatically). relay: Option, /// The active colour theme (applied to egui's visuals) and the supporting /// picker/editor state. theme: ThemeState, /// Wakes the winit loop when a spawned child emits/exits. waker: Waker, } /// The active theme plus the Settings picker/editor working state. struct ThemeState { /// Currently applied theme (persisted by name in the config). active: theme::Theme, /// Theme names shown in the picker, refreshed when Settings opens so files /// added on disk appear without a restart. names: Vec, /// Set when `active` needs (re)applying to egui's visuals. dirty: bool, /// Whether the in-app editor is open. While open its `draft` is applied as a /// live preview instead of `active`; closing without saving restores `active`. editing: bool, /// The editor's working copy. draft: theme::Theme, /// Last save result or error, shown under the editor. status: Option, } /// Apply `theme`'s palette to egui's visuals, preserving the font scaling and /// fonts already baked into the global style (we replace only `visuals`). fn apply_theme(ctx: &egui::Context, theme: &theme::Theme) { let mut style = (*ctx.global_style()).clone(); style.visuals = theme.visuals(); ctx.set_global_style(style); } /// One row of the theme editor: a label and a colour picker. Theme colours are /// opaque, so any alpha the picker introduces is clamped straight back out. fn color_row(ui: &mut egui::Ui, label: &str, color: &mut egui::Color32) { ui.label(label); ui.color_edit_button_srgba(color); let [r, g, b, _] = color.to_srgba_unmultiplied(); *color = egui::Color32::from_rgb(r, g, b); ui.end_row(); } impl PixelPassApp { /// Drain child output and reflect it into the tray. Runs on every wake, /// whether or not a window is shown, so notifications and the tray tooltip /// stay live while hidden. fn tick(&mut self) { self.pump_host_events(); self.pump_viewer_events(); self.sync_tray_status(); } /// Render the current screen. Called from inside the egui frame. fn draw(&mut self, ui: &mut egui::Ui) { // Apply whichever theme should be visible this frame. While the editor // is open its draft is previewed live; otherwise the active theme is // applied once (when dirty) and then sticks — the egui context persists // across the hide/show window cycle, so it survives close-to-tray. if self.theme.editing { apply_theme(ui.ctx(), &self.theme.draft); } else if self.theme.dirty { apply_theme(ui.ctx(), &self.theme.active); self.theme.dirty = false; } match self.screen { Screen::Menu => self.menu(ui), Screen::Host => self.host(ui), Screen::Viewer => self.viewer(ui), Screen::Settings => self.settings(ui), } } /// Mirror current activity into the tray icon's tooltip/menu. fn sync_tray_status(&mut self) { let status = if self.host.proc.is_some() { TrayStatus::Hosting { active: self.host.active, max: self.host.max, } } else if self.viewer.proc.is_some() { TrayStatus::Viewing } else { TrayStatus::Idle }; if let Some(tray) = &mut self.tray { tray.set_status(status); } } 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(); } ui.add_space(20.0); if ui.button("⚙ Settings").clicked() { // Refresh the picker so themes added to the folder since launch // (or last visit) show up without a restart. self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect(); self.screen = Screen::Settings; } }); } fn settings(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { if ui.button("← Menu").clicked() { self.screen = Screen::Menu; } ui.heading("Settings"); }); ui.separator(); // Body scrolls; the header above stays pinned. The Appearance editor // (13 colour rows + Save) overflows a short window otherwise — you'd // have to resize the window to reach the Save button. egui::ScrollArea::vertical().show(ui, |ui| self.settings_body(ui)); } fn settings_body(&mut self, ui: &mut egui::Ui) { ui.add_space(4.0); let resp = ui.checkbox( &mut self.close_to_tray, "Keep running in the tray when I close the window", ); if resp.changed() { persist_close_to_tray(self.close_to_tray); } ui.add_space(4.0); ui.label( egui::RichText::new( "Off: closing the window quits PixelPass.\n\ On: closing hides it to the system tray and any active stream \ keeps running — reopen it from the tray icon.", ) .small() .weak(), ); ui.add_space(12.0); let qr_resp = ui.checkbox(&mut self.show_qr, "Show QR-code panel on the host screen"); if qr_resp.changed() { persist_show_qr(self.show_qr); } ui.add_space(4.0); ui.label( egui::RichText::new( "Off: the host screen shows only the ticket text. \ Useful when both ends are computers and the QR is just extra clutter.", ) .small() .weak(), ); // The option does nothing without a tray to hide into; say so plainly. if !self.tray.as_ref().is_some_and(TrayHandle::registered) { ui.add_space(8.0); ui.colored_label( self.theme.active.warning, "⚠ No system tray detected — this option has no effect right now.", ); } ui.add_space(16.0); ui.separator(); ui.add_space(4.0); self.appearance(ui); } /// The "Appearance" block of the Settings screen: the theme picker, or the /// in-app editor when it's open. fn appearance(&mut self, ui: &mut egui::Ui) { ui.heading("Appearance"); ui.add_space(6.0); if self.theme.editing { self.theme_editor(ui); return; } // Theme picker. Collect any selection first so we're not holding an // immutable borrow of `self.theme.names` when we mutate `self.theme`. let current = self.theme.active.name.clone(); let mut pick: Option = None; ui.horizontal(|ui| { ui.label("Theme"); egui::ComboBox::from_id_salt("theme_picker") .selected_text(¤t) .show_ui(ui, |ui| { for name in &self.theme.names { if ui.selectable_label(*name == current, name).clicked() { pick = Some(name.clone()); } } }); }); if let Some(name) = pick { self.select_theme(&name); } ui.add_space(8.0); if ui.button("✎ Edit / create a theme").clicked() { self.start_theme_edit(); } ui.add_space(4.0); ui.label( egui::RichText::new( "Themes live as .toml files in your config folder \ (themes/). Drop one in to share or install it.", ) .small() .weak(), ); if let Some(status) = &self.theme.status { ui.add_space(4.0); ui.label(egui::RichText::new(status).small().weak()); } } /// The in-app theme editor: a colour picker per palette field with a live /// preview, plus Save (writes a .toml) / Cancel. fn theme_editor(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label("Name"); ui.text_edit_singleline(&mut self.theme.draft.name); }); ui.checkbox( &mut self.theme.draft.dark, "Dark base (sets the fallback for anything the palette doesn't name)", ); ui.add_space(6.0); egui::Grid::new("theme_editor_grid") .num_columns(2) .spacing([12.0, 6.0]) .show(ui, |ui| { let d = &mut self.theme.draft; color_row(ui, "Window background", &mut d.window_bg); color_row(ui, "Panel background", &mut d.panel_bg); color_row(ui, "Input background", &mut d.input_bg); color_row(ui, "Text", &mut d.text); color_row(ui, "Secondary text", &mut d.weak_text); color_row(ui, "Accent", &mut d.accent); color_row(ui, "Button", &mut d.button_bg); color_row(ui, "Button (hover)", &mut d.button_hovered); color_row(ui, "Streaming", &mut d.streaming); color_row(ui, "Waiting", &mut d.waiting); color_row(ui, "Success", &mut d.success); color_row(ui, "Warning", &mut d.warning); color_row(ui, "Error", &mut d.error); }); ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("💾 Save").clicked() { self.save_draft_theme(); } // Reset the draft to the original Default Dark palette. Previews // live (the editor applies the draft each frame), so it snaps back // immediately; Save persists it, Cancel discards. if ui.button("↺ Defaults").clicked() { self.theme.draft = theme::default_dark(); self.theme.status = Some("Reset to the Default Dark palette.".to_string()); } if ui.button("Cancel").clicked() { self.cancel_theme_edit(); } }); ui.add_space(4.0); ui.label( egui::RichText::new( "Changes preview live. Save writes a .toml to your themes folder; \ rename it to keep a built-in alongside your own version.", ) .small() .weak(), ); if let Some(status) = &self.theme.status { ui.add_space(4.0); ui.label(egui::RichText::new(status).small().weak()); } } /// Switch to the named theme: apply it, persist the choice, and reset the /// editor draft to match. fn select_theme(&mut self, name: &str) { self.theme.active = theme::load_named(name); self.theme.draft = self.theme.active.clone(); self.theme.dirty = true; self.theme.status = None; persist_theme(name); } fn start_theme_edit(&mut self) { self.theme.draft = self.theme.active.clone(); self.theme.editing = true; self.theme.status = None; } fn cancel_theme_edit(&mut self) { self.theme.editing = false; self.theme.draft = self.theme.active.clone(); self.theme.dirty = true; // discard the live preview, restore the active theme self.theme.status = None; } fn save_draft_theme(&mut self) { if self.theme.draft.name.trim().is_empty() { self.theme.status = Some("Give the theme a name before saving.".to_string()); return; } match theme::save_theme(&self.theme.draft) { Ok(path) => { self.theme.active = self.theme.draft.clone(); self.theme.editing = false; self.theme.dirty = true; self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect(); self.theme.status = Some(format!("Saved to {}", path.display())); persist_theme(&self.theme.active.name); } Err(e) => self.theme.status = Some(format!("Couldn't save: {e}")), } } // ── 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(); // Scroll the body, not the header — Menu/Host stay pinned at the top. // Needed once the QR panel landed: ticket + Copy + QR + Stop overflows a // shrunken window. egui::ScrollArea::vertical().show(ui, |ui| { 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(self.theme.active.error, 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.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(self.theme.active.streaming, "● Streaming"); } else if self.host.ticket.is_some() { ui.colored_label(self.theme.active.waiting, "● Waiting for viewers…"); } else { ui.label("Starting…"); } 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 = 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)) .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(self.theme.active.success, "✓ 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(), ); } // Lazy QR build: first draw after a Ticket event has `qr_texture = // None`, so we encode the ticket and load the texture once. The // 4-module quiet zone (white border) matters — phone scanners reject // QR codes flush against a non-white edge. Skipped when the user // disabled the QR panel in Settings. if self.show_qr && self.host.qr_texture.is_none() && let Ok(code) = qrcode::QrCode::new(ticket.as_bytes()) { let w = code.width(); let quiet = 4; let full = w + 2 * quiet; let colors = code.into_colors(); let mut pixels = vec![egui::Color32::WHITE; full * full]; for y in 0..w { for x in 0..w { if colors[y * w + x] == qrcode::Color::Dark { pixels[(y + quiet) * full + (x + quiet)] = egui::Color32::BLACK; } } } let img = egui::ColorImage::new([full, full], pixels); self.host.qr_texture = Some(ui.ctx().load_texture( "host_qr", img, egui::TextureOptions::NEAREST, )); } if self.show_qr && let Some(tex) = &self.host.qr_texture { ui.add_space(8.0); ui.add(egui::Image::new(tex).fit_to_exact_size(egui::vec2(200.0, 200.0))); } } if let Some(reason) = &self.host.last_refusal { ui.add_space(8.0); ui.colored_label(self.theme.active.warning, 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) { 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; self.host.viewers.clear(); self.host.qr_texture = None; 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()); } if let Some(relay) = &self.relay { args.push("--relay".to_string()); args.push(relay.clone()); } match ChildProc::spawn(&args, self.waker.clone()) { 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; self.host.viewers.clear(); self.host.qr_texture = 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 } => { // 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::ViewerJoined { id, active, max } => { self.host.active = active; self.host.max = max; if !self.host.viewers.contains(&id) { notify( "PixelPass — viewer connected", format!( "endpoint {} is now watching ({active}/{max})", short_id(&id) ), ); self.host.viewers.push(id); } } ChildEvent::ViewerLeft { id, active, max } => { self.host.active = active; self.host.max = max; if self.host.viewers.iter().any(|v| v == &id) { notify( "PixelPass — viewer disconnected", format!("endpoint {} left ({active}/{max})", short_id(&id)), ); self.host.viewers.retain(|v| v != &id); } } 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(self.theme.active.error, 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; // Grab focus once on screen entry so the user can paste/type straight // away without first clicking into the field. if std::mem::take(&mut self.viewer.focus_ticket) { ticket_resp.request_focus(); } // 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( self.theme.active.success, format!("→ endpoint {}", short_id(id)), ), None => ui.colored_label( self.theme.active.warning, "⚠ 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(); } } fn viewer_running(&mut self, ui: &mut egui::Ui) { if self.viewer.launched { ui.colored_label(self.theme.active.streaming, "● 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(self.theme.active.waiting, 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(); } self.viewer.focus_ticket = true; } fn start_viewer(&mut self) { 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 mut args = vec![ticket, "--output".to_string(), "json".to_string()]; if let Some(relay) = &self.relay { args.push("--relay".to_string()); args.push(relay.clone()); } match ChildProc::spawn(&args, self.waker.clone()) { 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 } }