5d519ede78
When the pasted code doesn't decode, Connect is greyed out; hovering it now shows "Paste a valid share code first." so the disabled state is self-explanatory, complementing the amber "doesn't look like a share code" line under the field. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
812 lines
27 KiB
Rust
812 lines
27 KiB
Rust
//! Graphical front-end (`pixelpass --gui`), compiled only with the `gui`
|
|
//! feature.
|
|
//!
|
|
//! Architecture: this window is a thin **shell-out** driver. It never touches
|
|
//! the capture / portal / gst / iroh machinery directly — instead it re-execs
|
|
//! this same binary in headless mode (`pixelpass --host --output json …` or
|
|
//! `pixelpass <ticket> --output json`) as a child process and parses the
|
|
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
|
|
//! shows. That keeps the fragile capture stack sealed in a separate process:
|
|
//! the GUI can be closed or crash without taking a live stream down.
|
|
|
|
mod child;
|
|
|
|
use eframe::egui;
|
|
|
|
use self::child::{ChildEvent, ChildProc};
|
|
|
|
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
|
|
/// main thread (a winit requirement), which is where `main` calls it from.
|
|
pub fn run() -> anyhow::Result<()> {
|
|
let options = eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default()
|
|
.with_inner_size([520.0, 480.0])
|
|
.with_min_inner_size([460.0, 380.0])
|
|
.with_title("PixelPass"),
|
|
..Default::default()
|
|
};
|
|
|
|
eframe::run_native(
|
|
"PixelPass",
|
|
options,
|
|
Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))),
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("GUI failed to start: {e}"))
|
|
}
|
|
|
|
/// Best-effort clipboard write. Returns whether it succeeded so callers can
|
|
/// show an honest "✓ Copied" / fallback hint (the clipboard can be flaky on
|
|
/// Wayland, and a silent miss is what left users pasting stale tickets).
|
|
fn set_clipboard(text: &str) -> bool {
|
|
arboard::Clipboard::new()
|
|
.and_then(|mut cb| cb.set_text(text.to_owned()))
|
|
.is_ok()
|
|
}
|
|
|
|
/// Best-effort clipboard read, backing the viewer Paste button and the
|
|
/// View-screen prefill. `None` on a flaky/empty clipboard — callers just leave
|
|
/// the field untouched.
|
|
fn get_clipboard() -> Option<String> {
|
|
arboard::Clipboard::new()
|
|
.and_then(|mut cb| cb.get_text())
|
|
.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 {
|
|
#[default]
|
|
Menu,
|
|
Host,
|
|
Viewer,
|
|
}
|
|
|
|
/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality`
|
|
/// argument value passed to the child.
|
|
#[derive(Default, PartialEq, Clone, Copy)]
|
|
enum QualitySel {
|
|
#[default]
|
|
Auto,
|
|
Source,
|
|
High,
|
|
Medium,
|
|
Low,
|
|
}
|
|
|
|
impl QualitySel {
|
|
fn as_arg(self) -> &'static str {
|
|
match self {
|
|
QualitySel::Auto => "auto",
|
|
QualitySel::Source => "source",
|
|
QualitySel::High => "high",
|
|
QualitySel::Medium => "medium",
|
|
QualitySel::Low => "low",
|
|
}
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
QualitySel::Auto => "Auto — pick from my upload speed",
|
|
QualitySel::Source => "Source — native resolution",
|
|
QualitySel::High => "High — up to 1080p",
|
|
QualitySel::Medium => "Medium — up to 720p",
|
|
QualitySel::Low => "Low — up to 480p",
|
|
}
|
|
}
|
|
|
|
const ALL: [QualitySel; 5] = [
|
|
QualitySel::Auto,
|
|
QualitySel::Source,
|
|
QualitySel::High,
|
|
QualitySel::Medium,
|
|
QualitySel::Low,
|
|
];
|
|
}
|
|
|
|
/// Player choices for the viewer screen.
|
|
#[derive(Default, PartialEq, Clone, Copy)]
|
|
enum PlayerSel {
|
|
#[default]
|
|
Mpv,
|
|
Vlc,
|
|
}
|
|
|
|
impl PlayerSel {
|
|
fn to_player(self) -> crate::interactive::Player {
|
|
match self {
|
|
PlayerSel::Mpv => crate::interactive::Player::Mpv,
|
|
PlayerSel::Vlc => crate::interactive::Player::Vlc,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Host-screen state: the config form fields plus, once started, the running
|
|
/// child and the latest values parsed from its event stream.
|
|
struct HostState {
|
|
// form
|
|
quality: QualitySel,
|
|
max_viewers: u32, // 0 = let the host auto-size from the bandwidth preflight
|
|
no_hwencode: bool,
|
|
window: bool,
|
|
// running session + accumulated live state
|
|
proc: Option<ChildProc>,
|
|
ticket: Option<String>,
|
|
info: Option<HostInfo>,
|
|
active: u32,
|
|
max: u32,
|
|
capturing: bool,
|
|
/// Whether the current ticket made it onto the clipboard (auto-copy on
|
|
/// arrival, or a manual Copy click). Drives the "✓ Copied" hint so the
|
|
/// user isn't left guessing whether to click Copy — the trap that had
|
|
/// people pasting a stale clipboard ticket.
|
|
copied: bool,
|
|
last_refusal: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
impl Default for HostState {
|
|
fn default() -> Self {
|
|
Self {
|
|
quality: QualitySel::default(),
|
|
max_viewers: 0,
|
|
no_hwencode: false,
|
|
window: false,
|
|
proc: None,
|
|
ticket: None,
|
|
info: None,
|
|
active: 0,
|
|
max: 0,
|
|
capturing: false,
|
|
copied: false,
|
|
last_refusal: None,
|
|
error: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The host config summary echoed back by the child's `host_info` event.
|
|
struct HostInfo {
|
|
display: String,
|
|
capture: String,
|
|
quality: String,
|
|
dimensions: String,
|
|
hw_encode: bool,
|
|
cap_source: String,
|
|
}
|
|
|
|
/// Viewer-screen state.
|
|
#[derive(Default)]
|
|
struct ViewerState {
|
|
ticket_input: String,
|
|
player: PlayerSel,
|
|
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>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct PixelPassApp {
|
|
screen: Screen,
|
|
host: HostState,
|
|
viewer: ViewerState,
|
|
}
|
|
|
|
impl eframe::App for PixelPassApp {
|
|
// eframe 0.34 hands us the central-panel `ui` directly.
|
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
// Drain any pending child events before drawing this frame.
|
|
self.pump_host_events();
|
|
self.pump_viewer_events();
|
|
|
|
match self.screen {
|
|
Screen::Menu => self.menu(ui),
|
|
Screen::Host => self.host(ui),
|
|
Screen::Viewer => self.viewer(ui),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PixelPassApp {
|
|
fn menu(&mut self, ui: &mut egui::Ui) {
|
|
ui.vertical_centered(|ui| {
|
|
ui.add_space(24.0);
|
|
ui.heading("PixelPass");
|
|
ui.label("P2P screen sharing");
|
|
ui.label(
|
|
egui::RichText::new(concat!("v", env!("CARGO_PKG_VERSION")))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.add_space(32.0);
|
|
if ui
|
|
.add_sized([260.0, 40.0], egui::Button::new("Host — share my screen"))
|
|
.clicked()
|
|
{
|
|
self.screen = Screen::Host;
|
|
}
|
|
ui.add_space(8.0);
|
|
if ui
|
|
.add_sized(
|
|
[260.0, 40.0],
|
|
egui::Button::new("View — watch someone's screen"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.screen = Screen::Viewer;
|
|
self.prefill_viewer_ticket();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Host screen ──────────────────────────────────────────────────────
|
|
|
|
fn host(&mut self, ui: &mut egui::Ui) {
|
|
let running = self.host.proc.is_some();
|
|
ui.horizontal(|ui| {
|
|
// Leaving the host screen stops the session (Drop on the child).
|
|
if ui.button("← Menu").clicked() {
|
|
self.stop_host();
|
|
self.screen = Screen::Menu;
|
|
}
|
|
ui.heading("Host");
|
|
});
|
|
ui.separator();
|
|
|
|
if running {
|
|
self.host_running(ui);
|
|
} else {
|
|
self.host_form(ui);
|
|
}
|
|
}
|
|
|
|
fn host_form(&mut self, ui: &mut egui::Ui) {
|
|
if let Some(err) = &self.host.error {
|
|
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
|
ui.add_space(8.0);
|
|
}
|
|
|
|
egui::Grid::new("host_form")
|
|
.num_columns(2)
|
|
.spacing([12.0, 10.0])
|
|
.show(ui, |ui| {
|
|
ui.label("Quality");
|
|
egui::ComboBox::from_id_salt("quality")
|
|
.selected_text(self.host.quality.label())
|
|
.show_ui(ui, |ui| {
|
|
for q in QualitySel::ALL {
|
|
ui.selectable_value(&mut self.host.quality, q, q.label());
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Max viewers");
|
|
ui.horizontal(|ui| {
|
|
ui.add(egui::DragValue::new(&mut self.host.max_viewers).range(0..=16));
|
|
if self.host.max_viewers == 0 {
|
|
ui.label("(auto from upload speed)");
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Options");
|
|
ui.vertical(|ui| {
|
|
ui.checkbox(&mut self.host.window, "Share a single window");
|
|
ui.checkbox(
|
|
&mut self.host.no_hwencode,
|
|
"Software encoding (no GPU / VAAPI)",
|
|
);
|
|
});
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(16.0);
|
|
if ui
|
|
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
|
|
.clicked()
|
|
{
|
|
self.start_host(ui.ctx().clone());
|
|
}
|
|
ui.add_space(8.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"On Wayland a \"Share Screen?\" dialog appears when the first \
|
|
viewer connects.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
|
|
fn host_running(&mut self, ui: &mut egui::Ui) {
|
|
if self.host.capturing {
|
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
|
} else if self.host.ticket.is_some() {
|
|
ui.colored_label(egui::Color32::YELLOW, "● Waiting for viewers…");
|
|
} else {
|
|
ui.label("Starting…");
|
|
}
|
|
|
|
ui.add_space(6.0);
|
|
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
|
|
|
|
if let Some(info) = &self.host.info {
|
|
ui.label(
|
|
egui::RichText::new(format!("{} · {}", info.display, info.capture))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(format!(
|
|
"{} · {} · {} · cap {}",
|
|
info.quality,
|
|
info.dimensions,
|
|
if info.hw_encode {
|
|
"HW encode"
|
|
} else {
|
|
"software encode"
|
|
},
|
|
info.cap_source
|
|
))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
|
|
ui.add_space(12.0);
|
|
|
|
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(
|
|
egui::Label::new(egui::RichText::new(&ticket).monospace().small())
|
|
.wrap()
|
|
.selectable(true),
|
|
);
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
if ui.button("📋 Copy code").clicked() {
|
|
self.copy_to_clipboard(&ticket);
|
|
}
|
|
if self.host.copied {
|
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Copied to clipboard");
|
|
}
|
|
});
|
|
if !self.host.copied {
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Couldn't auto-copy — click Copy, or select the code above.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.add_space(16.0);
|
|
if ui
|
|
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
|
|
.clicked()
|
|
{
|
|
self.stop_host();
|
|
}
|
|
}
|
|
|
|
fn start_host(&mut self, ctx: egui::Context) {
|
|
self.host.error = None;
|
|
self.host.last_refusal = None;
|
|
self.host.ticket = None;
|
|
self.host.info = None;
|
|
self.host.active = 0;
|
|
self.host.max = 0;
|
|
self.host.capturing = false;
|
|
self.host.copied = false;
|
|
|
|
let mut args = vec![
|
|
"--host".to_string(),
|
|
"--output".to_string(),
|
|
"json".to_string(),
|
|
"--quality".to_string(),
|
|
self.host.quality.as_arg().to_string(),
|
|
];
|
|
if self.host.max_viewers > 0 {
|
|
args.push("--max-viewers".to_string());
|
|
args.push(self.host.max_viewers.to_string());
|
|
}
|
|
if self.host.no_hwencode {
|
|
args.push("--no-hwencode".to_string());
|
|
}
|
|
if self.host.window {
|
|
args.push("--window".to_string());
|
|
}
|
|
|
|
match ChildProc::spawn(&args, ctx) {
|
|
Ok(p) => self.host.proc = Some(p),
|
|
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
|
|
}
|
|
}
|
|
|
|
fn stop_host(&mut self) {
|
|
// Dropping the ChildProc SIGINTs the child and reaps it.
|
|
self.host.proc = None;
|
|
self.host.capturing = false;
|
|
self.host.ticket = None;
|
|
self.host.copied = false;
|
|
}
|
|
|
|
/// Drain the host child's event channel into state, and detect an
|
|
/// unexpected child exit (e.g. a failed dependency check) so it surfaces
|
|
/// in the form instead of leaving a dead "running" view.
|
|
fn pump_host_events(&mut self) {
|
|
let events: Vec<ChildEvent> = match &self.host.proc {
|
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
|
None => return,
|
|
};
|
|
for ev in events {
|
|
self.apply_host_event(ev);
|
|
}
|
|
|
|
if let Some(p) = &mut self.host.proc
|
|
&& !p.is_alive()
|
|
{
|
|
if self.host.ticket.is_none() {
|
|
let tail = p.stderr_tail();
|
|
self.host.error = Some(if tail.trim().is_empty() {
|
|
"Host exited before it could start.".to_string()
|
|
} else {
|
|
format!("Host exited before it could start:\n{tail}")
|
|
});
|
|
}
|
|
self.host.proc = None;
|
|
self.host.capturing = false;
|
|
}
|
|
}
|
|
|
|
fn apply_host_event(&mut self, ev: ChildEvent) {
|
|
match ev {
|
|
ChildEvent::Ticket { value } => {
|
|
// Auto-copy on arrival, mirroring the CLI/interactive host
|
|
// (which copies the ticket and prints "copied to your
|
|
// clipboard"). A failure here is non-fatal: the ticket stays
|
|
// visible for manual copy, and `copied` stays false so the UI
|
|
// doesn't falsely claim success.
|
|
self.host.copied = set_clipboard(&value);
|
|
self.host.ticket = Some(value);
|
|
}
|
|
ChildEvent::HostInfo {
|
|
display_server,
|
|
capture,
|
|
quality,
|
|
dimensions,
|
|
hw_encode,
|
|
max_viewers,
|
|
max_viewers_source,
|
|
} => {
|
|
self.host.max = max_viewers;
|
|
self.host.info = Some(HostInfo {
|
|
display: display_server,
|
|
capture,
|
|
quality,
|
|
dimensions,
|
|
hw_encode,
|
|
cap_source: max_viewers_source,
|
|
});
|
|
}
|
|
ChildEvent::ViewerCount { active, max } => {
|
|
self.host.active = active;
|
|
self.host.max = max;
|
|
}
|
|
ChildEvent::Capture { state } => {
|
|
self.host.capturing = matches!(state, child::CaptureState::Started);
|
|
}
|
|
ChildEvent::ViewerRefused { reason } => self.host.last_refusal = Some(reason),
|
|
ChildEvent::Connected { .. } => {} // viewer-side; not used on the host screen
|
|
}
|
|
}
|
|
|
|
/// Manual Copy-button handler: copy and reflect success in the UI, or
|
|
/// surface a clear error if the clipboard rejected it.
|
|
fn copy_to_clipboard(&mut self, text: &str) {
|
|
if set_clipboard(text) {
|
|
self.host.copied = true;
|
|
self.host.error = None;
|
|
} else {
|
|
self.host.copied = false;
|
|
self.host.error = Some(
|
|
"Couldn't write to the clipboard. Select the code above and copy it manually."
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Viewer screen ─────────────────────────────────────────────────────
|
|
|
|
fn viewer(&mut self, ui: &mut egui::Ui) {
|
|
let running = self.viewer.proc.is_some();
|
|
ui.horizontal(|ui| {
|
|
if ui.button("← Menu").clicked() {
|
|
self.stop_viewer();
|
|
self.screen = Screen::Menu;
|
|
}
|
|
ui.heading("View");
|
|
});
|
|
ui.separator();
|
|
|
|
if running {
|
|
self.viewer_running(ui);
|
|
} else {
|
|
self.viewer_form(ui);
|
|
}
|
|
}
|
|
|
|
fn viewer_form(&mut self, ui: &mut egui::Ui) {
|
|
if let Some(err) = &self.viewer.error {
|
|
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
|
ui.add_space(8.0);
|
|
}
|
|
|
|
ui.label("Paste the share code you received:");
|
|
ui.add_space(4.0);
|
|
// A Paste button (read-side mirror of the host's Copy button — one
|
|
// click grabs the code the host just put on the clipboard) with the
|
|
// field filling the rest of the row. A single horizontal row, so it
|
|
// doesn't grab the panel's full height.
|
|
let ticket_resp = ui
|
|
.horizontal(|ui| {
|
|
if ui.button("📋 Paste").clicked()
|
|
&& let Some(text) = get_clipboard()
|
|
{
|
|
self.viewer.ticket_input = text.trim().to_string();
|
|
}
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
|
|
.desired_width(f32::INFINITY)
|
|
.hint_text("endpoint…"),
|
|
)
|
|
})
|
|
.inner;
|
|
// Enter in the field connects (gated on a decodable code below).
|
|
let enter_pressed =
|
|
ticket_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
|
|
|
// 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");
|
|
egui::ComboBox::from_id_salt("player")
|
|
.selected_text(match self.viewer.player {
|
|
PlayerSel::Mpv => "mpv",
|
|
PlayerSel::Vlc => "VLC",
|
|
})
|
|
.show_ui(ui, |ui| {
|
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Mpv, "mpv");
|
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Vlc, "VLC");
|
|
});
|
|
});
|
|
|
|
ui.add_space(16.0);
|
|
// Only dial a code that actually decodes — no point spawning a child to
|
|
// spend 15s timing out against garbage. Enter takes the same path.
|
|
let connect_clicked = ui
|
|
.add_enabled(
|
|
decoded_id.is_some(),
|
|
egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)),
|
|
)
|
|
.on_disabled_hover_text("Paste a valid share code first.")
|
|
.clicked();
|
|
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
|
|
self.start_viewer(ui.ctx().clone());
|
|
}
|
|
}
|
|
|
|
fn viewer_running(&mut self, ui: &mut egui::Ui) {
|
|
if self.viewer.launched {
|
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
|
ui.label("Player launched. Close it or disconnect to stop.");
|
|
} else if self.viewer.url.is_some() {
|
|
ui.label("Connected — launching player…");
|
|
} else {
|
|
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);
|
|
if ui
|
|
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
|
|
.clicked()
|
|
{
|
|
self.stop_viewer();
|
|
}
|
|
}
|
|
|
|
/// On entering the View screen, drop a clipboard ticket straight into the
|
|
/// field — the host's Copy button (and our auto-copy) usually leaves the
|
|
/// freshly-shared code right there. Guarded so it only fires when the field
|
|
/// is empty and the clipboard holds a *decodable* ticket, so stray
|
|
/// clipboard text never lands in the box; the live decode still shows the
|
|
/// id for the user to verify.
|
|
fn prefill_viewer_ticket(&mut self) {
|
|
if self.viewer.ticket_input.trim().is_empty()
|
|
&& self.viewer.proc.is_none()
|
|
&& let Some(text) = get_clipboard()
|
|
&& ticket_endpoint_id(&text).is_some()
|
|
{
|
|
self.viewer.ticket_input = text.trim().to_string();
|
|
}
|
|
}
|
|
|
|
fn start_viewer(&mut self, ctx: egui::Context) {
|
|
self.viewer.error = None;
|
|
self.viewer.url = None;
|
|
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),
|
|
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
|
|
}
|
|
}
|
|
|
|
fn stop_viewer(&mut self) {
|
|
self.viewer.proc = None;
|
|
self.viewer.url = None;
|
|
self.viewer.launched = false;
|
|
self.viewer.connecting_to = None;
|
|
}
|
|
|
|
fn pump_viewer_events(&mut self) {
|
|
let events: Vec<ChildEvent> = match &self.viewer.proc {
|
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
|
None => return,
|
|
};
|
|
for ev in events {
|
|
if let ChildEvent::Connected { url } = ev {
|
|
self.viewer.url = Some(url.clone());
|
|
if !self.viewer.launched {
|
|
match self.viewer.player.to_player().spawn(&url) {
|
|
Ok(()) => self.viewer.launched = true,
|
|
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(p) = &mut self.viewer.proc
|
|
&& !p.is_alive()
|
|
{
|
|
// Bridge ended (player closed) or the connection failed.
|
|
if self.viewer.url.is_none() {
|
|
let tail = p.stderr_tail();
|
|
self.viewer.error = Some(if tail.trim().is_empty() {
|
|
"Couldn't connect to the host (check the code).".to_string()
|
|
} else {
|
|
format!("Connection ended:\n{tail}")
|
|
});
|
|
}
|
|
self.viewer.proc = None;
|
|
self.viewer.launched = false;
|
|
self.viewer.url = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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
|
|
}
|
|
}
|