Add the tsunagi-tray GUI (egui) — first increment

A native, lightweight tray application to manage a running agent — no web
engine. It is a thin client over the agent's local control socket, reusing
tsunagi::ipc and the report types directly; it owns its own runtime, logger and
event loop, like the CLI.

This first increment:
- tray icon with an Open/Quit menu (tray-icon); menu events are read on a
  background thread so Open works even while the window is hidden;
- a small window that hides to the tray on close and reopens from the menu;
- a background tokio worker that polls status every 2s and runs commands off the
  UI thread, writing a snapshot the UI renders from and repainting egui;
- UI: device header, a per-network card (name, id, range, this device's
  address, peers connected/known, a Leave button, and the per-network active
  and broadcast switches), a join form (name + secret), and a transient
  action/error banner; an "agent unreachable" state that names the socket.

Socket resolution: TSUNAGI_CONTROL_SOCKET if set, else the platform default
(/var/run/tsunagi/agent.sock on macOS) — the same socket the group-access agent
serves.

Scope/notes: macOS first (Linux and Windows follow). Not yet done — macOS
accessory activation policy (so there is no dock icon), light/dark tray icons
from assets (the icon is drawn in code for now), and packaging/autostart. Builds
and is clippy-clean (-D warnings) on macOS; the UI itself has not been run in
this headless session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
UltradesuandClaude Opus 4.8 committed 2026-10-02 14:48:52 +01:00
1 parent de8e48bee7
commit 19509dc833
7 files changed
+3287 -89

No files matched your search

