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:
+116
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user