a7n-gui 0.42.0

Desktop interface for the Apeirogon software KVM
// SPDX-License-Identifier: GPL-3.0-or-later

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

use a7n_ipc::{DEFAULT_UDP_PORT, MachineId, PairingCode, PeerInfo, Request, StatusInfo};
use eframe::egui;
use rust_i18n::t;

use super::{address_text, firewall::firewall_warnings};
use crate::{App, Command, theme};
use theme::{BadgeKind, ButtonKind, ButtonSize, IconButtonKind, icon};

impl App {
    pub(super) fn machines_header(&mut self, ui: &mut egui::Ui) {
        let Some(status) = self.status.clone() else {
            return;
        };
        theme::page_header(
            ui,
            &t!("Machines"),
            &t!("Share input between these machines."),
            |ui| {
                if theme::button(
                    ui,
                    ButtonKind::Secondary,
                    ButtonSize::Md,
                    Some(if status.paused {
                        icon::PLAY
                    } else {
                        icon::PAUSE
                    }),
                    &if status.paused {
                        t!("Resume")
                    } else {
                        t!("Pause")
                    },
                )
                .clicked()
                {
                    self.send(Command::Request(Request::SetPaused {
                        paused: !status.paused,
                    }));
                }
            },
        );
    }

    pub(crate) fn machines(&mut self, ui: &mut egui::Ui) {
        let Some(status) = self.status.clone() else {
            return;
        };
        self.page_message(ui);
        firewall_warnings(ui, &status.firewall, self.hide_addresses());
        self.held_files(ui, &status);
        self.active_transfers(ui, &status);
        theme::overline(ui, &t!("This machine"));
        theme::card(ui, |ui| {
            machine_identity(
                ui,
                &status.name,
                status.machine,
                BadgeKind::Accent,
                &t!("This machine"),
            );
            if let Some(reason) = &status.input {
                theme::error(ui, &t!("Input unavailable: %{reason}", reason = reason));
            }
        });
        ui.add_space(theme::size::SECTION_GAP);
        self.paired_machines(ui, &status);
        ui.add_space(theme::size::SECTION_GAP);
        self.nearby_machines(ui, &status);
        ui.add_space(theme::size::SECTION_GAP);
        self.pairing_ui(ui);
    }

    fn paired_machines(&mut self, ui: &mut egui::Ui, status: &StatusInfo) {
        let peers: Vec<_> = status
            .peers
            .iter()
            .filter(|peer| peer.id != status.machine)
            .collect();
        theme::overline(
            ui,
            &t!(
                "Paired machines · %{count}",
                count = &peers.len().to_string()
            ),
        );
        if peers.is_empty() {
            theme::empty_card(
                ui,
                icon::MONITOR,
                &t!("No paired machines."),
                &t!("Choose Host or Join below."),
            );
        }
        for peer in peers {
            self.paired_machine(ui, status, peer);
        }
    }

