diff --git a/Cargo.toml b/Cargo.toml index 8f70d46..806744d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c93f45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + 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. diff --git a/README.md b/README.md index 496f721..c4a2c56 100644 --- a/README.md +++ b/README.md @@ -763,4 +763,4 @@ any of this. ## Licence -MIT OR Apache-2.0. +[WTFPL](LICENSE): do what the fuck you want to. diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index fb00242..3fa9437 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -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) => { diff --git a/crates/tsunagi-tray/src/about.rs b/crates/tsunagi-tray/src/about.rs new file mode 100644 index 0000000..d454eab --- /dev/null +++ b/crates/tsunagi-tray/src/about.rs @@ -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 "; +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(); +} diff --git a/crates/tsunagi-tray/src/agent.rs b/crates/tsunagi-tray/src/agent.rs index 749e3fe..8156647 100644 --- a/crates/tsunagi-tray/src/agent.rs +++ b/crates/tsunagi-tray/src/agent.rs @@ -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 = match command { - Command::Refresh => Ok(String::new()), Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active) .await .map(|report| { diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index b894b26..7427906 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -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 { diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index 480b9fc..93af0b3 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -233,6 +233,54 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { 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 { diff --git a/crates/tsunagi-tray/src/main.rs b/crates/tsunagi-tray/src/main.rs index 171f9ad..3c1357d 100644 --- a/crates/tsunagi-tray/src/main.rs +++ b/crates/tsunagi-tray/src/main.rs @@ -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; diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index 156668d..22b9487 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -40,6 +40,8 @@ pub(crate) struct UiState { pub(crate) host_edit: Option, /// Network ids whose devices window is open. pub(crate) open_devices: BTreeSet, + /// 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()); diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index 676cbc6..b67c3de 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -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, } /// What the agent found when it checked whether it may manage an interface. diff --git a/dist/arch/build-pkg.sh b/dist/arch/build-pkg.sh index 662f150..ed56847 100755 --- a/dist/arch/build-pkg.sh +++ b/dist/arch/build-pkg.sh @@ -69,8 +69,7 @@ builddate = $(date +%s) packager = AB 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