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:
Generated
+7
@@ -4176,6 +4176,7 @@ dependencies = [
|
|||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
|
"qrcode",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -4420,6 +4421,12 @@ version = "0.1.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
|
|||||||
+6
-1
@@ -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 }
|
winit = { version = "0.30", default-features = false, features = ["rwh_06", "x11", "wayland", "wayland-dlopen"], optional = true }
|
||||||
glutin = { version = "0.32", optional = true }
|
glutin = { version = "0.32", optional = true }
|
||||||
glutin-winit = { version = "0.5", 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]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
@@ -64,4 +69,4 @@ strip = "symbols"
|
|||||||
[features]
|
[features]
|
||||||
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
|
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
|
||||||
# build never pulls the GUI toolkit tree.
|
# 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"]
|
||||||
|
|||||||
+54
-23
@@ -323,8 +323,11 @@ impl Gfx {
|
|||||||
fn show(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
|
fn show(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
|
||||||
match std::mem::replace(&mut self.win, WinState::Between) {
|
match std::mem::replace(&mut self.win, WinState::Between) {
|
||||||
WinState::Hidden { context } => {
|
WinState::Hidden { context } => {
|
||||||
let window =
|
let window = glutin_winit::finalize_window(
|
||||||
glutin_winit::finalize_window(event_loop, window_attributes(), &self.gl_config)?;
|
event_loop,
|
||||||
|
window_attributes(),
|
||||||
|
&self.gl_config,
|
||||||
|
)?;
|
||||||
let surface = create_surface(&self.gl_display, &self.gl_config, &window)?;
|
let surface = create_surface(&self.gl_display, &self.gl_config, &window)?;
|
||||||
let context = context
|
let context = context
|
||||||
.make_current(&surface)
|
.make_current(&surface)
|
||||||
@@ -382,7 +385,8 @@ impl Gfx {
|
|||||||
viewport_output,
|
viewport_output,
|
||||||
} = eg.egui_ctx.run_ui(raw_input, run_ui);
|
} = 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
|
// Apply any window commands egui emitted (we issue none directly, but
|
||||||
// egui may request e.g. IME changes) and read the next repaint delay.
|
// egui may request e.g. IME changes) and read the next repaint delay.
|
||||||
@@ -466,11 +470,7 @@ impl App {
|
|||||||
/// actually present, otherwise quit.
|
/// actually present, otherwise quit.
|
||||||
fn on_close(&mut self, event_loop: &ActiveEventLoop) {
|
fn on_close(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
let hide = self.state.close_to_tray
|
let hide = self.state.close_to_tray
|
||||||
&& self
|
&& self.state.tray.as_ref().is_some_and(TrayHandle::registered);
|
||||||
.state
|
|
||||||
.tray
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(TrayHandle::registered);
|
|
||||||
if hide {
|
if hide {
|
||||||
if let Some(gfx) = self.gfx.as_mut() {
|
if let Some(gfx) = self.gfx.as_mut() {
|
||||||
gfx.hide();
|
gfx.hide();
|
||||||
@@ -496,12 +496,7 @@ impl ApplicationHandler<UserEvent> for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn window_event(
|
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||||
&mut self,
|
|
||||||
event_loop: &ActiveEventLoop,
|
|
||||||
_id: WindowId,
|
|
||||||
event: WindowEvent,
|
|
||||||
) {
|
|
||||||
if matches!(event, WindowEvent::CloseRequested) {
|
if matches!(event, WindowEvent::CloseRequested) {
|
||||||
self.on_close(event_loop);
|
self.on_close(event_loop);
|
||||||
return;
|
return;
|
||||||
@@ -788,6 +783,10 @@ struct HostState {
|
|||||||
/// Endpoint ids of the currently-connected viewers, in arrival order.
|
/// Endpoint ids of the currently-connected viewers, in arrival order.
|
||||||
/// Drives the per-viewer list and its Kick buttons.
|
/// Drives the per-viewer list and its Kick buttons.
|
||||||
viewers: Vec<String>,
|
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.
|
/// The host config summary echoed back by the child's `host_info` event.
|
||||||
@@ -1116,14 +1115,42 @@ impl PixelPassApp {
|
|||||||
.weak(),
|
.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 {
|
if let Some(reason) = &self.host.last_refusal {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.colored_label(
|
ui.colored_label(egui::Color32::from_rgb(220, 160, 60), format!("⚠ {reason}"));
|
||||||
egui::Color32::from_rgb(220, 160, 60),
|
|
||||||
format!("⚠ {reason}"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
@@ -1145,6 +1172,7 @@ impl PixelPassApp {
|
|||||||
self.host.capturing = false;
|
self.host.capturing = false;
|
||||||
self.host.copied = false;
|
self.host.copied = false;
|
||||||
self.host.viewers.clear();
|
self.host.viewers.clear();
|
||||||
|
self.host.qr_texture = None;
|
||||||
|
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
@@ -1177,6 +1205,7 @@ impl PixelPassApp {
|
|||||||
self.host.ticket = None;
|
self.host.ticket = None;
|
||||||
self.host.copied = false;
|
self.host.copied = false;
|
||||||
self.host.viewers.clear();
|
self.host.viewers.clear();
|
||||||
|
self.host.qr_texture = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain the host child's event channel into state, and detect an
|
/// Drain the host child's event channel into state, and detect an
|
||||||
@@ -1243,7 +1272,10 @@ impl PixelPassApp {
|
|||||||
if !self.host.viewers.contains(&id) {
|
if !self.host.viewers.contains(&id) {
|
||||||
notify(
|
notify(
|
||||||
"PixelPass — viewer connected",
|
"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);
|
self.host.viewers.push(id);
|
||||||
}
|
}
|
||||||
@@ -1491,10 +1523,9 @@ mod tests {
|
|||||||
fn sample_ticket() -> (String, String) {
|
fn sample_ticket() -> (String, String) {
|
||||||
let sk = iroh::SecretKey::from_bytes(&[7u8; 32]);
|
let sk = iroh::SecretKey::from_bytes(&[7u8; 32]);
|
||||||
let id = sk.public().to_string();
|
let id = sk.public().to_string();
|
||||||
let ticket = iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(
|
let ticket =
|
||||||
sk.public(),
|
iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(sk.public()))
|
||||||
))
|
.to_string();
|
||||||
.to_string();
|
|
||||||
(ticket, id)
|
(ticket, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user