Files
tsunagi/crates/tsunagi-tray/src/ui.rs
T
Aleksandr Bogomiakov 13bdcaf98d Render protocol priority buttons with Phosphor icons
The tray protocol selection used ordinary Unicode up/down arrows for its
priority buttons. The default UI fonts lack these glyphs, so both buttons
rendered missing-glyph boxes. Use the regular Phosphor ARROW_UP and ARROW_DOWN
icons already installed in the application's proportional font fallback.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with inherited control socket
  and group overrides removed: 509 passed, 2 existing ignored tests

No new tests were added for the two glyph substitutions. Manual tray rendering
and macOS/Windows checks were not performed. No release tag was requested for
this fix.
2026-10-12 17:19:53 +01:00

498 lines
18 KiB
Rust

//! The main window: a compact overview. Per-network detail (the full peer
//! table with inline plots) lives in a separate devices window (see
//! [`crate::devices`]), opened from each tile.
//!
//! Pure rendering: state comes from the snapshot and the traffic sampler, a
//! change to a switch or button sends a [`Command`] to the worker, and local UI
//! state (the join form, the unit toggle, which devices windows are open, an
//! in-progress hostname edit) lives in [`UiState`].
use std::collections::BTreeSet;
use eframe::egui;
use egui_phosphor::regular as icon;
use tsunagi::ipc::{NetworkReport, StatusReport};
use crate::agent::{AgentClient, Command, Snapshot};
use crate::devices;
use crate::exit;
use crate::format;
use crate::stats::{Traffic, Unit};
const OK: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
const BAD: egui::Color32 = egui::Color32::from_rgb(0xd0, 0x4a, 0x3c);
/// A neutral, not-garish red for the Leave button.
const LEAVE_RED: egui::Color32 = egui::Color32::from_rgb(0xa8, 0x3a, 0x3a);
/// Join form fields.
#[derive(Default)]
pub(crate) struct JoinForm {
pub(crate) name: String,
pub(crate) secret: String,
}
/// Local UI state kept across frames.
pub(crate) struct UiState {
pub(crate) join: JoinForm,
/// Whether rates and sparklines are shown in packets or bytes.
pub(crate) unit: Unit,
/// An in-progress hostname edit (`None` when not editing).
pub(crate) host_edit: Option<String>,
/// Network ids whose devices window is open.
pub(crate) open_devices: BTreeSet<String>,
/// Whether the About window is open.
pub(crate) about_open: bool,
/// An exit-node choice waiting for the user to confirm it.
pub(crate) exit_confirm: Option<devices::ExitConfirm>,
}
impl Default for UiState {
fn default() -> Self {
Self {
join: JoinForm::default(),
unit: Unit::Packets,
host_edit: None,
open_devices: BTreeSet::new(),
about_open: false,
exit_confirm: None,
}
}
}
/// Draws the main window.
pub(crate) fn draw(
ctx: &egui::Context,
agent: &AgentClient,
state: &mut UiState,
snapshot: &Snapshot,
traffic: &Traffic,
) {
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.heading("tsunagi");
ui.label(
egui::RichText::new(env!("CARGO_PKG_VERSION"))
.small()
.weak(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(icon::INFO).on_hover_text("About").clicked() {
state.about_open = true;
}
if snapshot.busy {
ui.add(egui::Spinner::new());
}
ui.separator();
ui.selectable_value(&mut state.unit, Unit::Bytes, "bytes");
ui.selectable_value(&mut state.unit, Unit::Packets, "pkts");
ui.label("show:");
});
});
ui.add_space(4.0);
});
egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| {
if let Some(action) = &snapshot.last_action {
ui.add_space(4.0);
match action {
Ok(message) => ui.colored_label(OK, message),
Err(error) => ui.colored_label(BAD, error),
};
}
ui.add_space(4.0);
draw_join(ui, agent, &mut state.join);
ui.add_space(4.0);
});
egui::CentralPanel::default().show(ctx, |ui| match &snapshot.status {
None => {
ui.add_space(8.0);
ui.label("Connecting to the agent…");
}
Some(Err(error)) => draw_unreachable(ui, agent, error),
Some(Ok(report)) => {
draw_header(ui, agent, state, report);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if report.networks.is_empty() {
ui.add_space(6.0);
ui.weak("No networks yet — join one below.");
}
for network in &report.networks {
draw_network(ui, agent, state, traffic, network, report);
}
});
}
});
devices::confirm_exit(ctx, agent, &mut state.exit_confirm, None);
}
/// The "agent cannot be reached" state.
fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
ui.add_space(8.0);
ui.colored_label(BAD, "The agent is not reachable.");
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.weak("socket");
ui.monospace(agent.socket().display().to_string());
});
ui.add_space(4.0);
ui.weak(error);
ui.add_space(6.0);
ui.weak(
"Start the agent, or check that this user may open its control socket \
(its group, mode 0660).",
);
}
/// The device header: id (with a copy button) and an editable hostname.
fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, report: &StatusReport) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.weak("device");
// Clicking the shortened id copies the full one.
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
});
ui.horizontal(|ui| {
ui.weak("host");
match &mut state.host_edit {
None => {
ui.label(&report.hostname);
if ui
.small_button(icon::PENCIL_SIMPLE)
.on_hover_text("rename")
.clicked()
{
state.host_edit = Some(report.hostname.clone());
}
}
Some(buffer) => {
let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0));
let submit = (response.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter)))
|| ui.small_button(icon::CHECK).on_hover_text("save").clicked();
if submit {
agent.send(Command::SetHostname(buffer.trim().to_string()));
state.host_edit = None;
} else if ui.small_button(icon::X).on_hover_text("cancel").clicked() {
state.host_edit = None;
}
}
}
});
if !report.cache_healthy {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
"cache unavailable",
);
}
}
/// One compact network tile.
fn draw_network(
ui: &mut egui::Ui,
agent: &AgentClient,
state: &mut UiState,
traffic: &Traffic,
network: &NetworkReport,
report: &StatusReport,
) {
ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
format::copy_field(ui, network.name.as_str(), &network.name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let leave =
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
.fill(LEAVE_RED);
if ui.add(leave).clicked() {
agent.send(Command::Leave {
network_id: network.network_id.clone(),
});
}
});
});
let address = network.overlay.as_ref().and_then(|o| o.address.as_ref());
let series = traffic.network(&network.network_id);
egui::Grid::new(("net", &network.network_id))
.num_columns(2)
.spacing([10.0, 3.0])
.show(ui, |ui| {
ui.weak("id");
format::copy_field(ui, &format::short(&network.network_id), &network.network_id);
ui.end_row();
if let Some(range) = &network.range {
ui.weak("range");
format::copy_field(ui, range, range);
ui.end_row();
}
if let Some(address) = address {
ui.weak("addr");
format::copy_field(ui, address, address);
ui.end_row();
}
ui.weak("peers");
ui.label(format!(
"{} online · {} known",
devices::online_count(network, &report.endpoint_id),
devices::known_count(network, &report.endpoint_id)
));
ui.end_row();
});
draw_protocols(ui, agent, network, &report.available_protocols);
format::sparkline(ui, series, state.unit, 34.0);
if !network.range_conflicts.is_empty() {
ui.colored_label(BAD, "Stopped: overlapping network ranges");
for conflict in &network.range_conflicts {
ui.label(format!("{} ({}) uses {}", conflict.name, conflict.network_id, conflict.range));
}
ui.label("Switching suspends these networks until this network stops, then resumes them automatically.");
if network.auto_start { ui.weak("This network will resume automatically when its range is free."); }
}
ui.horizontal(|ui| {
if !network.active && !network.range_conflicts.is_empty() {
if ui.button("Switch to this network").clicked() {
agent.send(Command::SetActive { network_id: network.network_id.clone(), active: true });
}
if network.auto_start && ui.button("Keep stopped").clicked() {
agent.send(Command::SetActive { network_id: network.network_id.clone(), active: false });
}
} else {
let mut active = network.active;
if ui.checkbox(&mut active, "active").changed() {
agent.send(Command::SetActive {
network_id: network.network_id.clone(),
active,
});
}
}
let mut broadcast = network.broadcast;
if ui.checkbox(&mut broadcast, "broadcast").changed() {
agent.send(Command::SetBroadcast {
network_id: network.network_id.clone(),
enabled: broadcast,
});
}
let mut offering = network.exit.offering;
if ui
.checkbox(&mut offering, "exit node")
.on_hover_text(
"let other members send all their internet traffic through this \
device, which can see it",
)
.changed()
{
agent.send(Command::SetExitOffer {
network_id: network.network_id.clone(),
enabled: offering,
});
}
});
ui.horizontal(|ui| {
// In the bottom left corner while this device sends its traffic through
// somebody: green while that works, red while it does not.
if let Some(via) = &network.exit.via {
let color = if network.exit.via_online {
exit::GOOD
} else {
exit::LOUD
};
let response = ui
.add(
egui::Label::new(
egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!(
"exit node: {} — click to stop",
exit::name_of(network, via)
));
if response.clicked() {
state.exit_confirm = Some(devices::ExitConfirm::stop_from_tile(network, via));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Show devices").clicked() {
state.open_devices.insert(network.network_id.clone());
}
if ui
.button(format!("{} Copy secret", icon::COPY))
.on_hover_text("copy this network's secret to the clipboard")
.clicked()
{
agent.send(Command::CopySecret {
network_id: network.network_id.clone(),
});
}
});
});
for warning in exit::warnings(network) {
draw_exit_warning(ui, &warning);
}
});
}
fn draw_protocols(
ui: &mut egui::Ui,
agent: &AgentClient,
network: &NetworkReport,
available: &[tsunagi::ipc::ProtocolReport],
) {
let summary = if network.protocols.is_empty() {
"none".into()
} else {
network.protocols.join(", ")
};
egui::CollapsingHeader::new(format!("protocols {summary}"))
.id_salt(("protocols", &network.network_id))
.show(ui, |ui| {
ui.weak("Preference order; changes apply immediately and are saved.");
let mut selected = network.protocols.clone();
let mut changed = false;
ui.add_enabled_ui(!agent.snapshot().busy, |ui| {
// Enabled items follow the user's preference; unchecked items
// follow the agent's catalog. The worker refreshes after edits.
let ordered: Vec<_> = network
.protocols
.iter()
.filter_map(|name| available.iter().find(|p| &p.name == name))
.chain(
available
.iter()
.filter(|p| !network.protocols.contains(&p.name)),
)
.collect();
for protocol in ordered {
ui.horizontal(|ui| {
let mut on = selected.contains(&protocol.name);
if ui.checkbox(&mut on, &protocol.name).changed() {
if on {
selected.push(protocol.name.clone());
} else {
selected.retain(|p| p != &protocol.name);
}
changed = true;
}
if let Some(index) = selected.iter().position(|p| p == &protocol.name) {
if ui
.add_enabled(index > 0, egui::Button::new(icon::ARROW_UP))
.on_hover_text("prefer earlier")
.clicked()
{
selected.swap(index, index - 1);
changed = true;
}
if ui
.add_enabled(
index + 1 < selected.len(),
egui::Button::new(icon::ARROW_DOWN),
)
.on_hover_text("prefer later")
.clicked()
{
selected.swap(index, index + 1);
changed = true;
}
}
});
ui.indent(("description", &network.network_id, &protocol.name), |ui| {
ui.weak(&protocol.summary);
});
}
});
if selected.is_empty() {
ui.weak("Control only; no IP traffic in this network.");
}
if changed {
agent.send(Command::SetProtocols {
network_id: network.network_id.clone(),
protocols: selected,
});
}
});
}
/// A prominent line about an exit node. One that carries a command copies it
/// when clicked.
fn draw_exit_warning(ui: &mut egui::Ui, warning: &exit::Warning) {
let text = egui::RichText::new(format!("{} {}", icon::WARNING, warning.text))
.color(exit::LOUD)
.strong();
let label = egui::Label::new(text).wrap();
match warning.copy {
Some(command) => {
let response = ui
.add(label.sense(egui::Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("click to copy the command");
if response.clicked() {
ui.ctx().copy_text(command.to_owned());
}
}
None => {
ui.add(label);
}
}
}
/// The join form: aligned name and secret, a generate button, and Join.
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
const INPUT_WIDTH: f32 = 208.0;
ui.label("Join or create a network");
egui::Grid::new("join")
.num_columns(2)
.spacing([8.0, 6.0])
.show(ui, |ui| {
ui.label("name");
ui.add(egui::TextEdit::singleline(&mut join.name).desired_width(INPUT_WIDTH));
ui.end_row();
ui.label("secret");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut join.secret)
.desired_width(INPUT_WIDTH - 26.0)
.password(true),
);
if ui
.button(icon::SHUFFLE)
.on_hover_text("Generate a new random secret")
.clicked()
{
join.secret = tsunagi::identity::NetworkSecret::generate()
.encode()
.as_str()
.to_owned();
}
});
ui.end_row();
});
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
if ui
.add_enabled(ready, egui::Button::new("Join / create"))
.clicked()
{
agent.send(Command::Join {
name: join.name.trim().to_string(),
secret: join.secret.trim().to_string(),
});
join.secret.clear();
}
}