From f4dcdec6624d3a07bbd4d91187e627906c43c6d3 Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Fri, 2 Oct 2026 19:23:06 +0100 Subject: [PATCH] Tray: separate tray and window processes; device list without self 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 --- crates/tsunagi-tray/Cargo.toml | 9 +- crates/tsunagi-tray/src/app.rs | 42 ++---- crates/tsunagi-tray/src/devices.rs | 108 +++++++++++---- crates/tsunagi-tray/src/main.rs | 48 ++++--- crates/tsunagi-tray/src/tray.rs | 206 +++++++++++++++++------------ crates/tsunagi-tray/src/ui.rs | 6 +- 6 files changed, 247 insertions(+), 172 deletions(-) diff --git a/crates/tsunagi-tray/Cargo.toml b/crates/tsunagi-tray/Cargo.toml index 16a39d9..56594dd 100644 --- a/crates/tsunagi-tray/Cargo.toml +++ b/crates/tsunagi-tray/Cargo.toml @@ -27,13 +27,14 @@ tracing-subscriber.workspace = true egui-phosphor = "0.9" # Linux only: tray-icon (already GTK-based there) needs the app to initialise -# GTK and run its main loop, which eframe/winit do not do. +# GTK and run its main loop. [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" -# 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] +# 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] diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index 2200c10..b894b26 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -1,5 +1,9 @@ -//! 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; @@ -8,24 +12,20 @@ use eframe::egui; 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 +37,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) @@ -88,7 +66,7 @@ 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)); } } @@ -117,7 +95,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 { diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index d5c6f56..480b9fc 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -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, + /// The full endpoint id, which clicking the short form copies. + id: &'a str, proto: &'a str, address: Option, handshake_secs: Option, 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> { - let hostnames: HashMap<&str, &str> = network - .peers +fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { + // 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 = ids .into_iter() @@ -156,15 +210,11 @@ fn devices(network: &NetworkReport) -> Vec> { 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,7 +228,7 @@ fn devices(network: &NetworkReport) -> Vec> { 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 } @@ -216,7 +266,7 @@ fn device_table( ui.end_row(); for device in devices { - format::copy_field(ui, &device.name, &device.copy); + name_cell(ui, device); ui.label(device.proto); match &device.address { Some(address) => format::copy_field(ui, address, address), diff --git a/crates/tsunagi-tray/src/main.rs b/crates/tsunagi-tray/src/main.rs index 05575a1..171f9ad 100644 --- a/crates/tsunagi-tray/src/main.rs +++ b/crates/tsunagi-tray/src/main.rs @@ -1,12 +1,12 @@ //! 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 agent; @@ -21,38 +21,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> { 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> { 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 +67,7 @@ fn main() -> Result<(), Box> { }), )?; - // The window loop has ended (Quit); let the runtime wind down. + // The window loop has ended; let the runtime wind down. drop(runtime); Ok(()) } diff --git a/crates/tsunagi-tray/src/tray.rs b/crates/tsunagi-tray/src/tray.rs index 76118eb..cd75dcf 100644 --- a/crates/tsunagi-tray/src/tray.rs +++ b/crates/tsunagi-tray/src/tray.rs @@ -1,31 +1,55 @@ -//! 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, 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>, } -/// 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. - /// On Linux the icon lives on the GTK thread instead (it is not `Send`). - #[cfg(not(target_os = "linux"))] - _icon: TrayIcon, - actions: mpsc::Receiver, +impl Window { + /// Starts the window unless one is already open. + /// + /// 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, + } + } + 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}"), + } + } + + /// 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. @@ -57,75 +81,91 @@ fn build() -> Result { }) } -impl Tray { - /// Builds the tray and starts reading its menu events. - /// - /// Created on the main thread on macOS and Windows (required on macOS). On - /// Linux the tray needs GTK, which eframe/winit never initialise, so it is - /// built on a dedicated thread that initialises GTK and runs its main loop. - /// `ctx` is used to wake the UI when a menu item is chosen. - pub(crate) fn new(ctx: egui::Context) -> Result { - #[cfg(target_os = "linux")] - let (open_id, quit_id) = { - let (ready_tx, ready_rx) = mpsc::sync_channel(1); - std::thread::Builder::new() - .name("tray-gtk".into()) - .spawn(move || { - if let Err(err) = gtk::init() { - let _ = ready_tx.send(Err(format!("cannot initialise GTK: {err}"))); - return; - } - match build() { - Ok(built) => { - let _ = ready_tx.send(Ok((built.open, built.quit))); - gtk::main(); - drop(built.icon); - } - Err(err) => { - let _ = ready_tx.send(Err(err)); - } - } - }) - .map_err(|err| err.to_string())?; - ready_rx - .recv() - .map_err(|_| "the tray thread exited before it was ready".to_string())?? - }; - #[cfg(not(target_os = "linux"))] - let (icon, open_id, quit_id) = { - let built = build()?; - (built.icon, built.open, built.quit) - }; +/// Acts on menu choices as the platform's loop delivers them. +fn handle_menu(built: &Built, window: Arc) { + 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); + } + })); +} - 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(); +/// 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> { + 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> { + use winit::application::ApplicationHandler; + use winit::event::WindowEvent; + use winit::event_loop::{ActiveEventLoop, EventLoop}; + use winit::window::WindowId; + + struct Pump { + window: Arc, + icon: Option, + } + + 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); + } + } + } - Ok(Self { - #[cfg(not(target_os = "linux"))] - _icon: icon, - actions: rx, - }) + fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {} } - /// The menu actions since the last call. - pub(crate) fn poll(&self) -> impl Iterator + '_ { - self.actions.try_iter() + 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. diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index 5cd1784..156668d 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -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}; @@ -114,7 +115,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 +192,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 +235,7 @@ fn draw_network( ui.label(format!( "{} connected · {} known", network.peers.len(), - network.members.len() + devices::known_count(network, own_id) )); ui.end_row(); });