    fn paired_machine(&mut self, ui: &mut egui::Ui, status: &StatusInfo, peer: &PeerInfo) {
        theme::card(ui, |ui| {
            machine_identity(
                ui,
                &peer.name,
                peer.id,
                if peer.waking {
                    BadgeKind::Warning
                } else if peer.connected {
                    BadgeKind::Online
                } else {
                    BadgeKind::Offline
                },
                &if peer.waking {
                    t!("Waking…")
                } else if peer.connected {
                    t!("Online")
                } else {
                    t!("Offline")
                },
            );
            super::peers::peer_problem(ui, peer);
            ui.horizontal(|ui| {
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    if theme::button(
                        ui,
                        ButtonKind::DangerQuiet,
                        ButtonSize::Md,
                        Some(icon::X_CIRCLE),
                        &t!("Unpair"),
                    )
                    .clicked()
                    {
                        self.send(Command::Request(Request::Unpair { machine: peer.id }));
                    }
                    if !peer.connected && peer.can_wake {
                        ui.add_enabled_ui(!peer.waking, |ui| {
                            if theme::button(
                                ui,
                                ButtonKind::Secondary,
                                ButtonSize::Md,
                                Some(icon::POWER),
                                &t!("Wake"),
                            )
                            .clicked()
                            {
                                self.send(Command::Request(Request::Wake { machine: peer.id }));
                            }
                        });
                    }
                    let nearby = status.nearby.iter().find(|nearby| nearby.id == peer.id);
                    ui.add_enabled_ui(!peer.connected && nearby.is_some(), |ui| {
                        if theme::button(
                            ui,
                            ButtonKind::Secondary,
                            ButtonSize::Md,
                            None,
                            &t!("Connect"),
                        )
                        .on_hover_text(t!("Connect",))
                        .clicked()
                            && let Some(nearby) = nearby
                        {
                            self.send(Command::Request(Request::Connect { addr: nearby.addr }));
                        }
                    });
                });
            });
        });
    }

    fn nearby_machines(&mut self, ui: &mut egui::Ui, status: &StatusInfo) {
        theme::overline(ui, &t!("Nearby machines"));
        let nearby: Vec<_> = status
            .nearby
            .iter()
            .filter(|nearby| {
                nearby.id != status.machine && !status.peers.iter().any(|peer| peer.id == nearby.id)
            })
            .collect();
        if nearby.is_empty() {
            theme::empty_card(
                ui,
                icon::BROADCAST,
                &t!("No nearby machines found."),
                &t!("Start Apeirogon on the same subnet."),
            );
        }
        for nearby in nearby {
            theme::card(ui, |ui| {
                ui.horizontal(|ui| {
                    theme::icon_tile(ui, icon::MONITOR);
                    ui.vertical(|ui| {
                        ui.label(t!("Nearby machine"));
                        machine_id(ui, nearby.id);
                        ui.add(
                            egui::Label::new(
                                egui::RichText::new(address_text(
                                    &nearby.addr,
                                    self.hide_addresses(),
                                ))
                                .font(theme::font_mono(13.0)),
                            )
                            .truncate(),
                        );
                    });
                });
                if theme::button(ui, ButtonKind::Secondary, ButtonSize::Md, None, &t!("Pair"))
                    .clicked()
                {
                    self.address = nearby.addr.to_string();
                }
            });
        }
    }

    fn pairing_ui(&mut self, ui: &mut egui::Ui) {
        theme::overline(ui, &t!("Pair a machine"));
        let count = if ui.available_width() < theme::STACK_WIDTH {
            1
        } else {
            2
        };
        ui.columns(count, |columns| {
            theme::card(&mut columns[0], |ui| {
                ui.heading(t!("Host"));
                ui.label(t!("Enter this one-time code on the other machine.",));
                if let Some((code, expiry)) = &self.pairing {
                    let remaining = expiry.saturating_duration_since(Instant::now()).as_secs();
                    if remaining == 0 {
                        theme::small(ui, &t!("Pairing code expired"));
                    } else {
                        ui.label(egui::RichText::new(code).font(theme::font_mono(13.0)));
                        theme::small(ui, &t!("Expires in %{remaining} s", remaining = remaining));
                    }
                }
                if theme::button(
                    ui,
                    ButtonKind::Primary,
                    ButtonSize::Md,
                    Some(icon::BROADCAST),
                    &t!("Host"),
                )
                .clicked()
                {
                    self.send(Command::Request(Request::PairHost));
                }
            });
            theme::card(&mut columns[count - 1], |ui| self.join_pairing(ui));
        });
    }

    fn join_pairing(&mut self, ui: &mut egui::Ui) {
        ui.heading(t!("Join"));
        ui.label(t!("Enter the host's address and code."));
        let hidden = self.hide_addresses();
        theme::masked_text_input(
            ui,
            &t!("Address"),
            &mut self.address,
            &t!("IP or IP:port"),
            true,
            hidden,
        );
        theme::text_input(
            ui,
            &t!("Pairing code"),
            &mut self.code,
            &t!("Six digits"),
            true,
        );
        let address = parse_address(&self.address);
        let code = PairingCode::parse(self.code.trim());
        if !self.address.is_empty() && address.is_err() {
            theme::error(ui, &t!("Enter an IP or IP:port; use [IPv6]:port"));
        }
        if !self.code.is_empty() && code.is_err() {
            theme::error(ui, &t!("Code must be six ASCII digits"));
        }
        ui.add_enabled_ui(address.is_ok() && code.is_ok(), |ui| {
            if theme::button(ui, ButtonKind::Secondary, ButtonSize::Md, None, &t!("Join")).clicked()
                && let (Ok(addr), Ok(code)) = (address, code)
            {
                self.send(Command::Request(Request::PairJoin { addr, code }));
                self.code.clear();
            }
        });
        ui.add_enabled_ui(address.is_ok(), |ui| {
            if theme::button(ui, ButtonKind::Ghost, ButtonSize::Sm, None, &t!("Connect")).clicked()
                && let Ok(addr) = address
            {
                self.send(Command::Request(Request::Connect { addr }));
            }
        });
    }
}

fn machine_identity(ui: &mut egui::Ui, name: &str, id: MachineId, kind: BadgeKind, status: &str) {
    ui.horizontal(|ui| {
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            copy_id(ui, id);
            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
                theme::icon_tile(ui, icon::MONITOR);
                ui.vertical(|ui| {
                    ui.spacing_mut().item_spacing.y = 2.0;
                    ui.spacing_mut().interact_size.y = 22.0;
                    ui.horizontal_wrapped(|ui| {
                        ui.add(
                            egui::Label::new(
                                egui::RichText::new(name)
                                    .font(theme::font_medium(14.0))
                                    .color(theme::Palette::of(ui.ctx()).text),
                            )
                            .truncate(),
                        );
                        theme::badge(ui, kind, status);
                    });
                    id_label(ui, id);
                });
            });
        });
    });
}

fn machine_id(ui: &mut egui::Ui, id: MachineId) {
    ui.horizontal(|ui| {
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            copy_id(ui, id);
            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
                id_label(ui, id);
            });
        });
    });
}

fn id_label(ui: &mut egui::Ui, id: MachineId) {
    ui.add(
        egui::Label::new(
            egui::RichText::new(id.to_string())
                .font(theme::font_mono(12.0))
                .color(theme::Palette::of(ui.ctx()).text3),
        )
        .truncate(),
    );
}

fn copy_id(ui: &mut egui::Ui, id: MachineId) {
    if theme::icon_button(ui, IconButtonKind::Ghost, icon::COPY, &t!("Copy ID"), false).clicked() {
        ui.ctx().copy_text(id.to_string());
    }
}

fn parse_address(text: &str) -> Result<SocketAddr, ()> {
    text.trim()
        .parse()
        .or_else(|_| {
            text.trim()
                .parse::<IpAddr>()
                .map(|ip| SocketAddr::new(ip, DEFAULT_UDP_PORT))
        })
        .map_err(|_| ())
}