Files
tsunagi/crates/tsunagi-tray/src/watch.rs
T

174 lines
5.5 KiB
Rust
Raw Normal View History

//! 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,
/// At least one configured network is blocked by an overlapping active range.
RangeConflict,
/// 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.auto_start && !network.range_conflicts.is_empty())
{
return Self::RangeConflict;
}
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::RangeConflict => "tsunagi: network range conflict; open to switch networks",
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 overlapping_networks_are_visible_even_while_other_peers_are_connected() {
let mut blocked = NetworkReport {
auto_start: true,
..Default::default()
};
blocked
.range_conflicts
.push(tsunagi::ipc::RangeConflictReport {
name: "other".into(),
network_id: "other-id".into(),
range: "10.90.0.0/16".into(),
});
let status = report(vec![network(2, ExitReport::default()), blocked]);
assert_eq!(Health::of(Some(&status)), Health::RangeConflict);
assert!(Health::RangeConflict.label().contains("switch networks"));
}
#[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
);
}
}