Compare commits

...
Author SHA1 Message Date
Aleksandr BogomiakovandClaude c62d29fa14 Arch package: start the service and let the GUI reach the agent
The control socket is now opened to the tsunagi group (unit), the Arch
package enables and starts the service on install, adds the installing user
to the group, restarts a running agent on upgrade, and depends on the
libraries the tray loads at run time. The tray looks for the agent's socket
again on every poll instead of once at start, and a missing indicator
library is reported instead of panicking. Repository URLs unified.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:29:51 +01:00
Aleksandr BogomiakovandClaude a6d9edabee Point the project repository at github.com/house-of-vanity/tsunagi
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:06:48 +01:00
Aleksandr BogomiakovandClaude 2f7f9980c7 Tray: open the devices window wider and let its table scroll sideways
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:05:08 +01:00
10 changed files with 107 additions and 39 deletions

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ mainline = { path = "vendor/mainline" }
edition = "2024"
rust-version = "1.91"
license = "WTFPL"
repository = "https://github.com/tsunagi-net/tsunagi"
repository = "https://github.com/house-of-vanity/tsunagi"
[workspace.lints.rust]
missing_docs = "warn"
+22 -19
View File
@@ -50,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
@@ -65,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,
@@ -93,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.
@@ -118,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());
{
@@ -152,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();
}
+4 -4
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use eframe::egui;
use crate::about;
use crate::agent::{AgentClient, resolve_socket};
use crate::agent::AgentClient;
use crate::devices;
use crate::stats::Traffic;
use crate::ui::{self, UiState};
@@ -38,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(),
@@ -88,7 +88,7 @@ impl App {
egui::ViewportId::from_hash_of("about"),
builder,
|vctx, _class| {
about::show(vctx, socket, snapshot);
about::show(vctx, &socket, snapshot);
!vctx.input(|i| i.viewport().close_requested())
},
);
@@ -112,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;
+1 -1
View File
@@ -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);
+21 -1
View File
@@ -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 {
+9 -4
View File
@@ -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
+39 -5
View File
@@ -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,7 +69,7 @@ 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}
@@ -72,21 +77,50 @@ arch = ${arch}
${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
+3 -1
View File
@@ -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,
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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.