From 48f551069961d0f77aac59a85b0be4f8244a9f05 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 25 May 2026 03:26:21 -0400 Subject: [PATCH] feat(gui): decode the ticket to flag a stale/invalid paste before connecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer screen now parses the pasted code client-side with the same `EndpointTicket::from_str` the headless viewer uses, and surfaces what it finds: - live preview under the paste box: green "→ endpoint …" for a valid ticket, amber "doesn't look like a share code" otherwise; - Connect is gated on a ticket that actually decodes (was: any non-empty text), so a garbage paste can't burn the 15s connect timeout; - the connecting line reads "● Connecting to …" instead of a bare "Connecting…"; - the host screen shows its own "endpoint …" with the same truncation, so the two ends are eyeball-comparable. This closes the loop on the stale-ticket trap: a dead/wrong code is now obvious the moment it's pasted, not 15s later. 5 unit tests cover the decode (real round-trip ticket) and short-id truncation. Co-Authored-By: Claude Opus 4.7 --- src/gui/mod.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index ba63074..03f4083 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -43,6 +43,32 @@ fn set_clipboard(text: &str) -> bool { .is_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 { + ticket + .trim() + .parse::() + .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 + } +} + /// Which screen the single window is currently showing. #[derive(Default, PartialEq)] enum Screen { @@ -173,6 +199,9 @@ struct ViewerState { proc: Option, url: Option, 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, error: Option, } @@ -342,6 +371,15 @@ impl PixelPassApp { 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 …" 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( @@ -548,6 +586,25 @@ impl PixelPassApp { .hint_text("endpoint…"), ); + // 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( + egui::Color32::LIGHT_GREEN, + format!("→ endpoint {}", short_id(id)), + ), + None => ui.colored_label( + egui::Color32::from_rgb(220, 160, 60), + "⚠ This doesn't look like a share code.", + ), + }; + } + ui.add_space(10.0); ui.horizontal(|ui| { ui.label("Player"); @@ -563,10 +620,11 @@ impl PixelPassApp { }); ui.add_space(16.0); - let can_connect = !self.viewer.ticket_input.trim().is_empty(); + // Only dial a code that actually decodes — no point spawning a child to + // spend 15s timing out against garbage. if ui .add_enabled( - can_connect, + decoded_id.is_some(), egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)), ) .clicked() @@ -582,7 +640,11 @@ impl PixelPassApp { } else if self.viewer.url.is_some() { ui.label("Connected — launching player…"); } else { - ui.colored_label(egui::Color32::YELLOW, "● Connecting…"); + let msg = match &self.viewer.connecting_to { + Some(id) => format!("● Connecting to {id}…"), + None => "● Connecting…".to_string(), + }; + ui.colored_label(egui::Color32::YELLOW, msg); } ui.add_space(16.0); @@ -600,6 +662,7 @@ impl PixelPassApp { 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 args = vec![ticket, "--output".to_string(), "json".to_string()]; match ChildProc::spawn(&args, ctx) { Ok(p) => self.viewer.proc = Some(p), @@ -611,6 +674,7 @@ impl PixelPassApp { self.viewer.proc = None; self.viewer.url = None; self.viewer.launched = false; + self.viewer.connecting_to = None; } fn pump_viewer_events(&mut self) { @@ -648,3 +712,52 @@ impl PixelPassApp { } } } + +#[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 + } +}