diff --git a/Cargo.lock b/Cargo.lock index 1ae0db6..ec81528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4176,6 +4176,7 @@ dependencies = [ "nix 0.30.1", "notify-rust", "pipewire", + "qrcode", "serde", "serde_json", "thiserror 2.0.18", @@ -4420,6 +4421,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index f607d43..3973448 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,11 @@ egui_glow = { version = "0.34.2", default-features = false, features = ["winit", winit = { version = "0.30", default-features = false, features = ["rwh_06", "x11", "wayland", "wayland-dlopen"], optional = true } glutin = { version = "0.32", optional = true } glutin-winit = { version = "0.5", optional = true } +# QR-encode the host ticket so a phone (or a second laptop with a webcam) can +# pick it up without typing 140 chars. default-features = false to skip the +# `image` crate dep tree — we render the modules to an `egui::ColorImage` +# directly. +qrcode = { version = "0.14", default-features = false, optional = true } [profile.release] lto = "thin" @@ -64,4 +69,4 @@ strip = "symbols" [features] # Opt-in graphical front-end (pixelpass --gui). Default-off so the headless # build never pulls the GUI toolkit tree. -gui = ["dep:eframe", "dep:notify-rust", "dep:ksni", "dep:egui_glow", "dep:winit", "dep:glutin", "dep:glutin-winit"] +gui = ["dep:eframe", "dep:notify-rust", "dep:ksni", "dep:egui_glow", "dep:winit", "dep:glutin", "dep:glutin-winit", "dep:qrcode"] diff --git a/src/gui/mod.rs b/src/gui/mod.rs index b83cab9..d2a71df 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -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 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, + /// 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, } /// 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) }