//! 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 code; mod presence; 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::presence::PresenceHandle; use self::tray::{TrayAction, TrayHandle, TrayStatus}; use crate::common::control::ControlMsg; use crate::common::friends::FriendState; /// 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()); // The friends presence service runs on its own thread too, waking us when // a control message arrives. let presence = presence::start(waker.clone(), relay.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 friends = crate::common::friends::load().unwrap_or_else(|e| { tracing::warn!("failed to load friends list: {e:#}"); Default::default() }); 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, presence, friends, display_name: gui_settings.display_name, met: Vec::new(), }; 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 } } /// A `Hello` control message carrying our display name — the self-introduction /// a viewer sends the host on connect, and the host's reply. fn control_hello(name: &str) -> ControlMsg { ControlMsg::Hello { name: name.to_string(), } } /// 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_display_name(value: &str) { let mut cfg = crate::common::config::load().unwrap_or_default(); cfg.gui.display_name = value.to_string(); 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, Friends, Settings, Shortcuts, } /// 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, /// The bare video ticket from the child (used for the host-id fingerprint /// line). The copy/QR/display use [`HostState::share_code`] instead. ticket: Option, /// The share code shown/copied/QR'd: the ticket wrapped with our control id /// (see [`code::wrap`]) when the presence service is up, else the bare /// ticket. Wrapping is what lets a viewer offer to befriend the host. share_code: 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, /// The host's stable control id, if the pasted code was a wrapped friend /// code. Lets us announce ourselves to the host (so both ends can befriend) /// once connected. `None` for a bare/CLI ticket. host_control_id: 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 always-on friends presence service (control-plane endpoint). `None` /// if it couldn't start (no identity), in which case friends features are /// simply absent. presence: Option, /// The persisted friends list (mutual-consent contacts). friends: crate::common::friends::FriendStore, /// Our display name, shown to friends. Persisted in `[gui] display_name`. display_name: String, /// Peers met this session (over a connection) who aren't yet in the friends /// list — drives the "add friend" offer. Session-scoped, not persisted. met: Vec, } /// A peer encountered this session but not yet befriended. struct MetPeer { id: iroh::EndpointId, /// Their reported display name (a short id placeholder until a name arrives). name: String, } /// 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(); } /// A titled group of [`color_row`]s in the theme editor — a bold subheading /// then a two-column grid, so the palette reads as sections instead of one /// long flat list. `id` must be unique per section (it salts the grid). fn color_section(ui: &mut egui::Ui, title: &str, id: &str, rows: impl FnOnce(&mut egui::Ui)) { ui.add_space(8.0); ui.label(egui::RichText::new(title).strong()); ui.add_space(2.0); egui::Grid::new(id) .num_columns(2) .spacing([12.0, 6.0]) .show(ui, rows); } /// One `(keys, description)` group on the Shortcuts screen: a bold subheading /// over a two-column grid (monospace keys, plain descriptions). fn shortcut_section(ui: &mut egui::Ui, title: &str, id: &str, rows: &[(&str, &str)]) { ui.add_space(8.0); ui.label(egui::RichText::new(title).strong()); ui.add_space(2.0); egui::Grid::new(id) .num_columns(2) .spacing([16.0, 6.0]) .show(ui, |ui| { for (keys, desc) in rows { ui.label(egui::RichText::new(*keys).monospace()); ui.label(*desc); 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.pump_presence_events(); self.sync_tray_status(); } /// Drain inbound control-plane messages and fold them into the friends list /// and the session's met-peers, queueing any replies. Collected up front so /// the presence borrow is released before we mutate `self` / re-borrow it to /// send. (Phase 4 adds the bell badge + ShareCode handling.) fn pump_presence_events(&mut self) { let Some(inbound) = self.presence.as_ref().map(|p| p.drain()) else { return; }; if inbound.is_empty() { return; } let my_name = self.display_name.clone(); let mut outbox: Vec<(iroh::EndpointId, ControlMsg)> = Vec::new(); let mut store_changed = false; for inb in inbound { let from = inb.from; match inb.msg { ControlMsg::Hello { name } => { // A peer announcing themselves (the viewer→host intro, or the // host's reply). Keep a known friend's name fresh; otherwise // record them as a met peer and reply once on first contact. if let Some(f) = self.friends.find_mut(&from) { f.name = name; store_changed = true; } else if self.note_met(from, name) { outbox.push((from, control_hello(&my_name))); } } ControlMsg::FriendRequest { name } => { self.note_met(from, name.clone()); if self.friends.on_friend_request(from, name.clone()) { // We'd already requested them — mutual, so it's settled. outbox.push(( from, ControlMsg::FriendAccept { name: my_name.clone(), }, )); notify( "PixelPass — now friends", format!("You and {name} are now friends."), ); } else { notify( "PixelPass — friend request", format!("{name} wants to be friends."), ); } store_changed = true; } ControlMsg::FriendAccept { name } => { if self.friends.on_friend_accept(from, name.clone()) { store_changed = true; notify( "PixelPass — request accepted", format!("{name} accepted your friend request."), ); } } ControlMsg::FriendDecline => { if self.friends.remove(&from) { store_changed = true; } } ControlMsg::ShareCode { name, ticket } => { // Phase 4 turns this into the bell badge + an in-app notice. // For now, only honour codes from accepted friends and log. if self.friends.is_accepted(&from) { tracing::info!(from = %from, %name, "presence: friend shared a code: {ticket}"); } else { tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend"); } } } } if store_changed { self.save_friends(); } if let Some(p) = &self.presence { for (peer, msg) in outbox { p.send(peer, msg); } } } /// Record a peer met this session. Returns true if they were newly added /// (false if we already knew them, in which case the name is refreshed). fn note_met(&mut self, id: iroh::EndpointId, name: String) -> bool { if let Some(m) = self.met.iter_mut().find(|m| m.id == id) { // Don't overwrite a real name with a short-id placeholder. if !name.is_empty() { m.name = name; } false } else { self.met.push(MetPeer { id, name }); true } } fn pending_incoming_count(&self) -> usize { self.friends .friends .iter() .filter(|f| f.state == FriendState::PendingIncoming) .count() } fn save_friends(&self) { if let Err(e) = self.friends.save() { tracing::warn!("failed to save friends list: {e:#}"); } } /// Send a friend request to a peer (or accept theirs if they already asked), /// persisting the new state and notifying the peer over the control plane. fn request_friend(&mut self, id: iroh::EndpointId, name: String) { let my_name = self.display_name.clone(); let msg = if matches!( self.friends.find(&id).map(|f| f.state), Some(FriendState::PendingIncoming) ) { self.friends.upsert(id, name, FriendState::Accepted); ControlMsg::FriendAccept { name: my_name } } else { self.friends.upsert(id, name, FriendState::PendingOutgoing); ControlMsg::FriendRequest { name: my_name } }; self.save_friends(); if let Some(p) = &self.presence { p.send(id, msg); } } /// Mark an incoming request accepted and tell the peer. fn accept_friend(&mut self, id: iroh::EndpointId) { let my_name = self.display_name.clone(); if let Some(f) = self.friends.find_mut(&id) { f.state = FriendState::Accepted; self.save_friends(); if let Some(p) = &self.presence { p.send(id, ControlMsg::FriendAccept { name: my_name }); } } } /// Remove a friend / decline a request / cancel an outgoing one, telling the /// peer so their side drops us too. fn remove_friend(&mut self, id: iroh::EndpointId) { if self.friends.remove(&id) { self.save_friends(); if let Some(p) = &self.presence { p.send(id, ControlMsg::FriendDecline); } } } /// The "people you just connected with" offer shown on the running host / /// viewer screens: met peers not yet in the friends list, each with an Add /// button. fn friend_offers(&mut self, ui: &mut egui::Ui) { let offers: Vec<(iroh::EndpointId, String)> = self .met .iter() .filter(|m| self.friends.find(&m.id).is_none()) .map(|m| (m.id, m.name.clone())) .collect(); if offers.is_empty() { return; } ui.add_space(12.0); ui.separator(); ui.label("People you just connected with:"); let mut add: Option<(iroh::EndpointId, String)> = None; for (id, name) in &offers { ui.horizontal(|ui| { ui.label(name.as_str()); if ui.small_button("➕ Add friend").clicked() { add = Some((*id, name.clone())); } }); } if let Some((id, name)) = add { self.request_friend(id, name); } } fn friends_screen(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { if ui.button("← Menu").clicked() { self.screen = Screen::Menu; } ui.heading("Friends"); }); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { // Your identity: editable display name + your stable id. ui.horizontal(|ui| { ui.label("Your name"); if ui .text_edit_singleline(&mut self.display_name) .on_hover_text("Shown to friends in requests and shared codes.") .changed() { persist_display_name(&self.display_name); } }); if let Some(p) = &self.presence { ui.label( egui::RichText::new(format!("Your ID: {}", short_id(&p.id().to_string()))) .small() .weak(), ); } else { ui.colored_label( self.theme.active.warning, "⚠ Friends service unavailable (no identity).", ); } ui.add_space(8.0); ui.separator(); ui.add_space(4.0); if self.friends.friends.is_empty() { ui.label( egui::RichText::new( "No friends yet. Connect with someone, then use \"Add friend\" \ on the host/view screen.", ) .weak(), ); return; } // Collect the chosen action first so we don't mutate the store while // iterating it. enum Action { Accept(iroh::EndpointId), Remove(iroh::EndpointId), } let mut action: Option = None; for f in &self.friends.friends { ui.horizontal(|ui| { ui.label(egui::RichText::new(&f.name).strong()); ui.label( egui::RichText::new(format!("· {}", short_id(&f.id.to_string()))) .small() .weak(), ); match f.state { FriendState::Accepted => { ui.label(egui::RichText::new("· friend").small().weak()); if ui.small_button("Remove").clicked() { action = Some(Action::Remove(f.id)); } } FriendState::PendingIncoming => { ui.label(egui::RichText::new("· wants to be friends").small().weak()); if ui.small_button("Accept").clicked() { action = Some(Action::Accept(f.id)); } if ui.small_button("Decline").clicked() { action = Some(Action::Remove(f.id)); } } FriendState::PendingOutgoing => { ui.label(egui::RichText::new("· request sent").small().weak()); if ui.small_button("Cancel").clicked() { action = Some(Action::Remove(f.id)); } } } }); } match action { Some(Action::Accept(id)) => self.accept_friend(id), Some(Action::Remove(id)) => self.remove_friend(id), None => {} } }); } /// Render the current screen. Called from inside the egui frame. fn draw(&mut self, ui: &mut egui::Ui) { self.handle_keys(ui); // Leaving Settings by any path (button, Esc, a shortcut) closes the // editor and restores the active theme, so a half-edited draft's live // preview never leaks onto other screens. if self.theme.editing && self.screen != Screen::Settings { self.cancel_theme_edit(); } // 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; } // Paint the themed window background behind everything. The app draws on // egui's bare background layer with no panel, so `window_fill` is never // shown — without this the only backdrop is the GL clear colour, which // the theme can't reach. Painted first, so it sits behind the widgets. let bg = if self.theme.editing { self.theme.draft.window_bg } else { self.theme.active.window_bg }; ui.painter() .rect_filled(ui.ctx().content_rect(), egui::CornerRadius::ZERO, bg); match self.screen { Screen::Menu => self.menu(ui), Screen::Host => self.host(ui), Screen::Viewer => self.viewer(ui), Screen::Friends => self.friends_screen(ui), Screen::Settings => self.settings(ui), Screen::Shortcuts => self.shortcuts(ui), } } /// Window-focused keyboard shortcuts (mirrored on the Shortcuts screen). /// Runs before the frame is drawn. Esc and F1 work regardless of focus /// (they aren't text-editing keys); the letter/Space actions only fire when /// no widget holds focus, so they don't clash with typing in a field or /// with egui using Space/Enter to activate the focused widget. fn handle_keys(&mut self, ui: &mut egui::Ui) { use egui::Key; // While a popup (colour picker, dropdown) is open, let it own the keys. if ui.ctx().any_popup_open() { return; } let key = |k: Key| ui.input(|i| i.key_pressed(k)); if key(Key::F1) { self.screen = Screen::Shortcuts; return; } if key(Key::Escape) { match self.screen { Screen::Host => { self.stop_host(); self.screen = Screen::Menu; } Screen::Viewer => { self.stop_viewer(); self.screen = Screen::Menu; } Screen::Settings if self.theme.editing => self.cancel_theme_edit(), Screen::Settings | Screen::Shortcuts | Screen::Friends => { self.screen = Screen::Menu } Screen::Menu => {} } return; } if ui.memory(|m| m.focused().is_some()) { return; } match self.screen { Screen::Menu => { if key(Key::H) { self.screen = Screen::Host; } else if key(Key::V) { self.screen = Screen::Viewer; self.prefill_viewer_ticket(); } else if key(Key::S) { self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect(); self.screen = Screen::Settings; } } Screen::Host => { if self.host.proc.is_some() { if key(Key::C) && let Some(code) = self.host.share_code.clone() { self.copy_to_clipboard(&code); } } else if key(Key::Space) || key(Key::Enter) { self.start_host(); } } // View's Enter (Connect) is handled in viewer_form. Screen::Viewer | Screen::Friends | Screen::Settings | Screen::Shortcuts => {} } } /// The Shortcuts screen: a static reference list of every key binding. fn shortcuts(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { if ui.button("← Menu").clicked() { self.screen = Screen::Menu; } ui.heading("Keyboard shortcuts"); }); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { shortcut_section( ui, "Anywhere", "sc_any", &[("Esc", "Back / close"), ("F1", "Open this list")], ); shortcut_section( ui, "Main menu", "sc_menu", &[ ("H", "Host — share my screen"), ("V", "View — watch someone's screen"), ("S", "Settings"), ], ); shortcut_section( ui, "Host", "sc_host", &[ ("Space / Enter", "Start hosting"), ("C", "Copy the share code"), ], ); shortcut_section( ui, "View", "sc_view", &[("Enter", "Connect to the pasted code")], ); }); } /// 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); let pending = self.pending_incoming_count(); let friends_label = if pending > 0 { format!("👥 Friends ({pending})") } else { "👥 Friends".to_string() }; if ui.button(friends_label).clicked() { self.screen = Screen::Friends; } ui.add_space(8.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; } ui.add_space(8.0); if ui.button("⌨ Keyboard shortcuts").clicked() { self.screen = Screen::Shortcuts; } }); } 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); let d = &mut self.theme.draft; color_section(ui, "Surfaces", "theme_grid_surfaces", |ui| { 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_section(ui, "Text & accent", "theme_grid_text", |ui| { color_row(ui, "Text", &mut d.text); color_row(ui, "Secondary text", &mut d.weak_text); color_row(ui, "Accent", &mut d.accent); }); color_section(ui, "Buttons", "theme_grid_buttons", |ui| { color_row(ui, "Button", &mut d.button_bg); color_row(ui, "Button (hover)", &mut d.button_hovered); }); color_section(ui, "Status colours", "theme_grid_status", |ui| { 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(share_code) = self.host.share_code.clone() { ui.label("Share this code with your viewer(s):"); if let Some(id) = self.host.ticket.as_deref().and_then(ticket_endpoint_id) { // 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(&share_code).monospace().small()) .wrap() .selectable(true), ); }); ui.add_space(4.0); ui.horizontal(|ui| { if ui.button("📋 Copy code").clicked() { self.copy_to_clipboard(&share_code); } 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 share code 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(share_code.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}")); } self.friend_offers(ui); 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.share_code = 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; self.met.clear(); 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.share_code = None; self.host.copied = false; self.host.viewers.clear(); self.host.qr_texture = None; self.met.clear(); } /// 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 } => { // Wrap the bare ticket with our stable control id so a viewer // learns who to befriend (see code::wrap). Falls back to the // bare ticket if the presence service isn't up. let share_code = match &self.presence { Some(p) => code::wrap(p.id(), &value), None => value.clone(), }; // 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 code stays // visible for manual copy, and `copied` stays false so the UI // doesn't falsely claim success. self.host.copied = set_clipboard(&share_code); self.host.ticket = Some(value); self.host.share_code = Some(share_code); } 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. Unwrap first so a wrapped // friend code decodes to its underlying ticket. let trimmed = self.viewer.ticket_input.trim().to_string(); let (host_ctrl, bare_ticket) = code::unwrap(&trimmed); let decoded_id = ticket_endpoint_id(&bare_ticket); 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.", ), }; if host_ctrl.is_some() { ui.label( egui::RichText::new("This host can be added as a friend after you connect.") .small() .weak(), ); } } 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); } self.friend_offers(ui); 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; // Unwrap a wrapped friend code into (host control id, bare ticket). The // child only ever sees the bare ticket; the control id is kept so we can // announce ourselves to the host once connected. let (host_ctrl, ticket) = code::unwrap(&self.viewer.ticket_input); self.viewer.host_control_id = host_ctrl; 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; self.viewer.host_control_id = None; self.met.clear(); } 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 this was a wrapped friend code, announce ourselves to the // host over the control plane so both ends can offer to befriend // each other. We record the host as a met peer up front (name // filled in when the host's Hello reply arrives). if let Some(host_id) = self.viewer.host_control_id { self.note_met(host_id, short_id(&host_id.to_string())); if let Some(p) = &self.presence { p.send(host_id, control_hello(&self.display_name)); } } } } 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 } }