Generated
+2676 -89
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "tsunagi-tray"
version = "0.1.0"
description = "Tray application to manage a running tsunagi agent."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "tsunagi-tray"
path = "src/main.rs"
[dependencies]
# The agent control client and the report types: the GUI is a thin client over
# the same local control interface the CLI uses, so it reuses these directly
# and never re-implements the protocol.
tsunagi = { path = "../tsunagi", version = "0.1.0" }
# Native, lightweight GUI — no web engine. eframe re-exports egui.
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
# Cross-platform tray icon + menu (Windows tray, macOS menu bar, Linux SNI).
tray-icon = "0.21"
# Background worker that talks to the agent off the UI thread.
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tracing.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
+183
View File
@@ -0,0 +1,183 @@
//! The client side: a background worker that talks to the agent over the local
//! control socket, off the UI thread.
//!
//! The UI never blocks on IO. A worker on a tokio runtime polls the agent for
//! status on an interval and runs the commands the UI sends (join, leave,
//! activate, broadcast), writing the latest snapshot into shared state and
//! asking egui to repaint. The whole thing is a thin layer over
//! [`tsunagi::ipc`]; no protocol is re-implemented here.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use eframe::egui;
use tokio::sync::mpsc;
use tsunagi::ipc::{self, StatusReport};
/// How often the worker refreshes status while idle.
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.
SetBroadcast { network_id: String, enabled: bool },
/// Join a network, or start one already configured.
Join { name: String, secret: String },
/// Leave a network entirely.
Leave { network_id: String },
}
/// The latest the worker knows, read by the UI each frame.
#[derive(Clone, Default)]
pub(crate) struct Snapshot {
/// The last status, or the reason it could not be read (agent down, no
/// permission on the socket, …).
pub status: Option<Result<StatusReport, String>>,
/// The outcome of the most recent command, for a transient banner.
pub last_action: Option<Result<String, String>>,
/// Whether a command is currently being executed.
pub busy: bool,
}
/// Handle the UI holds: send commands, read the latest snapshot.
pub(crate) struct AgentClient {
socket: PathBuf,
tx: mpsc::UnboundedSender<Command>,
shared: Arc<Mutex<Snapshot>>,
}
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 {
let (tx, rx) = mpsc::unbounded_channel();
let shared = Arc::new(Mutex::new(Snapshot::default()));
runtime.spawn(worker(socket.clone(), rx, Arc::clone(&shared), ctx));
Self { socket, tx, shared }
}
/// The control socket this client talks to.
pub(crate) fn socket(&self) -> &std::path::Path {
&self.socket
}
/// Sends a command; a dropped worker simply means nothing happens.
pub(crate) fn send(&self, command: Command) {
let _ = self.tx.send(command);
}
/// A copy of the latest snapshot.
pub(crate) fn snapshot(&self) -> Snapshot {
lock(&self.shared).clone()
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// 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>>,
ctx: egui::Context,
) {
refresh(&socket, &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;
}
command = rx.recv() => {
let Some(command) = command else { return };
run(&socket, command, &shared, &ctx).await;
refresh(&socket, &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)
.await
.map_err(|err| err.to_string());
{
let mut guard = lock(shared);
guard.status = Some(status);
}
ctx.request_repaint();
}
/// Runs one command and records its outcome.
async fn run(
socket: &std::path::Path,
command: Command,
shared: &Arc<Mutex<Snapshot>>,
ctx: &egui::Context,
) {
lock(shared).busy = true;
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| {
let state = if report.active { "started" } else { "stopped" };
format!("{} {}", state, report.name)
})
.map_err(|err| err.to_string()),
Command::SetBroadcast {
network_id,
enabled,
} => ipc::set_broadcast(socket, &network_id, enabled)
.await
.map(|on| format!("broadcast {}", if on { "on" } else { "off" }))
.map_err(|err| err.to_string()),
Command::Join { name, secret } => ipc::join_network(socket, &name, &secret)
.await
.map(|report| format!("joined {}", report.name))
.map_err(|err| err.to_string()),
Command::Leave { network_id } => ipc::leave_network(socket, &network_id)
.await
.map(|report| format!("left {}", report.name))
.map_err(|err| err.to_string()),
};
{
let mut guard = lock(shared);
guard.busy = false;
guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) {
None
} else {
Some(outcome)
};
}
ctx.request_repaint();
}
/// Where the GUI looks for the agent: `TSUNAGI_CONTROL_SOCKET` if set, else the
/// platform default (the system service's path).
pub(crate) fn resolve_socket() -> PathBuf {
if let Some(path) = std::env::var_os("TSUNAGI_CONTROL_SOCKET") {
return PathBuf::from(path);
}
ipc::default_control_socket_path()
}
+67
View File
@@ -0,0 +1,67 @@
//! The eframe application: wires the tray, the window and the agent worker.
use std::time::Duration;
use eframe::egui;
use crate::agent::{AgentClient, resolve_socket};
use crate::tray::{Action, Tray};
use crate::ui::{self, JoinForm};
/// The tray application state.
pub(crate) struct App {
agent: AgentClient,
tray: Tray,
join: JoinForm,
/// Set when the user chose Quit, so the next close actually exits instead
/// of hiding to the tray.
quitting: bool,
}
impl App {
/// Builds the app: resolves the socket, starts the worker, creates the tray.
pub(crate) fn new(
cc: &eframe::CreationContext<'_>,
runtime: tokio::runtime::Handle,
) -> Result<Self, String> {
let ctx = cc.egui_ctx.clone();
let agent = AgentClient::spawn(&runtime, ctx.clone(), resolve_socket());
let tray = Tray::new(ctx)?;
Ok(Self {
agent,
tray,
join: JoinForm::default(),
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 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();
ui::draw(ctx, &self.agent, &mut self.join, &snapshot);
// Keep ticking so fresh status and tray actions are picked up even when
// nothing else requests a repaint.
ctx.request_repaint_after(Duration::from_millis(500));
}
}
+52
View File
@@ -0,0 +1,52 @@
//! 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.
// The window is created hidden-to-tray on close rather than exiting; the binary
// itself may exit on Quit. Nothing here is a library.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod agent;
mod app;
mod tray;
mod ui;
use eframe::egui;
use crate::app::App;
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();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let handle = runtime.handle().clone();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("tsunagi")
.with_inner_size([380.0, 540.0])
.with_min_inner_size([320.0, 360.0]),
..Default::default()
};
eframe::run_native(
"tsunagi",
options,
Box::new(move |cc| {
App::new(cc, handle.clone())
.map(|app| Box::new(app) as Box<dyn eframe::App>)
.map_err(std::convert::Into::into)
}),
)?;
// The window loop has ended (Quit); let the runtime wind down.
drop(runtime);
Ok(())
}
+102
View File
@@ -0,0 +1,102 @@
//! The tray icon and its menu.
//!
//! 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.
use std::sync::mpsc;
use eframe::egui;
use tray_icon::menu::{Menu, MenuEvent, 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 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.
///
/// 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();
}
});
Ok(Self {
_icon: tray,
actions: rx,
})
}
/// The menu actions since the last call.
pub(crate) fn poll(&self) -> impl Iterator<Item = Action> + '_ {
self.actions.try_iter()
}
}
/// 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;
let centre = (SIZE as f32 - 1.0) / 2.0;
let radius = SIZE as f32 * 0.46;
let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize];
for y in 0..SIZE {
for x in 0..SIZE {
let (dx, dy) = (x as f32 - centre, y as f32 - centre);
if dx * dx + dy * dy <= radius * radius {
let i = ((y * SIZE + x) * 4) as usize;
rgba[i] = 0x2f;
rgba[i + 1] = 0x80;
rgba[i + 2] = 0xd8;
rgba[i + 3] = 0xff;
}
}
}
Icon::from_rgba(rgba, SIZE, SIZE).map_err(|err| err.to_string())
}
+178
View File
@@ -0,0 +1,178 @@
//! The window contents, drawn from the latest agent snapshot.
//!
//! Pure rendering: the current state comes from the snapshot, and a change to a
//! switch or a button sends a [`Command`] to the worker. egui is immediate
//! mode, so the switches reflect the agent's state and only a user change
//! dispatches a command.
use eframe::egui;
use tsunagi::ipc::{NetworkReport, StatusReport};
use crate::agent::{AgentClient, Command, Snapshot};
/// Join form state, kept across frames.
#[derive(Default)]
pub(crate) struct JoinForm {
pub(crate) name: String,
pub(crate) secret: String,
}
/// Draws the whole window.
pub(crate) fn draw(
ctx: &egui::Context,
agent: &AgentClient,
join: &mut JoinForm,
snapshot: &Snapshot,
) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("tsunagi");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("⟳").on_hover_text("Refresh").clicked() {
agent.send(Command::Refresh);
}
if snapshot.busy {
ui.add(egui::Spinner::new());
}
});
});
match &snapshot.status {
None => {
ui.add_space(8.0);
ui.label("Connecting to the agent…");
}
Some(Err(error)) => draw_unreachable(ui, agent, error),
Some(Ok(report)) => {
draw_header(ui, report);
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
if report.networks.is_empty() {
ui.label("No networks yet. Join one below.");
}
for network in &report.networks {
draw_network(ui, agent, network);
}
});
ui.separator();
draw_join(ui, agent, join);
}
}
if let Some(action) = &snapshot.last_action {
ui.add_space(6.0);
match action {
Ok(message) => ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), message),
Err(error) => ui.colored_label(egui::Color32::from_rgb(0xd0, 0x4a, 0x3c), error),
};
}
});
}
/// The "agent cannot be reached" state, with the socket it tried.
fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
ui.add_space(8.0);
ui.colored_label(
egui::Color32::from_rgb(0xd0, 0x4a, 0x3c),
"The agent is not reachable.",
);
ui.add_space(4.0);
ui.label(format!("socket: {}", agent.socket().display()));
ui.label(error);
ui.add_space(6.0);
ui.label(
"Start the agent, or check that this user may open its control socket \
(its group, mode 0660).",
);
}
/// The device line: id, hostname, cache health.
fn draw_header(ui: &mut egui::Ui, report: &StatusReport) {
ui.add_space(4.0);
ui.label(format!("device {}", short(&report.endpoint_id)));
ui.label(format!("host {}", report.hostname));
if !report.cache_healthy {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
"cache unavailable",
);
}
}
/// One network: name, range, this device's address, switches, peers, leave.
fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport) {
ui.add_space(6.0);
ui.group(|ui| {
ui.horizontal(|ui| {
ui.strong(network.name.as_str());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Leave").clicked() {
agent.send(Command::Leave {
network_id: network.network_id.clone(),
});
}
});
});
ui.label(format!("id {}", short(&network.network_id)));
if let Some(range) = &network.range {
ui.label(format!("range {range}"));
}
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
ui.label(format!("addr {address}"));
}
ui.horizontal(|ui| {
let mut active = network.active;
if ui.checkbox(&mut active, "active").changed() {
agent.send(Command::SetActive {
network_id: network.network_id.clone(),
active,
});
}
let mut broadcast = network.broadcast;
if ui.checkbox(&mut broadcast, "broadcast").changed() {
agent.send(Command::SetBroadcast {
network_id: network.network_id.clone(),
enabled: broadcast,
});
}
});
let connected = network.peers.len();
let known = network.members.len();
ui.label(format!("peers {connected} connected · {known} known"));
});
}
/// The join form: name + secret + button.
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
ui.add_space(4.0);
ui.label("Join a network");
ui.horizontal(|ui| {
ui.label("name");
ui.text_edit_singleline(&mut join.name);
});
ui.horizontal(|ui| {
ui.label("secret");
ui.add(egui::TextEdit::singleline(&mut join.secret).password(true));
});
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
if ui.add_enabled(ready, egui::Button::new("Join")).clicked() {
agent.send(Command::Join {
name: join.name.trim().to_string(),
secret: join.secret.trim().to_string(),
});
join.secret.clear();
}
}
/// A short, readable prefix of a long identifier (char-safe, no panic).
fn short(id: &str) -> String {
const KEEP: usize = 12;
if id.chars().count() <= KEEP {
id.to_string()
} else {
format!("{}…", id.chars().take(KEEP).collect::<String>())
}
}