diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index 8b56a53..0fb74cc 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -14,9 +14,10 @@ tauri-build = { version = "2.5.5" } [dependencies] base64 = "0.22" +png = "0.17" rand_core = { version = "0.6", features = ["getrandom"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tauri = { version = "2.10.1", features = ["tray-icon"] } +tauri = { version = "2.10.1", features = ["tray-icon", "image-png"] } x25519-dalek = { version = "2.0", features = ["static_secrets"] } diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index ed19605..cc7011f 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -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, + disconnected_icon: Image<'static>, + connected_icon: Image<'static>, status_item: MenuItem, received_item: MenuItem, sent_item: MenuItem, @@ -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, 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,