Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c62d29fa14 | ||
|
|
a6d9edabee | ||
|
|
2f7f9980c7 | ||
|
|
2e178a6f9d |
No files matched your search
+2
-2
@@ -14,8 +14,8 @@ mainline = { path = "vendor/mainline" }
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
||||
license = "WTFPL"
|
||||
repository = "https://github.com/house-of-vanity/tsunagi"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
|
||||
@@ -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.
|
||||
@@ -763,4 +763,4 @@ any of this.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
[WTFPL](LICENSE): do what the fuck you want to.
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -52,11 +50,12 @@ pub(crate) struct Snapshot {
|
||||
pub generation: u64,
|
||||
/// When this status was read, for computing rates between refreshes.
|
||||
pub at: Option<Instant>,
|
||||
/// The socket the last request went to.
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
/// Handle the UI holds: send commands, read the latest snapshot.
|
||||
pub(crate) struct AgentClient {
|
||||
socket: PathBuf,
|
||||
tx: mpsc::UnboundedSender<Command>,
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
/// A secret fetched for the clipboard, taken by the UI thread which owns
|
||||
@@ -67,23 +66,20 @@ pub(crate) struct AgentClient {
|
||||
|
||||
impl AgentClient {
|
||||
/// Starts the worker on `runtime`, reporting to `ctx` (for repaints).
|
||||
pub(crate) fn spawn(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
ctx: egui::Context,
|
||||
socket: PathBuf,
|
||||
) -> Self {
|
||||
pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let shared = Arc::new(Mutex::new(Snapshot::default()));
|
||||
let shared = Arc::new(Mutex::new(Snapshot {
|
||||
socket: resolve_socket(),
|
||||
..Snapshot::default()
|
||||
}));
|
||||
let pending_copy = Arc::new(Mutex::new(None));
|
||||
runtime.spawn(worker(
|
||||
socket.clone(),
|
||||
rx,
|
||||
Arc::clone(&shared),
|
||||
Arc::clone(&pending_copy),
|
||||
ctx,
|
||||
));
|
||||
Self {
|
||||
socket,
|
||||
tx,
|
||||
shared,
|
||||
pending_copy,
|
||||
@@ -95,9 +91,9 @@ impl AgentClient {
|
||||
lock(&self.pending_copy).take()
|
||||
}
|
||||
|
||||
/// The control socket this client talks to.
|
||||
pub(crate) fn socket(&self) -> &std::path::Path {
|
||||
&self.socket
|
||||
/// The control socket this client talks to, as last resolved.
|
||||
pub(crate) fn socket(&self) -> PathBuf {
|
||||
lock(&self.shared).socket.clone()
|
||||
}
|
||||
|
||||
/// Sends a command; a dropped worker simply means nothing happens.
|
||||
@@ -120,33 +116,37 @@ fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
|
||||
/// The worker loop: refresh on an interval and whenever a command runs.
|
||||
async fn worker(
|
||||
socket: PathBuf,
|
||||
mut rx: mpsc::UnboundedReceiver<Command>,
|
||||
shared: Arc<Mutex<Snapshot>>,
|
||||
pending_copy: Arc<Mutex<Option<String>>>,
|
||||
ctx: egui::Context,
|
||||
) {
|
||||
refresh(&socket, &shared, &ctx).await;
|
||||
refresh(&shared, &ctx).await;
|
||||
let mut tick = tokio::time::interval(POLL_INTERVAL);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tick.tick() => {
|
||||
refresh(&socket, &shared, &ctx).await;
|
||||
refresh(&shared, &ctx).await;
|
||||
}
|
||||
command = rx.recv() => {
|
||||
let Some(command) = command else { return };
|
||||
run(&socket, command, &shared, &pending_copy, &ctx).await;
|
||||
refresh(&socket, &shared, &ctx).await;
|
||||
run(&resolve_socket(), command, &shared, &pending_copy, &ctx).await;
|
||||
refresh(&shared, &ctx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads status and stores it, mapping any failure to a human string.
|
||||
async fn refresh(socket: &std::path::Path, shared: &Arc<Mutex<Snapshot>>, ctx: &egui::Context) {
|
||||
let status = ipc::request_status(socket)
|
||||
///
|
||||
/// The socket is looked for again each time: a window opened before the agent
|
||||
/// was started would otherwise keep asking the per-user path for ever, while
|
||||
/// the service came up at the system one.
|
||||
async fn refresh(shared: &Arc<Mutex<Snapshot>>, ctx: &egui::Context) {
|
||||
let socket = resolve_socket();
|
||||
let status = ipc::request_status(&socket)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
{
|
||||
@@ -154,6 +154,7 @@ async fn refresh(socket: &std::path::Path, shared: &Arc<Mutex<Snapshot>>, ctx: &
|
||||
guard.status = Some(status);
|
||||
guard.generation = guard.generation.wrapping_add(1);
|
||||
guard.at = Some(Instant::now());
|
||||
guard.socket = socket;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -170,7 +171,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| {
|
||||
|
||||
@@ -9,7 +9,8 @@ use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::agent::{AgentClient, resolve_socket};
|
||||
use crate::about;
|
||||
use crate::agent::AgentClient;
|
||||
use crate::devices;
|
||||
use crate::stats::Traffic;
|
||||
use crate::ui::{self, UiState};
|
||||
@@ -37,7 +38,7 @@ impl App {
|
||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||
ctx.set_fonts(fonts);
|
||||
|
||||
let agent = AgentClient::spawn(&runtime, ctx, resolve_socket());
|
||||
let agent = AgentClient::spawn(&runtime, ctx);
|
||||
Ok(Self {
|
||||
agent,
|
||||
state: UiState::default(),
|
||||
@@ -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 {
|
||||
@@ -87,7 +112,7 @@ impl App {
|
||||
let viewport_id = egui::ViewportId::from_hash_of(("devices", &id));
|
||||
let builder = egui::ViewportBuilder::default()
|
||||
.with_title(devices::title(network))
|
||||
.with_inner_size([720.0, 540.0])
|
||||
.with_inner_size([900.0, 540.0])
|
||||
.with_min_inner_size([480.0, 320.0]);
|
||||
let agent = &self.agent;
|
||||
let traffic = &self.traffic;
|
||||
|
||||
@@ -99,7 +99,7 @@ pub(crate) fn show(
|
||||
))
|
||||
.strong(),
|
||||
);
|
||||
egui::ScrollArea::vertical()
|
||||
egui::ScrollArea::both()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
device_table(ui, unit, &network.network_id, &devices, traffic);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -81,6 +81,26 @@ fn build() -> Result<Built, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// [`build`], with a missing indicator library reported instead of panicking.
|
||||
///
|
||||
/// The library is loaded on first use, and its binding panics when it is not
|
||||
/// installed, which reads as a bug in this program rather than a package that
|
||||
/// is not there.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn build_checked() -> Result<Built, String> {
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(|_| {}));
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(build));
|
||||
std::panic::set_hook(hook);
|
||||
built.unwrap_or_else(|_| {
|
||||
Err(
|
||||
"the tray needs libayatana-appindicator (Arch: libayatana-appindicator, \
|
||||
Debian/Ubuntu: libayatana-appindicator3-1), which is not installed"
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Acts on menu choices as the platform's loop delivers them.
|
||||
fn handle_menu(built: &Built, window: Arc<Window>) {
|
||||
let (open, quit) = (built.open.clone(), built.quit.clone());
|
||||
@@ -101,7 +121,7 @@ fn handle_menu(built: &Built, window: Arc<Window>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
gtk::init().map_err(|err| format!("cannot initialise GTK: {err}"))?;
|
||||
let built = build()?;
|
||||
let built = build_checked()?;
|
||||
handle_menu(
|
||||
&built,
|
||||
Arc::new(Window {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+9
-4
@@ -37,10 +37,15 @@ creates and owns its directories:
|
||||
| cache | `/var/cache/tsunagi` |
|
||||
| control socket | `/run/tsunagi/agent.sock` |
|
||||
|
||||
The polkit rule lets anyone in the `tsunagi` group configure systemd-resolved
|
||||
for the overlay zones (the service user is in that group). To run the agent as
|
||||
your own user as well, add yourself to the group: `sudo usermod -aG tsunagi
|
||||
"$USER"`.
|
||||
Installing the package enables and starts the service, and adds the user who ran
|
||||
pacman to the `tsunagi` group (log out and in again for it to apply). The
|
||||
control socket is open to that group, so members can run `tsunagi status`, the
|
||||
CLI and the tray GUI without sudo; anyone else adds themselves with `sudo
|
||||
usermod -aG tsunagi "$USER"`. The group also lets its members configure
|
||||
systemd-resolved for the overlay zones through the polkit rule.
|
||||
|
||||
The GUI package depends on `gtk3`, `libayatana-appindicator` (the tray icon),
|
||||
`xdotool` (its `libxdo`), and the usual windowing libraries.
|
||||
|
||||
## Removal
|
||||
|
||||
|
||||
Vendored
+40
-7
@@ -50,6 +50,11 @@ if [ -n "$tray" ]; then
|
||||
pkgname=tsunagi-gui
|
||||
pkgdesc="Serverless private mesh networking agent, with the tray GUI"
|
||||
relations=$'conflict = tsunagi\nprovides = tsunagi\nreplaces = tsunagi\n'
|
||||
# What the tray and its window load at run time; none of it is linked in
|
||||
# statically, so pacman has to be told.
|
||||
for dep in gtk3 libayatana-appindicator xdotool libxkbcommon wayland libx11 libglvnd; do
|
||||
relations+="depend = ${dep}"$'\n'
|
||||
done
|
||||
install -D -m 0755 "$tray" "$root/usr/bin/tsunagi-tray"
|
||||
install -D -m 0644 "$here/../linux/tsunagi-tray.desktop" \
|
||||
"$root/usr/share/applications/tsunagi-tray.desktop"
|
||||
@@ -64,30 +69,58 @@ cat > "$root/.PKGINFO" <<PKGINFO
|
||||
pkgname = ${pkgname}
|
||||
pkgver = ${pkgver}-${pkgrel}
|
||||
pkgdesc = ${pkgdesc}
|
||||
url = https://github.com/Ultradesu/tsunagi
|
||||
url = https://github.com/house-of-vanity/tsunagi
|
||||
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
|
||||
# in step, and refresh the desktop/icon caches (a no-op without a GUI payload).
|
||||
# Scriptlet: create the dedicated system user the service runs as, start the
|
||||
# service, and let whoever ran pacman use it. The control socket belongs to the
|
||||
# `tsunagi` group, so the tray and the CLI work for any member of it; the
|
||||
# person installing is added, since the package cannot know anyone else.
|
||||
cat > "$root/.INSTALL" <<'INSTALL'
|
||||
post_install() {
|
||||
getent group tsunagi >/dev/null 2>&1 || groupadd -r tsunagi
|
||||
getent passwd tsunagi >/dev/null 2>&1 || \
|
||||
useradd -r -g tsunagi -d /var/lib/tsunagi -s /usr/bin/nologin -c "tsunagi agent" tsunagi
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
command -v update-desktop-database >/dev/null 2>&1 && \
|
||||
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
|
||||
command -v gtk-update-icon-cache >/dev/null 2>&1 && \
|
||||
gtk-update-icon-cache -q -t /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||
|
||||
# Whoever ran pacman through sudo or pkexec (an AUR helper does too).
|
||||
installer="${SUDO_USER:-}"
|
||||
if [ -z "$installer" ] && [ -n "${PKEXEC_UID:-}" ]; then
|
||||
installer="$(getent passwd "$PKEXEC_UID" | cut -d: -f1)"
|
||||
fi
|
||||
if [ -n "$installer" ] && [ "$installer" != root ] && id "$installer" >/dev/null 2>&1; then
|
||||
usermod -aG tsunagi "$installer" >/dev/null 2>&1 || true
|
||||
echo ":: $installer was added to the tsunagi group; log out and in again so the"
|
||||
echo " tray and the CLI can reach the agent without sudo."
|
||||
else
|
||||
echo ":: add yourself to the tsunagi group to use the tray and the CLI without"
|
||||
echo " sudo: sudo usermod -aG tsunagi \$USER, then log out and in again."
|
||||
fi
|
||||
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl enable --now tsunagi.service >/dev/null 2>&1 || \
|
||||
echo ":: could not start tsunagi.service; see: systemctl status tsunagi"
|
||||
fi
|
||||
}
|
||||
post_upgrade() {
|
||||
post_install
|
||||
# Not enabled again: it may have been turned off on purpose. A running
|
||||
# agent is restarted, because the control protocol changes between builds
|
||||
# and a new client cannot talk to an old agent.
|
||||
command -v update-desktop-database >/dev/null 2>&1 && \
|
||||
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl try-restart tsunagi.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
pre_remove() {
|
||||
systemctl disable --now tsunagi.service >/dev/null 2>&1 || true
|
||||
|
||||
Vendored
+3
-1
@@ -45,6 +45,8 @@ if [ -n "$tray" ]; then
|
||||
# The GUI package is a superset of the headless one and must not coexist
|
||||
# with it (both ship /usr/bin/tsunagi).
|
||||
extra_control=$'Conflicts: tsunagi\nReplaces: tsunagi\nProvides: tsunagi\n'
|
||||
# What the tray and its window load at run time.
|
||||
extra_control+=$'Depends: libgtk-3-0 | libgtk-3-0t64, libayatana-appindicator3-1 | libappindicator3-1, libxdo3, libxkbcommon0, libwayland-client0, libx11-6, libgl1\n'
|
||||
desc_gui=" It also installs the tray GUI (tsunagi-tray) and a desktop launcher so it can be started from the applications menu."
|
||||
install -D -m 0755 "$tray" "$root/usr/bin/tsunagi-tray"
|
||||
install -D -m 0644 "$here/../linux/tsunagi-tray.desktop" \
|
||||
@@ -62,7 +64,7 @@ Architecture: ${arch}
|
||||
Maintainer: AB <ab@hexor.cy>
|
||||
Section: net
|
||||
Priority: optional
|
||||
Homepage: https://github.com/Ultradesu/tsunagi
|
||||
Homepage: https://github.com/house-of-vanity/tsunagi
|
||||
${extra_control}Description: Serverless private mesh networking agent
|
||||
tsunagi forms small private mesh IP networks between peers, with NAT
|
||||
traversal and no central server. This package installs a static,
|
||||
|
||||
Vendored
+3
-1
@@ -22,7 +22,9 @@ and `CAP_NET_BIND_SERVICE` (granted by systemd), not root. systemd creates its
|
||||
state (`/var/lib/tsunagi`), cache (`/var/cache/tsunagi`) and runtime
|
||||
(`/run/tsunagi`) directories, and the polkit rule lets it configure the
|
||||
resolver. Control it with `sudo` — the CLI finds the running service's socket at
|
||||
`/run/tsunagi/agent.sock` with no flags:
|
||||
`/run/tsunagi/agent.sock` with no flags. The socket is open to the `tsunagi`
|
||||
group, so after `sudo usermod -aG tsunagi "$USER"` (and logging in again) no
|
||||
`sudo` is needed:
|
||||
|
||||
```sh
|
||||
sudo tsunagi status
|
||||
|
||||
Vendored
+4
-2
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=tsunagi mesh agent
|
||||
Documentation=https://github.com/Ultradesu/tsunagi
|
||||
Documentation=https://github.com/house-of-vanity/tsunagi
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
@@ -16,7 +16,8 @@ CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
|
||||
# systemd creates and owns these for the service user, and the agent is pointed
|
||||
# at them: state in /var/lib/tsunagi, cache in /var/cache/tsunagi, and the
|
||||
# control socket in /run/tsunagi, which stays private to the service user.
|
||||
# control socket in /run/tsunagi. The socket is opened to the `tsunagi` group,
|
||||
# so a user who joins it can run the CLI and the tray without sudo.
|
||||
StateDirectory=tsunagi
|
||||
StateDirectoryMode=0700
|
||||
CacheDirectory=tsunagi
|
||||
@@ -25,6 +26,7 @@ RuntimeDirectoryMode=0755
|
||||
Environment=TSUNAGI_STATE_DIR=%S/tsunagi
|
||||
Environment=TSUNAGI_CACHE_DIR=%C/tsunagi
|
||||
Environment=TSUNAGI_CONTROL_SOCKET=%t/tsunagi/agent.sock
|
||||
Environment=TSUNAGI_CONTROL_GROUP=tsunagi
|
||||
|
||||
# The tarball installer puts the binary here; the deb and the Arch package
|
||||
# rewrite this to /usr/bin where they install it.
|
||||
|
||||
Reference in new issue
Block a user