use std::{ collections::HashMap, sync::{mpsc::Sender, Arc, Mutex}, }; #[cfg(target_os = "windows")] use image::{Rgba, RgbaImage}; use hyper_headset::devices::{DeviceEvent, DeviceProperties, PropertyType}; use tray_icon::{ menu::{CheckMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu}, TrayIcon, TrayIconBuilder, }; use winit::{application::ApplicationHandler, event::StartCause}; #[cfg(target_os = "windows")] use winreg::{ enums::{RegType, HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE}, RegKey, RegValue, }; #[cfg(target_os = "windows")] use crate::tray_battery_icon_state::{TrayBatteryIconState, WindowsIconKey}; const NO_COMPATIBLE_DEVICE: &str = "No compatible device found. Is the dongle plugged in?"; const HEADSET_NOT_CONNECTED: &str = "Headset is not connected"; #[cfg(target_os = "windows")] const RUN_KEY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; #[cfg(target_os = "windows")] const STARTUP_APPROVED_RUN_KEY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; #[cfg(target_os = "windows")] const STARTUP_VALUE_NAME: &str = "HyperHeadset"; const WINDOWS_ICON_SIZE: u32 = 16; #[cfg(target_os = "windows")] fn create_default_tray_icon() -> tray_icon::Icon { // embed a headset .ico/.png at compile time — no file needed at runtime let bytes = include_bytes!("../assets/headphone.png"); let img = image::load_from_memory(bytes).unwrap().into_rgba8(); let (w, h) = img.dimensions(); tray_icon::Icon::from_rgba(img.into_raw(), w, h).unwrap() } #[cfg(target_os = "windows")] fn draw_rect(image: &mut RgbaImage, x: i32, y: i32, width: i32, height: i32, color: Rgba) { for px in x.max(0)..(x + width).min(WINDOWS_ICON_SIZE as i32) { for py in y.max(0)..(y + height).min(WINDOWS_ICON_SIZE as i32) { image.put_pixel(px as u32, py as u32, color); } } } #[cfg(target_os = "windows")] fn draw_digit( image: &mut RgbaImage, digit: char, x: i32, y: i32, scale: i32, color: Rgba, ) { let rows = match digit { '0' => ["111", "101", "101", "101", "111"], // Narrow upright '1'. '1' => ["01", "01", "01", "01", "01"], '2' => ["111", "001", "111", "100", "111"], '3' => ["111", "001", "111", "001", "111"], '4' => ["101", "101", "111", "001", "001"], '5' => ["111", "100", "111", "001", "111"], '6' => ["111", "100", "111", "101", "111"], '7' => ["111", "001", "010", "010", "010"], '8' => ["111", "101", "111", "101", "111"], '9' => ["111", "101", "111", "001", "111"], _ => ["000", "000", "000", "000", "000"], }; for (row_index, row) in rows.iter().enumerate() { for (col_index, bit) in row.chars().enumerate() { if bit == '1' { draw_rect( image, x + (col_index as i32 * scale), y + (row_index as i32 * scale), scale, scale, color, ); } } } } #[cfg(target_os = "windows")] fn render_windows_battery_icon_rgba(key: WindowsIconKey) -> Vec { let mut image = RgbaImage::from_pixel(WINDOWS_ICON_SIZE, WINDOWS_ICON_SIZE, Rgba([0, 0, 0, 0])); // Charging overrides battery-level color with yellow background. let background_color = if key.charging { Rgba([245, 216, 64, 255]) } else if key.percent < 30 { Rgba([220, 90, 90, 255]) } else { Rgba([96, 196, 106, 255]) }; draw_rect( &mut image, 0, 0, WINDOWS_ICON_SIZE as i32, WINDOWS_ICON_SIZE as i32, background_color, ); // Custom compact "100" layout for 16x16: // keeps large text while enforcing spacing between all digits. if key.percent == 100 { let text_color = Rgba([10, 10, 10, 255]); let y = 3; // "1" (3x10) draw_rect(&mut image, 1, y, 1, 10, text_color); draw_rect(&mut image, 0, y + 9, 3, 1, text_color); // First "0" (5x10), 1px gap from "1" let z1 = 4; draw_rect(&mut image, z1, y, 5, 1, text_color); draw_rect(&mut image, z1, y + 9, 5, 1, text_color); draw_rect(&mut image, z1, y, 1, 10, text_color); draw_rect(&mut image, z1 + 4, y, 1, 10, text_color); // Second "0" (5x10), 1px gap from first "0" let z2 = 10; draw_rect(&mut image, z2, y, 5, 1, text_color); draw_rect(&mut image, z2, y + 9, 5, 1, text_color); draw_rect(&mut image, z2, y, 1, 10, text_color); draw_rect(&mut image, z2 + 4, y, 1, 10, text_color); return image.into_raw(); } let text = key.percent.to_string(); let mut scale = 2; // Borderless icon: preserve explicit outer padding + spacing between digits. let spacing = if text.len() >= 3 { 0 } else { 1 }; // Allow 100 to use full icon width so it can stay at scale 2. let horizontal_padding = if text.len() >= 3 { 0 } else { 1 }; let inner_left = horizontal_padding; let inner_right = (WINDOWS_ICON_SIZE as i32 - 1 - horizontal_padding).max(inner_left); let usable_width = (inner_right - inner_left + 1).max(1); let mut glyph_widths: Vec = text .chars() .map(|digit| if digit == '1' { 2 * scale } else { 3 * scale }) .collect(); let mut total_width: i32 = glyph_widths.iter().sum::() + spacing * (text.chars().count().saturating_sub(1) as i32); if total_width > usable_width && scale > 1 { // On 16x16 icons, enforce padding on both sides over large glyph size. scale = 1; glyph_widths = text .chars() .map(|digit| if digit == '1' { 2 * scale } else { 3 * scale }) .collect(); total_width = glyph_widths.iter().sum::() + spacing * (text.chars().count().saturating_sub(1) as i32); } let centered_start_x = inner_left + ((usable_width - total_width).max(0) / 2); let max_start_x = (inner_right - total_width + 1).max(inner_left); let start_x = centered_start_x.clamp(inner_left, max_start_x); let start_y = if scale == 2 { 3 } else { 5 }; let mut x = start_x; for (idx, digit) in text.chars().enumerate() { draw_digit(&mut image, digit, x, start_y, scale, Rgba([10, 10, 10, 255])); x += glyph_widths[idx] + spacing; } image.into_raw() } type CallbackMap = Arc>>>; pub struct TrayApp { pub tray_icon: Option, pub sender: Sender, callbacks: CallbackMap, current_state: Option>, #[cfg(target_os = "windows")] icon_cache: HashMap>, #[cfg(target_os = "windows")] current_icon_key: Option, } impl ApplicationHandler> for TrayApp { fn new_events(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop, cause: StartCause) { if cause == StartCause::Init { #[cfg(target_os = "windows")] unsafe { enable_dark_context_menus(); } #[cfg(target_os = "windows")] { self.tray_icon = Some( TrayIconBuilder::new() .with_menu(Box::new(Menu::new())) .with_icon(create_default_tray_icon()) .with_tooltip(NO_COMPATIBLE_DEVICE) .with_menu_on_left_click(true) .build() .unwrap(), ); } #[cfg(target_os = "macos")] { self.tray_icon = Some( TrayIconBuilder::new() .with_menu(Box::new(Menu::new())) .with_title("🎧") .with_tooltip(NO_COMPATIBLE_DEVICE) .with_menu_on_left_click(true) .build() .unwrap(), ); } self.update(None); } } fn user_event( &mut self, _el: &winit::event_loop::ActiveEventLoop, device_properties: Option, ) { self.update(device_properties); } fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {} fn window_event( &mut self, _event_loop: &winit::event_loop::ActiveEventLoop, _window_id: winit::window::WindowId, _event: winit::event::WindowEvent, ) { } } impl TrayApp { pub fn new(sender: Sender) -> Self { let callbacks: CallbackMap = Arc::new(Mutex::new(HashMap::new())); let callbacks_clone = Arc::clone(&callbacks); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { if let Ok(map) = callbacks_clone.try_lock() { if let Some(f) = map.get(&e.id) { f(); } } // Unknown id (read-only items, stale events) → silently ignored })); Self { tray_icon: None, sender, callbacks, current_state: None, #[cfg(target_os = "windows")] icon_cache: HashMap::new(), #[cfg(target_os = "windows")] current_icon_key: None, } } #[cfg(target_os = "windows")] fn update_windows_icon(&mut self, device_properties: Option<&DeviceProperties>) { let Some(tray) = self.tray_icon.as_ref() else { return; }; let icon_state = TrayBatteryIconState::from_device_properties(device_properties); let desired_key = icon_state.windows_icon_key(); if desired_key == self.current_icon_key { return; } if let Some(key) = desired_key { let rgba = self .icon_cache .entry(key) .or_insert_with(|| render_windows_battery_icon_rgba(key)) .clone(); if let Ok(icon) = tray_icon::Icon::from_rgba(rgba, WINDOWS_ICON_SIZE, WINDOWS_ICON_SIZE) { let _ = tray.set_icon(Some(icon)); } } else { let _ = tray.set_icon(Some(create_default_tray_icon())); } self.current_icon_key = desired_key; } fn update(&mut self, device_properties: Option) { if let Some(current_state) = self.current_state.as_ref() { if current_state == &device_properties { return; } } #[cfg(target_os = "windows")] self.update_windows_icon(device_properties.as_ref()); let Some(tray) = &mut self.tray_icon else { return; }; #[cfg(target_os = "windows")] let quit_item = MenuItem::new("Quit", true, None); let menu = Menu::new(); let mut new_callbacks: HashMap> = HashMap::new(); let Some(device_properties) = device_properties else { let _ = tray.set_tooltip(Some(NO_COMPATIBLE_DEVICE)); #[cfg(target_os = "macos")] tray.set_title(Some(&format!("🎧?"))); let status_item = MenuItem::new(NO_COMPATIBLE_DEVICE, false, None); menu.append(&status_item).unwrap(); menu.append(&PredefinedMenuItem::separator()).unwrap(); #[cfg(target_os = "windows")] { append_startup_toggle(&menu, &mut new_callbacks); menu.append(&quit_item).unwrap(); new_callbacks.insert(quit_item.id().clone(), Box::new(|| std::process::exit(0))); } #[cfg(target_os = "macos")] menu.append(&PredefinedMenuItem::quit(Some("Quit"))) .unwrap(); *self.callbacks.lock().unwrap() = new_callbacks; tray.set_menu(Some(Box::new(menu))); self.current_state = Some(device_properties); return; }; if !device_properties.connected.unwrap_or(false) { let _ = tray.set_tooltip(Some(HEADSET_NOT_CONNECTED)); #[cfg(target_os = "macos")] tray.set_title(Some(&format!("🎧?"))); let status_item = MenuItem::new(HEADSET_NOT_CONNECTED, false, None); menu.append(&status_item).unwrap(); menu.append(&PredefinedMenuItem::separator()).unwrap(); #[cfg(target_os = "windows")] { append_startup_toggle(&menu, &mut new_callbacks); menu.append(&quit_item).unwrap(); new_callbacks.insert(quit_item.id().clone(), Box::new(|| std::process::exit(0))); } #[cfg(target_os = "macos")] menu.append(&PredefinedMenuItem::quit(Some("Quit"))) .unwrap(); *self.callbacks.lock().unwrap() = new_callbacks; tray.set_menu(Some(Box::new(menu))); self.current_state = Some(Some(device_properties)); return; } #[cfg(target_os = "macos")] let _ = tray.set_tooltip(Some( device_properties .to_string_with_padding(0) .lines() .filter(|l| !l.contains("Unknown")) .collect::>() .join("\n"), )); #[cfg(target_os = "windows")] let _ = tray.set_tooltip(Some( device_properties .to_string_with_padding(0) .lines() .take(2) .filter(|l| !l.contains("Unknown")) .collect::>() .join("\n"), )); #[cfg(target_os = "macos")] if let Some(battery_level) = device_properties.battery_level { tray.set_title(Some(&format!("🎧 {battery_level}%"))); } for property in device_properties.get_properties() { match property { hyper_headset::devices::PropertyDescriptorWrapper::Int(property, []) => { let Some(current_value) = property.data else { continue; }; let menu_item = MenuItem::new( format!("{} {}{}", property.prefix, current_value, property.suffix), false, None, ); let _ = menu.append(&menu_item); } hyper_headset::devices::PropertyDescriptorWrapper::Int(property, items) => { let Some(current_value) = property.data else { continue; }; let submenu = Submenu::new( format!("{} {}{}", property.prefix, current_value, property.suffix), property.property_type == PropertyType::ReadWrite, ); for item_value in items { let entry = MenuItem::new(format!("{}{}", item_value, property.suffix), true, None); submenu.append(&entry).unwrap(); let create_event = property.create_event; let tx = self.sender.clone(); let entry_id = entry.id().clone(); new_callbacks.insert( entry_id, Box::new(move || { if let Some(event) = (create_event)(*item_value) { let _ = tx.send(event); } }), ); } menu.append(&submenu).unwrap(); } hyper_headset::devices::PropertyDescriptorWrapper::Bool(property) => { let Some(current_value) = property.data else { continue; }; let create_event = property.create_event; let update_sender = self.sender.clone(); let menu_item = MenuItem::new( format!("{} {}{}", property.prefix, current_value, property.suffix), property.property_type == PropertyType::ReadWrite && property.data.is_some(), None, ); let _ = menu.append(&menu_item); let menu_itme_id = menu_item.id().clone(); new_callbacks.insert( menu_itme_id, Box::new(move || { if let Some(command) = (create_event)(!current_value) { let _ = update_sender.send(command); } }), ); } hyper_headset::devices::PropertyDescriptorWrapper::String(property) => { let Some(current_value) = property.data else { continue; }; let menu_item = MenuItem::new( format!("{} {}{}", property.prefix, current_value, property.suffix), false, None, ); let _ = menu.append(&menu_item); } } } menu.append(&PredefinedMenuItem::separator()).unwrap(); #[cfg(target_os = "windows")] { append_startup_toggle(&menu, &mut new_callbacks); menu.append(&quit_item).unwrap(); new_callbacks.insert(quit_item.id().clone(), Box::new(|| std::process::exit(0))); } #[cfg(target_os = "macos")] menu.append(&PredefinedMenuItem::quit(Some("Quit"))) .unwrap(); *self.callbacks.lock().unwrap() = new_callbacks; tray.set_menu(Some(Box::new(menu))); self.current_state = Some(Some(device_properties)); } } #[cfg(target_os = "windows")] fn append_startup_toggle( menu: &Menu, callbacks: &mut HashMap>, ) { let startup_enabled = is_start_with_windows_enabled(); let startup_item = CheckMenuItem::new("Start with Windows", true, startup_enabled, None); let _ = menu.append(&startup_item); callbacks.insert( startup_item.id().clone(), Box::new(|| { let currently_enabled = is_start_with_windows_enabled(); if let Err(error) = set_start_with_windows_enabled(!currently_enabled) { eprintln!("Failed to update startup setting: {error}"); } }), ); } #[cfg(target_os = "windows")] fn startup_command_line() -> std::io::Result { let exe_path = std::env::current_exe()?; Ok(format!("\"{}\"", exe_path.display())) } #[cfg(target_os = "windows")] fn open_run_key_with_access(access: u32) -> std::io::Result { RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(RUN_KEY_PATH, access) } #[cfg(target_os = "windows")] fn open_or_create_run_key_with_access(access: u32) -> std::io::Result { let hkcu = RegKey::predef(HKEY_CURRENT_USER); let (run_key, _) = hkcu.create_subkey_with_flags(RUN_KEY_PATH, access)?; Ok(run_key) } #[cfg(target_os = "windows")] fn open_startup_approved_key_with_access(access: u32) -> std::io::Result { RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(STARTUP_APPROVED_RUN_KEY_PATH, access) } #[cfg(target_os = "windows")] fn open_or_create_startup_approved_key_with_access(access: u32) -> std::io::Result { let hkcu = RegKey::predef(HKEY_CURRENT_USER); let (key, _) = hkcu.create_subkey_with_flags(STARTUP_APPROVED_RUN_KEY_PATH, access)?; Ok(key) } #[cfg(target_os = "windows")] fn startup_approved_state() -> Option { let Ok(key) = open_startup_approved_key_with_access(KEY_READ) else { return None; }; let Ok(value) = key.get_raw_value(STARTUP_VALUE_NAME) else { return None; }; match value.bytes.first().copied() { Some(0x02) => Some(true), Some(0x03) => Some(false), _ => None, } } #[cfg(target_os = "windows")] fn set_startup_approved_state(enabled: bool) -> std::io::Result<()> { let key = open_or_create_startup_approved_key_with_access(KEY_SET_VALUE)?; // 0x02 => enabled, 0x03 => disabled (same convention used by Startup Apps) let state = if enabled { 0x02u8 } else { 0x03u8 }; key.set_raw_value( STARTUP_VALUE_NAME, &RegValue { vtype: RegType::REG_BINARY, bytes: vec![state, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0].into(), }, )?; Ok(()) } #[cfg(target_os = "windows")] fn is_start_with_windows_enabled() -> bool { let Ok(run_key) = open_run_key_with_access(KEY_READ) else { return false; }; if run_key.get_value::(STARTUP_VALUE_NAME).is_err() { return false; } startup_approved_state().unwrap_or(true) } #[cfg(target_os = "windows")] fn set_start_with_windows_enabled(enabled: bool) -> std::io::Result<()> { let run_key = open_or_create_run_key_with_access(KEY_SET_VALUE)?; if enabled { run_key.set_value(STARTUP_VALUE_NAME, &startup_command_line()?)?; set_startup_approved_state(true)?; } else { // Keep the Run entry so Windows Startup Apps can manage the toggle too. if run_key.get_value::(STARTUP_VALUE_NAME).is_err() { run_key.set_value(STARTUP_VALUE_NAME, &startup_command_line()?)?; } set_startup_approved_state(false)?; } Ok(()) } #[cfg(target_os = "windows")] /// Dark magic to set dark mode unsafe fn enable_dark_context_menus() { use windows::core::PCSTR; use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; let uxtheme = LoadLibraryW(windows::core::w!("uxtheme.dll")).unwrap(); // SetPreferredAppMode is ordinal 135 (undocumented, no name export) type SetPreferredAppMode = unsafe extern "system" fn(i32) -> i32; if let Some(func) = GetProcAddress(uxtheme, PCSTR(135 as *const u8)) { let set_mode: SetPreferredAppMode = std::mem::transmute(func); set_mode(1); // 1 = AllowDark (follows system theme) } // FlushMenuThemes is ordinal 136 — applies the change immediately type FlushMenuThemes = unsafe extern "system" fn(); if let Some(func) = GetProcAddress(uxtheme, PCSTR(136 as *const u8)) { let flush: FlushMenuThemes = std::mem::transmute(func); flush(); } }