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
Aleksandr BogomiakovandClaude f4dcdec662 Tray: separate tray and window processes; device list without self
Release / tsunagi-linux-aarch64 (push) Successful in 2m44s
Release / tsunagi-linux-x86_64 (push) Successful in 4m37s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The tray process holds only the icon and starts the same binary with
--window on Open; the window ends when closed, which works on Wayland where
a window cannot be hidden. The devices table now names devices as
hostname (id5), leaves out this device and counts the others.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 19:23:06 +01:00
Aleksandr BogomiakovandClaude 69310108f4 Remember the last hostname each peer announced
Persist announced hostnames per network (state schema 5) and report them for
members, so a device that is away is still named in `status` and the tray.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 19:23:06 +01:00
Aleksandr BogomiakovandClaude e7dff34ec2 Status: ask the agent for its state directory and its own CAP_NET_ADMIN
The CLI reached a packaged service's socket but read state from its own
default directory, so `network` saw nothing next to a running network, and
`status` reported the CLI binary's capabilities instead of the agent's.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 18:31:01 +01:00
Aleksandr BogomiakovandClaude a55ea883a1 Tray: initialise GTK and run its main loop on Linux
Release / tsunagi-linux-aarch64 (push) Successful in 3m14s
Release / tsunagi-linux-x86_64 (push) Successful in 5m21s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
tray-icon needs gtk::init() and a running GTK loop on Linux, which
eframe/winit never provide, so the tray panicked at startup with "GTK has
not been initialized". Build the tray on a dedicated GTK thread instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 17:54:38 +01:00
UltradesuandClaude Opus 4.8 3e066f991f CI: install libxdo-dev for the Linux tray link
Release / tsunagi-linux-aarch64 (push) Successful in 3m23s
Release / tsunagi-linux-x86_64 (push) Successful in 6m58s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The x86_64 Linux tray failed at link with "unable to find library -lxdo":
tray-icon pulls libxdo, whose .so was not present. Add libxdo-dev to the GUI
build's apt install, alongside libgtk-3-dev and libayatana-appindicator3-dev.
Everything else (aarch64 agent, macOS, Windows) already built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:51:53 +01:00
UltradesuandClaude Opus 4.8 3752419075 CI: build the Linux tray natively with GTK; drop the aarch64 GUI
Release / tsunagi-linux-aarch64 (push) Successful in 4m14s
Release / tsunagi-linux-x86_64 (push) Failing after 4m29s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The aarch64 GUI cross-build failed: tray-icon links GTK and libappindicator
through pkg-config, which zig cross-compilation cannot satisfy without a target
sysroot. Rather than set one up, the Linux tray is now built natively on the
x86_64 runner with libgtk-3-dev and libayatana-appindicator3-dev installed, so
pkg-config just works. The agent is still static musl for both arches.

The aarch64 Linux GUI is dropped for now: that archive stays agent-only and no
aarch64 tsunagi-gui package is produced. x86_64 gets the tray in its archive
and the tsunagi-gui .deb/.pkg.tar.zst. macOS and Windows build the tray
natively as before. Guarded on matrix.gnu_target (set only on x86_64 Linux).

Note: the native build links the runner's glibc, so the Linux GUI binary needs a
reasonably recent glibc; fine for current desktops. A lower baseline would need
a GTK sysroot or an older runner image.

YAML validated; the Linux GUI build is exercised only by a real CI run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:38:58 +01:00
22 changed files with 1005 additions and 226 deletions

No files matched your search

