Files
pixelpass/src/gui/mod.rs
T
mollusk 9e839ca452 feat(friends): friend store, mutual handshake, and Friends UI (phase 3)
Build the friends feature on top of the phase-2 control plane: you can
now befriend someone you've connected with and manage a contacts list.

- common/friends.rs: a persisted FriendStore in its own friends.toml
  (kept out of config.toml so a headless --reconfigure can't clobber it,
  same as identity.key). Friends are keyed by stable control EndpointId;
  state is PendingOutgoing / PendingIncoming / Accepted. The handshake
  transitions (on_friend_request → mutual-match detection, on_friend_
  accept) are pure and unit-tested.

- gui/code.rs: the bootstrap. The GUI host wraps its share code as
  `pixelpassF1:<control-id>.<ticket>` so a viewer learns the host's
  stable id; unwrap is lenient, so a bare/CLI ticket still works (no
  friend offer). The video/streaming path is untouched.

- presence service gains an outbound path (unbounded channel → per-msg
  send tasks) and exposes our control id for wrapping codes.

- gui wiring: on connect, the viewer announces itself to the host with a
  Hello (carrying our display name); the host replies once, so both ends
  learn each other and an "Add friend" offer appears on the running
  host/view screens. Incoming requests/accepts/declines fold into the
  store with desktop notifications. New Friends screen (accept/decline/
  remove, edit your display name, see your id) reachable from the menu,
  which shows a pending-request count. New [gui] display_name setting,
  seeded from $USER.

Verified: friends store + handshake transitions covered by unit tests
(7); code wrap/unwrap round-trips (4); the control loopback still passes;
the live GUI starts clean with the presence endpoint online. fmt +
clippy clean on both features; 41 gui + 8 headless tests pass. The full
two-party UX (connect → mutual add → persisted) wants a cross-machine
manual check, as usual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:42:14 -04:00

