Merge branch 'dev' into windows-startup

This commit is contained in:
Lennard Kittner
2026-03-22 22:58:28 +01:00
committed by GitHub
7 changed files with 306 additions and 5 deletions

View File

@@ -34,9 +34,20 @@ jobs:
- name: test - name: test
run: cargo test run: cargo test
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Build
run: cargo build --verbose
- name: test
run: cargo test
publish-aur: publish-aur:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-linux, build-macos] needs: [build-linux, build-macos, build-windows]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3

View File

@@ -188,6 +188,11 @@ Because the action only toggles Discord's state, you may need to synchronize it
## Contributing / TODOs ## Contributing / TODOs
- [ ] Update ksni - [ ] Update ksni
- [ ] Add Docs
- [ ] Add to crates.io
- [ ] Let CLI periodically output the state
- [ ] Optional CLI output in JSON
- [ ] Waybar applet
- [x] Menu bar app for MacOS. - [x] Menu bar app for MacOS.
- [x] Windows support - [x] Windows support
- [x] Allow configuration via tray app - [x] Allow configuration via tray app

View File

@@ -920,9 +920,15 @@ pub trait Device {
/// Refreshes the state by listening for events /// Refreshes the state by listening for events
/// Only the battery level is actively queried because it is not communicated by the device on its own /// Only the battery level is actively queried because it is not communicated by the device on its own
fn passive_refresh_state(&mut self) -> Result<(), DeviceError> { fn passive_refresh_state(&mut self) -> Result<(), DeviceError> {
let mut request_active_refresh = false;
if self.allow_passive_refresh() { if self.allow_passive_refresh() {
if let Some(events) = self.wait_for_updates(PASSIVE_REFRESH_TIME_OUT) { if let Some(events) = self.wait_for_updates(PASSIVE_REFRESH_TIME_OUT) {
for event in events { for event in events {
// Some headsets send this if they just turned on so we should refresh the
// state
if matches!(event, DeviceEvent::WirelessConnected(true)) {
request_active_refresh = true;
}
self.get_device_state_mut().update_self_with_event(&event); self.get_device_state_mut().update_self_with_event(&event);
} }
} }
@@ -933,10 +939,18 @@ pub trait Device {
std::thread::sleep(RESPONSE_DELAY); std::thread::sleep(RESPONSE_DELAY);
if let Some(events) = self.wait_for_updates(Duration::from_secs(1)) { if let Some(events) = self.wait_for_updates(Duration::from_secs(1)) {
for event in events { for event in events {
// Some headsets send this if they just turned on so we should refresh the
// state
if matches!(event, DeviceEvent::WirelessConnected(true)) {
request_active_refresh = true;
}
self.get_device_state_mut().update_self_with_event(&event); self.get_device_state_mut().update_self_with_event(&event);
} }
} }
} }
if request_active_refresh {
self.active_refresh_state()?;
}
Ok(()) Ok(())
} }

View File

@@ -6,6 +6,8 @@ mod status_tray;
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
mod status_tray_not_linux; mod status_tray_not_linux;
mod tray_battery_icon_state;
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn main() { fn main() {
use std::sync::mpsc; use std::sync::mpsc;

View File

@@ -6,6 +6,8 @@ use ksni::{
Handle, MenuItem, ToolTip, Tray, TrayService, Handle, MenuItem, ToolTip, Tray, TrayService,
}; };
use crate::tray_battery_icon_state::TrayBatteryIconState;
pub struct TrayHandler { pub struct TrayHandler {
handle: Handle<StatusTray>, handle: Handle<StatusTray>,
} }
@@ -54,7 +56,9 @@ impl Tray for StatusTray {
} }
fn icon_name(&self) -> String { fn icon_name(&self) -> String {
"audio-headset".into() TrayBatteryIconState::from_device_properties(self.device_properties.as_ref())
.linux_icon_name()
.to_string()
} }
fn tool_tip(&self) -> ToolTip { fn tool_tip(&self) -> ToolTip {
@@ -83,7 +87,9 @@ impl Tray for StatusTray {
.clone() .clone()
.unwrap_or("Unknown".to_string()), .unwrap_or("Unknown".to_string()),
description, description,
icon_name: "audio-headset".into(), icon_name: TrayBatteryIconState::from_device_properties(Some(device_properties))
.linux_icon_name()
.to_string(),
icon_pixmap: Vec::new(), icon_pixmap: Vec::new(),
} }
} }

View File

@@ -3,6 +3,8 @@ use std::{
sync::{mpsc::Sender, Arc, Mutex}, sync::{mpsc::Sender, Arc, Mutex},
}; };
#[cfg(target_os = "windows")]
use image::{Rgba, RgbaImage};
use hyper_headset::devices::{DeviceEvent, DeviceProperties, PropertyType}; use hyper_headset::devices::{DeviceEvent, DeviceProperties, PropertyType};
use tray_icon::{ use tray_icon::{
menu::{CheckMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu}, menu::{CheckMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu},
@@ -15,6 +17,9 @@ use winreg::{
RegKey, RegValue, 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 NO_COMPATIBLE_DEVICE: &str = "No compatible device found. Is the dongle plugged in?";
const HEADSET_NOT_CONNECTED: &str = "Headset is not connected"; const HEADSET_NOT_CONNECTED: &str = "Headset is not connected";
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -24,9 +29,10 @@ const STARTUP_APPROVED_RUN_KEY_PATH: &str =
r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
const STARTUP_VALUE_NAME: &str = "HyperHeadset"; const STARTUP_VALUE_NAME: &str = "HyperHeadset";
const WINDOWS_ICON_SIZE: u32 = 16;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn create_tray_icon() -> tray_icon::Icon { fn create_default_tray_icon() -> tray_icon::Icon {
// embed a headset .ico/.png at compile time — no file needed at runtime // embed a headset .ico/.png at compile time — no file needed at runtime
let bytes = include_bytes!("../assets/headphone.png"); let bytes = include_bytes!("../assets/headphone.png");
let img = image::load_from_memory(bytes).unwrap().into_rgba8(); let img = image::load_from_memory(bytes).unwrap().into_rgba8();
@@ -34,6 +40,143 @@ fn create_tray_icon() -> tray_icon::Icon {
tray_icon::Icon::from_rgba(img.into_raw(), w, h).unwrap() 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<u8>) {
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<u8>,
) {
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<u8> {
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<i32> = text
.chars()
.map(|digit| if digit == '1' { 2 * scale } else { 3 * scale })
.collect();
let mut total_width: i32 = glyph_widths.iter().sum::<i32>()
+ 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::<i32>()
+ 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<Mutex<HashMap<MenuId, Box<dyn Fn() + Send + Sync>>>>; type CallbackMap = Arc<Mutex<HashMap<MenuId, Box<dyn Fn() + Send + Sync>>>>;
pub struct TrayApp { pub struct TrayApp {
@@ -41,6 +184,10 @@ pub struct TrayApp {
pub sender: Sender<DeviceEvent>, pub sender: Sender<DeviceEvent>,
callbacks: CallbackMap, callbacks: CallbackMap,
current_state: Option<Option<DeviceProperties>>, current_state: Option<Option<DeviceProperties>>,
#[cfg(target_os = "windows")]
icon_cache: HashMap<WindowsIconKey, Vec<u8>>,
#[cfg(target_os = "windows")]
current_icon_key: Option<WindowsIconKey>,
} }
impl ApplicationHandler<Option<DeviceProperties>> for TrayApp { impl ApplicationHandler<Option<DeviceProperties>> for TrayApp {
@@ -56,7 +203,7 @@ impl ApplicationHandler<Option<DeviceProperties>> for TrayApp {
self.tray_icon = Some( self.tray_icon = Some(
TrayIconBuilder::new() TrayIconBuilder::new()
.with_menu(Box::new(Menu::new())) .with_menu(Box::new(Menu::new()))
.with_icon(create_tray_icon()) .with_icon(create_default_tray_icon())
.with_tooltip(NO_COMPATIBLE_DEVICE) .with_tooltip(NO_COMPATIBLE_DEVICE)
.with_menu_on_left_click(true) .with_menu_on_left_click(true)
.build() .build()
@@ -119,15 +266,51 @@ impl TrayApp {
sender, sender,
callbacks, callbacks,
current_state: None, 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<DeviceProperties>) { fn update(&mut self, device_properties: Option<DeviceProperties>) {
if let Some(current_state) = self.current_state.as_ref() { if let Some(current_state) = self.current_state.as_ref() {
if current_state == &device_properties { if current_state == &device_properties {
return; return;
} }
} }
#[cfg(target_os = "windows")]
self.update_windows_icon(device_properties.as_ref());
let Some(tray) = &mut self.tray_icon else { let Some(tray) = &mut self.tray_icon else {
return; return;
}; };

View File

@@ -0,0 +1,80 @@
use hyper_headset::devices::{ChargingStatus, DeviceProperties};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TrayBatteryIconState {
NoDevice,
Disconnected,
ConnectedUnknown,
Connected {
percent: u8,
charging: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg(target_os = "windows")]
pub struct WindowsIconKey {
pub percent: u8,
pub charging: bool,
}
impl TrayBatteryIconState {
pub fn from_device_properties(device_properties: Option<&DeviceProperties>) -> Self {
let Some(device_properties) = device_properties else {
return Self::NoDevice;
};
if !device_properties.connected.unwrap_or(false) {
return Self::Disconnected;
}
let charging = matches!(
device_properties.charging,
Some(ChargingStatus::Charging | ChargingStatus::FullyCharged)
);
let Some(percent) = device_properties.battery_level else {
return Self::ConnectedUnknown;
};
Self::Connected {
percent: percent.min(100),
charging,
}
}
#[cfg(target_os = "windows")]
pub fn windows_icon_key(self) -> Option<WindowsIconKey> {
match self {
Self::Connected { percent, charging } => Some(WindowsIconKey { percent, charging }),
_ => None,
}
}
#[cfg(target_os = "linux")]
pub fn linux_icon_name(self) -> &'static str {
match self {
Self::NoDevice | Self::Disconnected | Self::ConnectedUnknown => "audio-headset",
Self::Connected { percent, charging } => {
let level_name = if percent <= 10 {
"battery-caution"
} else if percent <= 30 {
"battery-low"
} else if percent <= 60 {
"battery-medium"
} else if percent <= 85 {
"battery-good"
} else {
"battery-full"
};
if charging {
match level_name {
"battery-caution" => "battery-caution-charging",
"battery-low" => "battery-low-charging",
"battery-medium" => "battery-medium-charging",
"battery-good" => "battery-good-charging",
_ => "battery-full-charging",
}
} else {
level_name
}
}
}
}
}