use std::sync::{Arc, Mutex};
use tracing::{debug, info, warn};
use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
use super::types::Co2Level;
static EGUI_CTX: Mutex<Option<egui::Context>> = Mutex::new(None);
static TRAY_EVENTS: Mutex<Vec<TrayIconEvent>> = Mutex::new(Vec::new());
static MENU_EVENTS: Mutex<Vec<MenuEvent>> = Mutex::new(Vec::new());
pub fn set_egui_context(ctx: egui::Context) {
if let Ok(mut guard) = EGUI_CTX.lock() {
*guard = Some(ctx);
}
}
#[derive(Debug)]
pub enum TrayError {
IconLoad(String),
TrayIcon(tray_icon::Error),
Menu(tray_icon::menu::Error),
}
impl std::fmt::Display for TrayError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrayError::IconLoad(s) => write!(f, "Failed to load icon: {}", s),
TrayError::TrayIcon(e) => write!(f, "Tray icon error: {}", e),
TrayError::Menu(e) => write!(f, "Menu error: {}", e),
}
}
}
impl std::error::Error for TrayError {}
impl From<tray_icon::Error> for TrayError {
fn from(e: tray_icon::Error) -> Self {
TrayError::TrayIcon(e)
}
}
impl From<tray_icon::menu::Error> for TrayError {
fn from(e: tray_icon::menu::Error) -> Self {
TrayError::Menu(e)
}
}
const ICON_PNG: &[u8] = include_bytes!("../../assets/aranet-icon.png");
#[derive(Debug, Clone)]
pub enum TrayCommand {
ShowWindow,
HideWindow,
ToggleWindow,
Scan,
RefreshAll,
OpenSettings,
Quit,
}
#[derive(Debug, Default)]
pub struct TrayState {
pub co2_level: Option<Co2Level>,
pub co2_ppm: Option<u16>,
pub window_visible: bool,
pub device_name: Option<String>,
pub last_alert_level: Option<Co2Level>,
pub colored_tray_icon: bool,
pub notifications_enabled: bool,
pub notification_sound: bool,
pub do_not_disturb: bool,
}
impl TrayState {
pub fn tooltip(&self) -> String {
let mut parts = vec!["Aranet".to_string()];
if let Some(name) = &self.device_name {
parts.push(format!("Device: {}", name));
}
if let Some(co2) = self.co2_ppm {
let level_text = match Co2Level::from_ppm(co2) {
Co2Level::Good => "Good",
Co2Level::Moderate => "Moderate",
Co2Level::Poor => "Poor",
Co2Level::Bad => "Bad",
};
parts.push(format!("CO2: {} ppm ({})", co2, level_text));
}
parts.join("\n")
}
}
pub struct TrayManager {
tray_icon: TrayIcon,
status_item: MenuItem,
scan_item: MenuItem,
refresh_item: MenuItem,
settings_item: MenuItem,
show_item: MenuItem,
hide_item: MenuItem,
quit_item: MenuItem,
state: Arc<Mutex<TrayState>>,
}
impl TrayManager {
pub fn new(state: Arc<Mutex<TrayState>>) -> Result<Self, TrayError> {
let (window_visible, colored_tray_icon) = state
.lock()
.map(|s| (s.window_visible, s.colored_tray_icon))
.unwrap_or((true, true));
let (icon, is_template) = load_tray_icon_for_level(None, colored_tray_icon)?;
let status_item = MenuItem::new("Aranet - No reading", false, None);
let scan_item = MenuItem::new("Scan for Devices", true, None);
let refresh_item = MenuItem::new("Refresh All", true, None);
let settings_item = MenuItem::new("Settings...", true, None);
let show_item = MenuItem::new("Show Aranet", !window_visible, None);
let hide_item = MenuItem::new("Hide to Tray", window_visible, None);
let quit_item = MenuItem::new("Quit", true, None);
let menu = Menu::new();
menu.append_items(&[
&status_item,
&PredefinedMenuItem::separator(),
&scan_item,
&refresh_item,
&settings_item,
&PredefinedMenuItem::separator(),
&show_item,
&hide_item,
&PredefinedMenuItem::separator(),
&quit_item,
])?;
let tooltip = state.lock().map(|s| s.tooltip()).unwrap_or_default();
let tray_icon = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip(&tooltip)
.with_icon(icon)
.with_icon_as_template(is_template)
.with_menu_on_left_click(false)
.build()?;
TrayIconEvent::set_event_handler(Some(move |event| {
debug!("TrayIconEvent received: {:?}", event);
if let Ok(mut guard) = TRAY_EVENTS.lock() {
guard.push(event);
}
if let Ok(guard) = EGUI_CTX.lock()
&& let Some(ctx) = guard.as_ref()
{
ctx.request_repaint();
}
}));
MenuEvent::set_event_handler(Some(move |event| {
debug!("MenuEvent received: {:?}", event);
if let Ok(mut guard) = MENU_EVENTS.lock() {
guard.push(event);
}
if let Ok(guard) = EGUI_CTX.lock()
&& let Some(ctx) = guard.as_ref()
{
ctx.request_repaint();
}
}));
info!("System tray icon created");
Ok(Self {
tray_icon,
status_item,
scan_item,
refresh_item,
settings_item,
show_item,
hide_item,
quit_item,
state,
})
}
pub fn process_events(&self) -> Vec<TrayCommand> {
let mut commands = Vec::new();
let menu_events: Vec<MenuEvent> = if let Ok(mut guard) = MENU_EVENTS.lock() {
std::mem::take(&mut *guard)
} else {
Vec::new()
};
for event in menu_events {
if event.id == self.scan_item.id() {
debug!("Tray: Scan clicked");
commands.push(TrayCommand::ShowWindow); commands.push(TrayCommand::Scan);
} else if event.id == self.refresh_item.id() {
debug!("Tray: Refresh All clicked");
commands.push(TrayCommand::RefreshAll);
} else if event.id == self.settings_item.id() {
debug!("Tray: Settings clicked");
commands.push(TrayCommand::ShowWindow); commands.push(TrayCommand::OpenSettings);
} else if event.id == self.show_item.id() {
debug!("Tray: Show window clicked");
commands.push(TrayCommand::ShowWindow);
} else if event.id == self.hide_item.id() {
debug!("Tray: Hide window clicked");
commands.push(TrayCommand::HideWindow);
} else if event.id == self.quit_item.id() {
debug!("Tray: Quit clicked");
commands.push(TrayCommand::Quit);
}
}
let tray_events: Vec<TrayIconEvent> = if let Ok(mut guard) = TRAY_EVENTS.lock() {
std::mem::take(&mut *guard)
} else {
Vec::new()
};
for event in tray_events {
match event {
TrayIconEvent::Click {
button,
button_state,
..
} => {
if button == tray_icon::MouseButton::Left
&& button_state == tray_icon::MouseButtonState::Up
{
debug!("Tray: Left click - toggle window");
commands.push(TrayCommand::ToggleWindow);
}
}
TrayIconEvent::DoubleClick { button, .. } => {
if button == tray_icon::MouseButton::Left {
debug!("Tray: Double click - show window");
commands.push(TrayCommand::ShowWindow);
}
}
_ => {}
}
}
commands
}
pub fn update_tooltip(&self) {
if let Ok(state) = self.state.lock() {
let tooltip = state.tooltip();
if let Err(e) = self.tray_icon.set_tooltip(Some(&tooltip)) {
warn!("Failed to update tray tooltip: {}", e);
}
let (status_text, level) = if let Some(co2) = state.co2_ppm {
let level = Co2Level::from_ppm(co2);
let level_text = match level {
Co2Level::Good => "Good",
Co2Level::Moderate => "Moderate",
Co2Level::Poor => "Poor",
Co2Level::Bad => "Bad",
};
(format!("CO2: {} ppm ({})", co2, level_text), Some(level))
} else {
("Aranet - No reading".to_string(), None)
};
self.status_item.set_text(&status_text);
self.update_icon_color(level.as_ref(), state.colored_tray_icon);
self.show_item.set_enabled(!state.window_visible);
self.hide_item.set_enabled(state.window_visible);
}
}
fn update_icon_color(&self, level: Option<&Co2Level>, use_colored: bool) {
match load_tray_icon_for_level(level, use_colored) {
Ok((icon, is_template)) => {
if let Err(e) = self
.tray_icon
.set_icon_with_as_template(Some(icon), is_template)
{
warn!("Failed to update tray icon: {}", e);
}
}
Err(e) => {
warn!("Failed to generate icon: {}", e);
}
}
}
}
fn load_tray_icon_for_level(
level: Option<&Co2Level>,
use_colored: bool,
) -> Result<(Icon, bool), TrayError> {
let mut img = image::load_from_memory(ICON_PNG)
.map_err(|e| TrayError::IconLoad(e.to_string()))?
.into_rgba8();
let use_template = if use_colored {
match level {
None | Some(Co2Level::Good) => true,
Some(Co2Level::Moderate | Co2Level::Poor | Co2Level::Bad) => false,
}
} else {
true
};
if use_template {
for pixel in img.pixels_mut() {
if pixel[3] > 0 {
pixel[0] = 255;
pixel[1] = 255;
pixel[2] = 255;
}
}
} else {
let (r, g, b) = match level {
Some(Co2Level::Moderate) => (255, 193, 7), Some(Co2Level::Poor) => (255, 152, 0), Some(Co2Level::Bad) => (244, 67, 54), _ => unreachable!(),
};
for pixel in img.pixels_mut() {
if pixel[3] > 128 {
pixel[0] = r;
pixel[1] = g;
pixel[2] = b;
}
}
}
let (width, height) = img.dimensions();
let icon = Icon::from_rgba(img.into_raw(), width, height)
.map_err(|e| TrayError::IconLoad(e.to_string()))?;
Ok((icon, use_template))
}
#[allow(unused_variables)]
pub fn send_notification(title: &str, body: &str, is_critical: bool, play_sound: bool) {
use notify_rust::Notification;
let mut notification = Notification::new();
notification.summary(title).body(body).appname("Aranet");
#[cfg(target_os = "macos")]
{
if play_sound {
notification.sound_name("default");
}
}
#[cfg(target_os = "linux")]
{
if is_critical {
notification.urgency(notify_rust::Urgency::Critical);
}
}
match notification.show() {
Ok(_) => debug!("Notification sent: {} - {}", title, body),
Err(e) => warn!("Failed to send notification: {}", e),
}
}
pub fn check_co2_threshold(state: &mut TrayState, co2_ppm: u16, device_name: &str) {
let level = Co2Level::from_ppm(co2_ppm);
if state.notifications_enabled && !state.do_not_disturb {
let should_notify = match (&state.last_alert_level, &level) {
(None, Co2Level::Poor | Co2Level::Bad) => true,
(Some(Co2Level::Good), Co2Level::Poor | Co2Level::Bad) => true,
(Some(Co2Level::Moderate), Co2Level::Poor | Co2Level::Bad) => true,
(Some(Co2Level::Poor), Co2Level::Bad) => true,
(Some(Co2Level::Bad | Co2Level::Poor), Co2Level::Good) => true,
_ => false,
};
if should_notify {
let (title, body, is_critical) = match level {
Co2Level::Good => (
"CO2 Level Normal",
format!("{}: {} ppm - Air quality is good", device_name, co2_ppm),
false,
),
Co2Level::Moderate => (
"CO2 Level Moderate",
format!("{}: {} ppm - Consider ventilating", device_name, co2_ppm),
false,
),
Co2Level::Poor => (
"CO2 Level Poor",
format!("{}: {} ppm - Ventilation recommended", device_name, co2_ppm),
true,
),
Co2Level::Bad => (
"CO2 Level Critical",
format!("{}: {} ppm - Ventilate immediately!", device_name, co2_ppm),
true,
),
};
send_notification(title, &body, is_critical, state.notification_sound);
state.last_alert_level = Some(level);
}
}
state.co2_level = Some(level);
state.co2_ppm = Some(co2_ppm);
}
#[cfg(target_os = "macos")]
pub fn hide_dock_icon() {
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
use objc2_foundation::MainThreadMarker;
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
debug!("Dock icon hidden");
} else {
warn!("Cannot hide dock icon: not on main thread");
}
}
#[cfg(target_os = "macos")]
pub fn show_dock_icon() {
use objc2::ClassType;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSImage};
use objc2_foundation::{MainThreadMarker, NSData};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
let icon_data = NSData::with_bytes(ICON_PNG);
if let Some(icon) = NSImage::initWithData(NSImage::alloc(), &icon_data) {
#[allow(unsafe_code)]
unsafe {
app.setApplicationIconImage(Some(&icon));
}
debug!("Dock icon shown with custom icon");
} else {
debug!("Dock icon shown (failed to load custom icon)");
}
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
debug!("App activated");
} else {
warn!("Cannot show dock icon: not on main thread");
}
}
#[cfg(not(target_os = "macos"))]
pub fn hide_dock_icon() {
}
#[cfg(not(target_os = "macos"))]
pub fn show_dock_icon() {
}