Files
tsunagi/crates/tsunagi-tray/src/app.rs
T
Aleksandr BogomiakovandClaude 0cfc1cbafc Tray state icon and window picture; firewall rules without the comment match; macOS app bundle
The tray icon is dark red with no connection (or a lost exit node), blue when
connected and green while an exit node is in use; the windows get the logo and
an app id that matches the desktop entry, and the exit icon moves to the
bottom left of the tile. Exit-node and broadcast firewall rules fall back to
being recognised by their shape on kernels that have no `comment` match, as
small router builds do. The macOS archive carries Tsunagi.app for the Homebrew
cask, and the daemon plist opens the control socket to the admin group.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 03:30:47 +01:00

145 lines
5.1 KiB
Rust

//! 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;
use crate::devices;
use crate::stats::Traffic;
use crate::ui::{self, UiState};
/// The window's state.
pub(crate) struct App {
agent: AgentClient,
state: UiState,
/// Traffic rates derived from successive snapshots.
traffic: Traffic,
/// The snapshot generation last folded into `traffic`.
sampled: u64,
}
impl App {
/// Builds the app: resolves the socket and starts the worker.
pub(crate) fn new(
cc: &eframe::CreationContext<'_>,
runtime: tokio::runtime::Handle,
) -> Result<Self, String> {
let ctx = cc.egui_ctx.clone();
// Add the Phosphor icon glyphs to the font set so the icon buttons
// render instead of showing as tofu.
let mut fonts = egui::FontDefinitions::default();
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
ctx.set_fonts(fonts);
let agent = AgentClient::spawn(&runtime, ctx);
Ok(Self {
agent,
state: UiState::default(),
traffic: Traffic::default(),
sampled: 0,
})
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let snapshot = self.agent.snapshot();
if snapshot.generation != self.sampled
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
{
self.traffic.observe(report, at);
self.sampled = snapshot.generation;
}
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.
if let Some(secret) = self.agent.take_pending_copy() {
ctx.copy_text(secret);
}
// 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_app_id(crate::icon::APP_ID)
.with_icon(crate::icon::window())
.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 {
return;
};
let open: Vec<String> = self.state.open_devices.iter().cloned().collect();
for id in open {
let Some(network) = report.networks.iter().find(|n| n.network_id == id) else {
// The network is gone (left/forgotten); close its window.
self.state.open_devices.remove(&id);
continue;
};
let viewport_id = egui::ViewportId::from_hash_of(("devices", &id));
let builder = egui::ViewportBuilder::default()
.with_title(devices::title(network))
.with_app_id(crate::icon::APP_ID)
.with_icon(crate::icon::window())
.with_inner_size([940.0, 540.0])
.with_min_inner_size([480.0, 320.0]);
let agent = &self.agent;
let traffic = &self.traffic;
// The window shares the single unit setting, so its toggle and the
// main window's stay in step.
let unit = &mut self.state.unit;
let confirm = &mut self.state.exit_confirm;
let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| {
devices::show(
vctx,
agent,
unit,
confirm,
network,
&report.endpoint_id,
traffic,
);
!vctx.input(|i| i.viewport().close_requested())
});
if !keep {
self.state.open_devices.remove(&id);
}
}
}
}