Compare commits

..
Author SHA1 Message Date
Aleksandr BogomiakovandClaude 2e178a6f9d Tray: About window, fixed-width device table with totals; WTFPL
Release / tsunagi-linux-aarch64 (push) Successful in 2m19s
Release / tsunagi-linux-x86_64 (push) Successful in 3m56s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Add an About window (client and agent details, protocol versions), pin the
device table's column widths and add a total row, show the version in the
header and drop the refresh button. Relicense under WTFPL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 20:16:01 +01:00
12 changed files with 370 additions and 34 deletions

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ mainline = { path = "vendor/mainline" }
[workspace.package]
edition = "2024"
rust-version = "1.91"
license = "MIT OR Apache-2.0"
license = "WTFPL"
repository = "https://github.com/tsunagi-net/tsunagi"
[workspace.lints.rust]
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
+1 -1
View File
@@ -763,4 +763,4 @@ any of this.
## Licence
MIT OR Apache-2.0.
[WTFPL](LICENSE): do what the fuck you want to.
+5
View File
@@ -4342,6 +4342,11 @@ async fn build_report(
state_dir: paths.state_dir.display().to_string(),
cache_dir: paths.cache_dir.display().to_string(),
program: program_path(),
version: env!("CARGO_PKG_VERSION").to_string(),
protocols: PROTOCOLS
.iter()
.map(|spec| format!("{} v{}", spec.name, spec.version))
.collect(),
privilege: match tsunagi::overlay::probe_net_admin() {
tsunagi::overlay::Privilege::Available => tsunagi::ipc::PrivilegeReport::Available,
tsunagi::overlay::Privilege::Missing(reason) => {
+175
View File
@@ -0,0 +1,175 @@
//! The About window: what this software is, and what it is talking to.
//!
//! Compact on purpose — one table for the client, one for the agent. The agent
//! half is whatever the agent says about itself, so a client and an agent that
//! are not the same build show up as such.
use eframe::egui;
use tsunagi::ipc::{PrivilegeReport, StatusReport};
use crate::agent::Snapshot;
use crate::format;
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
const AUTHOR: &str = "AB <tsunagi@hexor.cy>";
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Draws the window body into its viewport.
pub(crate) fn show(ctx: &egui::Context, socket: &std::path::Path, snapshot: &Snapshot) {
egui::CentralPanel::default().show(ctx, |ui| {
// Long paths and a taller agent table must scroll rather than be cut.
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| body(ui, socket, snapshot));
});
}
fn body(ui: &mut egui::Ui, socket: &std::path::Path, snapshot: &Snapshot) {
{
ui.horizontal(|ui| {
ui.heading("tsunagi");
ui.label(egui::RichText::new(VERSION).small().weak());
});
ui.label("Peer-to-peer mesh network with no server.");
ui.add_space(6.0);
section(ui, "client");
grid(ui, "about client", |ui| {
row(ui, "repository", |ui| {
ui.hyperlink(REPOSITORY);
});
row(ui, "author", |ui| {
format::copy_field(ui, AUTHOR, AUTHOR);
});
row(ui, "license", |ui| {
ui.label(env!("CARGO_PKG_LICENSE"));
});
row(ui, "build", |ui| {
ui.label(format!(
"{} {} · {}",
std::env::consts::OS,
std::env::consts::ARCH,
if cfg!(debug_assertions) {
"debug"
} else {
"release"
}
));
});
});
ui.add_space(8.0);
section(ui, "agent");
let socket = socket.display().to_string();
match &snapshot.status {
None => {
grid(ui, "about agent", |ui| {
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
row(ui, "state", |ui| {
ui.label("connecting…");
});
});
}
Some(Err(error)) => {
grid(ui, "about agent", |ui| {
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
row(ui, "state", |ui| {
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x4a, 0x3a), "unreachable");
});
row(ui, "reason", |ui| {
ui.add(egui::Label::new(error).wrap());
});
});
}
Some(Ok(report)) => agent_table(ui, &socket, report),
}
}
}
fn agent_table(ui: &mut egui::Ui, socket: &str, report: &StatusReport) {
grid(ui, "about agent", |ui| {
row(ui, "socket", |ui| format::copy_field(ui, socket, socket));
row(ui, "state", |ui| {
ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), "connected");
});
row(ui, "version", |ui| {
if report.version.is_empty() {
ui.weak("unknown");
} else if report.version == VERSION {
ui.label(&report.version);
} else {
ui.colored_label(
egui::Color32::from_rgb(0xd8, 0x9a, 0x2a),
format!("{} (client is {VERSION})", report.version),
);
}
});
if !report.protocols.is_empty() {
row(ui, "protocols", |ui| {
ui.label(report.protocols.join(" · "));
});
}
row(ui, "endpoint id", |ui| {
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
});
row(ui, "hostname", |ui| {
ui.label(&report.hostname);
});
if !report.program.is_empty() {
row(ui, "binary", |ui| {
format::copy_field(ui, &report.program, &report.program);
});
}
if !report.state_dir.is_empty() {
row(ui, "state dir", |ui| {
format::copy_field(ui, &report.state_dir, &report.state_dir);
});
row(ui, "cache dir", |ui| {
format::copy_field(ui, &report.cache_dir, &report.cache_dir);
});
}
row(ui, "interface", |ui| match &report.privilege {
PrivilegeReport::Unknown => {
ui.weak("unknown");
}
PrivilegeReport::Available => {
ui.label("can be managed (CAP_NET_ADMIN)");
}
PrivilegeReport::Missing(_) => {
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x9a, 0x2a), "no privileges");
}
PrivilegeReport::Unsupported => {
ui.label("not supported on this platform");
}
});
row(ui, "cache", |ui| {
ui.label(if report.cache_healthy {
"usable"
} else {
"unavailable"
});
});
row(ui, "listening", |ui| {
ui.label(report.bound_sockets.join(", "));
});
});
}
fn section(ui: &mut egui::Ui, title: &str) {
ui.label(egui::RichText::new(title).strong());
}
fn grid(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut egui::Ui)) {
egui::Grid::new(id)
.num_columns(2)
.spacing([12.0, 3.0])
.show(ui, add);
}
/// One label/value line of a table.
fn row(ui: &mut egui::Ui, label: &str, value: impl FnOnce(&mut egui::Ui)) {
ui.weak(label);
value(ui);
ui.end_row();
}
-3
View File
@@ -21,8 +21,6 @@ const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// A request from the UI to the agent.
pub(crate) enum Command {
/// Re-read status now.
Refresh,
/// Stop serving a network, or start serving it again.
SetActive { network_id: String, active: bool },
/// Turn local broadcast participation on or off for a network.
@@ -170,7 +168,6 @@ async fn run(
ctx.request_repaint();
let outcome: Result<String, String> = match command {
Command::Refresh => Ok(String::new()),
Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active)
.await
.map(|report| {
+25
View File
@@ -9,6 +9,7 @@ use std::time::Duration;
use eframe::egui;
use crate::about;
use crate::agent::{AgentClient, resolve_socket};
use crate::devices;
use crate::stats::Traffic;
@@ -59,6 +60,7 @@ impl eframe::App for App {
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
self.show_devices_windows(ctx, &snapshot);
self.show_about_window(ctx, &snapshot);
// A secret the worker fetched for the clipboard: copy it here, on the
// UI thread that owns the clipboard, exactly once.
@@ -72,6 +74,29 @@ impl eframe::App for App {
}
impl App {
/// Renders the About window while it is open.
fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
if !self.state.about_open {
return;
}
let builder = egui::ViewportBuilder::default()
.with_title("About tsunagi")
.with_inner_size([560.0, 520.0])
.with_min_inner_size([380.0, 280.0]);
let socket = self.agent.socket();
let keep = ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("about"),
builder,
|vctx, _class| {
about::show(vctx, socket, snapshot);
!vctx.input(|i| i.viewport().close_requested())
},
);
if !keep {
self.state.about_open = false;
}
}
/// Renders one separate window per open network-devices view.
fn show_devices_windows(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
let Some(Ok(report)) = &snapshot.status else {
+134 -21
View File
@@ -233,6 +233,54 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
devices
}
/// The widest value each column is expected to hold, so the table starts out
/// wide enough and does not reflow as numbers grow.
const COLUMN_SAMPLES: [&str; 8] = [
"",
"tcp-tls",
"255.255.255.255",
"23h 59m",
"99999/99999",
"1023.9 MB / 1023.9 MB",
"1023.9 kB/s",
"",
];
/// Column widths that only ever grow, kept across frames.
///
/// A grid sizes each column to its widest cell *this frame*, so a value that
/// gets shorter — `14.4 MB/s` giving way to `382 pkt/s` — would pull the
/// column in and push every cell after it. Remembering the widest seen keeps
/// the table where it is.
fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 8] {
let stored: Option<[f32; 8]> = ui.memory(|m| m.data.get_temp(id));
stored.unwrap_or_else(|| {
let font = egui::TextStyle::Body.resolve(ui.style());
let mut widths = [0.0; 8];
for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) {
if !sample.is_empty() {
*width = ui.fonts(|fonts| {
fonts
.layout_no_wrap(sample.to_owned(), font.clone(), egui::Color32::WHITE)
.size()
.x
});
}
}
widths
})
}
/// One cell, at least as wide as its column has ever been.
fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 8], add: impl FnOnce(&mut egui::Ui)) {
let floor = widths[column];
let response = ui.scope(|ui| {
ui.set_min_width(floor);
add(ui);
});
widths[column] = widths[column].max(response.response.rect.width());
}
/// One table of all devices: online rows carry live counters and a plot,
/// offline rows show what is known (name and address) and dashes.
fn device_table(
@@ -246,12 +294,14 @@ fn device_table(
ui.weak("none");
return;
}
let widths_id = egui::Id::new(("devices column widths", network_id));
let mut widths = column_widths(ui, widths_id);
egui::Grid::new("devices")
.num_columns(8)
.striped(true)
.spacing([10.0, 4.0])
.show(ui, |ui| {
for header in [
for (column, header) in [
"device",
"proto",
"address",
@@ -260,49 +310,112 @@ fn device_table(
"bytes",
"rate",
"plot",
] {
ui.weak(header);
]
.into_iter()
.enumerate()
{
cell(ui, column, &mut widths, |ui| {
ui.weak(header);
});
}
ui.end_row();
for device in devices {
name_cell(ui, device);
ui.label(device.proto);
match &device.address {
cell(ui, 0, &mut widths, |ui| name_cell(ui, device));
cell(ui, 1, &mut widths, |ui| {
ui.label(device.proto);
});
cell(ui, 2, &mut widths, |ui| match &device.address {
Some(address) => format::copy_field(ui, address, address),
None => {
ui.weak("—");
}
}
ui.label(
device
.handshake_secs
.map_or_else(|| "offline".to_string(), format::age),
);
});
cell(ui, 3, &mut widths, |ui| {
ui.label(
device
.handshake_secs
.map_or_else(|| "offline".to_string(), format::age),
);
});
match device.overlay.filter(|_| device.online) {
Some(peer) => {
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
ui.label(format!(
"{} / {}",
format::bytes(peer.tx_bytes as f64),
format::bytes(peer.rx_bytes as f64)
));
let series = traffic.peer(network_id, &peer.public_key);
ui.add(egui::Label::new(format::series_rate(series, unit)).truncate());
cell(ui, 4, &mut widths, |ui| {
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
});
cell(ui, 5, &mut widths, |ui| {
ui.label(format!(
"{} / {}",
format::bytes(peer.tx_bytes as f64),
format::bytes(peer.rx_bytes as f64)
));
});
cell(ui, 6, &mut widths, |ui| {
ui.add(
egui::Label::new(format::series_rate(series, unit))
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| {
format::sparkline(ui, series, unit, 18.0);
});
}
None => {
for _ in 0..4 {
ui.weak("—");
for column in 4..7 {
cell(ui, column, &mut widths, |ui| {
ui.weak("—");
});
}
ui.weak("—");
}
}
ui.end_row();
}
// Everything this agent has moved through the overlay, including
// devices that have since gone away.
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
for peer in devices.iter().filter_map(|d| d.overlay) {
tx_packets += peer.tx_packets;
rx_packets += peer.rx_packets;
tx_bytes += peer.tx_bytes;
rx_bytes += peer.rx_bytes;
}
cell(ui, 0, &mut widths, |ui| {
ui.strong("total");
});
for column in 1..4 {
cell(ui, column, &mut widths, |ui| {
ui.label("");
});
}
cell(ui, 4, &mut widths, |ui| {
ui.strong(format!("{tx_packets}/{rx_packets}"));
});
cell(ui, 5, &mut widths, |ui| {
ui.strong(format!(
"{} / {}",
format::bytes(tx_bytes as f64),
format::bytes(rx_bytes as f64)
));
});
cell(ui, 6, &mut widths, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(format::series_rate(traffic.network(network_id), unit))
.strong(),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.end_row();
});
ui.memory_mut(|m| {
m.data
.insert_temp(egui::Id::new("devices column widths"), widths)
});
}
fn yes_no(value: bool) -> &'static str {
+1
View File
@@ -9,6 +9,7 @@
// Nothing here is a library.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod about;
mod agent;
mod app;
mod devices;
+10 -6
View File
@@ -40,6 +40,8 @@ pub(crate) struct UiState {
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,
}
impl Default for UiState {
@@ -49,6 +51,7 @@ impl Default for UiState {
unit: Unit::Packets,
host_edit: None,
open_devices: BTreeSet::new(),
about_open: false,
}
}
}
@@ -65,13 +68,14 @@ pub(crate) fn draw(
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::ARROWS_CLOCKWISE)
.on_hover_text("Refresh")
.clicked()
{
agent.send(Command::Refresh);
if ui.button(icon::INFO).on_hover_text("About").clicked() {
state.about_open = true;
}
if snapshot.busy {
ui.add(egui::Spinner::new());
+4
View File
@@ -366,6 +366,10 @@ pub struct StatusReport {
/// It is the agent that creates the interface, so the client's own
/// capabilities say nothing about it.
pub privilege: PrivilegeReport,
/// The version of the agent's software.
pub version: String,
/// The data plane protocols it has, each as `name vN` (wire version).
pub protocols: Vec<String>,
}
/// What the agent found when it checked whether it may manage an interface.
+1 -2
View File
@@ -69,8 +69,7 @@ builddate = $(date +%s)
packager = AB <ab@hexor.cy>
size = ${size}
arch = ${arch}
${relations}license = MIT
license = Apache
${relations}license = WTFPL
PKGINFO
# Scriptlet: create the dedicated system user the service runs as, keep systemd