Files
pixelpass/src/gui/mod.rs
T
molluskandClaude Opus 4.8 6d0bf99076 fix(friends): five robustness bugs in the friends/control plane
Found in a bug audit of the just-merged friends-list feature. No crashes
or security holes, but five real state/correctness bugs:

- Host child dying on its own left the share campaign running, so it kept
  pushing a now-dead ticket to friends (retrying offline ones forever) and
  leaked share_status/met/share_code. The unexpected-exit path now captures
  the stderr error, then routes through the full stop_host() teardown
  (notably stop_share). (gui/mod.rs pump_host_events)

- on_friend_request downgraded an already-Accepted friend back to
  PendingIncoming when they re-sent a request (e.g. after losing their
  store). It now stays Accepted and re-confirms. (friends.rs)

- on_friend_accept advanced *any* known peer to Accepted, including a
  PendingIncoming one — a peer could mark itself accepted without the local
  user's consent. Now only a PendingOutgoing request we sent is honoured.
  (friends.rs)

- A ShareCode redelivered by an ACK-loss retry fired a duplicate desktop
  notification. push_notice now reports whether the code is new/changed and
  only then toasts. (gui/mod.rs)

- An inbound control message could be delayed up to IO_TIMEOUT on a degraded
  link because handle() awaited the sender's close before forwarding it.
  Forward to the UI first, then await close so the ACK still flushes.
  (control.rs)

