gui: QR-code panel for the host ticket

Encodes the relay-only ticket as a QR with a 4-module quiet zone so a
phone (or a second laptop with a webcam) can pick the room up without
typing 140+ characters. Built lazily on the first draw after a Ticket
event, NEAREST-filtered, 200x200 logical; cleared on session start and
stop.

Pulls `qrcode` 0.14 with `default-features = false` so the heavy `image`
crate tree is skipped — we render modules straight to an
`egui::ColorImage` ourselves.

Reapplies the idea from Gemini's stale `feat/gemini-branch-qrcode`
(`7f07583`) against the post-hand-rolled-loop GUI; the original commit
no longer cherry-picks because gui/mod.rs was rewritten for the
true-Wayland-window-hide work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 05:07:14 -04:00
parent 6f1ccf3923
commit 7a03dee12f
3 changed files with 67 additions and 24 deletions
+54 -23
View File
@@ -323,8 +323,11 @@ impl Gfx {
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 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)
@@ -382,7 +385,8 @@ impl Gfx {
viewport_output,
} = eg.egui_ctx.run_ui(raw_input, run_ui);
eg.egui_winit.handle_platform_output(window, platform_output);
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.
@@ -466,11 +470,7 @@ impl App {
/// 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);
&& self.state.tray.as_ref().is_some_and(TrayHandle::registered);
if hide {
if let Some(gfx) = self.gfx.as_mut() {
gfx.hide();
@@ -496,12 +496,7 @@ impl ApplicationHandler<UserEvent> for App {
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
if matches!(event, WindowEvent::CloseRequested) {
self.on_close(event_loop);
return;
@@ -788,6 +783,10 @@ struct HostState {
/// 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.
@@ -1116,14 +1115,42 @@ impl PixelPassApp {
.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.
if 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 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(
egui::Color32::from_rgb(220, 160, 60),
format!("{reason}"),
);
ui.colored_label(egui::Color32::from_rgb(220, 160, 60), format!("{reason}"));
}
ui.add_space(16.0);
@@ -1145,6 +1172,7 @@ impl PixelPassApp {
self.host.capturing = false;
self.host.copied = false;
self.host.viewers.clear();
self.host.qr_texture = None;
let mut args = vec![
"--host".to_string(),
@@ -1177,6 +1205,7 @@ impl PixelPassApp {
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
@@ -1243,7 +1272,10 @@ impl PixelPassApp {
if !self.host.viewers.contains(&id) {
notify(
"PixelPass — viewer connected",
format!("endpoint {} is now watching ({active}/{max})", short_id(&id)),
format!(
"endpoint {} is now watching ({active}/{max})",
short_id(&id)
),
);
self.host.viewers.push(id);
}
@@ -1491,10 +1523,9 @@ mod tests {
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();
let ticket =
iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(sk.public()))
.to_string();
(ticket, id)
}