2432 lines
91 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 self::child::{ChildEvent, ChildProc};
use self::presence::PresenceHandle;
use self::tray::{TrayAction, TrayHandle, TrayStatus};
use crate::common::control::ControlMsg;
use crate::common::friends::FriendState;
/// Initial / minimum window size, in logical points. Initial height fits the
/// host screen (ticket + Copy + QR + Stop) without needing to scroll on a 1080p
/// display; the host screen still wraps in a ScrollArea for the case where the
/// user shrinks the window below MIN_INNER_SIZE proportions.
const INNER_SIZE: [f64; 2] = [520.0, 640.0];
const MIN_INNER_SIZE: [f64; 2] = [460.0, 380.0];
/// Events delivered to the winit loop from off the UI thread (or from egui).
pub enum UserEvent {
/// Something changed off the UI thread — a headless child emitted a JSON
/// event or exited (see [`Waker`]). Drain it on the next tick, and repaint
/// if a window is currently shown.
Wake,
/// A system-tray icon/menu action (see [`tray`]).
Tray(TrayAction),
}
/// A cheap, cloneable handle that wakes the winit event loop from any thread.
///
/// Used by the headless-child reader threads: pinging this wakes the loop
/// **even when the window has been dropped to the tray** (no egui frame is
/// running then, so egui's own repaint callback would go quiet after the first
/// request — see the egui repaint bookkeeping). That's what keeps join/leave
/// desktop notifications and the tray tooltip live while hidden.
#[derive(Clone)]
pub struct Waker {
proxy: EventLoopProxy<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(),
};
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(),
}
}
/// 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>,
}
/// A peer encountered this session but not yet befriended.
struct MetPeer {
id: iroh::EndpointId,
/// Their reported display name (a short id placeholder until a name arrives).
name: String,
}
/// The active theme plus the Settings picker/editor working state.
struct ThemeState {
/// Currently applied theme (persisted by name in the config).
active: theme::Theme,
/// Theme names shown in the picker, refreshed when Settings opens so files
/// added on disk appear without a restart.
names: Vec<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 inbound control-plane messages and fold them into the friends list
/// and the session's met-peers, queueing any replies. Collected up front so
/// the presence borrow is released before we mutate `self` / re-borrow it to
/// send. (Phase 4 adds the bell badge + ShareCode handling.)
fn pump_presence_events(&mut self) {
let Some(inbound) = self.presence.as_ref().map(|p| p.drain()) else {
return;
};
if inbound.is_empty() {
return;
}
let my_name = self.display_name.clone();
let mut outbox: Vec<(iroh::EndpointId, ControlMsg)> = Vec::new();
let mut store_changed = false;
for inb in inbound {
let from = inb.from;
match inb.msg {
ControlMsg::Hello { name } => {
// A peer announcing themselves (the viewer→host intro, or the
// host's reply). Keep a known friend's name fresh; otherwise
// record them as a met peer and reply once on first contact.
if let Some(f) = self.friends.find_mut(&from) {
f.name = name;
store_changed = true;
} else if self.note_met(from, name) {
outbox.push((from, control_hello(&my_name)));
}
}
ControlMsg::FriendRequest { name } => {
self.note_met(from, name.clone());
if self.friends.on_friend_request(from, name.clone()) {
// We'd already requested them — mutual, so it's settled.
outbox.push((
from,
ControlMsg::FriendAccept {
name: my_name.clone(),
},
));
notify(
"PixelPass — now friends",
format!("You and {name} are now friends."),
);
} else {
notify(
"PixelPass — friend request",
format!("{name} wants to be friends."),
);
}
store_changed = true;
}
ControlMsg::FriendAccept { name } => {
if self.friends.on_friend_accept(from, name.clone()) {
store_changed = true;
notify(
"PixelPass — request accepted",
format!("{name} accepted your friend request."),
);
}
}
ControlMsg::FriendDecline => {
if self.friends.remove(&from) {
store_changed = true;
}
}
ControlMsg::ShareCode { name, ticket } => {
// Phase 4 turns this into the bell badge + an in-app notice.
// For now, only honour codes from accepted friends and log.
if self.friends.is_accepted(&from) {
tracing::info!(from = %from, %name, "presence: friend shared a code: {ticket}");
} else {
tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend");
}
}
}
}
if store_changed {
self.save_friends();
}
if let Some(p) = &self.presence {
for (peer, msg) in outbox {
p.send(peer, msg);
}
}
}
/// Record a peer met this session. Returns true if they were newly added
/// (false if we already knew them, in which case the name is refreshed).
fn note_met(&mut self, id: iroh::EndpointId, name: String) -> bool {
if let Some(m) = self.met.iter_mut().find(|m| m.id == id) {
// Don't overwrite a real name with a short-id placeholder.
if !name.is_empty() {
m.name = name;
}
false
} else {
self.met.push(MetPeer { id, name });
true
}
}
fn pending_incoming_count(&self) -> usize {
self.friends
.friends
.iter()
.filter(|f| f.state == FriendState::PendingIncoming)
.count()
}
fn save_friends(&self) {
if let Err(e) = self.friends.save() {
tracing::warn!("failed to save friends list: {e:#}");
}
}
/// Send a friend request to a peer (or accept theirs if they already asked),
/// persisting the new state and notifying the peer over the control plane.
fn request_friend(&mut self, id: iroh::EndpointId, name: String) {
let my_name = self.display_name.clone();
let msg = if matches!(
self.friends.find(&id).map(|f| f.state),
Some(FriendState::PendingIncoming)
) {
self.friends.upsert(id, name, FriendState::Accepted);
ControlMsg::FriendAccept { name: my_name }
} else {
self.friends.upsert(id, name, FriendState::PendingOutgoing);
ControlMsg::FriendRequest { name: my_name }
};
self.save_friends();
if let Some(p) = &self.presence {
p.send(id, msg);
}
}
/// Mark an incoming request accepted and tell the peer.
fn accept_friend(&mut self, id: iroh::EndpointId) {
let my_name = self.display_name.clone();
if let Some(f) = self.friends.find_mut(&id) {
f.state = FriendState::Accepted;
self.save_friends();
if let Some(p) = &self.presence {
p.send(id, ControlMsg::FriendAccept { name: my_name });
}
}
}
/// Remove a friend / decline a request / cancel an outgoing one, telling the
/// peer so their side drops us too.
fn remove_friend(&mut self, id: iroh::EndpointId) {
if self.friends.remove(&id) {
self.save_friends();
if let Some(p) = &self.presence {
p.send(id, ControlMsg::FriendDecline);
}
}
}
/// The "people you just connected with" offer shown on the running host /
/// viewer screens: met peers not yet in the friends list, each with an Add
/// button.
fn friend_offers(&mut self, ui: &mut egui::Ui) {
let offers: Vec<(iroh::EndpointId, String)> = self
.met
.iter()
.filter(|m| self.friends.find(&m.id).is_none())
.map(|m| (m.id, m.name.clone()))
.collect();
if offers.is_empty() {
return;
}
ui.add_space(12.0);
ui.separator();
ui.label("People you just connected with:");
let mut add: Option<(iroh::EndpointId, String)> = None;
for (id, name) in &offers {
ui.horizontal(|ui| {
ui.label(name.as_str());
if ui.small_button(" Add friend").clicked() {
add = Some((*id, name.clone()));
}
});
}
if let Some((id, name)) = add {
self.request_friend(id, name);
}
}
fn friends_screen(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
if ui.button("← Menu").clicked() {
self.screen = Screen::Menu;
}
ui.heading("Friends");
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
// Your identity: editable display name + your stable id.
ui.horizontal(|ui| {
ui.label("Your name");
if ui
.text_edit_singleline(&mut self.display_name)
.on_hover_text("Shown to friends in requests and shared codes.")
.changed()
{
persist_display_name(&self.display_name);
}
});
if let Some(p) = &self.presence {
ui.label(
egui::RichText::new(format!("Your ID: {}", short_id(&p.id().to_string())))
.small()
.weak(),
);
} else {
ui.colored_label(
self.theme.active.warning,
"⚠ Friends service unavailable (no identity).",
);
}
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
if self.friends.friends.is_empty() {
ui.label(
egui::RichText::new(
"No friends yet. Connect with someone, then use \"Add friend\" \
on the host/view screen.",
)
.weak(),
);
return;
}
// Collect the chosen action first so we don't mutate the store while
// iterating it.
enum Action {
Accept(iroh::EndpointId),
Remove(iroh::EndpointId),
}
let mut action: Option<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),
}
}
/// 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);
}
});
}
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(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.friend_offers(ui);
ui.add_space(16.0);
if ui
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
.clicked()
{
self.stop_host();
}
}
fn start_host(&mut self) {
self.host.error = None;
self.host.last_refusal = None;
self.host.ticket = None;
self.host.share_code = None;
self.host.info = None;
self.host.active = 0;
self.host.max = 0;
self.host.capturing = false;
self.host.copied = false;
self.host.viewers.clear();
self.host.qr_texture = None;
self.met.clear();
let mut args = vec![
"--host".to_string(),
"--output".to_string(),
"json".to_string(),
"--quality".to_string(),
self.host.quality.as_arg().to_string(),
];
if self.host.max_viewers > 0 {
args.push("--max-viewers".to_string());
args.push(self.host.max_viewers.to_string());
}
if self.host.no_hwencode {
args.push("--no-hwencode".to_string());
}
if self.host.window {
args.push("--window".to_string());
}
if let Some(relay) = &self.relay {
args.push("--relay".to_string());
args.push(relay.clone());
}
match ChildProc::spawn(&args, self.waker.clone()) {
Ok(p) => self.host.proc = Some(p),
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
}
}
fn stop_host(&mut self) {
// Dropping the ChildProc SIGINTs the child and reaps it.
self.host.proc = None;
self.host.capturing = false;
self.host.ticket = None;
self.host.share_code = None;
self.host.copied = false;
self.host.viewers.clear();
self.host.qr_texture = None;
self.met.clear();
}
/// Drain the host child's event channel into state, and detect an
/// unexpected child exit (e.g. a failed dependency check) so it surfaces
/// in the form instead of leaving a dead "running" view.
fn pump_host_events(&mut self) {
let events: Vec<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 } => {
// Wrap the bare ticket with our stable control id so a viewer
// learns who to befriend (see code::wrap). Falls back to the
// bare ticket if the presence service isn't up.
let share_code = match &self.presence {
Some(p) => code::wrap(p.id(), &value),
None => value.clone(),
};
// Auto-copy on arrival, mirroring the CLI/interactive host
// (which copies the ticket and prints "copied to your
// clipboard"). A failure here is non-fatal: the code stays
// visible for manual copy, and `copied` stays false so the UI
// doesn't falsely claim success.
self.host.copied = set_clipboard(&share_code);
self.host.ticket = Some(value);
self.host.share_code = Some(share_code);
}
ChildEvent::HostInfo {
display_server,
capture,
quality,
dimensions,
hw_encode,
max_viewers,
max_viewers_source,
} => {
self.host.max = max_viewers;
self.host.info = Some(HostInfo {
display: display_server,
capture,
quality,
dimensions,
hw_encode,
cap_source: max_viewers_source,
});
}
ChildEvent::ViewerJoined { id, active, max } => {
self.host.active = active;
self.host.max = max;
if !self.host.viewers.contains(&id) {
notify(
"PixelPass — viewer connected",
format!(
"endpoint {} is now watching ({active}/{max})",
short_id(&id)
),
);
self.host.viewers.push(id);
}
}
ChildEvent::ViewerLeft { id, active, max } => {
self.host.active = active;
self.host.max = max;
if self.host.viewers.iter().any(|v| v == &id) {
notify(
"PixelPass — viewer disconnected",
format!("endpoint {} left ({active}/{max})", short_id(&id)),
);
self.host.viewers.retain(|v| v != &id);
}
}
ChildEvent::Capture { state } => {
self.host.capturing = matches!(state, child::CaptureState::Started);
}
ChildEvent::ViewerRefused { reason } => self.host.last_refusal = Some(reason),
ChildEvent::Connected { .. } => {} // viewer-side; not used on the host screen
}
}
/// Manual Copy-button handler: copy and reflect success in the UI, or
/// surface a clear error if the clipboard rejected it.
fn copy_to_clipboard(&mut self, text: &str) {
if set_clipboard(text) {
self.host.copied = true;
self.host.error = None;
} else {
self.host.copied = false;
self.host.error = Some(
"Couldn't write to the clipboard. Select the code above and copy it manually."
.to_string(),
);
}
}
// ── Viewer screen ─────────────────────────────────────────────────────
fn viewer(&mut self, ui: &mut egui::Ui) {
let running = self.viewer.proc.is_some();
ui.horizontal(|ui| {
if ui.button("← Menu").clicked() {
self.stop_viewer();
self.screen = Screen::Menu;
}
ui.heading("View");
});
ui.separator();
if running {
self.viewer_running(ui);
} else {
self.viewer_form(ui);
}
}
fn viewer_form(&mut self, ui: &mut egui::Ui) {
if let Some(err) = &self.viewer.error {
ui.colored_label(self.theme.active.error, err);
ui.add_space(8.0);
}
ui.label("Paste the share code you received:");
ui.add_space(4.0);
// A Paste button (read-side mirror of the host's Copy button — one
// click grabs the code the host just put on the clipboard) with the
// field filling the rest of the row. A single horizontal row, so it
// doesn't grab the panel's full height.
let ticket_resp = ui
.horizontal(|ui| {
if ui.button("📋 Paste").clicked()
&& let Some(text) = get_clipboard()
{
self.viewer.ticket_input = text.trim().to_string();
}
ui.add(
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
.desired_width(f32::INFINITY)
.hint_text("endpoint…"),
)
})
.inner;
// Grab focus once on screen entry so the user can paste/type straight
// away without first clicking into the field.
if std::mem::take(&mut self.viewer.focus_ticket) {
ticket_resp.request_focus();
}
// Enter in the field connects (gated on a decodable code below).
let enter_pressed =
ticket_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
// Decode the pasted code live: confirms it's a real ticket and shows
// which host it points at, so a stale clipboard paste is caught here
// instead of after the 15s connect timeout. Unwrap first so a wrapped
// friend code decodes to its underlying ticket.
let trimmed = self.viewer.ticket_input.trim().to_string();
let (host_ctrl, bare_ticket) = code::unwrap(&trimmed);
let decoded_id = ticket_endpoint_id(&bare_ticket);
if !trimmed.is_empty() {
ui.add_space(4.0);
match &decoded_id {
Some(id) => ui.colored_label(
self.theme.active.success,
format!("→ endpoint {}", short_id(id)),
),
None => ui.colored_label(
self.theme.active.warning,
"⚠ This doesn't look like a share code.",
),
};
if host_ctrl.is_some() {
ui.label(
egui::RichText::new("This host can be added as a friend after you connect.")
.small()
.weak(),
);
}
}
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label("Player");
egui::ComboBox::from_id_salt("player")
.selected_text(match self.viewer.player {
PlayerSel::Mpv => "mpv",
PlayerSel::Vlc => "VLC",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.viewer.player, PlayerSel::Mpv, "mpv");
ui.selectable_value(&mut self.viewer.player, PlayerSel::Vlc, "VLC");
});
});
ui.add_space(16.0);
// Only dial a code that actually decodes — no point spawning a child to
// spend 15s timing out against garbage. Enter takes the same path.
let connect_clicked = ui
.add_enabled(
decoded_id.is_some(),
egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)),
)
.on_disabled_hover_text("Paste a valid share code first.")
.clicked();
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
self.start_viewer();
}
}
fn viewer_running(&mut self, ui: &mut egui::Ui) {
if self.viewer.launched {
ui.colored_label(self.theme.active.streaming, "● Streaming");
ui.label("Player launched. Close it or disconnect to stop.");
} else if self.viewer.url.is_some() {
ui.label("Connected — launching player…");
} else {
let msg = match &self.viewer.connecting_to {
Some(id) => format!("● Connecting to {id}"),
None => "● Connecting…".to_string(),
};
ui.colored_label(self.theme.active.waiting, msg);
}
self.friend_offers(ui);
ui.add_space(16.0);
if ui
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
.clicked()
{
self.stop_viewer();
}
}
/// On entering the View screen, drop a clipboard ticket straight into the
/// field — the host's Copy button (and our auto-copy) usually leaves the
/// freshly-shared code right there. Guarded so it only fires when the field
/// is empty and the clipboard holds a *decodable* ticket, so stray
/// clipboard text never lands in the box; the live decode still shows the
/// id for the user to verify.
fn prefill_viewer_ticket(&mut self) {
if self.viewer.ticket_input.trim().is_empty()
&& self.viewer.proc.is_none()
&& let Some(text) = get_clipboard()
&& ticket_endpoint_id(&text).is_some()
{
self.viewer.ticket_input = text.trim().to_string();
}
self.viewer.focus_ticket = true;
}
fn start_viewer(&mut self) {
self.viewer.error = None;
self.viewer.url = None;
self.viewer.launched = false;
// Unwrap a wrapped friend code into (host control id, bare ticket). The
// child only ever sees the bare ticket; the control id is kept so we can
// announce ourselves to the host once connected.
let (host_ctrl, ticket) = code::unwrap(&self.viewer.ticket_input);
self.viewer.host_control_id = host_ctrl;
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
let mut args = vec![ticket, "--output".to_string(), "json".to_string()];
if let Some(relay) = &self.relay {
args.push("--relay".to_string());
args.push(relay.clone());
}
match ChildProc::spawn(&args, self.waker.clone()) {
Ok(p) => self.viewer.proc = Some(p),
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
}
}
fn stop_viewer(&mut self) {
self.viewer.proc = None;
self.viewer.url = None;
self.viewer.launched = false;
self.viewer.connecting_to = None;
self.viewer.host_control_id = None;
self.met.clear();
}
fn pump_viewer_events(&mut self) {
let events: Vec<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
}
}