Adds two friends-store transition tests (accept ignores a pending-incoming
peer; request doesn't downgrade an accepted friend). 47 gui / 8 headless
tests pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:10:22 -04:00

2756 lines
104 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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 std::collections::BTreeMap;
use self::child::{ChildEvent, ChildProc};
use self::presence::{PresenceEvent, PresenceHandle};
use self::tray::{TrayAction, TrayHandle, TrayStatus};
use crate::common::control::ControlMsg;
use crate::common::friends::{FriendState, FriendStore};
/// 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());
// 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(),
share_status: BTreeMap::new(),
notices: Vec::new(),
show_notices: false,
};
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
}
}
/// 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(),
}
}
/// The accepted friends a host's share push targets: every accepted friend
/// whose per-friend `share` flag is on (toggled on the host form, persisted in
/// `friends.toml`). A free function over the store so the rule is unit-testable
/// without a live UI.
fn selected_share_targets(friends: &FriendStore) -> Vec<iroh::EndpointId> {
friends
.friends
.iter()
.filter(|f| f.state == FriendState::Accepted && f.share)
.map(|f| f.id)
.collect()
}
/// 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<ChildProc>,
/// 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<String>,
/// 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<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>,
/// 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<iroh::EndpointId>,
/// 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 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<PresenceHandle>,
/// 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<MetPeer>,
/// Delivery state for the current host session's share campaign: a friend is
/// present once targeted, `true` once their ACK arrives. Drives the live
/// "delivered / retrying" list on the running host screen. Cleared on stop.
share_status: BTreeMap<iroh::EndpointId, bool>,
/// Share codes friends have pushed to us, awaiting the user — the bell badge.
/// Deduped by sender (a friend re-hosting replaces their stale code).
notices: Vec<ShareNotice>,
/// Whether the bell's notification list is currently expanded.
show_notices: bool,
}
/// 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,
}
/// A share code a friend pushed to us over the control plane, shown in the bell
/// panel until the user watches or dismisses it.
struct ShareNotice {
/// The friend who shared — the dedupe key (one live notice per friend).
from: iroh::EndpointId,
/// Their display name, for the panel row.
name: String,
/// The share code to drop into the viewer when "Watch" is clicked.
code: 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<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();
}
/// 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 presence-service events — inbound control messages and share-code
/// delivery receipts — folding them into the friends list, the session's
/// met-peers, the bell notices, and the live share status, queueing any
/// replies. Collected up front so the presence borrow is released before we
/// mutate `self` / re-borrow it to send.
fn pump_presence_events(&mut self) {
let Some(events) = self.presence.as_ref().map(|p| p.drain()) else {
return;
};
if events.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 event in events {
let inb = match event {
PresenceEvent::ShareDelivered { peer } => {
// A code we pushed reached this friend — flip their row.
if let Some(s) = self.share_status.get_mut(&peer) {
*s = true;
}
continue;
}
PresenceEvent::Message(inb) => inb,
};
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 } => {
// Only accepted friends may push us a code — a stranger's is
// ignored, so the control plane can't be used to spam viewers.
if self.friends.is_accepted(&from) {
// Keep the stored name fresh from the live push.
if let Some(f) = self.friends.find_mut(&from) {
f.name = name.clone();
store_changed = true;
}
// Only toast for a new/changed code — an ACK-loss retry
// redelivers the same code and shouldn't fire again.
if self.push_notice(from, name.clone(), ticket) {
notify(
"PixelPass — a friend is sharing",
format!("{name} is sharing their screen. Open PixelPass to watch."),
);
}
} 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:#}");
}
}
/// Record a share code a friend pushed us, replacing any prior notice from
/// the same friend (their previous code is stale once they re-host). Returns
/// `true` if this is a new notice or a *different* code than we already had
/// from them — i.e. worth a fresh desktop notification. A duplicate delivery
/// (an ACK-loss retry redelivering the same code) updates in place and
/// returns `false`, so it doesn't fire a second toast.
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) -> bool {
if let Some(n) = self.notices.iter_mut().find(|n| n.from == from) {
let changed = n.code != code;
n.name = name;
n.code = code;
changed
} else {
self.notices.push(ShareNotice { from, name, code });
true
}
}
/// Begin pushing the current host share code to the selected accepted
/// friends. Called once the child reports its ticket (so `share_code` is
/// set). Seeds the per-friend status map to "retrying" and hands the campaign
/// to the presence service, which delivers now and retries offline friends
/// while we host.
fn begin_share(&mut self) {
self.share_status.clear();
let Some(code) = self.host.share_code.clone() else {
return;
};
let targets = selected_share_targets(&self.friends);
if targets.is_empty() {
return;
}
self.share_status = targets.iter().map(|id| (*id, false)).collect();
if let Some(p) = &self.presence {
p.start_share(
ControlMsg::ShareCode {
name: self.display_name.clone(),
ticket: code,
},
targets,
);
}
}
/// 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<Action> = 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),
}
// The notification bell floats over every screen (an overlay Area, so it
// doesn't disturb the per-screen layouts), drawn last to sit on top.
self.notification_bell(ui);
}
/// A bell in the top-right corner with a red badge counting share codes
/// friends have pushed us. Clicking it toggles a panel listing them, each
/// openable straight into the viewer. An overlay so it rides above whichever
/// screen is showing without fighting its scroll areas or back buttons.
fn notification_bell(&mut self, ui: &mut egui::Ui) {
let count = self.notices.len();
egui::Area::new(egui::Id::new("notif_bell"))
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-10.0, 8.0))
.order(egui::Order::Foreground)
.show(ui.ctx(), |ui| {
ui.horizontal(|ui| {
if count > 0 {
ui.label(
egui::RichText::new(format!(" {count} "))
.color(egui::Color32::WHITE)
.background_color(egui::Color32::from_rgb(200, 40, 40))
.strong(),
);
}
if ui
.button("🔔")
.on_hover_text("Screen codes shared by friends")
.clicked()
{
self.show_notices = !self.show_notices;
}
});
});
if self.show_notices {
self.notification_panel(ui);
}
}
/// The dropdown listing pushed share codes. Each row offers Watch (open it in
/// the viewer) and Dismiss; collected first so the list isn't mutated mid-draw.
fn notification_panel(&mut self, ui: &mut egui::Ui) {
let mut watch: Option<String> = None;
let mut dismiss: Option<iroh::EndpointId> = None;
let mut clear_all = false;
egui::Area::new(egui::Id::new("notif_panel"))
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-10.0, 40.0))
.order(egui::Order::Foreground)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_max_width(280.0);
ui.label(egui::RichText::new("Shared screen codes").strong());
ui.separator();
if self.notices.is_empty() {
ui.label(egui::RichText::new("No codes from friends right now.").weak());
return;
}
for n in &self.notices {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(&n.name).strong());
ui.label(egui::RichText::new("is sharing their screen").weak());
});
ui.horizontal(|ui| {
if ui.button("▶ Watch").clicked() {
watch = Some(n.code.clone());
dismiss = Some(n.from);
}
if ui.small_button("Dismiss").clicked() {
dismiss = Some(n.from);
}
});
ui.separator();
}
if ui.small_button("Clear all").clicked() {
clear_all = true;
}
});
});
if clear_all {
self.notices.clear();
self.show_notices = false;
}
if let Some(id) = dismiss {
self.notices.retain(|n| n.from != id);
}
if let Some(code) = watch {
self.viewer.ticket_input = code;
self.viewer.focus_ticket = true;
self.screen = Screen::Viewer;
self.show_notices = false;
}
}
/// 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<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);
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);
}
});
}
/// The "share my code with these friends" picker on the host form. Lists the
/// accepted friends as checkboxes (ticked = will receive the code when I
/// start). Selection is the *exclusion* set, so the default ships the code to
/// everyone. Hidden when there's no presence service or no accepted friends.
fn share_picker(&mut self, ui: &mut egui::Ui) {
if self.presence.is_none() {
return;
}
let accepted: Vec<(iroh::EndpointId, String, bool)> = self
.friends
.friends
.iter()
.filter(|f| f.state == FriendState::Accepted)
.map(|f| (f.id, f.name.clone(), f.share))
.collect();
if accepted.is_empty() {
return;
}
ui.add_space(12.0);
ui.separator();
ui.label("Auto-share my code with:");
for (id, name, share) in &accepted {
let mut on = *share;
if ui.checkbox(&mut on, name.as_str()).changed() {
if let Some(f) = self.friends.find_mut(id) {
f.share = on;
}
self.save_friends();
}
}
ui.label(
egui::RichText::new(
"Ticked friends get this session's code automatically — even if \
they're offline now (we keep retrying while you host).",
)
.small()
.weak(),
);
}
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();
});
self.share_picker(ui);
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(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 <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(&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.share_status_list(ui);
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();
}
}
/// Live status of the host's share push: each targeted friend with a
/// delivered ✓ or a "retrying" marker (offline friends are chased until they
/// come online or the session stops). Empty — and so hidden — when no friends
/// were selected to share with.
fn share_status_list(&mut self, ui: &mut egui::Ui) {
if self.share_status.is_empty() {
return;
}
ui.add_space(10.0);
ui.separator();
ui.label("Shared this code with:");
for (id, delivered) in &self.share_status {
let name = self
.friends
.find(id)
.map(|f| f.name.clone())
.unwrap_or_else(|| short_id(&id.to_string()));
ui.horizontal(|ui| {
ui.label(format!("• {name}"));
if *delivered {
ui.colored_label(self.theme.active.success, "✓ delivered");
} else {
ui.colored_label(self.theme.active.waiting, "… offline, retrying");
}
});
}
}
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();
// Any previous campaign is stale; the new session's Ticket event will
// start a fresh one once its code arrives.
self.share_status.clear();
if let Some(p) = &self.presence {
p.stop_share();
}
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();
// The code is no longer valid, so stop chasing offline friends with it.
self.share_status.clear();
if let Some(p) = &self.presence {
p.stop_share();
}
}
/// 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);
}
let dead = self.host.proc.as_mut().is_some_and(|p| !p.is_alive());
if dead {
// If it never reached a ticket, capture why (from the stderr tail)
// before tearing down. Then run the *full* Stop cleanup — most
// importantly stop_share, so a host that died on its own stops
// pushing its now-dead code to friends. Without this the campaign
// would keep retrying offline friends with a stale ticket for the
// life of the GUI, and share_status/met/share_code would leak.
let error = self.host.ticket.is_none().then(|| {
let tail = self
.host
.proc
.as_mut()
.map(|p| p.stderr_tail())
.unwrap_or_default();
if tail.trim().is_empty() {
"Host exited before it could start.".to_string()
} else {
format!("Host exited before it could start:\n{tail}")
}
});
self.stop_host();
self.host.error = error;
}
}
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);
// Now that there's a code, push it to the selected friends.
self.begin_share();
}
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<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 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
}
fn id() -> iroh::EndpointId {
iroh::SecretKey::generate().public()
}
#[test]
fn share_targets_are_accepted_friends_with_share_on() {
let mut friends = FriendStore::default();
let alice = id();
let bob = id();
let pending = id();
friends.upsert(alice, "Alice".into(), FriendState::Accepted);
friends.upsert(bob, "Bob".into(), FriendState::Accepted);
friends.upsert(pending, "Pat".into(), FriendState::PendingIncoming);
// Default (share on for all) → every accepted friend, never a pending one.
let all = selected_share_targets(&friends);
assert_eq!(all.len(), 2);
assert!(all.contains(&alice) && all.contains(&bob));
assert!(!all.contains(&pending));
// Turning Bob's share off drops only Bob.
friends.find_mut(&bob).unwrap().share = false;
let some = selected_share_targets(&friends);
assert_eq!(some, vec![alice]);
}
#[test]
fn share_targets_empty_without_accepted_friends() {
let mut friends = FriendStore::default();
friends.upsert(id(), "Out".into(), FriendState::PendingOutgoing);
assert!(selected_share_targets(&friends).is_empty());
}
}