Files
pixelpass/src/gui/mod.rs
T
mollusk c876c61ec6 fix(gui): scroll the Settings body and add a Defaults reset to the editor
The Settings screen grew past a short window once the Appearance section
landed, forcing a manual resize to reach the Save button. Wrap the body in a
vertical ScrollArea (header stays pinned), mirroring the Host screen. Also
add a '↺ Defaults' button to the right of Save in the theme editor that
resets the draft to the original Default Dark palette (previews live).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 03:09:40 -04:00

1859 lines
68 KiB
Rust

//! 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 <ticket> --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<UserEvent>,
}
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<Surface<WindowSurface>> {
let (w, h): (u32, u32) = window.inner_size().into();
let attrs = SurfaceAttributesBuilder::<WindowSurface>::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<glow::Context>,
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<WindowSurface>,
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<Self> {
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<Gfx>,
/// 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<Instant>,
}
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<UserEvent> 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<String>) -> anyhow::Result<()> {
let event_loop = EventLoop::<UserEvent>::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<String> {
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<String> {
ticket
.trim()
.parse::<iroh_tickets::endpoint::EndpointTicket>()
.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<ChildProc>,
ticket: Option<String>,
info: Option<HostInfo>,
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<String>,
error: Option<String>,
/// Endpoint ids of the currently-connected viewers, in arrival order.
/// Drives the per-viewer list and its Kick buttons.
viewers: Vec<String>,
/// 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<egui::TextureHandle>,
}
/// 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<ChildProc>,
url: Option<String>,
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<String>,
/// 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<String>,
}
/// 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<TrayHandle>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
}
/// 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<String> = None;
ui.horizontal(|ui| {
ui.label("Theme");
egui::ComboBox::from_id_salt("theme_picker")
.selected_text(&current)
.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<String> = None;
for id in &self.host.viewers {
ui.horizontal(|ui| {
ui.label(format!("• endpoint {}", short_id(id)));
if ui.small_button("Kick").clicked() {
kick = Some(id.clone());
}
});
}
if let Some(id) = kick
&& let Some(p) = &mut self.host.proc
{
p.send_command(&format!("kick {id}"));
}
if let Some(info) = &self.host.info {
ui.label(
egui::RichText::new(format!("{} · {}", info.display, info.capture))
.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 <id>…" 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<ChildEvent> = 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<ChildEvent> = 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
}
}