c876c61ec6
The Settings screen grew past a short window once the Appearance section landed, forcing a manual resize to reach the Save button. Wrap the body in a vertical ScrollArea (header stays pinned), mirroring the Host screen. Also add a '↺ Defaults' button to the right of Save in the theme editor that resets the draft to the original Default Dark palette (previews live). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1859 lines
68 KiB
Rust
1859 lines
68 KiB
Rust
//! Graphical front-end (`pixelpass --gui`), compiled only with the `gui`
|
|
//! feature.
|
|
//!
|
|
//! Architecture: this window is a thin **shell-out** driver. It never touches
|
|
//! the capture / portal / gst / iroh machinery directly — instead it re-execs
|
|
//! this same binary in headless mode (`pixelpass --host --output json …` or
|
|
//! `pixelpass <ticket> --output json`) as a child process and parses the
|
|
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
|
|
//! shows. That keeps the fragile capture stack sealed in a separate process:
|
|
//! the GUI can be closed or crash without taking a live stream down.
|
|
//!
|
|
//! ## Windowing: why we hand-roll the loop instead of using `eframe`
|
|
//!
|
|
//! The "keep running in the tray" feature needs to truly *hide* the window
|
|
//! while a stream keeps running in the child. On Wayland there is no
|
|
//! unmap-but-keep-alive request in xdg-shell, so winit's `set_visible(false)`
|
|
//! is a deliberate no-op there — the only way to make a toplevel vanish is to
|
|
//! **destroy its surface** and recreate it later. `eframe::run_native` owns the
|
|
//! one window it will never let you drop, so we replace it with a hand-rolled
|
|
//! [`winit::application::ApplicationHandler`] + `glutin` + [`egui_glow`] loop:
|
|
//!
|
|
//! * **Hide to tray** ([`Gfx::hide`]) parks the GL context as *not-current*
|
|
//! and drops the [`winit::window::Window`] + its GL surface — the Wayland
|
|
//! surface is genuinely gone, natively, on both Wayland and X11.
|
|
//! * **Show** ([`Gfx::show`], on a tray click) recreates the window + surface
|
|
//! and makes the parked context current again.
|
|
//!
|
|
//! The GL context, `glutin` display/config, and the `egui_glow` painter (with
|
|
//! its uploaded font/texture atlas) and `egui-winit` state (with its clipboard
|
|
//! connection) are **kept** across the cycle — only the OS window and its
|
|
//! surface churn. That preserves the hard-won Wayland clipboard integration and
|
|
//! avoids re-uploading textures on every show.
|
|
//!
|
|
//! Wakeups are routed through winit's [`winit::event_loop::EventLoopProxy`]
|
|
//! (see [`Waker`] and [`tray`]) rather than egui's repaint callback, because a
|
|
//! child event or tray click must wake the loop even while the window is
|
|
//! dropped and no egui frame is running.
|
|
|
|
mod child;
|
|
mod theme;
|
|
mod tray;
|
|
|
|
use std::num::NonZeroU32;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use eframe::egui;
|
|
use egui_glow::EguiGlow;
|
|
use egui_glow::egui_winit;
|
|
use egui_glow::glow::{self, HasContext as _};
|
|
use glutin::config::{Config, ConfigTemplateBuilder};
|
|
use glutin::context::{
|
|
ContextApi, ContextAttributesBuilder, NotCurrentContext, NotCurrentGlContext as _,
|
|
PossiblyCurrentContext, PossiblyCurrentGlContext as _,
|
|
};
|
|
use glutin::display::{Display, GetGlDisplay as _, GlDisplay as _};
|
|
use glutin::surface::{
|
|
GlSurface as _, Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface,
|
|
};
|
|
use glutin_winit::{ApiPreference, DisplayBuilder};
|
|
use winit::application::ApplicationHandler;
|
|
use winit::event::WindowEvent;
|
|
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
|
|
use winit::raw_window_handle::HasWindowHandle as _;
|
|
use winit::window::{Window, WindowAttributes, WindowId};
|
|
|
|
use self::child::{ChildEvent, ChildProc};
|
|
use self::tray::{TrayAction, TrayHandle, TrayStatus};
|
|
|
|
/// Initial / minimum window size, in logical points. Initial height fits the
|
|
/// host screen (ticket + Copy + QR + Stop) without needing to scroll on a 1080p
|
|
/// display; the host screen still wraps in a ScrollArea for the case where the
|
|
/// user shrinks the window below MIN_INNER_SIZE proportions.
|
|
const INNER_SIZE: [f64; 2] = [520.0, 640.0];
|
|
const MIN_INNER_SIZE: [f64; 2] = [460.0, 380.0];
|
|
|
|
/// Events delivered to the winit loop from off the UI thread (or from egui).
|
|
pub enum UserEvent {
|
|
/// Something changed off the UI thread — a headless child emitted a JSON
|
|
/// event or exited (see [`Waker`]). Drain it on the next tick, and repaint
|
|
/// if a window is currently shown.
|
|
Wake,
|
|
/// A system-tray icon/menu action (see [`tray`]).
|
|
Tray(TrayAction),
|
|
}
|
|
|
|
/// A cheap, cloneable handle that wakes the winit event loop from any thread.
|
|
///
|
|
/// Used by the headless-child reader threads: pinging this wakes the loop
|
|
/// **even when the window has been dropped to the tray** (no egui frame is
|
|
/// running then, so egui's own repaint callback would go quiet after the first
|
|
/// request — see the egui repaint bookkeeping). That's what keeps join/leave
|
|
/// desktop notifications and the tray tooltip live while hidden.
|
|
#[derive(Clone)]
|
|
pub struct Waker {
|
|
proxy: EventLoopProxy<UserEvent>,
|
|
}
|
|
|
|
impl Waker {
|
|
/// Wake the loop. A send error only means the loop has already exited, in
|
|
/// which case there is nothing left to wake.
|
|
pub fn wake(&self) {
|
|
let _ = self.proxy.send_event(UserEvent::Wake);
|
|
}
|
|
}
|
|
|
|
/// Window attributes used for both the first window and every recreated one, so
|
|
/// a window restored from the tray is identical to the original.
|
|
fn window_attributes() -> WindowAttributes {
|
|
use winit::dpi::LogicalSize;
|
|
// Wayland sources the titlebar/taskbar icon from the .desktop file matched
|
|
// by this app_id (NOT from `with_window_icon`, which Wayland ignores) — it
|
|
// must equal the installed pixelpass.desktop. `with_name(general, instance)`
|
|
// sets the Wayland app_id to `general`.
|
|
use winit::platform::wayland::WindowAttributesExtWayland as _;
|
|
|
|
let mut attrs = WindowAttributes::default()
|
|
.with_title("PixelPass")
|
|
.with_inner_size(LogicalSize::new(INNER_SIZE[0], INNER_SIZE[1]))
|
|
.with_min_inner_size(LogicalSize::new(MIN_INNER_SIZE[0], MIN_INNER_SIZE[1]))
|
|
// Stay unmapped until the first frame is painted, to avoid a flash of an
|
|
// empty window (on Wayland the surface only maps on the first swap
|
|
// anyway; this matters mostly for X11).
|
|
.with_visible(false)
|
|
.with_name("pixelpass", "pixelpass");
|
|
|
|
// X11 titlebar/taskbar icon (`_NET_WM_ICON`), embedded at compile time so
|
|
// the binary stays self-contained. Wayland ignores it (uses app_id above);
|
|
// X11 falls back to it. Non-fatal on failure.
|
|
match eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")) {
|
|
Ok(icon) => match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
|
|
Ok(winit_icon) => attrs = attrs.with_window_icon(Some(winit_icon)),
|
|
Err(e) => tracing::warn!("could not build X11 window icon: {e}"),
|
|
},
|
|
Err(e) => tracing::warn!("could not load embedded window icon: {e}"),
|
|
}
|
|
attrs
|
|
}
|
|
|
|
/// Build a GL surface for `window` using the established display + config.
|
|
fn create_surface(
|
|
display: &Display,
|
|
config: &Config,
|
|
window: &Window,
|
|
) -> anyhow::Result<Surface<WindowSurface>> {
|
|
let (w, h): (u32, u32) = window.inner_size().into();
|
|
let attrs = SurfaceAttributesBuilder::<WindowSurface>::new().build(
|
|
window.window_handle()?.as_raw(),
|
|
NonZeroU32::new(w).unwrap_or(NonZeroU32::MIN),
|
|
NonZeroU32::new(h).unwrap_or(NonZeroU32::MIN),
|
|
);
|
|
// SAFETY: `window` outlives the surface — both live in the same `WinState`
|
|
// and are dropped together — so the raw handle stays valid for the
|
|
// surface's lifetime.
|
|
let surface = unsafe { display.create_window_surface(config, &attrs)? };
|
|
Ok(surface)
|
|
}
|
|
|
|
/// GL + egui state that persists for the whole GUI session. Only the OS window
|
|
/// and its surface are dropped/recreated on hide/show (see the module docs);
|
|
/// everything here outlives the cycle.
|
|
struct Gfx {
|
|
gl: Arc<glow::Context>,
|
|
gl_display: Display,
|
|
gl_config: Config,
|
|
egui_glow: EguiGlow,
|
|
/// egui→window command state (title changes etc.), threaded through
|
|
/// [`egui_winit::process_viewport_commands`] each frame.
|
|
viewport_info: egui::ViewportInfo,
|
|
win: WinState,
|
|
}
|
|
|
|
/// Whether the window is currently mapped. The GL *context* is preserved in
|
|
/// both states; `Between` is only a momentary placeholder during a transition.
|
|
// Exactly one `WinState` exists (it's a field of the single `Gfx`), never a
|
|
// collection of them, so the inter-variant size gap costs ~250 idle bytes once
|
|
// — not worth boxing the window/surface that every frame touches.
|
|
#[allow(clippy::large_enum_variant)]
|
|
enum WinState {
|
|
Shown {
|
|
window: Window,
|
|
surface: Surface<WindowSurface>,
|
|
context: PossiblyCurrentContext,
|
|
},
|
|
Hidden {
|
|
context: NotCurrentContext,
|
|
},
|
|
/// Transient placeholder held only inside [`Gfx::hide`] / [`Gfx::show`].
|
|
Between,
|
|
}
|
|
|
|
impl Gfx {
|
|
/// Full first-time initialization: pick a GL config, create the window,
|
|
/// context, and surface, make the context current, and build the egui glow
|
|
/// integration. Runs once, from `resumed`.
|
|
fn create(event_loop: &ActiveEventLoop) -> anyhow::Result<Self> {
|
|
let template = ConfigTemplateBuilder::new()
|
|
.prefer_hardware_accelerated(None)
|
|
.with_depth_size(0)
|
|
.with_stencil_size(0)
|
|
.with_transparency(false);
|
|
|
|
let (maybe_window, gl_config) = DisplayBuilder::new()
|
|
.with_preference(ApiPreference::FallbackEgl)
|
|
.with_window_attributes(Some(window_attributes()))
|
|
.build(event_loop, template, |mut configs| {
|
|
configs.next().expect("no GL config matched")
|
|
})
|
|
.map_err(|e| anyhow::anyhow!("failed to choose a GL config: {e}"))?;
|
|
|
|
let gl_display = gl_config.display();
|
|
|
|
// On EGL/Wayland the window is built during `build`; on GLX it is
|
|
// deferred until the visual is known, so finalize it here.
|
|
let window = match maybe_window {
|
|
Some(w) => w,
|
|
None => glutin_winit::finalize_window(event_loop, window_attributes(), &gl_config)?,
|
|
};
|
|
|
|
let raw = window.window_handle()?.as_raw();
|
|
let ctx_attrs = ContextAttributesBuilder::new().build(Some(raw));
|
|
// Fall back to a GLES context if a core GL context can't be created.
|
|
let gles_attrs = ContextAttributesBuilder::new()
|
|
.with_context_api(ContextApi::Gles(None))
|
|
.build(Some(raw));
|
|
// SAFETY: `raw` comes from `window`, which lives at least as long as the
|
|
// context (both are owned by this `Gfx`).
|
|
let not_current = unsafe {
|
|
gl_display
|
|
.create_context(&gl_config, &ctx_attrs)
|
|
.or_else(|_| gl_display.create_context(&gl_config, &gles_attrs))
|
|
}
|
|
.map_err(|e| anyhow::anyhow!("failed to create a GL context: {e}"))?;
|
|
|
|
let surface = create_surface(&gl_display, &gl_config, &window)?;
|
|
let context = not_current
|
|
.make_current(&surface)
|
|
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
|
|
// Vsync, so the frame loop self-throttles and idles cheaply.
|
|
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
|
|
|
|
// SAFETY: the loader is only called while `gl_display` is alive, which
|
|
// outlives the returned context.
|
|
let gl = Arc::new(unsafe {
|
|
glow::Context::from_loader_function(|s| match std::ffi::CString::new(s) {
|
|
Ok(name) => gl_display.get_proc_address(name.as_c_str()),
|
|
Err(_) => std::ptr::null(),
|
|
})
|
|
});
|
|
|
|
let egui_glow = EguiGlow::new(event_loop, gl.clone(), None, None, true);
|
|
|
|
let mut fonts = egui::FontDefinitions::default();
|
|
fonts.font_data.insert(
|
|
"noto_sans".to_owned(),
|
|
egui::FontData::from_static(include_bytes!("../../assets/NotoSans-Regular.ttf")).into(),
|
|
);
|
|
fonts
|
|
.families
|
|
.get_mut(&egui::FontFamily::Proportional)
|
|
.unwrap()
|
|
.insert(0, "noto_sans".to_owned());
|
|
egui_glow.egui_ctx.set_fonts(fonts);
|
|
|
|
let mut style = (*egui_glow.egui_ctx.global_style()).clone();
|
|
for font_id in style.text_styles.values_mut() {
|
|
font_id.size *= 1.25;
|
|
}
|
|
egui_glow.egui_ctx.set_global_style(style);
|
|
|
|
window.set_visible(true);
|
|
window.request_redraw();
|
|
|
|
Ok(Self {
|
|
gl,
|
|
gl_display,
|
|
gl_config,
|
|
egui_glow,
|
|
viewport_info: egui::ViewportInfo::default(),
|
|
win: WinState::Shown {
|
|
window,
|
|
surface,
|
|
context,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn shown_window(&self) -> Option<&Window> {
|
|
match &self.win {
|
|
WinState::Shown { window, .. } => Some(window),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Hide to tray: park the context as not-current and drop the window +
|
|
/// surface. The Wayland surface is genuinely destroyed (the only way to
|
|
/// hide a toplevel there).
|
|
fn hide(&mut self) {
|
|
match std::mem::replace(&mut self.win, WinState::Between) {
|
|
WinState::Shown {
|
|
window,
|
|
surface,
|
|
context,
|
|
} => match context.make_not_current() {
|
|
Ok(not_current) => {
|
|
// Order matters: the context must be made not-current before
|
|
// its surface is dropped.
|
|
drop(surface);
|
|
drop(window);
|
|
self.win = WinState::Hidden {
|
|
context: not_current,
|
|
};
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("hide-to-tray: make_not_current failed: {e}");
|
|
// Leave `Between` (window already gone); a later Show will
|
|
// log that there's no parked context and the user can quit
|
|
// from the tray. This effectively never happens.
|
|
}
|
|
},
|
|
other => self.win = other, // already hidden / transient
|
|
}
|
|
}
|
|
|
|
/// Restore from the tray: recreate the window + surface and make the parked
|
|
/// context current again.
|
|
fn show(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
|
|
match std::mem::replace(&mut self.win, WinState::Between) {
|
|
WinState::Hidden { context } => {
|
|
let window = glutin_winit::finalize_window(
|
|
event_loop,
|
|
window_attributes(),
|
|
&self.gl_config,
|
|
)?;
|
|
let surface = create_surface(&self.gl_display, &self.gl_config, &window)?;
|
|
let context = context
|
|
.make_current(&surface)
|
|
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
|
|
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
|
|
window.set_visible(true);
|
|
window.request_redraw();
|
|
self.win = WinState::Shown {
|
|
window,
|
|
surface,
|
|
context,
|
|
};
|
|
Ok(())
|
|
}
|
|
WinState::Shown {
|
|
window,
|
|
surface,
|
|
context,
|
|
} => {
|
|
// Already shown — just nudge a repaint and restore.
|
|
window.request_redraw();
|
|
self.win = WinState::Shown {
|
|
window,
|
|
surface,
|
|
context,
|
|
};
|
|
Ok(())
|
|
}
|
|
WinState::Between => {
|
|
anyhow::bail!("no parked GL context to restore the window from")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run one egui frame and paint it. Returns how long egui wants to wait
|
|
/// before the next repaint (`Duration::MAX` == idle, sleep until an event).
|
|
/// No-op returning `MAX` if the window isn't currently shown.
|
|
fn paint_frame(&mut self, run_ui: impl FnMut(&mut egui::Ui)) -> Duration {
|
|
let WinState::Shown {
|
|
window,
|
|
surface,
|
|
context,
|
|
} = &self.win
|
|
else {
|
|
return Duration::MAX;
|
|
};
|
|
let eg = &mut self.egui_glow;
|
|
|
|
let raw_input = eg.egui_winit.take_egui_input(window);
|
|
let egui::FullOutput {
|
|
platform_output,
|
|
textures_delta,
|
|
shapes,
|
|
pixels_per_point,
|
|
viewport_output,
|
|
} = eg.egui_ctx.run_ui(raw_input, run_ui);
|
|
|
|
eg.egui_winit
|
|
.handle_platform_output(window, platform_output);
|
|
|
|
// Apply any window commands egui emitted (we issue none directly, but
|
|
// egui may request e.g. IME changes) and read the next repaint delay.
|
|
let (repaint_delay, commands) = match viewport_output.get(&egui::ViewportId::ROOT) {
|
|
Some(out) => (out.repaint_delay, out.commands.clone()),
|
|
None => (Duration::MAX, Vec::new()),
|
|
};
|
|
if !commands.is_empty() {
|
|
let mut actions = Vec::new();
|
|
egui_winit::process_viewport_commands(
|
|
&eg.egui_ctx,
|
|
&mut self.viewport_info,
|
|
commands,
|
|
window,
|
|
&mut actions,
|
|
);
|
|
}
|
|
|
|
let clipped = eg.egui_ctx.tessellate(shapes, pixels_per_point);
|
|
for (id, image_delta) in &textures_delta.set {
|
|
eg.painter.set_texture(*id, image_delta);
|
|
}
|
|
|
|
let dimensions: [u32; 2] = window.inner_size().into();
|
|
// SAFETY: the context is current (we are in the `Shown` arm) and `gl`
|
|
// belongs to it.
|
|
unsafe {
|
|
self.gl.clear_color(0.08, 0.08, 0.08, 1.0);
|
|
self.gl.clear(glow::COLOR_BUFFER_BIT);
|
|
}
|
|
eg.painter
|
|
.paint_primitives(dimensions, pixels_per_point, &clipped);
|
|
for id in &textures_delta.free {
|
|
eg.painter.free_texture(*id);
|
|
}
|
|
|
|
if let Err(e) = surface.swap_buffers(context) {
|
|
tracing::warn!("GL swap_buffers failed: {e}");
|
|
}
|
|
repaint_delay
|
|
}
|
|
}
|
|
|
|
/// The winit application: owns the persistent GL/egui state ([`Gfx`]) and the
|
|
/// PixelPass UI logic ([`PixelPassApp`]), and routes events between them.
|
|
struct App {
|
|
state: PixelPassApp,
|
|
/// `None` until the first `resumed`; `Some` for the rest of the session
|
|
/// (the window inside may be Shown or Hidden).
|
|
gfx: Option<Gfx>,
|
|
/// When the next repaint is due, derived from egui's per-frame delay and
|
|
/// external wakes. `None` == idle (sleep until an event).
|
|
repaint_at: Option<Instant>,
|
|
}
|
|
|
|
impl App {
|
|
fn shown_window(&self) -> Option<&Window> {
|
|
self.gfx.as_ref().and_then(Gfx::shown_window)
|
|
}
|
|
|
|
fn request_redraw(&self) {
|
|
if let Some(w) = self.shown_window() {
|
|
w.request_redraw();
|
|
}
|
|
}
|
|
|
|
/// Run an egui frame now (if shown) and record the next repaint deadline.
|
|
fn redraw(&mut self) {
|
|
let App {
|
|
gfx,
|
|
state,
|
|
repaint_at,
|
|
..
|
|
} = self;
|
|
let Some(gfx) = gfx.as_mut() else { return };
|
|
let delay = gfx.paint_frame(|ui| state.draw(ui));
|
|
*repaint_at = (delay < Duration::MAX).then(|| Instant::now() + delay);
|
|
}
|
|
|
|
/// The window's close button: hide to the tray if enabled and a tray is
|
|
/// actually present, otherwise quit.
|
|
fn on_close(&mut self, event_loop: &ActiveEventLoop) {
|
|
let hide = self.state.close_to_tray
|
|
&& self.state.tray.as_ref().is_some_and(TrayHandle::registered);
|
|
if hide {
|
|
if let Some(gfx) = self.gfx.as_mut() {
|
|
gfx.hide();
|
|
}
|
|
self.repaint_at = None;
|
|
} else {
|
|
event_loop.exit();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ApplicationHandler<UserEvent> for App {
|
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
|
if self.gfx.is_some() {
|
|
return; // already initialized
|
|
}
|
|
match Gfx::create(event_loop) {
|
|
Ok(gfx) => self.gfx = Some(gfx),
|
|
Err(e) => {
|
|
tracing::error!("GUI: could not create the window/GL context: {e}");
|
|
event_loop.exit();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
|
if matches!(event, WindowEvent::CloseRequested) {
|
|
self.on_close(event_loop);
|
|
return;
|
|
}
|
|
if matches!(event, WindowEvent::RedrawRequested) {
|
|
self.redraw();
|
|
return;
|
|
}
|
|
if let WindowEvent::Resized(size) = &event
|
|
&& let Some(gfx) = self.gfx.as_ref()
|
|
&& let WinState::Shown {
|
|
surface, context, ..
|
|
} = &gfx.win
|
|
&& let (Some(w), Some(h)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height))
|
|
{
|
|
surface.resize(context, w, h);
|
|
}
|
|
|
|
// Feed the event to egui and repaint if it wants one.
|
|
let Some(gfx) = self.gfx.as_mut() else { return };
|
|
let WinState::Shown { window, .. } = &gfx.win else {
|
|
return;
|
|
};
|
|
let response = gfx.egui_glow.egui_winit.on_window_event(window, &event);
|
|
if response.repaint {
|
|
window.request_redraw();
|
|
}
|
|
}
|
|
|
|
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
|
|
match event {
|
|
UserEvent::Wake => {
|
|
// A child emitted/closed: update state (and fire notifications /
|
|
// tray tooltip) even while hidden, then repaint if shown.
|
|
self.state.tick();
|
|
self.request_redraw();
|
|
}
|
|
UserEvent::Tray(TrayAction::Show) => {
|
|
if let Some(gfx) = self.gfx.as_mut()
|
|
&& let Err(e) = gfx.show(event_loop)
|
|
{
|
|
tracing::error!("could not restore the window from the tray: {e}");
|
|
}
|
|
self.state.tick();
|
|
}
|
|
UserEvent::Tray(TrayAction::Quit) => {
|
|
event_loop.exit();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
|
let flow = match self.repaint_at {
|
|
None => ControlFlow::Wait,
|
|
Some(at) if at > Instant::now() => ControlFlow::WaitUntil(at),
|
|
Some(_) => {
|
|
// Due now: ask for a frame if there's a window to paint into;
|
|
// otherwise (hidden) drop the pending repaint and sleep.
|
|
if let Some(w) = self.shown_window() {
|
|
w.request_redraw();
|
|
} else {
|
|
self.repaint_at = None;
|
|
}
|
|
ControlFlow::Wait
|
|
}
|
|
};
|
|
event_loop.set_control_flow(flow);
|
|
}
|
|
|
|
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
|
|
// Release GL resources only if the context is current (window shown);
|
|
// when hidden the context isn't current, and the process is exiting
|
|
// anyway so the driver reclaims everything.
|
|
if let Some(gfx) = self.gfx.as_mut()
|
|
&& matches!(gfx.win, WinState::Shown { .. })
|
|
{
|
|
gfx.egui_glow.painter.destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Launch the GUI. Blocks until the window is closed (or the tray Quit is
|
|
/// chosen). Runs on the main thread, a winit requirement, which is where `main`
|
|
/// calls it from.
|
|
/// `relay` is the parent's `--relay` flag (if any), forwarded to every child
|
|
/// process so a relay chosen on the GUI command line reaches the headless
|
|
/// host/viewer. (The `PIXELPASS_RELAY` env var is inherited regardless; this
|
|
/// covers the flag form.)
|
|
pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
|
let event_loop = EventLoop::<UserEvent>::with_user_event()
|
|
.build()
|
|
.map_err(|e| anyhow::anyhow!("failed to build the event loop: {e}"))?;
|
|
event_loop.set_control_flow(ControlFlow::Wait);
|
|
|
|
let proxy = event_loop.create_proxy();
|
|
let waker = Waker {
|
|
proxy: proxy.clone(),
|
|
};
|
|
// The tray runs on its own thread and wakes us via the proxy.
|
|
let tray = tray::start(proxy.clone());
|
|
let gui_settings = crate::common::config::load()
|
|
.map(|c| c.gui)
|
|
.unwrap_or_default();
|
|
|
|
let active = theme::load_named(&gui_settings.theme);
|
|
let names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
|
let draft = active.clone();
|
|
|
|
let state = PixelPassApp {
|
|
screen: Screen::default(),
|
|
host: HostState::default(),
|
|
viewer: ViewerState::default(),
|
|
tray,
|
|
close_to_tray: gui_settings.close_to_tray,
|
|
show_qr: gui_settings.show_qr,
|
|
relay,
|
|
theme: ThemeState {
|
|
active,
|
|
names,
|
|
dirty: true, // apply on the first frame
|
|
editing: false,
|
|
draft,
|
|
status: None,
|
|
},
|
|
waker,
|
|
};
|
|
let mut app = App {
|
|
state,
|
|
gfx: None,
|
|
repaint_at: None,
|
|
};
|
|
|
|
event_loop
|
|
.run_app(&mut app)
|
|
.map_err(|e| anyhow::anyhow!("GUI event loop error: {e}"))
|
|
}
|
|
|
|
/// Best-effort clipboard write. Returns whether it succeeded so callers can
|
|
/// show an honest "✓ Copied" / fallback hint (the clipboard can be flaky on
|
|
/// Wayland, and a silent miss is what left users pasting stale tickets).
|
|
fn set_clipboard(text: &str) -> bool {
|
|
arboard::Clipboard::new()
|
|
.and_then(|mut cb| cb.set_text(text.to_owned()))
|
|
.is_ok()
|
|
}
|
|
|
|
/// Best-effort clipboard read, backing the viewer Paste button and the
|
|
/// View-screen prefill. `None` on a flaky/empty clipboard — callers just leave
|
|
/// the field untouched.
|
|
fn get_clipboard() -> Option<String> {
|
|
arboard::Clipboard::new()
|
|
.and_then(|mut cb| cb.get_text())
|
|
.ok()
|
|
}
|
|
|
|
/// Decode the host endpoint id from a ticket string, using the exact same
|
|
/// parse the viewer does (`EndpointTicket::from_str`), so the GUI agrees with
|
|
/// the child about what's a valid code. `None` means it isn't a pixelpass
|
|
/// ticket at all. Used to surface a stale/garbage paste *before* the 15s
|
|
/// connect timeout, and to show which host the viewer is dialing — the missing
|
|
/// signal that let people repeatedly dial a long-dead host.
|
|
fn ticket_endpoint_id(ticket: &str) -> Option<String> {
|
|
ticket
|
|
.trim()
|
|
.parse::<iroh_tickets::endpoint::EndpointTicket>()
|
|
.ok()
|
|
.map(|t| t.endpoint_addr().id.to_string())
|
|
}
|
|
|
|
/// Eyeball-comparable short form of an endpoint id (full ids are long). Enough
|
|
/// to spot that two ids differ; the host and viewer screens show the same
|
|
/// truncation so a stale ticket reads as an obvious mismatch.
|
|
fn short_id(id: &str) -> String {
|
|
let prefix: String = id.chars().take(12).collect();
|
|
if prefix.len() < id.len() {
|
|
format!("{prefix}…")
|
|
} else {
|
|
prefix
|
|
}
|
|
}
|
|
|
|
/// Fire a desktop notification, on a detached thread so the D-Bus round-trip
|
|
/// can't stall the egui frame. Best-effort: with no notification daemon it
|
|
/// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this
|
|
/// needs no system libdbus and no GTK event loop.)
|
|
fn notify(summary: &'static str, body: String) {
|
|
std::thread::spawn(move || {
|
|
if let Err(e) = notify_rust::Notification::new()
|
|
.appname("PixelPass")
|
|
.summary(summary)
|
|
.body(&body)
|
|
.show()
|
|
{
|
|
tracing::warn!("desktop notification failed: {e}");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Persist just the close-to-tray preference, preserving the rest of the
|
|
/// on-disk config (e.g. the bandwidth section the headless child may have
|
|
/// written). Best-effort: a write failure is logged, not surfaced.
|
|
fn persist_close_to_tray(value: bool) {
|
|
let mut cfg = crate::common::config::load().unwrap_or_default();
|
|
cfg.gui.close_to_tray = value;
|
|
if let Err(e) = crate::common::config::save(&cfg) {
|
|
tracing::warn!("failed to save settings: {e}");
|
|
}
|
|
}
|
|
|
|
fn persist_show_qr(value: bool) {
|
|
let mut cfg = crate::common::config::load().unwrap_or_default();
|
|
cfg.gui.show_qr = value;
|
|
if let Err(e) = crate::common::config::save(&cfg) {
|
|
tracing::warn!("failed to save settings: {e}");
|
|
}
|
|
}
|
|
|
|
fn persist_theme(name: &str) {
|
|
let mut cfg = crate::common::config::load().unwrap_or_default();
|
|
cfg.gui.theme = name.to_string();
|
|
if let Err(e) = crate::common::config::save(&cfg) {
|
|
tracing::warn!("failed to save settings: {e}");
|
|
}
|
|
}
|
|
|
|
/// Which screen the single window is currently showing.
|
|
#[derive(Default, PartialEq)]
|
|
enum Screen {
|
|
#[default]
|
|
Menu,
|
|
Host,
|
|
Viewer,
|
|
Settings,
|
|
}
|
|
|
|
/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality`
|
|
/// argument value passed to the child.
|
|
#[derive(Default, PartialEq, Clone, Copy)]
|
|
enum QualitySel {
|
|
#[default]
|
|
Auto,
|
|
Source,
|
|
High,
|
|
Medium,
|
|
Low,
|
|
}
|
|
|
|
impl QualitySel {
|
|
fn as_arg(self) -> &'static str {
|
|
match self {
|
|
QualitySel::Auto => "auto",
|
|
QualitySel::Source => "source",
|
|
QualitySel::High => "high",
|
|
QualitySel::Medium => "medium",
|
|
QualitySel::Low => "low",
|
|
}
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
QualitySel::Auto => "Auto — pick from my upload speed",
|
|
QualitySel::Source => "Source — native resolution",
|
|
QualitySel::High => "High — up to 1080p",
|
|
QualitySel::Medium => "Medium — up to 720p",
|
|
QualitySel::Low => "Low — up to 480p",
|
|
}
|
|
}
|
|
|
|
const ALL: [QualitySel; 5] = [
|
|
QualitySel::Auto,
|
|
QualitySel::Source,
|
|
QualitySel::High,
|
|
QualitySel::Medium,
|
|
QualitySel::Low,
|
|
];
|
|
}
|
|
|
|
/// Player choices for the viewer screen.
|
|
#[derive(Default, PartialEq, Clone, Copy)]
|
|
enum PlayerSel {
|
|
#[default]
|
|
Mpv,
|
|
Vlc,
|
|
}
|
|
|
|
impl PlayerSel {
|
|
fn to_player(self) -> crate::interactive::Player {
|
|
match self {
|
|
PlayerSel::Mpv => crate::interactive::Player::Mpv,
|
|
PlayerSel::Vlc => crate::interactive::Player::Vlc,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Host-screen state: the config form fields plus, once started, the running
|
|
/// child and the latest values parsed from its event stream.
|
|
#[derive(Default)]
|
|
struct HostState {
|
|
// form
|
|
quality: QualitySel,
|
|
max_viewers: u32, // 0 = let the host auto-size from the bandwidth preflight
|
|
no_hwencode: bool,
|
|
window: bool,
|
|
// running session + accumulated live state
|
|
proc: Option<ChildProc>,
|
|
ticket: Option<String>,
|
|
info: Option<HostInfo>,
|
|
active: u32,
|
|
max: u32,
|
|
capturing: bool,
|
|
/// Whether the current ticket made it onto the clipboard (auto-copy on
|
|
/// arrival, or a manual Copy click). Drives the "✓ Copied" hint so the
|
|
/// user isn't left guessing whether to click Copy — the trap that had
|
|
/// people pasting a stale clipboard ticket.
|
|
copied: bool,
|
|
last_refusal: Option<String>,
|
|
error: Option<String>,
|
|
/// Endpoint ids of the currently-connected viewers, in arrival order.
|
|
/// Drives the per-viewer list and its Kick buttons.
|
|
viewers: Vec<String>,
|
|
/// QR-code rendering of the current ticket, lazily built on the first draw
|
|
/// after a Ticket event lands. Cleared on session start/stop so the next
|
|
/// session's ticket regenerates it.
|
|
qr_texture: Option<egui::TextureHandle>,
|
|
}
|
|
|
|
/// The host config summary echoed back by the child's `host_info` event.
|
|
struct HostInfo {
|
|
display: String,
|
|
capture: String,
|
|
quality: String,
|
|
dimensions: String,
|
|
hw_encode: bool,
|
|
cap_source: String,
|
|
}
|
|
|
|
/// Viewer-screen state.
|
|
#[derive(Default)]
|
|
struct ViewerState {
|
|
ticket_input: String,
|
|
player: PlayerSel,
|
|
proc: Option<ChildProc>,
|
|
url: Option<String>,
|
|
launched: bool,
|
|
/// Short endpoint id we're dialing, decoded from the ticket at Connect.
|
|
/// Shown in the "Connecting to …" line so a dead host is identifiable.
|
|
connecting_to: Option<String>,
|
|
/// Set when the View screen opens so the code field grabs focus once
|
|
/// (cleared on use, so it doesn't steal focus every frame).
|
|
focus_ticket: bool,
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// The PixelPass UI logic — screens, the running children, and the tray. Knows
|
|
/// nothing about windowing; [`App`] drives its [`PixelPassApp::draw`] each frame
|
|
/// and [`PixelPassApp::tick`] on each wake.
|
|
struct PixelPassApp {
|
|
screen: Screen,
|
|
host: HostState,
|
|
viewer: ViewerState,
|
|
/// System-tray handle; `None` if the tray couldn't start, in which case the
|
|
/// close button always quits (never hides).
|
|
tray: Option<TrayHandle>,
|
|
/// Persisted preference: when true (and a tray is present), the close button
|
|
/// hides to the tray instead of quitting. Loaded at startup, written on
|
|
/// toggle in Settings.
|
|
close_to_tray: bool,
|
|
/// Persisted preference: render the QR-code panel on the host screen.
|
|
/// Defaults to on; toggled in Settings.
|
|
show_qr: bool,
|
|
/// The parent's `--relay` flag, forwarded to host/viewer children so the
|
|
/// flag form reaches them (env-var form is inherited automatically).
|
|
relay: Option<String>,
|
|
/// The active colour theme (applied to egui's visuals) and the supporting
|
|
/// picker/editor state.
|
|
theme: ThemeState,
|
|
/// Wakes the winit loop when a spawned child emits/exits.
|
|
waker: Waker,
|
|
}
|
|
|
|
/// The active theme plus the Settings picker/editor working state.
|
|
struct ThemeState {
|
|
/// Currently applied theme (persisted by name in the config).
|
|
active: theme::Theme,
|
|
/// Theme names shown in the picker, refreshed when Settings opens so files
|
|
/// added on disk appear without a restart.
|
|
names: Vec<String>,
|
|
/// Set when `active` needs (re)applying to egui's visuals.
|
|
dirty: bool,
|
|
/// Whether the in-app editor is open. While open its `draft` is applied as a
|
|
/// live preview instead of `active`; closing without saving restores `active`.
|
|
editing: bool,
|
|
/// The editor's working copy.
|
|
draft: theme::Theme,
|
|
/// Last save result or error, shown under the editor.
|
|
status: Option<String>,
|
|
}
|
|
|
|
/// Apply `theme`'s palette to egui's visuals, preserving the font scaling and
|
|
/// fonts already baked into the global style (we replace only `visuals`).
|
|
fn apply_theme(ctx: &egui::Context, theme: &theme::Theme) {
|
|
let mut style = (*ctx.global_style()).clone();
|
|
style.visuals = theme.visuals();
|
|
ctx.set_global_style(style);
|
|
}
|
|
|
|
/// One row of the theme editor: a label and a colour picker. Theme colours are
|
|
/// opaque, so any alpha the picker introduces is clamped straight back out.
|
|
fn color_row(ui: &mut egui::Ui, label: &str, color: &mut egui::Color32) {
|
|
ui.label(label);
|
|
ui.color_edit_button_srgba(color);
|
|
let [r, g, b, _] = color.to_srgba_unmultiplied();
|
|
*color = egui::Color32::from_rgb(r, g, b);
|
|
ui.end_row();
|
|
}
|
|
|
|
impl PixelPassApp {
|
|
/// Drain child output and reflect it into the tray. Runs on every wake,
|
|
/// whether or not a window is shown, so notifications and the tray tooltip
|
|
/// stay live while hidden.
|
|
fn tick(&mut self) {
|
|
self.pump_host_events();
|
|
self.pump_viewer_events();
|
|
self.sync_tray_status();
|
|
}
|
|
|
|
/// Render the current screen. Called from inside the egui frame.
|
|
fn draw(&mut self, ui: &mut egui::Ui) {
|
|
// Apply whichever theme should be visible this frame. While the editor
|
|
// is open its draft is previewed live; otherwise the active theme is
|
|
// applied once (when dirty) and then sticks — the egui context persists
|
|
// across the hide/show window cycle, so it survives close-to-tray.
|
|
if self.theme.editing {
|
|
apply_theme(ui.ctx(), &self.theme.draft);
|
|
} else if self.theme.dirty {
|
|
apply_theme(ui.ctx(), &self.theme.active);
|
|
self.theme.dirty = false;
|
|
}
|
|
match self.screen {
|
|
Screen::Menu => self.menu(ui),
|
|
Screen::Host => self.host(ui),
|
|
Screen::Viewer => self.viewer(ui),
|
|
Screen::Settings => self.settings(ui),
|
|
}
|
|
}
|
|
|
|
/// Mirror current activity into the tray icon's tooltip/menu.
|
|
fn sync_tray_status(&mut self) {
|
|
let status = if self.host.proc.is_some() {
|
|
TrayStatus::Hosting {
|
|
active: self.host.active,
|
|
max: self.host.max,
|
|
}
|
|
} else if self.viewer.proc.is_some() {
|
|
TrayStatus::Viewing
|
|
} else {
|
|
TrayStatus::Idle
|
|
};
|
|
if let Some(tray) = &mut self.tray {
|
|
tray.set_status(status);
|
|
}
|
|
}
|
|
|
|
fn menu(&mut self, ui: &mut egui::Ui) {
|
|
ui.vertical_centered(|ui| {
|
|
ui.add_space(24.0);
|
|
ui.heading("PixelPass");
|
|
ui.label("P2P screen sharing");
|
|
ui.label(
|
|
egui::RichText::new(concat!("v", env!("CARGO_PKG_VERSION")))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.add_space(32.0);
|
|
if ui
|
|
.add_sized([260.0, 40.0], egui::Button::new("Host — share my screen"))
|
|
.clicked()
|
|
{
|
|
self.screen = Screen::Host;
|
|
}
|
|
ui.add_space(8.0);
|
|
if ui
|
|
.add_sized(
|
|
[260.0, 40.0],
|
|
egui::Button::new("View — watch someone's screen"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.screen = Screen::Viewer;
|
|
self.prefill_viewer_ticket();
|
|
}
|
|
ui.add_space(20.0);
|
|
if ui.button("⚙ Settings").clicked() {
|
|
// Refresh the picker so themes added to the folder since launch
|
|
// (or last visit) show up without a restart.
|
|
self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
|
self.screen = Screen::Settings;
|
|
}
|
|
});
|
|
}
|
|
|
|
fn settings(&mut self, ui: &mut egui::Ui) {
|
|
ui.horizontal(|ui| {
|
|
if ui.button("← Menu").clicked() {
|
|
self.screen = Screen::Menu;
|
|
}
|
|
ui.heading("Settings");
|
|
});
|
|
ui.separator();
|
|
// Body scrolls; the header above stays pinned. The Appearance editor
|
|
// (13 colour rows + Save) overflows a short window otherwise — you'd
|
|
// have to resize the window to reach the Save button.
|
|
egui::ScrollArea::vertical().show(ui, |ui| self.settings_body(ui));
|
|
}
|
|
|
|
fn settings_body(&mut self, ui: &mut egui::Ui) {
|
|
ui.add_space(4.0);
|
|
|
|
let resp = ui.checkbox(
|
|
&mut self.close_to_tray,
|
|
"Keep running in the tray when I close the window",
|
|
);
|
|
if resp.changed() {
|
|
persist_close_to_tray(self.close_to_tray);
|
|
}
|
|
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Off: closing the window quits PixelPass.\n\
|
|
On: closing hides it to the system tray and any active stream \
|
|
keeps running — reopen it from the tray icon.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
|
|
ui.add_space(12.0);
|
|
let qr_resp = ui.checkbox(&mut self.show_qr, "Show QR-code panel on the host screen");
|
|
if qr_resp.changed() {
|
|
persist_show_qr(self.show_qr);
|
|
}
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Off: the host screen shows only the ticket text. \
|
|
Useful when both ends are computers and the QR is just extra clutter.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
|
|
// The option does nothing without a tray to hide into; say so plainly.
|
|
if !self.tray.as_ref().is_some_and(TrayHandle::registered) {
|
|
ui.add_space(8.0);
|
|
ui.colored_label(
|
|
self.theme.active.warning,
|
|
"⚠ No system tray detected — this option has no effect right now.",
|
|
);
|
|
}
|
|
|
|
ui.add_space(16.0);
|
|
ui.separator();
|
|
ui.add_space(4.0);
|
|
self.appearance(ui);
|
|
}
|
|
|
|
/// The "Appearance" block of the Settings screen: the theme picker, or the
|
|
/// in-app editor when it's open.
|
|
fn appearance(&mut self, ui: &mut egui::Ui) {
|
|
ui.heading("Appearance");
|
|
ui.add_space(6.0);
|
|
|
|
if self.theme.editing {
|
|
self.theme_editor(ui);
|
|
return;
|
|
}
|
|
|
|
// Theme picker. Collect any selection first so we're not holding an
|
|
// immutable borrow of `self.theme.names` when we mutate `self.theme`.
|
|
let current = self.theme.active.name.clone();
|
|
let mut pick: Option<String> = None;
|
|
ui.horizontal(|ui| {
|
|
ui.label("Theme");
|
|
egui::ComboBox::from_id_salt("theme_picker")
|
|
.selected_text(¤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);
|
|
|
|
egui::Grid::new("theme_editor_grid")
|
|
.num_columns(2)
|
|
.spacing([12.0, 6.0])
|
|
.show(ui, |ui| {
|
|
let d = &mut self.theme.draft;
|
|
color_row(ui, "Window background", &mut d.window_bg);
|
|
color_row(ui, "Panel background", &mut d.panel_bg);
|
|
color_row(ui, "Input background", &mut d.input_bg);
|
|
color_row(ui, "Text", &mut d.text);
|
|
color_row(ui, "Secondary text", &mut d.weak_text);
|
|
color_row(ui, "Accent", &mut d.accent);
|
|
color_row(ui, "Button", &mut d.button_bg);
|
|
color_row(ui, "Button (hover)", &mut d.button_hovered);
|
|
color_row(ui, "Streaming", &mut d.streaming);
|
|
color_row(ui, "Waiting", &mut d.waiting);
|
|
color_row(ui, "Success", &mut d.success);
|
|
color_row(ui, "Warning", &mut d.warning);
|
|
color_row(ui, "Error", &mut d.error);
|
|
});
|
|
|
|
ui.add_space(10.0);
|
|
ui.horizontal(|ui| {
|
|
if ui.button("💾 Save").clicked() {
|
|
self.save_draft_theme();
|
|
}
|
|
// Reset the draft to the original Default Dark palette. Previews
|
|
// live (the editor applies the draft each frame), so it snaps back
|
|
// immediately; Save persists it, Cancel discards.
|
|
if ui.button("↺ Defaults").clicked() {
|
|
self.theme.draft = theme::default_dark();
|
|
self.theme.status = Some("Reset to the Default Dark palette.".to_string());
|
|
}
|
|
if ui.button("Cancel").clicked() {
|
|
self.cancel_theme_edit();
|
|
}
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Changes preview live. Save writes a .toml to your themes folder; \
|
|
rename it to keep a built-in alongside your own version.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
if let Some(status) = &self.theme.status {
|
|
ui.add_space(4.0);
|
|
ui.label(egui::RichText::new(status).small().weak());
|
|
}
|
|
}
|
|
|
|
/// Switch to the named theme: apply it, persist the choice, and reset the
|
|
/// editor draft to match.
|
|
fn select_theme(&mut self, name: &str) {
|
|
self.theme.active = theme::load_named(name);
|
|
self.theme.draft = self.theme.active.clone();
|
|
self.theme.dirty = true;
|
|
self.theme.status = None;
|
|
persist_theme(name);
|
|
}
|
|
|
|
fn start_theme_edit(&mut self) {
|
|
self.theme.draft = self.theme.active.clone();
|
|
self.theme.editing = true;
|
|
self.theme.status = None;
|
|
}
|
|
|
|
fn cancel_theme_edit(&mut self) {
|
|
self.theme.editing = false;
|
|
self.theme.draft = self.theme.active.clone();
|
|
self.theme.dirty = true; // discard the live preview, restore the active theme
|
|
self.theme.status = None;
|
|
}
|
|
|
|
fn save_draft_theme(&mut self) {
|
|
if self.theme.draft.name.trim().is_empty() {
|
|
self.theme.status = Some("Give the theme a name before saving.".to_string());
|
|
return;
|
|
}
|
|
match theme::save_theme(&self.theme.draft) {
|
|
Ok(path) => {
|
|
self.theme.active = self.theme.draft.clone();
|
|
self.theme.editing = false;
|
|
self.theme.dirty = true;
|
|
self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
|
self.theme.status = Some(format!("Saved to {}", path.display()));
|
|
persist_theme(&self.theme.active.name);
|
|
}
|
|
Err(e) => self.theme.status = Some(format!("Couldn't save: {e}")),
|
|
}
|
|
}
|
|
|
|
// ── Host screen ──────────────────────────────────────────────────────
|
|
|
|
fn host(&mut self, ui: &mut egui::Ui) {
|
|
let running = self.host.proc.is_some();
|
|
ui.horizontal(|ui| {
|
|
// Leaving the host screen stops the session (Drop on the child).
|
|
if ui.button("← Menu").clicked() {
|
|
self.stop_host();
|
|
self.screen = Screen::Menu;
|
|
}
|
|
ui.heading("Host");
|
|
});
|
|
ui.separator();
|
|
|
|
// Scroll the body, not the header — Menu/Host stay pinned at the top.
|
|
// Needed once the QR panel landed: ticket + Copy + QR + Stop overflows a
|
|
// shrunken window.
|
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
if running {
|
|
self.host_running(ui);
|
|
} else {
|
|
self.host_form(ui);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn host_form(&mut self, ui: &mut egui::Ui) {
|
|
if let Some(err) = &self.host.error {
|
|
ui.colored_label(self.theme.active.error, err);
|
|
ui.add_space(8.0);
|
|
}
|
|
|
|
egui::Grid::new("host_form")
|
|
.num_columns(2)
|
|
.spacing([12.0, 10.0])
|
|
.show(ui, |ui| {
|
|
ui.label("Quality");
|
|
egui::ComboBox::from_id_salt("quality")
|
|
.selected_text(self.host.quality.label())
|
|
.show_ui(ui, |ui| {
|
|
for q in QualitySel::ALL {
|
|
ui.selectable_value(&mut self.host.quality, q, q.label());
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Max viewers");
|
|
ui.horizontal(|ui| {
|
|
ui.add(egui::DragValue::new(&mut self.host.max_viewers).range(0..=16));
|
|
if self.host.max_viewers == 0 {
|
|
ui.label("(auto from upload speed)");
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Options");
|
|
ui.vertical(|ui| {
|
|
ui.checkbox(&mut self.host.window, "Share a single window");
|
|
ui.checkbox(
|
|
&mut self.host.no_hwencode,
|
|
"Software encoding (no GPU / VAAPI)",
|
|
);
|
|
});
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(16.0);
|
|
if ui
|
|
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
|
|
.clicked()
|
|
{
|
|
self.start_host();
|
|
}
|
|
ui.add_space(8.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"On Wayland a \"Share Screen?\" dialog appears when the first \
|
|
viewer connects.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
|
|
fn host_running(&mut self, ui: &mut egui::Ui) {
|
|
if self.host.capturing {
|
|
ui.colored_label(self.theme.active.streaming, "● Streaming");
|
|
} else if self.host.ticket.is_some() {
|
|
ui.colored_label(self.theme.active.waiting, "● Waiting for viewers…");
|
|
} else {
|
|
ui.label("Starting…");
|
|
}
|
|
|
|
ui.add_space(6.0);
|
|
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
|
|
|
|
// Per-viewer list with a Kick button each. Collect the click first so
|
|
// we're not borrowing self.host.viewers while we reach for the child.
|
|
let mut kick: Option<String> = None;
|
|
for id in &self.host.viewers {
|
|
ui.horizontal(|ui| {
|
|
ui.label(format!("• endpoint {}", short_id(id)));
|
|
if ui.small_button("Kick").clicked() {
|
|
kick = Some(id.clone());
|
|
}
|
|
});
|
|
}
|
|
if let Some(id) = kick
|
|
&& let Some(p) = &mut self.host.proc
|
|
{
|
|
p.send_command(&format!("kick {id}"));
|
|
}
|
|
|
|
if let Some(info) = &self.host.info {
|
|
ui.label(
|
|
egui::RichText::new(format!("{} · {}", info.display, info.capture))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(format!(
|
|
"{} · {} · {} · cap {}",
|
|
info.quality,
|
|
info.dimensions,
|
|
if info.hw_encode {
|
|
"HW encode"
|
|
} else {
|
|
"software encode"
|
|
},
|
|
info.cap_source
|
|
))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
|
|
ui.add_space(12.0);
|
|
|
|
if let Some(ticket) = self.host.ticket.clone() {
|
|
ui.label("Share this code with your viewer(s):");
|
|
if let Some(id) = ticket_endpoint_id(&ticket) {
|
|
// The viewer shows "Connecting to <id>…" with this same
|
|
// truncation, so the two ends can be eyeballed for a match.
|
|
ui.label(
|
|
egui::RichText::new(format!("This host: endpoint {}", short_id(&id)))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
ui.add_space(4.0);
|
|
egui::Frame::group(ui.style()).show(ui, |ui| {
|
|
ui.add(
|
|
egui::Label::new(egui::RichText::new(&ticket).monospace().small())
|
|
.wrap()
|
|
.selectable(true),
|
|
);
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
if ui.button("📋 Copy code").clicked() {
|
|
self.copy_to_clipboard(&ticket);
|
|
}
|
|
if self.host.copied {
|
|
ui.colored_label(self.theme.active.success, "✓ Copied to clipboard");
|
|
}
|
|
});
|
|
if !self.host.copied {
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Couldn't auto-copy — click Copy, or select the code above.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
|
|
// Lazy QR build: first draw after a Ticket event has `qr_texture =
|
|
// None`, so we encode the ticket and load the texture once. The
|
|
// 4-module quiet zone (white border) matters — phone scanners reject
|
|
// QR codes flush against a non-white edge. Skipped when the user
|
|
// disabled the QR panel in Settings.
|
|
if self.show_qr
|
|
&& self.host.qr_texture.is_none()
|
|
&& let Ok(code) = qrcode::QrCode::new(ticket.as_bytes())
|
|
{
|
|
let w = code.width();
|
|
let quiet = 4;
|
|
let full = w + 2 * quiet;
|
|
let colors = code.into_colors();
|
|
let mut pixels = vec![egui::Color32::WHITE; full * full];
|
|
for y in 0..w {
|
|
for x in 0..w {
|
|
if colors[y * w + x] == qrcode::Color::Dark {
|
|
pixels[(y + quiet) * full + (x + quiet)] = egui::Color32::BLACK;
|
|
}
|
|
}
|
|
}
|
|
let img = egui::ColorImage::new([full, full], pixels);
|
|
self.host.qr_texture = Some(ui.ctx().load_texture(
|
|
"host_qr",
|
|
img,
|
|
egui::TextureOptions::NEAREST,
|
|
));
|
|
}
|
|
if self.show_qr
|
|
&& let Some(tex) = &self.host.qr_texture
|
|
{
|
|
ui.add_space(8.0);
|
|
ui.add(egui::Image::new(tex).fit_to_exact_size(egui::vec2(200.0, 200.0)));
|
|
}
|
|
}
|
|
|
|
if let Some(reason) = &self.host.last_refusal {
|
|
ui.add_space(8.0);
|
|
ui.colored_label(self.theme.active.warning, format!("⚠ {reason}"));
|
|
}
|
|
|
|
ui.add_space(16.0);
|
|
if ui
|
|
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
|
|
.clicked()
|
|
{
|
|
self.stop_host();
|
|
}
|
|
}
|
|
|
|
fn start_host(&mut self) {
|
|
self.host.error = None;
|
|
self.host.last_refusal = None;
|
|
self.host.ticket = None;
|
|
self.host.info = None;
|
|
self.host.active = 0;
|
|
self.host.max = 0;
|
|
self.host.capturing = false;
|
|
self.host.copied = false;
|
|
self.host.viewers.clear();
|
|
self.host.qr_texture = None;
|
|
|
|
let mut args = vec![
|
|
"--host".to_string(),
|
|
"--output".to_string(),
|
|
"json".to_string(),
|
|
"--quality".to_string(),
|
|
self.host.quality.as_arg().to_string(),
|
|
];
|
|
if self.host.max_viewers > 0 {
|
|
args.push("--max-viewers".to_string());
|
|
args.push(self.host.max_viewers.to_string());
|
|
}
|
|
if self.host.no_hwencode {
|
|
args.push("--no-hwencode".to_string());
|
|
}
|
|
if self.host.window {
|
|
args.push("--window".to_string());
|
|
}
|
|
if let Some(relay) = &self.relay {
|
|
args.push("--relay".to_string());
|
|
args.push(relay.clone());
|
|
}
|
|
|
|
match ChildProc::spawn(&args, self.waker.clone()) {
|
|
Ok(p) => self.host.proc = Some(p),
|
|
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
|
|
}
|
|
}
|
|
|
|
fn stop_host(&mut self) {
|
|
// Dropping the ChildProc SIGINTs the child and reaps it.
|
|
self.host.proc = None;
|
|
self.host.capturing = false;
|
|
self.host.ticket = None;
|
|
self.host.copied = false;
|
|
self.host.viewers.clear();
|
|
self.host.qr_texture = None;
|
|
}
|
|
|
|
/// Drain the host child's event channel into state, and detect an
|
|
/// unexpected child exit (e.g. a failed dependency check) so it surfaces
|
|
/// in the form instead of leaving a dead "running" view.
|
|
fn pump_host_events(&mut self) {
|
|
let events: Vec<ChildEvent> = match &self.host.proc {
|
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
|
None => return,
|
|
};
|
|
for ev in events {
|
|
self.apply_host_event(ev);
|
|
}
|
|
|
|
if let Some(p) = &mut self.host.proc
|
|
&& !p.is_alive()
|
|
{
|
|
if self.host.ticket.is_none() {
|
|
let tail = p.stderr_tail();
|
|
self.host.error = Some(if tail.trim().is_empty() {
|
|
"Host exited before it could start.".to_string()
|
|
} else {
|
|
format!("Host exited before it could start:\n{tail}")
|
|
});
|
|
}
|
|
self.host.proc = None;
|
|
self.host.capturing = false;
|
|
}
|
|
}
|
|
|
|
fn apply_host_event(&mut self, ev: ChildEvent) {
|
|
match ev {
|
|
ChildEvent::Ticket { value } => {
|
|
// Auto-copy on arrival, mirroring the CLI/interactive host
|
|
// (which copies the ticket and prints "copied to your
|
|
// clipboard"). A failure here is non-fatal: the ticket stays
|
|
// visible for manual copy, and `copied` stays false so the UI
|
|
// doesn't falsely claim success.
|
|
self.host.copied = set_clipboard(&value);
|
|
self.host.ticket = Some(value);
|
|
}
|
|
ChildEvent::HostInfo {
|
|
display_server,
|
|
capture,
|
|
quality,
|
|
dimensions,
|
|
hw_encode,
|
|
max_viewers,
|
|
max_viewers_source,
|
|
} => {
|
|
self.host.max = max_viewers;
|
|
self.host.info = Some(HostInfo {
|
|
display: display_server,
|
|
capture,
|
|
quality,
|
|
dimensions,
|
|
hw_encode,
|
|
cap_source: max_viewers_source,
|
|
});
|
|
}
|
|
ChildEvent::ViewerJoined { id, active, max } => {
|
|
self.host.active = active;
|
|
self.host.max = max;
|
|
if !self.host.viewers.contains(&id) {
|
|
notify(
|
|
"PixelPass — viewer connected",
|
|
format!(
|
|
"endpoint {} is now watching ({active}/{max})",
|
|
short_id(&id)
|
|
),
|
|
);
|
|
self.host.viewers.push(id);
|
|
}
|
|
}
|
|
ChildEvent::ViewerLeft { id, active, max } => {
|
|
self.host.active = active;
|
|
self.host.max = max;
|
|
if self.host.viewers.iter().any(|v| v == &id) {
|
|
notify(
|
|
"PixelPass — viewer disconnected",
|
|
format!("endpoint {} left ({active}/{max})", short_id(&id)),
|
|
);
|
|
self.host.viewers.retain(|v| v != &id);
|
|
}
|
|
}
|
|
ChildEvent::Capture { state } => {
|
|
self.host.capturing = matches!(state, child::CaptureState::Started);
|
|
}
|
|
ChildEvent::ViewerRefused { reason } => self.host.last_refusal = Some(reason),
|
|
ChildEvent::Connected { .. } => {} // viewer-side; not used on the host screen
|
|
}
|
|
}
|
|
|
|
/// Manual Copy-button handler: copy and reflect success in the UI, or
|
|
/// surface a clear error if the clipboard rejected it.
|
|
fn copy_to_clipboard(&mut self, text: &str) {
|
|
if set_clipboard(text) {
|
|
self.host.copied = true;
|
|
self.host.error = None;
|
|
} else {
|
|
self.host.copied = false;
|
|
self.host.error = Some(
|
|
"Couldn't write to the clipboard. Select the code above and copy it manually."
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Viewer screen ─────────────────────────────────────────────────────
|
|
|
|
fn viewer(&mut self, ui: &mut egui::Ui) {
|
|
let running = self.viewer.proc.is_some();
|
|
ui.horizontal(|ui| {
|
|
if ui.button("← Menu").clicked() {
|
|
self.stop_viewer();
|
|
self.screen = Screen::Menu;
|
|
}
|
|
ui.heading("View");
|
|
});
|
|
ui.separator();
|
|
|
|
if running {
|
|
self.viewer_running(ui);
|
|
} else {
|
|
self.viewer_form(ui);
|
|
}
|
|
}
|
|
|
|
fn viewer_form(&mut self, ui: &mut egui::Ui) {
|
|
if let Some(err) = &self.viewer.error {
|
|
ui.colored_label(self.theme.active.error, err);
|
|
ui.add_space(8.0);
|
|
}
|
|
|
|
ui.label("Paste the share code you received:");
|
|
ui.add_space(4.0);
|
|
// A Paste button (read-side mirror of the host's Copy button — one
|
|
// click grabs the code the host just put on the clipboard) with the
|
|
// field filling the rest of the row. A single horizontal row, so it
|
|
// doesn't grab the panel's full height.
|
|
let ticket_resp = ui
|
|
.horizontal(|ui| {
|
|
if ui.button("📋 Paste").clicked()
|
|
&& let Some(text) = get_clipboard()
|
|
{
|
|
self.viewer.ticket_input = text.trim().to_string();
|
|
}
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
|
|
.desired_width(f32::INFINITY)
|
|
.hint_text("endpoint…"),
|
|
)
|
|
})
|
|
.inner;
|
|
// Grab focus once on screen entry so the user can paste/type straight
|
|
// away without first clicking into the field.
|
|
if std::mem::take(&mut self.viewer.focus_ticket) {
|
|
ticket_resp.request_focus();
|
|
}
|
|
// Enter in the field connects (gated on a decodable code below).
|
|
let enter_pressed =
|
|
ticket_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
|
|
|
// Decode the pasted code live: confirms it's a real ticket and shows
|
|
// which host it points at, so a stale clipboard paste is caught here
|
|
// instead of after the 15s connect timeout.
|
|
let trimmed = self.viewer.ticket_input.trim().to_string();
|
|
let decoded_id = ticket_endpoint_id(&trimmed);
|
|
if !trimmed.is_empty() {
|
|
ui.add_space(4.0);
|
|
match &decoded_id {
|
|
Some(id) => ui.colored_label(
|
|
self.theme.active.success,
|
|
format!("→ endpoint {}", short_id(id)),
|
|
),
|
|
None => ui.colored_label(
|
|
self.theme.active.warning,
|
|
"⚠ This doesn't look like a share code.",
|
|
),
|
|
};
|
|
}
|
|
|
|
ui.add_space(10.0);
|
|
ui.horizontal(|ui| {
|
|
ui.label("Player");
|
|
egui::ComboBox::from_id_salt("player")
|
|
.selected_text(match self.viewer.player {
|
|
PlayerSel::Mpv => "mpv",
|
|
PlayerSel::Vlc => "VLC",
|
|
})
|
|
.show_ui(ui, |ui| {
|
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Mpv, "mpv");
|
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Vlc, "VLC");
|
|
});
|
|
});
|
|
|
|
ui.add_space(16.0);
|
|
// Only dial a code that actually decodes — no point spawning a child to
|
|
// spend 15s timing out against garbage. Enter takes the same path.
|
|
let connect_clicked = ui
|
|
.add_enabled(
|
|
decoded_id.is_some(),
|
|
egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)),
|
|
)
|
|
.on_disabled_hover_text("Paste a valid share code first.")
|
|
.clicked();
|
|
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
|
|
self.start_viewer();
|
|
}
|
|
}
|
|
|
|
fn viewer_running(&mut self, ui: &mut egui::Ui) {
|
|
if self.viewer.launched {
|
|
ui.colored_label(self.theme.active.streaming, "● Streaming");
|
|
ui.label("Player launched. Close it or disconnect to stop.");
|
|
} else if self.viewer.url.is_some() {
|
|
ui.label("Connected — launching player…");
|
|
} else {
|
|
let msg = match &self.viewer.connecting_to {
|
|
Some(id) => format!("● Connecting to {id}…"),
|
|
None => "● Connecting…".to_string(),
|
|
};
|
|
ui.colored_label(self.theme.active.waiting, msg);
|
|
}
|
|
|
|
ui.add_space(16.0);
|
|
if ui
|
|
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
|
|
.clicked()
|
|
{
|
|
self.stop_viewer();
|
|
}
|
|
}
|
|
|
|
/// On entering the View screen, drop a clipboard ticket straight into the
|
|
/// field — the host's Copy button (and our auto-copy) usually leaves the
|
|
/// freshly-shared code right there. Guarded so it only fires when the field
|
|
/// is empty and the clipboard holds a *decodable* ticket, so stray
|
|
/// clipboard text never lands in the box; the live decode still shows the
|
|
/// id for the user to verify.
|
|
fn prefill_viewer_ticket(&mut self) {
|
|
if self.viewer.ticket_input.trim().is_empty()
|
|
&& self.viewer.proc.is_none()
|
|
&& let Some(text) = get_clipboard()
|
|
&& ticket_endpoint_id(&text).is_some()
|
|
{
|
|
self.viewer.ticket_input = text.trim().to_string();
|
|
}
|
|
self.viewer.focus_ticket = true;
|
|
}
|
|
|
|
fn start_viewer(&mut self) {
|
|
self.viewer.error = None;
|
|
self.viewer.url = None;
|
|
self.viewer.launched = false;
|
|
|
|
let ticket = self.viewer.ticket_input.trim().to_string();
|
|
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
|
|
let mut args = vec![ticket, "--output".to_string(), "json".to_string()];
|
|
if let Some(relay) = &self.relay {
|
|
args.push("--relay".to_string());
|
|
args.push(relay.clone());
|
|
}
|
|
match ChildProc::spawn(&args, self.waker.clone()) {
|
|
Ok(p) => self.viewer.proc = Some(p),
|
|
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
|
|
}
|
|
}
|
|
|
|
fn stop_viewer(&mut self) {
|
|
self.viewer.proc = None;
|
|
self.viewer.url = None;
|
|
self.viewer.launched = false;
|
|
self.viewer.connecting_to = None;
|
|
}
|
|
|
|
fn pump_viewer_events(&mut self) {
|
|
let events: Vec<ChildEvent> = match &self.viewer.proc {
|
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
|
None => return,
|
|
};
|
|
for ev in events {
|
|
if let ChildEvent::Connected { url } = ev {
|
|
self.viewer.url = Some(url.clone());
|
|
if !self.viewer.launched {
|
|
match self.viewer.player.to_player().spawn(&url) {
|
|
Ok(()) => self.viewer.launched = true,
|
|
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(p) = &mut self.viewer.proc
|
|
&& !p.is_alive()
|
|
{
|
|
// Bridge ended (player closed) or the connection failed.
|
|
if self.viewer.url.is_none() {
|
|
let tail = p.stderr_tail();
|
|
self.viewer.error = Some(if tail.trim().is_empty() {
|
|
"Couldn't connect to the host (check the code).".to_string()
|
|
} else {
|
|
format!("Connection ended:\n{tail}")
|
|
});
|
|
}
|
|
self.viewer.proc = None;
|
|
self.viewer.launched = false;
|
|
self.viewer.url = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A real, parseable ticket built the same way the host builds one
|
|
/// (`EndpointTicket::new`), from a deterministic key so the id is stable.
|
|
fn sample_ticket() -> (String, String) {
|
|
let sk = iroh::SecretKey::from_bytes(&[7u8; 32]);
|
|
let id = sk.public().to_string();
|
|
let ticket =
|
|
iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(sk.public()))
|
|
.to_string();
|
|
(ticket, id)
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_endpoint_id_from_a_valid_ticket() {
|
|
let (ticket, id) = sample_ticket();
|
|
assert_eq!(ticket_endpoint_id(&ticket).as_deref(), Some(id.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn tolerates_surrounding_whitespace() {
|
|
let (ticket, _) = sample_ticket();
|
|
assert!(ticket_endpoint_id(&format!(" \n{ticket}\t ")).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_ticket_input() {
|
|
// The empty/whitespace/garbage cases that gate the Connect button off.
|
|
assert_eq!(ticket_endpoint_id(""), None);
|
|
assert_eq!(ticket_endpoint_id(" "), None);
|
|
assert_eq!(ticket_endpoint_id("hello world"), None);
|
|
assert_eq!(ticket_endpoint_id("endpointbutnotreally"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn short_id_truncates_long_ids() {
|
|
assert_eq!(short_id("0123456789abcdefghij"), "0123456789ab…");
|
|
}
|
|
|
|
#[test]
|
|
fn short_id_leaves_short_or_exact_ids_intact() {
|
|
assert_eq!(short_id("abc"), "abc");
|
|
assert_eq!(short_id("0123456789ab"), "0123456789ab"); // exactly 12, no ellipsis
|
|
}
|
|
}
|