gled 2.32.0

gled is an application for creating animations and effects on artnet or dmx installations
//! The "Remote access" window: lets browsers and other devices connect to
//! this machine, shows where to point them and who is connected.

use std::{
    net::IpAddr,
    time::{Duration, Instant},
};

use egui::{Color32, Context, DragValue, Grid, Id, RichText, Vec2, ViewportId};

use crate::remote::RemoteAccess;
use gled_ui::ui::window_common::{gled_window_frame, tool_window_builder};

/// How often the list of local addresses is refreshed while the window is
/// open, so a new Wi-Fi shows up.
const ADDRESS_REFRESH: Duration = Duration::from_secs(5);

#[derive(Debug, Default)]
pub struct RemoteAccessWindow {
    open: bool,
    addresses: Vec<IpAddr>,
    addresses_read: Option<Instant>,
}

impl RemoteAccessWindow {
    #[cfg_attr(feature = "profiling", profiling::function)]
    pub fn update(&mut self, ctx: &Context, remote_access: &mut RemoteAccess) {
        if !self.open {
            return;
        }
        if self
            .addresses_read
            .is_none_or(|read| read.elapsed() > ADDRESS_REFRESH)
        {
            self.addresses = local_addresses();
            self.addresses_read = Some(Instant::now());
        }

        ctx.show_viewport_immediate(
            ViewportId(Id::new("remote access window")),
            tool_window_builder("Remote access")
                .with_title("Remote access")
                .with_inner_size(Vec2::new(520.0, 420.0))
                .with_min_inner_size(Vec2::new(420.0, 300.0)),
            |ctx, _viewport_class| {
                ctx.input(|input| {
                    if input.viewport().close_requested()
                        || gled_ui::ui::window_common::embedded_close_clicked("Remote access")
                    {
                        self.close();
                    }
                });
                gled_window_frame(ctx, "Remote access", |ui| {
                    self.contents(ui, remote_access);
                });
            },
        );
    }

    fn contents(&mut self, ui: &mut egui::Ui, remote_access: &mut RemoteAccess) {
        let mut settings = remote_access.settings();
        ui.checkbox(
            &mut settings.enabled,
            "Allow browsers and other devices to control gled",
        );
        ui.add_space(6.0);
        Grid::new("remote access ports")
            .num_columns(2)
            .show(ui, |ui| {
                ui.label("HTTPS port");
                ui.add(DragValue::new(&mut settings.https_port).range(1..=65535));
                ui.end_row();

                let mut redirect = settings.http_redirect_port.is_some();
                ui.checkbox(&mut redirect, "HTTP redirect port");
                let mut port = settings.http_redirect_port.unwrap_or(8080);
                ui.add_enabled(redirect, DragValue::new(&mut port).range(1..=65535));
                settings.http_redirect_port = redirect.then_some(port);
                ui.end_row();
            });
        // Applying unchanged settings does nothing.
        remote_access.change(settings);

        ui.separator();
        if let Some(error) = remote_access.error() {
            ui.label(RichText::new(error).color(Color32::RED));
            return;
        }
        let (Some(port), Some(fingerprint)) =
            (remote_access.https_port(), remote_access.fingerprint())
        else {
            ui.label("Off. Only this window controls gled.");
            return;
        };

        ui.label("Open one of these addresses in a browser with WebGPU (Chrome, Edge, Safari):");
        for url in urls(&self.addresses, port) {
            ui.horizontal(|ui| {
                if ui.small_button("Copy").clicked() {
                    ui.ctx().copy_text(url.clone());
                }
                ui.monospace(url);
            });
        }
        ui.add_space(6.0);
        ui.label(
            "The browser warns about the certificate the first time, because gled made it \
             itself. Accept it if the fingerprint matches:",
        );
        ui.add(egui::Label::new(RichText::new(fingerprint).monospace().small()).wrap());

        ui.separator();
        let clients: Vec<_> = remote_access.clients().cloned().collect();
        if clients.is_empty() {
            ui.label("Nobody is connected.");
            return;
        }
        ui.label(format!("Connected: {}", clients.len()));
        Grid::new("remote access clients")
            .num_columns(3)
            .striped(true)
            .show(ui, |ui| {
                for client in clients {
                    ui.monospace(client.peer.ip().to_string());
                    ui.label(client.app_version);
                    ui.label(format_since(client.since.elapsed()));
                    ui.end_row();
                }
            });
    }

    pub fn open(&mut self) {
        self.open = true;
        self.addresses_read = None;
    }

    pub fn close(&mut self) {
        self.open = false;
    }
}

/// The addresses of this machine other devices can reach: no loopback, no
/// link-local IPv6.
fn local_addresses() -> Vec<IpAddr> {
    let networks = sysinfo::Networks::new_with_refreshed_list();
    let mut addresses: Vec<IpAddr> = networks
        .values()
        .flat_map(|network| network.ip_networks().iter().map(|ip| ip.addr))
        .filter(|addr| match addr {
            IpAddr::V4(addr) => !addr.is_loopback() && !addr.is_link_local(),
            IpAddr::V6(addr) => {
                !addr.is_loopback() && !addr.is_unicast_link_local() && !addr.is_multicast()
            }
        })
        .collect();
    addresses.sort();
    addresses.dedup();
    addresses
}

fn urls(addresses: &[IpAddr], port: u16) -> Vec<String> {
    let port = if port == 443 {
        String::new()
    } else {
        format!(":{port}")
    };
    let mut urls = Vec::new();
    if let Ok(hostname) = gethostname::gethostname().into_string() {
        let hostname = hostname.trim_end_matches(".local");
        if !hostname.is_empty() {
            urls.push(format!("https://{hostname}.local{port}"));
        }
    }
    for address in addresses {
        match address {
            IpAddr::V4(address) => urls.push(format!("https://{address}{port}")),
            IpAddr::V6(address) => urls.push(format!("https://[{address}]{port}")),
        }
    }
    urls
}

fn format_since(elapsed: Duration) -> String {
    let seconds = elapsed.as_secs();
    if seconds < 60 {
        format!("{seconds} s")
    } else if seconds < 3600 {
        format!("{} min", seconds / 60)
    } else {
        format!("{} h {} min", seconds / 3600, seconds / 60 % 60)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn urls_leave_out_the_default_port() {
        let addresses = [
            "192.168.1.5".parse().expect("ip"),
            "fd00::5".parse().expect("ip"),
        ];
        let urls = urls(&addresses, 443);
        assert!(urls.contains(&"https://192.168.1.5".to_owned()));
        assert!(urls.contains(&"https://[fd00::5]".to_owned()));
        let urls = super::urls(&addresses, 8443);
        assert!(urls.contains(&"https://192.168.1.5:8443".to_owned()));
    }
}