feat: add dynamic tray icon with connected/disconnected states and green checkmark badge

Add png dependency and tauri image-png feature to support custom tray icon rendering. Load base disconnected icon from bundled PNG and generate connected variant with green circular badge containing white checkmark overlay. Implement draw_check_badge, draw_line, and blend_pixel helpers using Bresenham's line algorithm for badge rendering. Store both icon variants and TrayIcon reference in TrayState and update icon
This commit is contained in:
2026-03-18 10:28:03 +01:00
parent 9f32c273e0
commit 799bc6550e
2 changed files with 131 additions and 9 deletions

View File

@@ -1,14 +1,16 @@
mod tunnel_manager;
use std::{fs, net::TcpListener, path::PathBuf, sync::Mutex};
use std::{fs, io::Cursor, net::TcpListener, path::PathBuf, sync::Mutex};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use png::{ColorType, Decoder};
use rand_core::OsRng;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItem, MenuItemBuilder, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, State, WebviewWindow, Window, WindowEvent, Wry,
};
use x25519_dalek::{PublicKey, StaticSecret};
@@ -24,6 +26,9 @@ struct AppState {
}
struct TrayState {
tray_icon: TrayIcon<Wry>,
disconnected_icon: Image<'static>,
connected_icon: Image<'static>,
status_item: MenuItem<Wry>,
received_item: MenuItem<Wry>,
sent_item: MenuItem<Wry>,
@@ -535,6 +540,12 @@ fn update_tray_menu(app: &AppHandle, metrics: TunnelMetrics) -> Result<(), Strin
let _ = tray.received_item.set_text(format!("Received: {}", format_data_size(metrics.rx_bytes)));
let _ = tray.sent_item.set_text(format!("Sent: {}", format_data_size(metrics.tx_bytes)));
let _ = tray.toggle_item.set_text(toggle_text);
let icon = if metrics.active {
tray.connected_icon.clone()
} else {
tray.disconnected_icon.clone()
};
let _ = tray.tray_icon.set_icon(Some(icon));
Ok(())
}
@@ -589,12 +600,121 @@ fn hide_main_window(window: &Window) {
let _ = window.hide();
}
fn load_tray_icons() -> Result<(Image<'static>, Image<'static>), String> {
let disconnected = Image::from_bytes(include_bytes!("../icons/icon.png"))
.map_err(|err| format!("Unable to load tray icon: {err}"))?
.to_owned();
let connected = build_connected_tray_icon()?;
Ok((disconnected, connected))
}
fn build_connected_tray_icon() -> Result<Image<'static>, String> {
let decoder = Decoder::new(Cursor::new(include_bytes!("../icons/icon.png")));
let mut reader = decoder
.read_info()
.map_err(|err| format!("Unable to decode tray icon: {err}"))?;
let mut buffer = vec![0; reader.output_buffer_size()];
let info = reader
.next_frame(&mut buffer)
.map_err(|err| format!("Unable to read tray icon frame: {err}"))?;
if info.color_type != ColorType::Rgba {
return Err("Unsupported tray icon color format".into());
}
let width = info.width;
let height = info.height;
let pixels = &mut buffer[..info.buffer_size()];
draw_check_badge(pixels, width as usize, height as usize);
Ok(Image::new_owned(pixels.to_vec(), width, height))
}
fn draw_check_badge(pixels: &mut [u8], width: usize, height: usize) {
let radius = ((width.min(height) as f32) * 0.22_f32).max(5.0) as i32;
let center_x = width as i32 - radius - 1;
let center_y = height as i32 - radius - 1;
for y in 0..height as i32 {
for x in 0..width as i32 {
let dx = x - center_x;
let dy = y - center_y;
if dx * dx + dy * dy <= radius * radius {
blend_pixel(pixels, width, x as usize, y as usize, [42, 199, 105, 255]);
}
}
}
let start_x = center_x - radius / 2;
let start_y = center_y + radius / 10;
let mid_x = center_x - radius / 8;
let mid_y = center_y + radius / 2;
let end_x = center_x + radius / 2;
let end_y = center_y - radius / 3;
draw_line(pixels, width, start_x, start_y, mid_x, mid_y, [255, 255, 255, 255]);
draw_line(pixels, width, mid_x, mid_y, end_x, end_y, [255, 255, 255, 255]);
draw_line(pixels, width, start_x, start_y - 1, mid_x, mid_y - 1, [255, 255, 255, 255]);
draw_line(pixels, width, mid_x, mid_y - 1, end_x, end_y - 1, [255, 255, 255, 255]);
}
fn draw_line(
pixels: &mut [u8],
width: usize,
mut x0: i32,
mut y0: i32,
x1: i32,
y1: i32,
color: [u8; 4],
) {
let dx = (x1 - x0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let dy = -(y1 - y0).abs();
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
loop {
if x0 >= 0 && y0 >= 0 {
blend_pixel(pixels, width, x0 as usize, y0 as usize, color);
}
if x0 == x1 && y0 == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x0 += sx;
}
if e2 <= dx {
err += dx;
y0 += sy;
}
}
}
fn blend_pixel(pixels: &mut [u8], width: usize, x: usize, y: usize, color: [u8; 4]) {
let index = (y * width + x) * 4;
if index + 3 >= pixels.len() {
return;
}
let alpha = color[3] as f32 / 255.0;
let inverse = 1.0 - alpha;
pixels[index] = (color[0] as f32 * alpha + pixels[index] as f32 * inverse) as u8;
pixels[index + 1] = (color[1] as f32 * alpha + pixels[index + 1] as f32 * inverse) as u8;
pixels[index + 2] = (color[2] as f32 * alpha + pixels[index + 2] as f32 * inverse) as u8;
pixels[index + 3] = ((color[3] as f32) + pixels[index + 3] as f32 * inverse).min(255.0) as u8;
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let single_instance_lock = TcpListener::bind(SINGLE_INSTANCE_ADDR)
.map_err(|_| format!("{} is already running.", app.package_info().name))?;
let (disconnected_icon, connected_icon) = load_tray_icons()?;
let status_item = MenuItemBuilder::with_id("status", "Status: Disconnected").build(app)?;
let received_item = MenuItemBuilder::with_id("received", "Received: 0 B").build(app)?;
@@ -617,12 +737,10 @@ pub fn run() {
])
.build()?;
let mut tray = TrayIconBuilder::new().menu(&menu).show_menu_on_left_click(false);
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray
let tray_icon = TrayIconBuilder::new()
.menu(&menu)
.show_menu_on_left_click(false)
.icon(disconnected_icon.clone())
.on_menu_event(|app, event| match event.id().as_ref() {
"status" | "received" | "sent" => {}
"toggle" => {
@@ -656,6 +774,9 @@ pub fn run() {
app.manage(AppState {
session: Mutex::new(None),
tray: Mutex::new(Some(TrayState {
tray_icon,
disconnected_icon,
connected_icon,
status_item,
received_item,
sent_item,