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>
This commit is contained in:
1 parent
c9c653023d
commit
0cfc1cbafc
14 files changed
+692
-90
No files matched your search
@@ -81,6 +81,8 @@ impl App {
|
||||
}
|
||||
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();
|
||||
@@ -112,6 +114,8 @@ impl App {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! The application's picture: the logo drawn in code, in the colour of the
|
||||
//! state it stands for, so there is no asset to ship or to go missing.
|
||||
//!
|
||||
//! It is the same shape as `dist/linux/tsunagi.svg` (a disc, a ring, a dot).
|
||||
|
||||
use crate::watch::Health;
|
||||
|
||||
/// Normal operation. The colour of the logo itself.
|
||||
const BLUE: [u8; 3] = [0x2f, 0x80, 0xd8];
|
||||
/// An exit node is in use.
|
||||
const GREEN: [u8; 3] = [0x3c, 0xb0, 0x4a];
|
||||
/// Nothing is connected, or the exit node in use is gone.
|
||||
const DARK_RED: [u8; 3] = [0x9a, 0x1f, 0x1f];
|
||||
|
||||
/// The window application id, which is also the name of the desktop entry
|
||||
/// (`tsunagi-tray.desktop`). The compositor finds the icon through it.
|
||||
pub(crate) const APP_ID: &str = "tsunagi-tray";
|
||||
|
||||
const SIZE: u32 = 64;
|
||||
const SAMPLES: u32 = 4;
|
||||
|
||||
/// What the logo looks like at a point of its 128 × 128 design, as
|
||||
/// premultiplied-free RGBA in `0.0..=1.0`.
|
||||
fn shade(x: f32, y: f32, color: [u8; 3]) -> [f32; 4] {
|
||||
let distance = ((x - 64.0).powi(2) + (y - 64.0).powi(2)).sqrt();
|
||||
if distance > 56.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let mut pixel = [
|
||||
f32::from(color[0]) / 255.0,
|
||||
f32::from(color[1]) / 255.0,
|
||||
f32::from(color[2]) / 255.0,
|
||||
];
|
||||
let mut white = 0.0;
|
||||
if (26.5..=33.5).contains(&distance) {
|
||||
white = 0.9;
|
||||
}
|
||||
if distance <= 9.0 {
|
||||
white = 1.0;
|
||||
}
|
||||
for channel in &mut pixel {
|
||||
*channel = *channel * (1.0 - white) + white;
|
||||
}
|
||||
[pixel[0], pixel[1], pixel[2], 1.0]
|
||||
}
|
||||
|
||||
/// The logo as RGBA pixels, antialiased by supersampling.
|
||||
fn rgba(color: [u8; 3]) -> Vec<u8> {
|
||||
let scale = 128.0 / SIZE as f32;
|
||||
let mut out = Vec::with_capacity((SIZE * SIZE * 4) as usize);
|
||||
for py in 0..SIZE {
|
||||
for px in 0..SIZE {
|
||||
let mut sum = [0.0f32; 4];
|
||||
for sy in 0..SAMPLES {
|
||||
for sx in 0..SAMPLES {
|
||||
let x = (px as f32 + (sx as f32 + 0.5) / SAMPLES as f32) * scale;
|
||||
let y = (py as f32 + (sy as f32 + 0.5) / SAMPLES as f32) * scale;
|
||||
let [r, g, b, a] = shade(x, y, color);
|
||||
sum[0] += r * a;
|
||||
sum[1] += g * a;
|
||||
sum[2] += b * a;
|
||||
sum[3] += a;
|
||||
}
|
||||
}
|
||||
let count = (SAMPLES * SAMPLES) as f32;
|
||||
let alpha = sum[3] / count;
|
||||
let unmultiply = if sum[3] > 0.0 { 1.0 / sum[3] } else { 0.0 };
|
||||
for channel in &sum[..3] {
|
||||
out.push((channel * unmultiply * 255.0).round() as u8);
|
||||
}
|
||||
out.push((alpha * 255.0).round() as u8);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn colour_of(health: Health) -> [u8; 3] {
|
||||
match health {
|
||||
Health::Disconnected => DARK_RED,
|
||||
Health::Connected => BLUE,
|
||||
Health::ExitNode => GREEN,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tray icon for a state.
|
||||
pub(crate) fn tray(health: Health) -> Option<tray_icon::Icon> {
|
||||
tray_icon::Icon::from_rgba(rgba(colour_of(health)), SIZE, SIZE).ok()
|
||||
}
|
||||
|
||||
/// The icon of the application's windows.
|
||||
pub(crate) fn window() -> eframe::egui::IconData {
|
||||
eframe::egui::IconData {
|
||||
rgba: rgba(BLUE),
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] {
|
||||
let at = ((y * SIZE + x) * 4) as usize;
|
||||
[pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logo_is_a_coloured_disc_with_a_white_centre_and_clear_corners() {
|
||||
let pixels = rgba(GREEN);
|
||||
assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize);
|
||||
assert_eq!(pixel(&pixels, 0, 0)[3], 0, "the corner is transparent");
|
||||
let centre = pixel(&pixels, SIZE / 2, SIZE / 2);
|
||||
assert_eq!(
|
||||
centre,
|
||||
[255, 255, 255, 255],
|
||||
"the dot in the middle is white"
|
||||
);
|
||||
// Halfway between the ring and the edge is the colour itself.
|
||||
let body = pixel(&pixels, SIZE / 2, SIZE / 2 - (SIZE * 44 / 128));
|
||||
assert_eq!(body, [GREEN[0], GREEN[1], GREEN[2], 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_state_has_its_own_colour() {
|
||||
let colours = [
|
||||
colour_of(Health::Disconnected),
|
||||
colour_of(Health::Connected),
|
||||
colour_of(Health::ExitNode),
|
||||
];
|
||||
assert_ne!(colours[0], colours[1]);
|
||||
assert_ne!(colours[1], colours[2]);
|
||||
assert_ne!(colours[0], colours[2]);
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,11 @@ mod app;
|
||||
mod devices;
|
||||
mod exit;
|
||||
mod format;
|
||||
mod icon;
|
||||
mod stats;
|
||||
mod tray;
|
||||
mod ui;
|
||||
mod watch;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
@@ -53,7 +55,8 @@ fn window() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// A fixed, small window; the whole layout targets this size.
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("tsunagi")
|
||||
.with_app_id("tsunagi")
|
||||
.with_app_id(icon::APP_ID)
|
||||
.with_icon(icon::window())
|
||||
.with_inner_size([360.0, 600.0])
|
||||
.with_resizable(false),
|
||||
..Default::default()
|
||||
|
||||
@@ -8,7 +8,10 @@ use std::process::{Child, Command};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
use tray_icon::{TrayIcon, TrayIconBuilder};
|
||||
|
||||
use crate::icon;
|
||||
use crate::watch::{self, Health};
|
||||
|
||||
/// The window process, when there is one.
|
||||
struct Window {
|
||||
@@ -69,9 +72,9 @@ fn build() -> Result<Built, String> {
|
||||
menu.append(&quit).map_err(|err| err.to_string())?;
|
||||
|
||||
let icon = TrayIconBuilder::new()
|
||||
.with_tooltip("tsunagi")
|
||||
.with_tooltip(Health::Disconnected.label())
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(make_icon()?)
|
||||
.with_icon(icon::tray(Health::Disconnected).ok_or("cannot draw the tray icon")?)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(Built {
|
||||
@@ -101,6 +104,16 @@ fn build_checked() -> Result<Built, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows a state on the icon, if it is a different one than is showing.
|
||||
fn show(icon: &TrayIcon, shown: &mut Option<Health>, health: Health) {
|
||||
if *shown == Some(health) {
|
||||
return;
|
||||
}
|
||||
*shown = Some(health);
|
||||
let _ = icon.set_icon(icon::tray(health));
|
||||
let _ = icon.set_tooltip(Some(health.label()));
|
||||
}
|
||||
|
||||
/// 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());
|
||||
@@ -128,8 +141,22 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
);
|
||||
|
||||
// The icon belongs to this thread, so the answers come to it: the poller
|
||||
// queues them and the loop here takes them off once a second.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
watch::spawn(move |health| {
|
||||
let _ = tx.send(health);
|
||||
});
|
||||
let icon = built.icon;
|
||||
let mut shown = None;
|
||||
gtk::glib::timeout_add_seconds_local(1, move || {
|
||||
while let Ok(health) = rx.try_recv() {
|
||||
show(&icon, &mut shown, health);
|
||||
}
|
||||
gtk::glib::ControlFlow::Continue
|
||||
});
|
||||
gtk::main();
|
||||
drop(built.icon);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -148,9 +175,16 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
struct Pump {
|
||||
window: Arc<Window>,
|
||||
icon: Option<TrayIcon>,
|
||||
shown: Option<Health>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Pump {
|
||||
impl ApplicationHandler<Health> for Pump {
|
||||
fn user_event(&mut self, _event_loop: &ActiveEventLoop, health: Health) {
|
||||
if let Some(icon) = &self.icon {
|
||||
show(icon, &mut self.shown, health);
|
||||
}
|
||||
}
|
||||
|
||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
if self.icon.is_some() {
|
||||
return;
|
||||
@@ -170,7 +204,7 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
|
||||
let mut builder = EventLoop::builder();
|
||||
let mut builder = EventLoop::<Health>::with_user_event();
|
||||
// macOS: live in the menu bar with no Dock icon.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -178,33 +212,17 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
builder.with_activation_policy(ActivationPolicy::Accessory);
|
||||
}
|
||||
let event_loop = builder.build()?;
|
||||
let proxy = event_loop.create_proxy();
|
||||
watch::spawn(move |health| {
|
||||
let _ = proxy.send_event(health);
|
||||
});
|
||||
let mut pump = Pump {
|
||||
window: Arc::new(Window {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
icon: None,
|
||||
shown: None,
|
||||
};
|
||||
event_loop.run_app(&mut pump)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
@@ -283,32 +283,31 @@ fn draw_network(
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// In the corner while this device sends its traffic through
|
||||
// somebody: green while that works, red while it does not.
|
||||
if let Some(via) = &network.exit.via {
|
||||
let color = if network.exit.via_online {
|
||||
exit::GOOD
|
||||
} else {
|
||||
exit::LOUD
|
||||
};
|
||||
let response = ui
|
||||
.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
|
||||
)
|
||||
.sense(egui::Sense::click()),
|
||||
// In the bottom left corner while this device sends its traffic through
|
||||
// somebody: green while that works, red while it does not.
|
||||
if let Some(via) = &network.exit.via {
|
||||
let color = if network.exit.via_online {
|
||||
exit::GOOD
|
||||
} else {
|
||||
exit::LOUD
|
||||
};
|
||||
let response = ui
|
||||
.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!(
|
||||
"exit node: {} — click to stop",
|
||||
exit::name_of(network, via)
|
||||
));
|
||||
if response.clicked() {
|
||||
state.exit_confirm =
|
||||
Some(devices::ExitConfirm::stop_from_tile(network, via));
|
||||
}
|
||||
.sense(egui::Sense::click()),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!(
|
||||
"exit node: {} — click to stop",
|
||||
exit::name_of(network, via)
|
||||
));
|
||||
if response.clicked() {
|
||||
state.exit_confirm = Some(devices::ExitConfirm::stop_from_tile(network, via));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Show devices").clicked() {
|
||||
state.open_devices.insert(network.network_id.clone());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! What the tray icon shows: whether the agent has anybody to talk to, and
|
||||
//! whether this device sends its traffic through an exit node.
|
||||
//!
|
||||
//! The tray process has no window and no event loop of its own to poll from,
|
||||
//! so a thread asks the agent on an interval and hands each answer to a
|
||||
//! callback, which puts it where the platform wants it.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tsunagi::ipc::{self, StatusReport};
|
||||
|
||||
use crate::agent::resolve_socket;
|
||||
|
||||
/// How often the agent is asked.
|
||||
const INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// What the icon says.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Health {
|
||||
/// Nobody is connected, or the agent cannot be reached, or the exit node
|
||||
/// this device uses is gone, which blocks its traffic.
|
||||
Disconnected,
|
||||
/// Connected, nothing out of the ordinary.
|
||||
Connected,
|
||||
/// This device's traffic leaves through an exit node, and that works.
|
||||
ExitNode,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
/// The state a status report amounts to; `None` is an agent that did not
|
||||
/// answer.
|
||||
pub(crate) fn of(report: Option<&StatusReport>) -> Self {
|
||||
let Some(report) = report else {
|
||||
return Self::Disconnected;
|
||||
};
|
||||
if !report
|
||||
.networks
|
||||
.iter()
|
||||
.any(|network| !network.peers.is_empty())
|
||||
{
|
||||
return Self::Disconnected;
|
||||
}
|
||||
match report
|
||||
.networks
|
||||
.iter()
|
||||
.find(|network| network.exit.via.is_some())
|
||||
{
|
||||
Some(network) if network.exit.via_online => Self::ExitNode,
|
||||
Some(_) => Self::Disconnected,
|
||||
None => Self::Connected,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tooltip of the icon.
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Disconnected => "tsunagi: not connected",
|
||||
Self::Connected => "tsunagi: connected",
|
||||
Self::ExitNode => "tsunagi: using an exit node",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts asking the agent, and calls `notify` with every answer.
|
||||
pub(crate) fn spawn(notify: impl Fn(Health) + Send + 'static) {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("tray-status".into())
|
||||
.spawn(move || {
|
||||
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let report = runtime.block_on(async {
|
||||
tokio::time::timeout(INTERVAL, ipc::request_status(resolve_socket()))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
});
|
||||
notify(Health::of(report.as_ref()));
|
||||
std::thread::sleep(INTERVAL);
|
||||
}
|
||||
});
|
||||
if let Err(err) = spawned {
|
||||
eprintln!("cannot watch the agent for the tray icon: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tsunagi::ipc::{ExitReport, NetworkReport, PeerReport};
|
||||
|
||||
fn network(peers: usize, exit: ExitReport) -> NetworkReport {
|
||||
NetworkReport {
|
||||
peers: vec![PeerReport::default(); peers],
|
||||
exit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn report(networks: Vec<NetworkReport>) -> StatusReport {
|
||||
StatusReport {
|
||||
networks,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_agent_or_nobody_connected_is_the_red_state() {
|
||||
assert_eq!(Health::of(None), Health::Disconnected);
|
||||
assert_eq!(Health::of(Some(&report(vec![]))), Health::Disconnected);
|
||||
assert_eq!(
|
||||
Health::of(Some(&report(vec![network(0, ExitReport::default())]))),
|
||||
Health::Disconnected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_in_any_network_is_the_blue_state() {
|
||||
let status = report(vec![
|
||||
network(0, ExitReport::default()),
|
||||
network(2, ExitReport::default()),
|
||||
]);
|
||||
assert_eq!(Health::of(Some(&status)), Health::Connected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_node_in_use_is_green_while_it_works_and_red_once_it_is_gone() {
|
||||
let using = |online| ExitReport {
|
||||
via: Some("abc".into()),
|
||||
via_online: online,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
Health::of(Some(&report(vec![network(1, using(true))]))),
|
||||
Health::ExitNode
|
||||
);
|
||||
assert_eq!(
|
||||
Health::of(Some(&report(vec![network(1, using(false))]))),
|
||||
Health::Disconnected
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user