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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user