Add tray / menu bar app for MacOS and Windows
This commit is contained in:
2239
Cargo.lock
generated
2239
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,9 @@ clap = { version = "4.5.32", features = ["derive"] }
|
|||||||
enigo = "0.6.1"
|
enigo = "0.6.1"
|
||||||
hidapi = { path = "vendor/hidapi" }
|
hidapi = { path = "vendor/hidapi" }
|
||||||
thistermination = "1.0.0"
|
thistermination = "1.0.0"
|
||||||
|
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||||
|
tray-icon = "0.21.3"
|
||||||
|
winit = "0.30.13"
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dialog = "0.3.0"
|
dialog = "0.3.0"
|
||||||
ksni = "0.2.0"
|
ksni = "0.2.0"
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ pub struct DeviceState {
|
|||||||
pub device_properties: DeviceProperties,
|
pub device_properties: DeviceProperties,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DeviceProperties {
|
pub struct DeviceProperties {
|
||||||
pub product_id: u16,
|
pub product_id: u16,
|
||||||
pub vendor_id: u16,
|
pub vendor_id: u16,
|
||||||
@@ -376,7 +376,7 @@ pub struct PropertyDescriptor<T: 'static> {
|
|||||||
pub data: Option<T>,
|
pub data: Option<T>,
|
||||||
pub suffix: &'static str,
|
pub suffix: &'static str,
|
||||||
pub property_type: PropertyType,
|
pub property_type: PropertyType,
|
||||||
pub create_event: &'static dyn Fn(T) -> Option<DeviceEvent>,
|
pub create_event: &'static (dyn Fn(T) -> Option<DeviceEvent> + Send + Sync),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Debug> Debug for PropertyDescriptor<T> {
|
impl<T: Debug> Debug for PropertyDescriptor<T> {
|
||||||
@@ -675,7 +675,7 @@ pub enum DeviceEvent {
|
|||||||
NoiseGateActive(bool),
|
NoiseGateActive(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||||
pub enum Color {
|
pub enum Color {
|
||||||
BlackBlack,
|
BlackBlack,
|
||||||
WhiteWhite,
|
WhiteWhite,
|
||||||
|
|||||||
126
src/main.rs
126
src/main.rs
@@ -1,9 +1,113 @@
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod status_tray;
|
mod status_tray;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
mod status_tray_not_linux;
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn main() {
|
fn main() {
|
||||||
eprintln!("The tray app currently only supports Linux. Please use hyper_headset_cli instead.");
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
use hyper_headset::devices::{DeviceEvent, DeviceProperties};
|
||||||
|
use winit::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
|
||||||
|
|
||||||
|
use crate::status_tray_not_linux::TrayApp;
|
||||||
|
|
||||||
|
let event_loop: EventLoop<Option<DeviceProperties>> =
|
||||||
|
EventLoop::with_user_event().build().unwrap();
|
||||||
|
let proxy: EventLoopProxy<Option<DeviceProperties>> = event_loop.create_proxy();
|
||||||
|
event_loop.set_control_flow(ControlFlow::Wait);
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel::<DeviceEvent>();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::{Arg, Command};
|
||||||
|
use enigo::{Direction, Enigo, Key, Keyboard, Settings};
|
||||||
|
|
||||||
|
use hyper_headset::devices::connect_compatible_device;
|
||||||
|
|
||||||
|
let matches = Command::new(env!("CARGO_PKG_NAME"))
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.author(env!("CARGO_PKG_AUTHORS"))
|
||||||
|
.about("A tray application for monitoring HyperX headsets.")
|
||||||
|
.arg(
|
||||||
|
Arg::new("refresh_interval")
|
||||||
|
.long("refresh_interval")
|
||||||
|
.required(false)
|
||||||
|
.help("Set the refresh interval (in seconds)")
|
||||||
|
.default_value("3")
|
||||||
|
.value_parser(clap::value_parser!(u64)),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("press_mute_key")
|
||||||
|
.long("press_mute_key")
|
||||||
|
.required(false)
|
||||||
|
.help("The app will simulate pressing the microphone mute key whoever the headsets is muted or unmuted.")
|
||||||
|
.default_value("true")
|
||||||
|
.value_parser(clap::value_parser!(bool)),
|
||||||
|
)
|
||||||
|
.get_matches();
|
||||||
|
|
||||||
|
let mut enigo = Enigo::new(&Settings::default()).unwrap();
|
||||||
|
let refresh_interval = *matches.get_one::<u64>("refresh_interval").unwrap_or(&3);
|
||||||
|
let press_mute_key = *matches.get_one::<bool>("press_mute_key").unwrap_or(&true);
|
||||||
|
let refresh_interval = Duration::from_secs(refresh_interval);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut device = loop {
|
||||||
|
match connect_compatible_device() {
|
||||||
|
Ok(d) => break d,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = proxy.send_event(None);
|
||||||
|
eprintln!("Connecting failed with error: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run loop
|
||||||
|
let mut run_counter = 0;
|
||||||
|
loop {
|
||||||
|
let mute_state = device.get_device_state().device_properties.muted;
|
||||||
|
match if run_counter % 30 == 0 {
|
||||||
|
device.active_refresh_state()
|
||||||
|
} else {
|
||||||
|
device.passive_refresh_state()
|
||||||
|
} {
|
||||||
|
Ok(()) => (),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
let _ = proxy
|
||||||
|
.send_event(Some(device.get_device_state().device_properties.clone()));
|
||||||
|
break; // try to reconnect
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if mute_state.is_some()
|
||||||
|
&& mute_state != device.get_device_state().device_properties.muted
|
||||||
|
{
|
||||||
|
if press_mute_key {
|
||||||
|
enigo.key(Key::F20, Direction::Click).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// with the default refresh_interval the state is only actively queried every 3min
|
||||||
|
// querying the device to frequently can lead to instability
|
||||||
|
let first = rx.recv_timeout(refresh_interval);
|
||||||
|
for command in first.into_iter().chain(rx.try_iter()) {
|
||||||
|
let _ = device.try_apply(command);
|
||||||
|
std::thread::sleep(hyper_headset::devices::RESPONSE_DELAY);
|
||||||
|
let _ = device.active_refresh_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = proxy.send_event(Some(device.get_device_state().device_properties.clone()));
|
||||||
|
run_counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event_loop.run_app(&mut TrayApp::new(tx)).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -16,22 +120,19 @@ fn main() {
|
|||||||
use hyper_headset::devices::connect_compatible_device;
|
use hyper_headset::devices::connect_compatible_device;
|
||||||
use status_tray::{StatusTray, TrayHandler};
|
use status_tray::{StatusTray, TrayHandler};
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
use hyper_headset::act_as_askpass_handler;
|
||||||
{
|
use hyper_headset::prompt_user_for_udev_rule;
|
||||||
use hyper_headset::act_as_askpass_handler;
|
|
||||||
use hyper_headset::prompt_user_for_udev_rule;
|
|
||||||
|
|
||||||
if let Ok(name) = std::env::current_exe() {
|
if let Ok(name) = std::env::current_exe() {
|
||||||
if let Some(name) = name.to_str() {
|
if let Some(name) = name.to_str() {
|
||||||
if let Ok(askpass) = std::env::var("SUDO_ASKPASS") {
|
if let Ok(askpass) = std::env::var("SUDO_ASKPASS") {
|
||||||
if name == askpass {
|
if name == askpass {
|
||||||
act_as_askpass_handler();
|
act_as_askpass_handler();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prompt_user_for_udev_rule();
|
|
||||||
}
|
}
|
||||||
|
prompt_user_for_udev_rule();
|
||||||
let matches = Command::new(env!("CARGO_PKG_NAME"))
|
let matches = Command::new(env!("CARGO_PKG_NAME"))
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.author(env!("CARGO_PKG_AUTHORS"))
|
.author(env!("CARGO_PKG_AUTHORS"))
|
||||||
@@ -91,7 +192,6 @@ fn main() {
|
|||||||
if mute_state.is_some()
|
if mute_state.is_some()
|
||||||
&& mute_state != device.get_device_state().device_properties.muted
|
&& mute_state != device.get_device_state().device_properties.muted
|
||||||
{
|
{
|
||||||
//TODO: macOS and windows have to use another key
|
|
||||||
if press_mute_key {
|
if press_mute_key {
|
||||||
enigo.key(Key::MicMute, Direction::Click).unwrap();
|
enigo.key(Key::MicMute, Direction::Click).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
254
src/status_tray_not_linux.rs
Normal file
254
src/status_tray_not_linux.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{mpsc::Sender, Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use hyper_headset::devices::{DeviceEvent, DeviceProperties, PropertyType};
|
||||||
|
use tray_icon::{
|
||||||
|
menu::{IconMenuItem, Menu, MenuEvent, MenuId, Submenu},
|
||||||
|
TrayIcon, TrayIconBuilder,
|
||||||
|
};
|
||||||
|
use winit::{application::ApplicationHandler, event::StartCause, event_loop::ControlFlow};
|
||||||
|
|
||||||
|
//TODO: maybe use MenuItem instead of IconMenuItem but than I probably have to patch Muda because
|
||||||
|
//it crashes sometimes when trying to handle an image with zero size
|
||||||
|
|
||||||
|
const NO_COMPATIBLE_DEVICE: &str = "No compatible device found. Is the dongle plugged in?";
|
||||||
|
const HEADSET_NOT_CONNECTED: &str = "Headset is not connected";
|
||||||
|
|
||||||
|
fn placeholder_icon() -> tray_icon::menu::Icon {
|
||||||
|
tray_icon::menu::Icon::from_rgba(vec![0, 0, 0, 0], 1, 1).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallbackMap = Arc<Mutex<HashMap<MenuId, Box<dyn Fn() + Send + Sync>>>>;
|
||||||
|
|
||||||
|
pub struct TrayApp {
|
||||||
|
pub tray_icon: Option<TrayIcon>,
|
||||||
|
pub sender: Sender<DeviceEvent>,
|
||||||
|
callbacks: CallbackMap,
|
||||||
|
current_state: Option<Option<DeviceProperties>>,
|
||||||
|
//TODO: maybe not needed anymore?
|
||||||
|
pending_update: Option<Option<DeviceProperties>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationHandler<Option<DeviceProperties>> for TrayApp {
|
||||||
|
fn new_events(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop, cause: StartCause) {
|
||||||
|
if cause == StartCause::Init {
|
||||||
|
self.tray_icon = Some(
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.with_menu(Box::new(Menu::new()))
|
||||||
|
.with_title("🎧")
|
||||||
|
.with_tooltip(NO_COMPATIBLE_DEVICE)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.update(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_event(
|
||||||
|
&mut self,
|
||||||
|
el: &winit::event_loop::ActiveEventLoop,
|
||||||
|
device_properties: Option<DeviceProperties>,
|
||||||
|
) {
|
||||||
|
// Don't call set_menu here — macOS menu is still active at this point.
|
||||||
|
// Buffer the update and apply it once the event loop is idle.
|
||||||
|
self.pending_update = Some(device_properties);
|
||||||
|
el.set_control_flow(ControlFlow::Poll); // wake about_to_wait immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called once the event loop has drained all pending events — menu is closed by now
|
||||||
|
fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
|
||||||
|
if let Some(props) = self.pending_update.take() {
|
||||||
|
self.update(props);
|
||||||
|
}
|
||||||
|
el.set_control_flow(ControlFlow::Wait); // go back to sleeping
|
||||||
|
}
|
||||||
|
|
||||||
|
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<DeviceEvent>) -> 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,
|
||||||
|
pending_update: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, device_properties: Option<DeviceProperties>) {
|
||||||
|
if let Some(current_state) = self.current_state.as_ref() {
|
||||||
|
if current_state == &device_properties {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(tray) = &mut self.tray_icon else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let quit_item = IconMenuItem::new("Quit", true, Some(placeholder_icon()), None);
|
||||||
|
let menu = Menu::new();
|
||||||
|
let mut new_callbacks: HashMap<MenuId, Box<dyn Fn() + Send + Sync>> = HashMap::new();
|
||||||
|
|
||||||
|
let Some(device_properties) = device_properties else {
|
||||||
|
let _ = tray.set_tooltip(Some(NO_COMPATIBLE_DEVICE));
|
||||||
|
tray.set_title(Some(&format!("🎧?")));
|
||||||
|
let status_item =
|
||||||
|
IconMenuItem::new(NO_COMPATIBLE_DEVICE, false, Some(placeholder_icon()), None);
|
||||||
|
menu.append(&status_item).unwrap();
|
||||||
|
menu.append(&quit_item).unwrap();
|
||||||
|
new_callbacks.insert(quit_item.id().clone(), Box::new(|| std::process::exit(0)));
|
||||||
|
|
||||||
|
*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));
|
||||||
|
tray.set_title(Some(&format!("🎧?")));
|
||||||
|
let status_item =
|
||||||
|
IconMenuItem::new(HEADSET_NOT_CONNECTED, false, Some(placeholder_icon()), None);
|
||||||
|
menu.append(&status_item).unwrap();
|
||||||
|
menu.append(&quit_item).unwrap();
|
||||||
|
new_callbacks.insert(quit_item.id().clone(), Box::new(|| std::process::exit(0)));
|
||||||
|
|
||||||
|
*self.callbacks.lock().unwrap() = new_callbacks;
|
||||||
|
tray.set_menu(Some(Box::new(menu)));
|
||||||
|
self.current_state = Some(Some(device_properties));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tray.set_tooltip(Some(
|
||||||
|
device_properties
|
||||||
|
.to_string_with_padding(0)
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.contains("Unknown"))
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join("\n"),
|
||||||
|
));
|
||||||
|
|
||||||
|
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 = IconMenuItem::new(
|
||||||
|
format!("{} {}{}", property.prefix, current_value, property.suffix),
|
||||||
|
false,
|
||||||
|
Some(placeholder_icon()),
|
||||||
|
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 = IconMenuItem::new(
|
||||||
|
format!("{}{}", item_value, property.suffix),
|
||||||
|
true,
|
||||||
|
Some(placeholder_icon()),
|
||||||
|
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 = IconMenuItem::new(
|
||||||
|
format!("{} {}{}", property.prefix, current_value, property.suffix),
|
||||||
|
property.property_type == PropertyType::ReadWrite
|
||||||
|
&& property.data.is_some(),
|
||||||
|
Some(placeholder_icon()),
|
||||||
|
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 = IconMenuItem::new(
|
||||||
|
format!("{} {}{}", property.prefix, current_value, property.suffix),
|
||||||
|
false,
|
||||||
|
Some(placeholder_icon()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let _ = menu.append(&menu_item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.append(&quit_item).unwrap();
|
||||||
|
new_callbacks.insert(quit_item.id().clone(), Box::new(|| std::process::exit(0)));
|
||||||
|
|
||||||
|
*self.callbacks.lock().unwrap() = new_callbacks;
|
||||||
|
tray.set_menu(Some(Box::new(menu)));
|
||||||
|
self.current_state = Some(Some(device_properties));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user