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
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>
This commit is contained in:
1 parent
69310108f4
commit
f4dcdec662
6 files changed
+247
-172
No files matched your search
@@ -27,13 +27,14 @@ tracing-subscriber.workspace = true
|
|||||||
egui-phosphor = "0.9"
|
egui-phosphor = "0.9"
|
||||||
|
|
||||||
# Linux only: tray-icon (already GTK-based there) needs the app to initialise
|
# 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]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
gtk = "0.18"
|
gtk = "0.18"
|
||||||
|
|
||||||
# macOS only: set the app's activation policy to Accessory so it lives in the
|
# Windows and macOS: the tray process has no GUI toolkit, only an event loop to
|
||||||
# menu bar with no Dock icon. Pinned to eframe's winit so the types unify.
|
# serve the icon (and, on macOS, the Accessory activation policy that keeps it
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
# out of the Dock). Pinned to eframe's winit so the types unify.
|
||||||
|
[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -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.
|
//! 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 std::time::Duration;
|
||||||
|
|
||||||
@@ -8,24 +12,20 @@ use eframe::egui;
|
|||||||
use crate::agent::{AgentClient, resolve_socket};
|
use crate::agent::{AgentClient, resolve_socket};
|
||||||
use crate::devices;
|
use crate::devices;
|
||||||
use crate::stats::Traffic;
|
use crate::stats::Traffic;
|
||||||
use crate::tray::{Action, Tray};
|
|
||||||
use crate::ui::{self, UiState};
|
use crate::ui::{self, UiState};
|
||||||
|
|
||||||
/// The tray application state.
|
/// The window's state.
|
||||||
pub(crate) struct App {
|
pub(crate) struct App {
|
||||||
agent: AgentClient,
|
agent: AgentClient,
|
||||||
tray: Tray,
|
|
||||||
state: UiState,
|
state: UiState,
|
||||||
/// Traffic rates derived from successive snapshots.
|
/// Traffic rates derived from successive snapshots.
|
||||||
traffic: Traffic,
|
traffic: Traffic,
|
||||||
/// The snapshot generation last folded into `traffic`.
|
/// The snapshot generation last folded into `traffic`.
|
||||||
sampled: u64,
|
sampled: u64,
|
||||||
/// Set when the user chose Quit, so the next close exits rather than hides.
|
|
||||||
quitting: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
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(
|
pub(crate) fn new(
|
||||||
cc: &eframe::CreationContext<'_>,
|
cc: &eframe::CreationContext<'_>,
|
||||||
runtime: tokio::runtime::Handle,
|
runtime: tokio::runtime::Handle,
|
||||||
@@ -37,40 +37,18 @@ impl App {
|
|||||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||||
ctx.set_fonts(fonts);
|
ctx.set_fonts(fonts);
|
||||||
|
|
||||||
let agent = AgentClient::spawn(&runtime, ctx.clone(), resolve_socket());
|
let agent = AgentClient::spawn(&runtime, ctx, resolve_socket());
|
||||||
let tray = Tray::new(ctx)?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
agent,
|
agent,
|
||||||
tray,
|
|
||||||
state: UiState::default(),
|
state: UiState::default(),
|
||||||
traffic: Traffic::default(),
|
traffic: Traffic::default(),
|
||||||
sampled: 0,
|
sampled: 0,
|
||||||
quitting: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
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();
|
let snapshot = self.agent.snapshot();
|
||||||
if snapshot.generation != self.sampled
|
if snapshot.generation != self.sampled
|
||||||
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
|
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
|
||||||
@@ -88,7 +66,7 @@ impl eframe::App for App {
|
|||||||
ctx.copy_text(secret);
|
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));
|
ctx.request_repaint_after(Duration::from_millis(500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +95,7 @@ impl App {
|
|||||||
// main window's stay in step.
|
// main window's stay in step.
|
||||||
let unit = &mut self.state.unit;
|
let unit = &mut self.state.unit;
|
||||||
let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| {
|
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())
|
!vctx.input(|i| i.viewport().close_requested())
|
||||||
});
|
});
|
||||||
if !keep {
|
if !keep {
|
||||||
|
|||||||
@@ -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.
|
/// One row of the device table, built from a tunnel and/or a signed member.
|
||||||
struct Device<'a> {
|
struct Device<'a> {
|
||||||
online: bool,
|
online: bool,
|
||||||
name: String,
|
/// The last hostname seen for it, kept while it is offline.
|
||||||
/// What clicking the name copies: `hostname.network` so it resolves, or
|
hostname: Option<&'a str>,
|
||||||
/// the short id when there is no hostname.
|
/// What clicking the hostname copies: `hostname.network`, so it resolves.
|
||||||
copy: String,
|
copy: Option<String>,
|
||||||
|
/// The full endpoint id, which clicking the short form copies.
|
||||||
|
id: &'a str,
|
||||||
proto: &'a str,
|
proto: &'a str,
|
||||||
address: Option<String>,
|
address: Option<String>,
|
||||||
handshake_secs: Option<u64>,
|
handshake_secs: Option<u64>,
|
||||||
overlay: Option<&'a OverlayPeerReport>,
|
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.
|
/// Draws the window body into its viewport.
|
||||||
pub(crate) fn show(
|
pub(crate) fn show(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
_agent: &AgentClient,
|
_agent: &AgentClient,
|
||||||
unit: &mut Unit,
|
unit: &mut Unit,
|
||||||
network: &NetworkReport,
|
network: &NetworkReport,
|
||||||
|
own_id: &str,
|
||||||
traffic: &Traffic,
|
traffic: &Traffic,
|
||||||
) {
|
) {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
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);
|
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
|
||||||
ui.add_space(8.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();
|
let online = devices.iter().filter(|d| d.online).count();
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!(
|
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
|
/// Builds the device list: every endpoint known from a live tunnel or the
|
||||||
/// signed membership, online ones first.
|
/// signed membership, online ones first.
|
||||||
fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
|
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
|
||||||
let hostnames: HashMap<&str, &str> = network
|
// The signed members carry the last name seen, so a device that is away
|
||||||
.peers
|
// is still called by it; a live announcement is fresher and wins.
|
||||||
|
let mut hostnames: HashMap<&str, &str> = network
|
||||||
|
.members
|
||||||
.iter()
|
.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();
|
.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
|
let overlay_peers = network
|
||||||
.overlay
|
.overlay
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|o| o.peers.as_slice())
|
.map(|o| o.peers.as_slice())
|
||||||
.unwrap_or(&[]);
|
.unwrap_or(&[]);
|
||||||
|
|
||||||
let mut ids: Vec<&str> = Vec::new();
|
let ids = known_ids(network, own_id);
|
||||||
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 mut devices: Vec<Device> = ids
|
let mut devices: Vec<Device> = ids
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -156,15 +210,11 @@ fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
|
|||||||
let member = network.members.iter().find(|m| m.endpoint_id == id);
|
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 online = overlay.is_some_and(|p| p.handshake_secs_ago.is_some());
|
||||||
let hostname = hostnames.get(id).copied();
|
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 {
|
Device {
|
||||||
online,
|
online,
|
||||||
name,
|
hostname,
|
||||||
copy,
|
copy: hostname.map(|h| format!("{h}.{}", network.name)),
|
||||||
|
id,
|
||||||
proto: overlay.map_or("—", |p| p.protocol.as_str()),
|
proto: overlay.map_or("—", |p| p.protocol.as_str()),
|
||||||
address: overlay
|
address: overlay
|
||||||
.and_then(|p| p.address.clone())
|
.and_then(|p| p.address.clone())
|
||||||
@@ -178,7 +228,7 @@ fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
|
|||||||
devices.sort_by(|a, b| {
|
devices.sort_by(|a, b| {
|
||||||
b.online
|
b.online
|
||||||
.cmp(&a.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
|
devices
|
||||||
}
|
}
|
||||||
@@ -216,7 +266,7 @@ fn device_table(
|
|||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
for device in devices {
|
for device in devices {
|
||||||
format::copy_field(ui, &device.name, &device.copy);
|
name_cell(ui, device);
|
||||||
ui.label(device.proto);
|
ui.label(device.proto);
|
||||||
match &device.address {
|
match &device.address {
|
||||||
Some(address) => format::copy_field(ui, address, address),
|
Some(address) => format::copy_field(ui, address, address),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! Tray application for managing a running tsunagi agent.
|
//! Tray application for managing a running tsunagi agent.
|
||||||
//!
|
//!
|
||||||
//! A small window (networks, their peers and addresses, join/leave, per-network
|
//! A tray icon and menu (Open, Quit) that opens a small window (networks, their
|
||||||
//! active and broadcast switches) plus a tray menu (Open, Quit). It is a thin
|
//! peers and addresses, join/leave, per-network active and broadcast switches).
|
||||||
//! client over the agent's local control socket — the same interface the CLI
|
//! There is no window until Open, and closing it leaves the icon. The window
|
||||||
//! uses — and owns its own runtime, logger and event loop, like the CLI binary.
|
//! 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
|
// Nothing here is a library.
|
||||||
// itself may exit on Quit. Nothing here is a library.
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
@@ -21,38 +21,42 @@ use eframe::egui;
|
|||||||
|
|
||||||
use crate::app::App;
|
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>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
|
||||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
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()
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?;
|
.build()?;
|
||||||
let handle = runtime.handle().clone();
|
let handle = runtime.handle().clone();
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "macos"), expect(unused_mut))]
|
let options = eframe::NativeOptions {
|
||||||
let mut options = eframe::NativeOptions {
|
|
||||||
// A fixed, small window; the whole layout targets this size.
|
// A fixed, small window; the whole layout targets this size.
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_title("tsunagi")
|
.with_title("tsunagi")
|
||||||
|
.with_app_id("tsunagi")
|
||||||
.with_inner_size([360.0, 600.0])
|
.with_inner_size([360.0, 600.0])
|
||||||
.with_resizable(false)
|
.with_resizable(false),
|
||||||
// Start hidden: the app lives in the tray and the window opens from
|
|
||||||
// the menu.
|
|
||||||
.with_visible(false),
|
|
||||||
..Default::default()
|
..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(
|
eframe::run_native(
|
||||||
"tsunagi",
|
"tsunagi",
|
||||||
options,
|
options,
|
||||||
@@ -63,7 +67,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);
|
drop(runtime);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+123
-83
@@ -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,
|
//! This is the process that stays. It holds no window and no GUI toolkit of
|
||||||
//! join/leave) lives in the window. Menu clicks are read on a background
|
//! its own; choosing Open starts this same binary again as the window, which
|
||||||
//! thread and turned into [`Action`]s, so they are handled even while the
|
//! is a perfectly ordinary program that ends when it is closed.
|
||||||
//! window is hidden and egui would otherwise not be repainting.
|
|
||||||
|
|
||||||
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::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
|
||||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||||
|
|
||||||
/// What the user picked from the tray menu.
|
/// The window process, when there is one.
|
||||||
pub(crate) enum Action {
|
struct Window {
|
||||||
/// Show (and focus) the window.
|
child: Mutex<Option<Child>>,
|
||||||
Open,
|
|
||||||
/// Quit the application.
|
|
||||||
Quit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The live tray icon plus the channel of menu actions.
|
impl Window {
|
||||||
pub(crate) struct Tray {
|
/// Starts the window unless one is already open.
|
||||||
/// 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`).
|
/// Raising an existing one is not something a process can ask for on
|
||||||
#[cfg(not(target_os = "linux"))]
|
/// every platform, so choosing Open while it is open does nothing.
|
||||||
_icon: TrayIcon,
|
fn open(&self) {
|
||||||
actions: mpsc::Receiver<Action>,
|
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.
|
/// A built tray icon and the ids of its menu items.
|
||||||
@@ -57,75 +81,91 @@ fn build() -> Result<Built, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tray {
|
/// Acts on menu choices as the platform's loop delivers them.
|
||||||
/// Builds the tray and starts reading its menu events.
|
fn handle_menu(built: &Built, window: Arc<Window>) {
|
||||||
///
|
let (open, quit) = (built.open.clone(), built.quit.clone());
|
||||||
/// Created on the main thread on macOS and Windows (required on macOS). On
|
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
|
||||||
/// Linux the tray needs GTK, which eframe/winit never initialise, so it is
|
if event.id == open {
|
||||||
/// built on a dedicated thread that initialises GTK and runs its main loop.
|
window.open();
|
||||||
/// `ctx` is used to wake the UI when a menu item is chosen.
|
} else if event.id == quit {
|
||||||
pub(crate) fn new(ctx: egui::Context) -> Result<Self, String> {
|
window.close();
|
||||||
#[cfg(target_os = "linux")]
|
std::process::exit(0);
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
/// Shows the icon and runs until Quit.
|
||||||
std::thread::spawn(move || {
|
///
|
||||||
// The global menu-event channel is fed by the OS event loop.
|
/// Linux: the tray needs GTK, which nothing else here initialises, so this
|
||||||
while let Ok(event) = MenuEvent::receiver().recv() {
|
/// thread does and runs its main loop.
|
||||||
let action = if event.id == open_id {
|
#[cfg(target_os = "linux")]
|
||||||
Action::Open
|
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
} else if event.id == quit_id {
|
gtk::init().map_err(|err| format!("cannot initialise GTK: {err}"))?;
|
||||||
Action::Quit
|
let built = build()?;
|
||||||
} else {
|
handle_menu(
|
||||||
continue;
|
&built,
|
||||||
};
|
Arc::new(Window {
|
||||||
if tx.send(action).is_err() {
|
child: Mutex::new(None),
|
||||||
break;
|
}),
|
||||||
}
|
);
|
||||||
ctx.request_repaint();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
_icon: icon,
|
|
||||||
actions: rx,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The menu actions since the last call.
|
let mut builder = EventLoop::builder();
|
||||||
pub(crate) fn poll(&self) -> impl Iterator<Item = Action> + '_ {
|
// macOS: live in the menu bar with no Dock icon.
|
||||||
self.actions.try_iter()
|
#[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.
|
/// A small round icon, drawn in code so there is no asset to ship yet.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use egui_phosphor::regular as icon;
|
|||||||
use tsunagi::ipc::{NetworkReport, StatusReport};
|
use tsunagi::ipc::{NetworkReport, StatusReport};
|
||||||
|
|
||||||
use crate::agent::{AgentClient, Command, Snapshot};
|
use crate::agent::{AgentClient, Command, Snapshot};
|
||||||
|
use crate::devices;
|
||||||
use crate::format;
|
use crate::format;
|
||||||
use crate::stats::{Traffic, Unit};
|
use crate::stats::{Traffic, Unit};
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ pub(crate) fn draw(
|
|||||||
ui.weak("No networks yet — join one below.");
|
ui.weak("No networks yet — join one below.");
|
||||||
}
|
}
|
||||||
for network in &report.networks {
|
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,
|
state: &mut UiState,
|
||||||
traffic: &Traffic,
|
traffic: &Traffic,
|
||||||
network: &NetworkReport,
|
network: &NetworkReport,
|
||||||
|
own_id: &str,
|
||||||
) {
|
) {
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||||
@@ -233,7 +235,7 @@ fn draw_network(
|
|||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
"{} connected · {} known",
|
"{} connected · {} known",
|
||||||
network.peers.len(),
|
network.peers.len(),
|
||||||
network.members.len()
|
devices::known_count(network, own_id)
|
||||||
));
|
));
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user