9e839ca452
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>
2432 lines
91 KiB
Rust
2432 lines
91 KiB
Rust
//! Graphical front-end (`pixelpass --gui`), compiled only with the `gui`
|
||
//! feature.
|
||
//!
|
||
//! Architecture: this window is a thin **shell-out** driver. It never touches
|
||
//! the capture / portal / gst / iroh machinery directly — instead it re-execs
|
||
//! this same binary in headless mode (`pixelpass --host --output json …` or
|
||
//! `pixelpass <ticket> --output json`) as a child process and parses the
|
||
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
|
||
//! shows. That keeps the fragile capture stack sealed in a separate process:
|
||
//! the GUI can be closed or crash without taking a live stream down.
|
||
//!
|
||
//! ## Windowing: why we hand-roll the loop instead of using `eframe`
|
||
//!
|
||
//! The "keep running in the tray" feature needs to truly *hide* the window
|
||
//! while a stream keeps running in the child. On Wayland there is no
|
||
//! unmap-but-keep-alive request in xdg-shell, so winit's `set_visible(false)`
|
||
//! is a deliberate no-op there — the only way to make a toplevel vanish is to
|
||
//! **destroy its surface** and recreate it later. `eframe::run_native` owns the
|
||
//! one window it will never let you drop, so we replace it with a hand-rolled
|
||
//! [`winit::application::ApplicationHandler`] + `glutin` + [`egui_glow`] loop:
|
||
//!
|
||
//! * **Hide to tray** ([`Gfx::hide`]) parks the GL context as *not-current*
|
||
//! and drops the [`winit::window::Window`] + its GL surface — the Wayland
|
||
//! surface is genuinely gone, natively, on both Wayland and X11.
|
||
//! * **Show** ([`Gfx::show`], on a tray click) recreates the window + surface
|
||
//! and makes the parked context current again.
|
||
//!
|
||
//! The GL context, `glutin` display/config, and the `egui_glow` painter (with
|
||
//! its uploaded font/texture atlas) and `egui-winit` state (with its clipboard
|
||
//! connection) are **kept** across the cycle — only the OS window and its
|
||
//! surface churn. That preserves the hard-won Wayland clipboard integration and
|
||
//! avoids re-uploading textures on every show.
|
||
//!
|
||
//! Wakeups are routed through winit's [`winit::event_loop::EventLoopProxy`]
|
||
//! (see [`Waker`] and [`tray`]) rather than egui's repaint callback, because a
|
||
//! child event or tray click must wake the loop even while the window is
|
||
//! dropped and no egui frame is running.
|
||
|
||
mod child;
|
||
mod 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(¤t)
|
||
.show_ui(ui, |ui| {
|
||
for name in &self.theme.names {
|
||
if ui.selectable_label(*name == current, name).clicked() {
|
||
pick = Some(name.clone());
|
||
}
|
||
}
|
||
});
|
||
});
|
||
if let Some(name) = pick {
|
||
self.select_theme(&name);
|
||
}
|
||
|
||
ui.add_space(8.0);
|
||
if ui.button("✎ Edit / create a theme").clicked() {
|
||
self.start_theme_edit();
|
||
}
|
||
ui.add_space(4.0);
|
||
ui.label(
|
||
egui::RichText::new(
|
||
"Themes live as .toml files in your config folder \
|
||
(themes/). Drop one in to share or install it.",
|
||
)
|
||
.small()
|
||
.weak(),
|
||
);
|
||
if let Some(status) = &self.theme.status {
|
||
ui.add_space(4.0);
|
||
ui.label(egui::RichText::new(status).small().weak());
|
||
}
|
||
}
|
||
|
||
/// The in-app theme editor: a colour picker per palette field with a live
|
||
/// preview, plus Save (writes a .toml) / Cancel.
|
||
fn theme_editor(&mut self, ui: &mut egui::Ui) {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Name");
|
||
ui.text_edit_singleline(&mut self.theme.draft.name);
|
||
});
|
||
ui.checkbox(
|
||
&mut self.theme.draft.dark,
|
||
"Dark base (sets the fallback for anything the palette doesn't name)",
|
||
);
|
||
ui.add_space(6.0);
|
||
|
||
let d = &mut self.theme.draft;
|
||
color_section(ui, "Surfaces", "theme_grid_surfaces", |ui| {
|
||
color_row(ui, "Window background", &mut d.window_bg);
|
||
color_row(ui, "Panel background", &mut d.panel_bg);
|
||
color_row(ui, "Input background", &mut d.input_bg);
|
||
});
|
||
color_section(ui, "Text & accent", "theme_grid_text", |ui| {
|
||
color_row(ui, "Text", &mut d.text);
|
||
color_row(ui, "Secondary text", &mut d.weak_text);
|
||
color_row(ui, "Accent", &mut d.accent);
|
||
});
|
||
color_section(ui, "Buttons", "theme_grid_buttons", |ui| {
|
||
color_row(ui, "Button", &mut d.button_bg);
|
||
color_row(ui, "Button (hover)", &mut d.button_hovered);
|
||
});
|
||
color_section(ui, "Status colours", "theme_grid_status", |ui| {
|
||
color_row(ui, "Streaming", &mut d.streaming);
|
||
color_row(ui, "Waiting", &mut d.waiting);
|
||
color_row(ui, "Success", &mut d.success);
|
||
color_row(ui, "Warning", &mut d.warning);
|
||
color_row(ui, "Error", &mut d.error);
|
||
});
|
||
|
||
ui.add_space(10.0);
|
||
ui.horizontal(|ui| {
|
||
if ui.button("💾 Save").clicked() {
|
||
self.save_draft_theme();
|
||
}
|
||
// Reset the draft to the original Default Dark palette. Previews
|
||
// live (the editor applies the draft each frame), so it snaps back
|
||
// immediately; Save persists it, Cancel discards.
|
||
if ui.button("↺ Defaults").clicked() {
|
||
self.theme.draft = theme::default_dark();
|
||
self.theme.status = Some("Reset to the Default Dark palette.".to_string());
|
||
}
|
||
if ui.button("Cancel").clicked() {
|
||
self.cancel_theme_edit();
|
||
}
|
||
});
|
||
ui.add_space(4.0);
|
||
ui.label(
|
||
egui::RichText::new(
|
||
"Changes preview live. Save writes a .toml to your themes folder; \
|
||
rename it to keep a built-in alongside your own version.",
|
||
)
|
||
.small()
|
||
.weak(),
|
||
);
|
||
if let Some(status) = &self.theme.status {
|
||
ui.add_space(4.0);
|
||
ui.label(egui::RichText::new(status).small().weak());
|
||
}
|
||
}
|
||
|
||
/// Switch to the named theme: apply it, persist the choice, and reset the
|
||
/// editor draft to match.
|
||
fn select_theme(&mut self, name: &str) {
|
||
self.theme.active = theme::load_named(name);
|
||
self.theme.draft = self.theme.active.clone();
|
||
self.theme.dirty = true;
|
||
self.theme.status = None;
|
||
persist_theme(name);
|
||
}
|
||
|
||
fn start_theme_edit(&mut self) {
|
||
self.theme.draft = self.theme.active.clone();
|
||
self.theme.editing = true;
|
||
self.theme.status = None;
|
||
}
|
||
|
||
fn cancel_theme_edit(&mut self) {
|
||
self.theme.editing = false;
|
||
self.theme.draft = self.theme.active.clone();
|
||
self.theme.dirty = true; // discard the live preview, restore the active theme
|
||
self.theme.status = None;
|
||
}
|
||
|
||
fn save_draft_theme(&mut self) {
|
||
if self.theme.draft.name.trim().is_empty() {
|
||
self.theme.status = Some("Give the theme a name before saving.".to_string());
|
||
return;
|
||
}
|
||
match theme::save_theme(&self.theme.draft) {
|
||
Ok(path) => {
|
||
self.theme.active = self.theme.draft.clone();
|
||
self.theme.editing = false;
|
||
self.theme.dirty = true;
|
||
self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
||
self.theme.status = Some(format!("Saved to {}", path.display()));
|
||
persist_theme(&self.theme.active.name);
|
||
}
|
||
Err(e) => self.theme.status = Some(format!("Couldn't save: {e}")),
|
||
}
|
||
}
|
||
|
||
// ── Host screen ──────────────────────────────────────────────────────
|
||
|
||
fn host(&mut self, ui: &mut egui::Ui) {
|
||
let running = self.host.proc.is_some();
|
||
ui.horizontal(|ui| {
|
||
// Leaving the host screen stops the session (Drop on the child).
|
||
if ui.button("← Menu").clicked() {
|
||
self.stop_host();
|
||
self.screen = Screen::Menu;
|
||
}
|
||
ui.heading("Host");
|
||
});
|
||
ui.separator();
|
||
|
||
// Scroll the body, not the header — Menu/Host stay pinned at the top.
|
||
// Needed once the QR panel landed: ticket + Copy + QR + Stop overflows a
|
||
// shrunken window.
|
||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||
if running {
|
||
self.host_running(ui);
|
||
} else {
|
||
self.host_form(ui);
|
||
}
|
||
});
|
||
}
|
||
|
||
fn host_form(&mut self, ui: &mut egui::Ui) {
|
||
if let Some(err) = &self.host.error {
|
||
ui.colored_label(self.theme.active.error, err);
|
||
ui.add_space(8.0);
|
||
}
|
||
|
||
egui::Grid::new("host_form")
|
||
.num_columns(2)
|
||
.spacing([12.0, 10.0])
|
||
.show(ui, |ui| {
|
||
ui.label("Quality");
|
||
egui::ComboBox::from_id_salt("quality")
|
||
.selected_text(self.host.quality.label())
|
||
.show_ui(ui, |ui| {
|
||
for q in QualitySel::ALL {
|
||
ui.selectable_value(&mut self.host.quality, q, q.label());
|
||
}
|
||
});
|
||
ui.end_row();
|
||
|
||
ui.label("Max viewers");
|
||
ui.horizontal(|ui| {
|
||
ui.add(egui::DragValue::new(&mut self.host.max_viewers).range(0..=16));
|
||
if self.host.max_viewers == 0 {
|
||
ui.label("(auto from upload speed)");
|
||
}
|
||
});
|
||
ui.end_row();
|
||
|
||
ui.label("Options");
|
||
ui.vertical(|ui| {
|
||
ui.checkbox(&mut self.host.window, "Share a single window");
|
||
ui.checkbox(
|
||
&mut self.host.no_hwencode,
|
||
"Software encoding (no GPU / VAAPI)",
|
||
);
|
||
});
|
||
ui.end_row();
|
||
});
|
||
|
||
ui.add_space(16.0);
|
||
if ui
|
||
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
|
||
.clicked()
|
||
{
|
||
self.start_host();
|
||
}
|
||
ui.add_space(8.0);
|
||
ui.label(
|
||
egui::RichText::new(
|
||
"On Wayland a \"Share Screen?\" dialog appears when the first \
|
||
viewer connects.",
|
||
)
|
||
.small()
|
||
.weak(),
|
||
);
|
||
}
|
||
|
||
fn host_running(&mut self, ui: &mut egui::Ui) {
|
||
if self.host.capturing {
|
||
ui.colored_label(self.theme.active.streaming, "● Streaming");
|
||
} else if self.host.ticket.is_some() {
|
||
ui.colored_label(self.theme.active.waiting, "● Waiting for viewers…");
|
||
} else {
|
||
ui.label("Starting…");
|
||
}
|
||
|
||
ui.add_space(6.0);
|
||
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
|
||
|
||
// Per-viewer list with a Kick button each. Collect the click first so
|
||
// we're not borrowing self.host.viewers while we reach for the child.
|
||
let mut kick: Option<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
|
||
}
|
||
}
|