+17 -8
View File
@@ -45,7 +45,6 @@ jobs:
deb: true
deb_arch: arm64
pkg_arch: aarch64
gnu_target: aarch64-unknown-linux-gnu
- os: macos-latest
target: aarch64-apple-darwin
asset: tsunagi-macos-aarch64
@@ -111,11 +110,19 @@ jobs:
shell: bash
run: |
set -euo pipefail
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
if [[ "${{ runner.os }}" == "Linux" ]]; then
rustup target add "${{ matrix.gnu_target }}"
cargo zigbuild --release --locked \
--target "${{ matrix.gnu_target }}.2.28" --bin tsunagi-tray
if [[ -n "${{ matrix.gnu_target }}" ]]; then
# tray-icon links GTK and libappindicator through pkg-config, which
# has no cross story under zig, so the Linux GUI is built natively
# (x86_64 only) against the runner's glibc.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
cargo build --release --locked \
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
else
echo "no GUI build for ${{ matrix.target }}"
fi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
fi
@@ -133,11 +140,13 @@ jobs:
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The tray GUI alongside it: glibc build on Linux, native elsewhere.
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
tray="tsunagi-tray"
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
if [[ "${{ runner.os }}" == "Linux" ]]; then
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
if [[ -n "${{ matrix.gnu_target }}" ]]; then
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
fi
else
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
fi
@@ -201,7 +210,7 @@ jobs:
# The GUI variants: the same agent plus the tray binary and a desktop
# launcher, named tsunagi-gui (conflicting with the headless packages).
- name: Build the GUI packages
if: matrix.deb
if: matrix.gnu_target
shell: bash
run: |
set -euo pipefail
+17 -8
View File
@@ -47,7 +47,6 @@ jobs:
deb: true
deb_arch: arm64
pkg_arch: aarch64
gnu_target: aarch64-unknown-linux-gnu
- os: macos-latest
target: aarch64-apple-darwin
asset: tsunagi-macos-aarch64
@@ -113,11 +112,19 @@ jobs:
shell: bash
run: |
set -euo pipefail
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
if [[ "${{ runner.os }}" == "Linux" ]]; then
rustup target add "${{ matrix.gnu_target }}"
cargo zigbuild --release --locked \
--target "${{ matrix.gnu_target }}.2.28" --bin tsunagi-tray
if [[ -n "${{ matrix.gnu_target }}" ]]; then
# tray-icon links GTK and libappindicator through pkg-config, which
# has no cross story under zig, so the Linux GUI is built natively
# (x86_64 only) against the runner's glibc.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
cargo build --release --locked \
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
else
echo "no GUI build for ${{ matrix.target }}"
fi
else
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
fi
@@ -135,11 +142,13 @@ jobs:
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
# The tray GUI alongside it: glibc build on Linux, native elsewhere.
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
tray="tsunagi-tray"
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
if [[ "${{ runner.os }}" == "Linux" ]]; then
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
if [[ -n "${{ matrix.gnu_target }}" ]]; then
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
fi
else
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
fi
@@ -203,7 +212,7 @@ jobs:
# The GUI variants: the same agent plus the tray binary and a desktop
# launcher, named tsunagi-gui (conflicting with the headless packages).
- name: Build the GUI packages
if: matrix.deb
if: matrix.gnu_target
shell: bash
run: |
set -euo pipefail
Generated
+1
View File
@@ -6329,6 +6329,7 @@ version = "0.1.0"
dependencies = [
"eframe",
"egui-phosphor",
"gtk",
"tokio",
"tracing",
"tracing-subscriber",
+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.
+115 -17
View File
@@ -838,6 +838,33 @@ enum Observed {
},
}
/// The directories the running agent uses, unless the user named some.
///
/// The control socket of a packaged service is found without flags, but the
/// state it keeps is not in this user's default directory — reading that
/// would report a device that has joined nothing next to an agent that has
/// joined a network. The agent knows where its state is, so it is asked.
async fn agent_paths(
paths: StoragePaths,
socket: &std::path::Path,
is_custom_state: bool,
) -> StoragePaths {
if is_custom_state || !tsunagi::ipc::is_serving(socket).await {
return paths;
}
match tsunagi::ipc::request_status(socket).await {
Ok(report) if !report.state_dir.is_empty() => {
let cache = if report.cache_dir.is_empty() {
paths.cache_dir
} else {
PathBuf::from(report.cache_dir)
};
StoragePaths::new(report.state_dir, cache)
}
_ => paths,
}
}
/// Asks the agent, and falls back to the state store.
async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
let socket_present = tsunagi::ipc::is_serving(socket).await;
@@ -1374,6 +1401,7 @@ impl tsunagi::ipc::ReportSource for AgentControl {
self.plugin.as_deref(),
self.tcp_tls.as_deref(),
dns,
&self.paths,
)
.await
})
@@ -1667,6 +1695,7 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
match args.action {
None => show_networks(&paths, &socket).await,
Some(NetworkAction::Broadcast { network, choice }) => {
@@ -1702,6 +1731,7 @@ async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>>
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
let name = NetworkName::new(args.network)?;
// A name this device already has means that network; a name nobody
// has means a new one, and no secret is needed to make a network with
@@ -1861,16 +1891,15 @@ async fn show_networks(
use report::{Health, Report, Row, Section};
let stored = stored_networks(paths);
if stored.is_empty() {
eprintln!("no network has been joined");
return Ok(());
}
let observed = observe(paths, socket).await;
let live = match &observed {
Observed::Agent(report) => report.networks.clone(),
Observed::Stored { .. } => Vec::new(),
};
if stored.is_empty() && live.is_empty() {
eprintln!("no network has been joined");
return Ok(());
}
let mut out = Report::new();
let mut section = Section::new("networks");
@@ -1921,6 +1950,20 @@ async fn show_networks(
.with_note(note),
);
}
// What the agent runs but this process could not read from the store,
// for instance when the state is not readable by this user.
for network in live.iter().filter(|live| {
!stored
.iter()
.any(|n| n.network_id.to_string() == live.network_id)
}) {
let state = if network.active { "running" } else { "stopped" };
section.push(Row::new(
Health::Info,
network.name.clone(),
format!("{} · {state}", network.network_id),
));
}
out.push(section);
print_report("tsunagi networks", &out)
}
@@ -2163,6 +2206,7 @@ async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
let enable = match args.action {
None => return show_dns(&paths, &socket).await,
@@ -2256,6 +2300,7 @@ async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
match args.action {
None => show_identity(&paths, &socket).await,
@@ -2448,6 +2493,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
let observed = observe(&paths, &socket).await;
let mut out = Report::new();
@@ -2519,7 +2565,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
});
}
out.push(host_section());
out.push(host_section(&observed));
out.push(addresses_section().await);
print_report("tsunagi status", &out)
}
@@ -2631,10 +2677,8 @@ impl MemberRow<'_> {
self.transport.is_some()
}
/// What to call it: the name it announced, or a short form of its id.
///
/// A member that is away has no hostname, because nothing durable records
/// one — only the signed claim survives, and that carries an address.
/// What to call it: the name it announced — the last one, when it is
/// away — or a short form of its id.
fn label(&self) -> String {
match self.hostname {
Some(hostname) => hostname.to_string(),
@@ -2667,10 +2711,11 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve
let row = entry(&mut rows, &member.endpoint_id);
row.overlay_address_v4 = member.overlay_address_v4.as_deref();
row.failed_dials = member.failed_dials;
row.hostname = member.hostname.as_deref();
}
for peer in &network.peers {
let row = entry(&mut rows, &peer.endpoint_id);
row.hostname = peer.hostname.as_deref();
row.hostname = peer.hostname.as_deref().or(row.hostname);
row.transport = Some(&peer.transport);
row.rtt_ms = peer.rtt_ms;
}
@@ -3001,7 +3046,11 @@ fn short(text: &str, len: usize) -> String {
}
/// What this host can and cannot do for the data plane.
fn host_section() -> report::Section {
///
/// The interface is created by the agent, so when one answered, its own
/// report of whether it may do that is the answer; what this process holds
/// says nothing about it.
fn host_section(observed: &Observed) -> report::Section {
use report::{Health, Row, Section};
let mut host = Section::new("host");
@@ -3041,8 +3090,20 @@ fn host_section() -> report::Section {
});
}
use tsunagi::ipc::PrivilegeReport;
use tsunagi::overlay::{Privilege, probe_net_admin};
match probe_net_admin() {
let (privilege, program) = match observed {
Observed::Agent(report) => match &report.privilege {
PrivilegeReport::Unknown => (probe_net_admin(), program_path()),
PrivilegeReport::Available => (Privilege::Available, report.program.clone()),
PrivilegeReport::Missing(reason) => {
(Privilege::Missing(reason.clone()), report.program.clone())
}
PrivilegeReport::Unsupported => (Privilege::Unsupported, report.program.clone()),
},
Observed::Stored { .. } => (probe_net_admin(), program_path()),
};
match privilege {
Privilege::Available => {
let held = if cfg!(target_os = "windows") {
"assumes an elevated process; creation reports if not"
@@ -3062,10 +3123,7 @@ fn host_section() -> report::Section {
// the eye is meant to scan.
host.push(
Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held").with_note(
format!(
"sudo setcap cap_net_admin,cap_net_bind_service+p {}",
program_path()
),
format!("sudo setcap cap_net_admin,cap_net_bind_service+p {program}"),
),
);
host.push(Row::new(
@@ -3990,6 +4048,7 @@ async fn build_report(
wireguard: Option<&WireguardPlugin>,
tcp_tls: Option<&TcpTlsPlugin>,
dns: Option<DnsState>,
paths: &StoragePaths,
) -> tsunagi::ipc::StatusReport {
use tsunagi::ipc::{
MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport,
@@ -4250,6 +4309,10 @@ async fn build_report(
.iter()
.find(|candidate| candidate.endpoint_id == member.endpoint_id)
.map_or(0, |candidate| candidate.consecutive_failures),
hostname: member
.last_hostname
.clone()
.or_else(|| member.hostname.clone()),
})
.collect(),
range: network.range.map(|range| range.to_string()),
@@ -4276,6 +4339,21 @@ async fn build_report(
cache_healthy: status.cache_healthy,
networks,
dns,
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) => {
tsunagi::ipc::PrivilegeReport::Missing(reason)
}
tsunagi::overlay::Privilege::Unsupported => tsunagi::ipc::PrivilegeReport::Unsupported,
},
}
}
@@ -4577,11 +4655,13 @@ mod status_tests {
endpoint_id: OWN.into(),
overlay_address_v4: Some("10.13.37.69".into()),
failed_dials: 0,
hostname: None,
},
MemberReport {
endpoint_id: ONLINE.into(),
overlay_address_v4: Some("10.13.37.237".into()),
failed_dials: 0,
hostname: None,
},
],
range: Some("10.13.37.0/24".into()),
@@ -4822,6 +4902,7 @@ mod status_tests {
endpoint_id: AWAY.into(),
overlay_address_v4: Some("10.13.37.99".into()),
failed_dials: 9,
hostname: None,
});
let rows = member_rows(&network, OWN);
@@ -4831,6 +4912,22 @@ mod status_tests {
assert_eq!(rows[1].endpoint_id, AWAY);
}
#[test]
fn an_offline_member_keeps_the_last_name_it_announced() {
let mut network = network_after_a_peer_returned();
network.members.push(MemberReport {
endpoint_id: AWAY.into(),
overlay_address_v4: Some("10.13.37.99".into()),
failed_dials: 0,
hostname: Some("laptop".into()),
});
let rows = member_rows(&network, OWN);
let away = rows.iter().find(|row| row.endpoint_id == AWAY).unwrap();
assert!(!away.online());
assert_eq!(away.label(), "laptop");
}
#[test]
fn a_member_that_is_away_is_stated_rather_than_flagged() {
// In a mesh of laptops a member being away is the ordinary
@@ -4842,6 +4939,7 @@ mod status_tests {
endpoint_id: AWAY.into(),
overlay_address_v4: Some("10.13.37.99".into()),
failed_dials: 9,
hostname: None,
});
let mut out = report::Report::new();
+9 -3
View File
@@ -26,9 +26,15 @@ tracing.workspace = true
tracing-subscriber.workspace = true
egui-phosphor = "0.9"
# macOS only: set the app's activation policy to Accessory so it lives in the
# menu bar with no Dock icon. Pinned to eframe's winit so the types unify.
[target.'cfg(target_os = "macos")'.dependencies]
# Linux only: tray-icon (already GTK-based there) needs the app to initialise
# GTK and run its main loop.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
# Windows and macOS: the tray process has no GUI toolkit, only an event loop to
# serve the icon (and, on macOS, the Accessory activation policy that keeps it
# out of the Dock). Pinned to eframe's winit so the types unify.
[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
winit = "0.30"
[lints]
+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| {
+35 -32
View File
@@ -1,31 +1,32 @@
//! The eframe application: wires the tray, the main window, the per-network
//! The eframe application behind the window: the main view, the per-network
//! devices windows, and the agent worker.
//!
//! It runs in its own process, started by the tray when the user chooses Open
//! and gone when the window is closed. The tray, which has to outlive the
//! window, lives in the other process; see [`crate::tray`].
use std::time::Duration;
use eframe::egui;
use crate::about;
use crate::agent::{AgentClient, resolve_socket};
use crate::devices;
use crate::stats::Traffic;
use crate::tray::{Action, Tray};
use crate::ui::{self, UiState};
/// The tray application state.
/// The window's state.
pub(crate) struct App {
agent: AgentClient,
tray: Tray,
state: UiState,
/// Traffic rates derived from successive snapshots.
traffic: Traffic,
/// The snapshot generation last folded into `traffic`.
sampled: u64,
/// Set when the user chose Quit, so the next close exits rather than hides.
quitting: bool,
}
impl App {
/// Builds the app: resolves the socket, starts the worker, creates the tray.
/// Builds the app: resolves the socket and starts the worker.
pub(crate) fn new(
cc: &eframe::CreationContext<'_>,
runtime: tokio::runtime::Handle,
@@ -37,40 +38,18 @@ impl App {
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
ctx.set_fonts(fonts);
let agent = AgentClient::spawn(&runtime, ctx.clone(), resolve_socket());
let tray = Tray::new(ctx)?;
let agent = AgentClient::spawn(&runtime, ctx, resolve_socket());
Ok(Self {
agent,
tray,
state: UiState::default(),
traffic: Traffic::default(),
sampled: 0,
quitting: false,
})
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
for action in self.tray.poll() {
match action {
Action::Open => {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
Action::Quit => {
self.quitting = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
// Closing the main window hides it to the tray; only Quit truly exits.
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
}
let snapshot = self.agent.snapshot();
if snapshot.generation != self.sampled
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
@@ -81,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.
@@ -88,12 +68,35 @@ impl eframe::App for App {
ctx.copy_text(secret);
}
// Keep ticking so fresh status and tray actions are picked up.
// Keep ticking so fresh status is picked up.
ctx.request_repaint_after(Duration::from_millis(500));
}
}
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 {
@@ -117,7 +120,7 @@ impl App {
// main window's stay in step.
let unit = &mut self.state.unit;
let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| {
devices::show(vctx, agent, unit, network, traffic);
devices::show(vctx, agent, unit, network, &report.endpoint_id, traffic);
!vctx.input(|i| i.viewport().close_requested())
});
if !keep {
+212 -49
View File
@@ -23,22 +23,51 @@ pub(crate) fn title(network: &NetworkReport) -> String {
/// One row of the device table, built from a tunnel and/or a signed member.
struct Device<'a> {
online: bool,
name: String,
/// What clicking the name copies: `hostname.network` so it resolves, or
/// the short id when there is no hostname.
copy: String,
/// The last hostname seen for it, kept while it is offline.
hostname: Option<&'a str>,
/// What clicking the hostname copies: `hostname.network`, so it resolves.
copy: Option<String>,
/// The full endpoint id, which clicking the short form copies.
id: &'a str,
proto: &'a str,
address: Option<String>,
handshake_secs: Option<u64>,
overlay: Option<&'a OverlayPeerReport>,
}
impl Device<'_> {
fn sort_key(&self) -> String {
self.hostname.unwrap_or(self.id).to_lowercase()
}
}
/// The first characters of an endpoint id, enough to tell devices apart.
fn short_id(id: &str) -> String {
id.chars().take(5).collect()
}
/// The device cell: the hostname and, in brackets, the start of the id.
/// Clicking either copies its own full value.
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
match (device.hostname, &device.copy) {
(Some(hostname), Some(copy)) => {
format::copy_field(ui, hostname, copy);
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
}
_ => format::copy_field(ui, &short_id(device.id), device.id),
}
});
}
/// Draws the window body into its viewport.
pub(crate) fn show(
ctx: &egui::Context,
_agent: &AgentClient,
unit: &mut Unit,
network: &NetworkReport,
own_id: &str,
traffic: &Traffic,
) {
egui::CentralPanel::default().show(ctx, |ui| {
@@ -60,7 +89,7 @@ pub(crate) fn show(
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
ui.add_space(8.0);
let devices = devices(network);
let devices = devices(network, own_id);
let online = devices.iter().filter(|d| d.online).count();
ui.label(
egui::RichText::new(format!(
@@ -123,31 +152,56 @@ fn summary(ui: &mut egui::Ui, network: &NetworkReport) {
});
}
/// Every other device this network knows of, from a live tunnel or the signed
/// membership. This device is in the roster too but is not one of the others.
fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> {
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let mut ids: Vec<&str> = Vec::new();
let candidates = overlay_peers
.iter()
.map(|p| p.endpoint_id.as_str())
.chain(network.peers.iter().map(|p| p.endpoint_id.as_str()))
.chain(network.members.iter().map(|m| m.endpoint_id.as_str()));
for id in candidates {
if id != own_id && !ids.contains(&id) {
ids.push(id);
}
}
ids
}
/// How many other devices this network knows of, connected or not.
pub(crate) fn known_count(network: &NetworkReport, own_id: &str) -> usize {
known_ids(network, own_id).len()
}
/// Builds the device list: every endpoint known from a live tunnel or the
/// signed membership, online ones first.
fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
let hostnames: HashMap<&str, &str> = network
.peers
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
// The signed members carry the last name seen, so a device that is away
// is still called by it; a live announcement is fresher and wins.
let mut hostnames: HashMap<&str, &str> = network
.members
.iter()
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h)))
.filter_map(|m| m.hostname.as_deref().map(|h| (m.endpoint_id.as_str(), h)))
.collect();
hostnames.extend(
network
.peers
.iter()
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))),
);
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let mut ids: Vec<&str> = Vec::new();
for peer in overlay_peers {
if !ids.contains(&peer.endpoint_id.as_str()) {
ids.push(&peer.endpoint_id);
}
}
for member in &network.members {
if !ids.contains(&member.endpoint_id.as_str()) {
ids.push(&member.endpoint_id);
}
}
let ids = known_ids(network, own_id);
let mut devices: Vec<Device> = ids
.into_iter()
@@ -156,15 +210,11 @@ fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
let member = network.members.iter().find(|m| m.endpoint_id == id);
let online = overlay.is_some_and(|p| p.handshake_secs_ago.is_some());
let hostname = hostnames.get(id).copied();
let name = hostname.map_or_else(
|| format::short(overlay.map_or(id, |p| p.public_key.as_str())),
str::to_string,
);
let copy = hostname.map_or_else(|| name.clone(), |h| format!("{h}.{}", network.name));
Device {
online,
name,
copy,
hostname,
copy: hostname.map(|h| format!("{h}.{}", network.name)),
id,
proto: overlay.map_or("—", |p| p.protocol.as_str()),
address: overlay
.and_then(|p| p.address.clone())
@@ -178,11 +228,59 @@ fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
devices.sort_by(|a, b| {
b.online
.cmp(&a.online)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.sort_key().cmp(&b.sort_key()))
});
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(
@@ -196,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",
@@ -210,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 {
format::copy_field(ui, &device.name, &device.copy);
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 {
+27 -22
View File
@@ -1,14 +1,15 @@
//! Tray application for managing a running tsunagi agent.
//!
//! A small window (networks, their peers and addresses, join/leave, per-network
//! active and broadcast switches) plus a tray menu (Open, Quit). It is a thin
//! client over the agent's local control socket — the same interface the CLI
//! uses — and owns its own runtime, logger and event loop, like the CLI binary.
//! A tray icon and menu (Open, Quit) that opens a small window (networks, their
//! peers and addresses, join/leave, per-network active and broadcast switches).
//! There is no window until Open, and closing it leaves the icon. The window
//! is a thin client over the agent's local control socket — the same interface
//! the CLI uses.
// The window is created hidden-to-tray on close rather than exiting; the binary
// itself may exit on Quit. Nothing here is a library.
// Nothing here is a library.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod about;
mod agent;
mod app;
mod devices;
@@ -21,38 +22,42 @@ use eframe::egui;
use crate::app::App;
/// The argument that makes this binary the window rather than the tray.
const WINDOW_ARG: &str = "--window";
fn main() -> Result<(), Box<dyn std::error::Error>> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
// Two processes from one binary. The tray is what stays; the window is
// started by it and ends when it is closed. A window that merely hides is
// not something every platform can do — Wayland has no such thing — and a
// hidden one that stays is worse than none.
if std::env::args().any(|arg| arg == WINDOW_ARG) {
window()
} else {
tray::run()
}
}
/// Runs the window until it is closed.
fn window() -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let handle = runtime.handle().clone();
#[cfg_attr(not(target_os = "macos"), expect(unused_mut))]
let mut options = eframe::NativeOptions {
let options = eframe::NativeOptions {
// A fixed, small window; the whole layout targets this size.
viewport: egui::ViewportBuilder::default()
.with_title("tsunagi")
.with_app_id("tsunagi")
.with_inner_size([360.0, 600.0])
.with_resizable(false)
// Start hidden: the app lives in the tray and the window opens from
// the menu.
.with_visible(false),
.with_resizable(false),
..Default::default()
};
// macOS: live in the menu bar with no Dock icon (tray-only).
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|builder| {
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
builder.with_activation_policy(ActivationPolicy::Accessory);
}));
}
eframe::run_native(
"tsunagi",
options,
@@ -63,7 +68,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}),
)?;
// The window loop has ended (Quit); let the runtime wind down.
// The window loop has ended; let the runtime wind down.
drop(runtime);
Ok(())
}
+156 -68
View File
@@ -1,85 +1,173 @@
//! The tray icon and its menu.
//! The tray: the icon, its menu, and starting the window.
//!
//! The menu is just Open and Quit; everything else (per-network switches,
//! join/leave) lives in the window. Menu clicks are read on a background
//! thread and turned into [`Action`]s, so they are handled even while the
//! window is hidden and egui would otherwise not be repainting.
//! This is the process that stays. It holds no window and no GUI toolkit of
//! its own; choosing Open starts this same binary again as the window, which
//! is a perfectly ordinary program that ends when it is closed.
use std::sync::mpsc;
use std::process::{Child, Command};
use std::sync::{Arc, Mutex};
use eframe::egui;
use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
/// What the user picked from the tray menu.
pub(crate) enum Action {
/// Show (and focus) the window.
Open,
/// Quit the application.
Quit,
/// The window process, when there is one.
struct Window {
child: Mutex<Option<Child>>,
}
/// The live tray icon plus the channel of menu actions.
pub(crate) struct Tray {
/// Kept alive for as long as the app runs; dropping it removes the icon.
_icon: TrayIcon,
actions: mpsc::Receiver<Action>,
}
impl Tray {
/// Builds the tray and starts reading its menu events.
impl Window {
/// Starts the window unless one is already open.
///
/// Created on the main thread (required on macOS); `ctx` is used to wake the
/// UI when a menu item is chosen.
pub(crate) fn new(ctx: egui::Context) -> Result<Self, String> {
let open = MenuItem::new("Open tsunagi", true, None);
let quit = MenuItem::new("Quit", true, None);
let menu = Menu::new();
menu.append(&open).map_err(|err| err.to_string())?;
menu.append(&PredefinedMenuItem::separator())
.map_err(|err| err.to_string())?;
menu.append(&quit).map_err(|err| err.to_string())?;
let icon = make_icon()?;
let tray = TrayIconBuilder::new()
.with_tooltip("tsunagi")
.with_menu(Box::new(menu))
.with_icon(icon)
.build()
.map_err(|err| err.to_string())?;
let open_id = open.id().clone();
let quit_id = quit.id().clone();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
// The global menu-event channel is fed by the OS event loop.
while let Ok(event) = MenuEvent::receiver().recv() {
let action = if event.id == open_id {
Action::Open
} else if event.id == quit_id {
Action::Quit
} else {
continue;
};
if tx.send(action).is_err() {
break;
}
ctx.request_repaint();
/// Raising an existing one is not something a process can ask for on
/// every platform, so choosing Open while it is open does nothing.
fn open(&self) {
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
if let Some(child) = slot.as_mut() {
match child.try_wait() {
Ok(None) => return,
_ => *slot = None,
}
});
Ok(Self {
_icon: tray,
actions: rx,
})
}
let started = std::env::current_exe()
.map_err(|err| err.to_string())
.and_then(|exe| {
Command::new(exe)
.arg(crate::WINDOW_ARG)
.spawn()
.map_err(|err| err.to_string())
});
match started {
Ok(child) => *slot = Some(child),
Err(err) => eprintln!("cannot open the window: {err}"),
}
}
/// The menu actions since the last call.
pub(crate) fn poll(&self) -> impl Iterator<Item = Action> + '_ {
self.actions.try_iter()
/// Closes the window if it is open.
fn close(&self) {
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
if let Some(mut child) = slot.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// A built tray icon and the ids of its menu items.
struct Built {
icon: TrayIcon,
open: MenuId,
quit: MenuId,
}
fn build() -> Result<Built, String> {
let open = MenuItem::new("Open tsunagi", true, None);
let quit = MenuItem::new("Quit", true, None);
let menu = Menu::new();
menu.append(&open).map_err(|err| err.to_string())?;
menu.append(&PredefinedMenuItem::separator())
.map_err(|err| err.to_string())?;
menu.append(&quit).map_err(|err| err.to_string())?;
let icon = TrayIconBuilder::new()
.with_tooltip("tsunagi")
.with_menu(Box::new(menu))
.with_icon(make_icon()?)
.build()
.map_err(|err| err.to_string())?;
Ok(Built {
icon,
open: open.id().clone(),
quit: quit.id().clone(),
})
}
/// 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());
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
if event.id == open {
window.open();
} else if event.id == quit {
window.close();
std::process::exit(0);
}
}));
}
/// Shows the icon and runs until Quit.
///
/// Linux: the tray needs GTK, which nothing else here initialises, so this
/// thread does and runs its main loop.
#[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()?;
handle_menu(
&built,
Arc::new(Window {
child: Mutex::new(None),
}),
);
gtk::main();
drop(built.icon);
Ok(())
}
/// Shows the icon and runs until Quit.
///
/// Windows and macOS: the icon needs an event loop on the main thread to be
/// served, and macOS wants it created once that loop is running. There are no
/// windows in it; it exists to be pumped.
#[cfg(not(target_os = "linux"))]
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::window::WindowId;
struct Pump {
window: Arc<Window>,
icon: Option<TrayIcon>,
}
impl ApplicationHandler for Pump {
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
if self.icon.is_some() {
return;
}
match build() {
Ok(built) => {
handle_menu(&built, Arc::clone(&self.window));
self.icon = Some(built.icon);
}
Err(err) => {
eprintln!("cannot create the tray icon: {err}");
std::process::exit(1);
}
}
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
let mut builder = EventLoop::builder();
// macOS: live in the menu bar with no Dock icon.
#[cfg(target_os = "macos")]
{
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
builder.with_activation_policy(ActivationPolicy::Accessory);
}
let event_loop = builder.build()?;
let mut pump = Pump {
window: Arc::new(Window {
child: Mutex::new(None),
}),
icon: None,
};
event_loop.run_app(&mut pump)?;
Ok(())
}
/// A small round icon, drawn in code so there is no asset to ship yet.
fn make_icon() -> Result<Icon, String> {
const SIZE: u32 = 32;
+14 -8
View File
@@ -15,6 +15,7 @@ use egui_phosphor::regular as icon;
use tsunagi::ipc::{NetworkReport, StatusReport};
use crate::agent::{AgentClient, Command, Snapshot};
use crate::devices;
use crate::format;
use crate::stats::{Traffic, Unit};
@@ -39,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 {
@@ -48,6 +51,7 @@ impl Default for UiState {
unit: Unit::Packets,
host_edit: None,
open_devices: BTreeSet::new(),
about_open: false,
}
}
}
@@ -64,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());
@@ -114,7 +119,7 @@ pub(crate) fn draw(
ui.weak("No networks yet — join one below.");
}
for network in &report.networks {
draw_network(ui, agent, state, traffic, network);
draw_network(ui, agent, state, traffic, network, &report.endpoint_id);
}
});
}
@@ -191,6 +196,7 @@ fn draw_network(
state: &mut UiState,
traffic: &Traffic,
network: &NetworkReport,
own_id: &str,
) {
ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
@@ -233,7 +239,7 @@ fn draw_network(
ui.label(format!(
"{} connected · {} known",
network.peers.len(),
network.members.len()
devices::known_count(network, own_id)
));
ui.end_row();
});
@@ -83,6 +83,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
cache_healthy: status.cache_healthy,
networks,
dns: None,
..Default::default()
}
})
})
+48 -1
View File
@@ -331,6 +331,9 @@ struct Runtime {
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
/// The highest version this agent has ever published for this network.
own_version: u64,
/// The last hostname each peer announced, kept across restarts so a
/// member that is away can still be called by name.
known_hostnames: HashMap<EndpointId, String>,
}
impl Runtime {
@@ -382,6 +385,7 @@ impl Runtime {
state: StateSet::new(),
pending_state: Vec::new(),
own_version: 0,
known_hostnames: HashMap::new(),
}
}
@@ -844,6 +848,17 @@ impl Runtime {
.own_record_version(self.network_id, self.local_id)
.await
.unwrap_or(0);
for (id, hostname) in self
.params
.storage
.peer_hostnames(self.network_id)
.await
.unwrap_or_default()
{
if let Ok(id) = EndpointId::from_bytes(&id) {
self.known_hostnames.insert(id, hostname);
}
}
self.ensure_own_claim().await;
self.publish_allocations().await;
@@ -1885,6 +1900,33 @@ impl Runtime {
}
}
/// Keeps the name a peer announced, on disk too when it changed.
///
/// Written off the loop: a name that is not stored this time is stored
/// the next time the peer announces it.
fn remember_hostname(&mut self, peer: EndpointId, hostname: &str) {
if hostname.is_empty()
|| self
.known_hostnames
.get(&peer)
.is_some_and(|h| h == hostname)
{
return;
}
self.known_hostnames.insert(peer, hostname.to_string());
let storage = self.params.storage.clone();
let network_id = self.network_id;
let hostname = hostname.to_string();
tokio::spawn(async move {
if let Err(err) = storage
.remember_peer_hostname(network_id, *peer.as_bytes(), hostname)
.await
{
tracing::debug!(%err, "could not remember a peer's hostname");
}
});
}
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
match &message {
ControlMessage::Announce(announcement) => {
@@ -1894,6 +1936,7 @@ impl Runtime {
session.broadcast = announcement.broadcast;
session.capabilities = capabilities.clone();
}
self.remember_hostname(peer, &announcement.hostname);
self.update_broadcast();
self.dispatch_capabilities(peer, &capabilities);
self.ensure_links();
@@ -1994,7 +2037,10 @@ impl Runtime {
broadcast: session.broadcast,
endpoint_id: session.peer,
role: session.role,
hostname: session.hostname.clone(),
hostname: session
.hostname
.clone()
.or_else(|| self.known_hostnames.get(&session.peer).cloned()),
protocols: session
.capabilities
.iter()
@@ -2052,6 +2098,7 @@ impl Runtime {
// both hold it would be untrue on one of them.
overlay_address_v4: self.state.address_of(&endpoint_id),
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
last_hostname: self.known_hostnames.get(&endpoint_id).cloned(),
})
})
.collect();
+5
View File
@@ -80,6 +80,11 @@ pub struct MemberStatus {
/// Signed, so it is still known while the member is away — which is what
/// lets an absent member be named rather than shown as a bare id.
pub hostname: Option<String>,
/// The last name this agent saw it announce, whether or not it is here.
///
/// Not signed, so it is a convenience for naming a member that is away
/// and never a claim: the zone is served from `hostname`.
pub last_hostname: Option<String>,
}
/// Status of one authenticated session.
+34
View File
@@ -352,6 +352,38 @@ pub struct StatusReport {
pub networks: Vec<NetworkReport>,
/// The local DNS service, when one was asked for.
pub dns: Option<DnsReport>,
/// The state directory the agent is actually using.
///
/// A client that was not told one would look in its own default, which is
/// not the agent's when the agent runs as a service under another user.
pub state_dir: String,
/// The cache directory the agent is actually using.
pub cache_dir: String,
/// The executable the agent runs, which is what needs the capability.
pub program: String,
/// Whether the agent itself can manage the overlay interface.
///
/// 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.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PrivilegeReport {
/// Not checked.
#[default]
Unknown,
/// It can.
Available,
/// It cannot, with what was found.
Missing(String),
/// This platform has no provisioner yet.
Unsupported,
}
/// The local DNS service.
@@ -436,6 +468,8 @@ pub struct MemberReport {
pub overlay_address_v4: Option<String>,
/// Consecutive failed dial attempts, when this agent is trying to reach it.
pub failed_dials: u32,
/// The last hostname this agent saw it announce, kept while it is away.
pub hostname: Option<String>,
}
/// One control plane peer.
+22 -1
View File
@@ -264,7 +264,8 @@ impl Storage {
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
self.with_state(move |state| {
state.remove_network(network_id)?;
state.forget_signed_records(network_id)
state.forget_signed_records(network_id)?;
state.forget_peer_hostnames(network_id)
})
.await?;
self.with_cache((), move |cache| cache.forget_network(network_id))
@@ -283,6 +284,26 @@ impl Storage {
.await
}
/// Remembers the hostname a member announced, so it can still be named
/// once it is away.
pub async fn remember_peer_hostname(
&self,
network_id: NetworkId,
endpoint_id: [u8; 32],
hostname: String,
) -> Result<()> {
self.with_state(move |state| {
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
})
.await
}
/// The hostnames members of a network last announced.
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
self.with_state(move |state| state.peer_hostnames(network_id))
.await
}
/// Records an address hint. Failures are non-fatal.
pub async fn record_hint(
&self,
+101 -2
View File
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
use crate::state::{RecordBody, SignedRecord};
/// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 4;
pub const SCHEMA_VERSION: i64 = 5;
/// Key of the stored hostname setting.
const SETTING_HOSTNAME: &str = "hostname";
@@ -191,13 +191,34 @@ impl StateStore {
PRAGMA user_version = 4;
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
}
if found < 5 {
self.conn
.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS peer_hostnames (
network_id BLOB NOT NULL,
endpoint_id BLOB NOT NULL,
hostname TEXT NOT NULL,
PRIMARY KEY (network_id, endpoint_id)
);
PRAGMA user_version = 5;
COMMIT;",
)
.map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?;
}
Ok(())
}
/// Confirms the expected tables exist, so that a truncated or foreign
/// database is reported rather than used.
fn verify_shape(&self) -> Result<()> {
for table in ["device_identity", "networks", "settings", "signed_records"] {
for table in [
"device_identity",
"networks",
"settings",
"signed_records",
"peer_hostnames",
] {
let present: Option<String> = self
.conn
.query_row(
@@ -635,6 +656,61 @@ impl StateStore {
Ok(())
}
/// Remembers the hostname a member last announced.
pub fn remember_peer_hostname(
&self,
network_id: NetworkId,
endpoint_id: &[u8; 32],
hostname: &str,
) -> Result<()> {
self.conn
.execute(
"INSERT INTO peer_hostnames (network_id, endpoint_id, hostname)
VALUES (?1, ?2, ?3)
ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname",
params![
network_id.as_bytes().as_slice(),
endpoint_id.as_slice(),
hostname
],
)
.map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?;
Ok(())
}
/// The last hostname every member of a network announced.
pub fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
let mut stmt = self
.conn
.prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1")
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
let rows = stmt
.query_map(params![network_id.as_bytes().as_slice()], |row| {
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?))
})
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
let mut out = Vec::new();
for row in rows {
let (id, hostname) =
row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) {
out.push((id, hostname));
}
}
Ok(out)
}
/// Forgets every remembered hostname of a network.
pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> {
self.conn
.execute(
"DELETE FROM peer_hostnames WHERE network_id = ?1",
params![network_id.as_bytes().as_slice()],
)
.map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?;
Ok(())
}
/// Reads an arbitrary setting.
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
self.conn
@@ -698,6 +774,29 @@ mod broadcast_storage_tests {
use super::*;
use crate::identity::NetworkKeys;
#[test]
fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.sqlite");
let name = NetworkName::new("hostnames").unwrap();
let secret = NetworkSecret::from_bytes([9; 32]).unwrap();
let id = NetworkKeys::derive(&name, &secret).network_id();
let peer = [3u8; 32];
let store = StateStore::open(&path).unwrap();
store.remember_peer_hostname(id, &peer, "laptop").unwrap();
store.remember_peer_hostname(id, &peer, "laptop-2").unwrap();
drop(store);
let store = StateStore::open(&path).unwrap();
assert_eq!(
store.peer_hostnames(id).unwrap(),
vec![(peer, "laptop-2".to_string())]
);
store.forget_peer_hostnames(id).unwrap();
assert!(store.peer_hostnames(id).unwrap().is_empty());
}
#[test]
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
let dir = tempfile::tempdir().unwrap();
+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