feat(gui): decode the ticket to flag a stale/invalid paste before connecting

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 <id>…" 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 <id>…" instead of a bare
  "Connecting…";
- the host screen shows its own "endpoint <id>…" 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 03:26:21 -04:00
parent 125e44c033
commit 48f5510699
+116 -3
View File
@@ -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<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
}
}
/// Which screen the single window is currently showing.
#[derive(Default, PartialEq)]
enum Screen {
@@ -173,6 +199,9 @@ struct ViewerState {
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>,
error: Option<String>,
}
@@ -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 <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(
@@ -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
}
}