Files
tsunagi/crates/tsunagi-tray/src/icon.rs
T
Aleksandr Bogomiakov ed28e86748 Derive default network ranges and switch overlapping networks
Every first network previously proposed 10.13.37.0/24. Independently created
networks therefore collided when a device joined more than one of them.
Use the existing stable NetworkId-derived /24 in 10/8 for every new network,
independent of activation order or peer availability. Legacy and explicit
signed ranges outrank the derived default; remaining range disagreements
still use author-id ordering. The derivation, identity labels, handshake and
signed record encoding stay unchanged. There is no creator, timestamp or vote.

Reserve ranges before starting network actors and serialise start, stop and
switching through the agent lifecycle lock. Refuse equal or nested overlaps
without changing a network's subnet locally. A late authenticated range that
collides suspends only its network before updating plugins or the interface.
Coalesced watch notifications let the agent clean up outside the network actor
and reconsider suspended networks when ranges change.

An explicit switch stops every blocker before activating the chosen network.
Suspended networks retain their startup request and resume whenever their
ranges become free, including switching from a wide range to a narrow one.
A manual stop cancels that request even for an already suspended network.
Persist preference order and enabling together in an SQLite transaction so
the user's selection survives restart; no database schema migration is needed.

CLI status and network listing show blocker names, ids, ranges and the start
command. Network start switches explicitly and reports suspended networks;
joining an overlapping network reports it as configured but inactive. The
tray adds conflict details, an amber icon, Switch to this network and Keep
stopped actions. Local IPC wire version is 19 for the extended reports, so
restart the agent and tray together. Release versions and peer wire formats
are unchanged. Update the user documentation and compatibility tests.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET
  removed from the test environment: 486 passed, 2 pre-existing ignored tests
- final focused range_conflicts integration suite after making discovery
  fixtures deterministic: 6 passed

Earlier validation attempts caught outdated diagnostic assertions, a new
test helper scope error, and the inherited system control socket override.
Those were corrected before the successful runs. Plain cargo was unavailable
in the host shell, so validation used the Nix development toolchain. macOS,
Windows, privileged host TUN operations and manual tray interaction were not
exercised.
2026-10-11 11:58:27 +01:00

137 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::RangeConflict => [0xd9, 0x91, 0x20],
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]);
}
}