├── .cargo └── config.toml ├── .github └── workflows │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── License.md ├── README.md ├── build.rs ├── resources └── icon.png ├── rustfmt.toml └── src ├── audio ├── mod.rs └── platforms │ ├── dummy.rs │ └── windows.rs ├── config.rs ├── debouncer.rs ├── devices ├── arctis_nova_7.rs ├── dummy.rs └── mod.rs ├── main.rs ├── notification.rs ├── renderer ├── d3d11.rs ├── gl.rs └── mod.rs ├── tray.rs ├── ui ├── central_panel │ ├── headset.rs │ ├── mod.rs │ └── profile.rs ├── mod.rs └── side_panel.rs └── util.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-msvc] 2 | rustflags = ["-Ctarget-feature=+crt-static"] 3 | 4 | [target.i686-pc-windows-msvc] 5 | rustflags = ["-Ctarget-feature=+crt-static"] 6 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [created] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | release: 9 | name: ${{ matrix.target }} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - os: windows-latest 16 | target: x86_64-pc-windows-msvc 17 | api: directx 18 | - os: ubuntu-latest 19 | target: x86_64-unknown-linux-gnu 20 | api: opengl 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Install Rust 28 | uses: dtolnay/rust-toolchain@stable 29 | with: 30 | target: ${{ matrix.target }} 31 | 32 | - name: Setup Cache 33 | uses: Swatinem/rust-cache@v2 34 | 35 | - name: Install Linux Dependencies 36 | if: ${{ runner.os == 'Linux' }} 37 | run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libayatana-appindicator3-dev libudev-dev 38 | 39 | - name: Build Binary 40 | run: cargo build --release --locked --target=${{ matrix.target }} --no-default-features --features ${{ matrix.api }} --color=always --verbose 41 | 42 | - name: Package (*nix) 43 | if: ${{ runner.os != 'Windows' }} 44 | run: > 45 | tar -cv 46 | -C target/${{ matrix.target }}/release/ headset-controller 47 | | gzip --best > 'headset-controller-${{ matrix.target }}.tar.gz' 48 | 49 | - name: Package (Windows) 50 | if: runner.os == 'Windows' 51 | run: > 52 | 7z a headset-controller-${{ matrix.target }}.zip 53 | ./target/${{ matrix.target }}/release/headset-controller.exe 54 | 55 | - name: Upload artifact 56 | uses: actions/upload-artifact@v3 57 | with: 58 | name: ${{ matrix.target }} 59 | path: | 60 | *.zip 61 | *.tar.gz 62 | 63 | - name: Create release 64 | if: startsWith(github.ref, 'refs/tags/v') 65 | uses: softprops/action-gh-release@v1 66 | with: 67 | draft: true 68 | files: | 69 | *.zip 70 | *.tar.gz -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /target 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "headset-controller" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [profile.release] 7 | lto = true 8 | # strip="symbols" 9 | codegen-units=1 10 | 11 | [features] 12 | directx = ["egui-d3d11"] 13 | opengl = ["glutin", "glow", "egui_glow", "glutin-tao"] 14 | default = ["opengl"] 15 | 16 | [dependencies] 17 | tracing = "0.1" 18 | tracing-subscriber = "0.3" 19 | tracing-error = "0.2" 20 | color-eyre = "0.6" 21 | 22 | tokio = { version = "1", features = ["full"]} 23 | 24 | directories-next = "2" 25 | serde = { version = "1", features = ["derive"] } 26 | ron = "0.8" 27 | once_cell = "1" 28 | futures-lite = "1" 29 | crossbeam-utils = "0.8" 30 | static_assertions = "1" 31 | fixed-map = "0.9" 32 | 33 | async-hid = { git = "https://github.com/sidit77/async-hid.git"} 34 | #async-hid = {path = "../async-hid"} 35 | 36 | egui = "0.22" 37 | raw-window-handle = "0.5" 38 | tao = { version = "0.20", features = ["tray"] } 39 | egui-tao = { git = "https://github.com/sidit77/egui-tao.git"} 40 | 41 | glutin = { version = "0.30", optional = true } 42 | glow = { version = "0.12", optional = true } 43 | egui_glow = { version = "0.22", optional = true } 44 | glutin-tao = { git = "https://github.com/sidit77/glutin-tao.git", optional = true } 45 | 46 | egui-d3d11 = { git = "https://github.com/sidit77/egui-d3d11.git", optional = true} 47 | 48 | [target."cfg(not(target_os = \"windows\"))".dependencies] 49 | notify-rust = "4.7" 50 | png = "0.17" 51 | 52 | [target."cfg(target_os = \"windows\")".dependencies] 53 | com-policy-config = "0.3" 54 | widestring = "1" 55 | winreg = "0.50" 56 | dunce = "1" 57 | 58 | [target."cfg(target_os = \"windows\")".dependencies.windows] 59 | version = "0.48" 60 | features = [ 61 | "Win32_Foundation", 62 | "Win32_Media_Audio", 63 | "Win32_Media_Audio_Endpoints", 64 | "Win32_System_Com", 65 | "Win32_UI_Shell_PropertiesSystem", 66 | "Win32_Security", 67 | "Win32_System_Com_StructuredStorage", 68 | "Win32_Devices_FunctionDiscovery", 69 | "Win32_System_SystemInformation", 70 | "Win32_System_Threading", 71 | "Foundation_Collections", 72 | "Data_Xml_Dom", 73 | "UI_Notifications", 74 | "implement", 75 | ] 76 | 77 | [target."cfg(target_os = \"windows\")".build-dependencies] 78 | tauri-winres = "0.1" 79 | ico = "0.3" -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 sidit77 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # headset-controller 2 | An app to configure wireless headphones. 3 | 4 | This is meant to be a more lightweight replacement for software like the *SteelSeries Engine*. 5 | 6 | It's also mostly cross-platform. 7 | 8 | ## Screenshots 9 | ![image](https://github.com/sidit77/headset-controller/assets/5053369/e93e96ce-fa3d-4ca0-8a2a-fac36a17c602) 10 | 11 | ## Why? 12 | My problems with ✨*Gamer*✨ software are perfectly expressed in [this rant by Adam from ShortCircuit](https://www.youtube.com/watch?v=0jxeNPHhalc&t=578s). 13 | 14 | > I finally have it downloaded, but every single short circuit we do on gaming headsets that have some sort of (censored) proprietary dumb (censored) software takes like an hour and a half longer than they need to, because we're always fumbling around with this extra bull (censored) that provides the consumer nothing in return, other than what? 15 | > 16 | > You can make your pretty lights good? 17 | > 18 | > How hard is that to do? 19 | > 20 | > How hard is it to make good software 21 | > 22 | > that makes your lights go good? 23 | > 24 | > I really don't know. 25 | > 26 | > I'm not a software developer. 27 | > 28 | > Maybe it's really, really, really, really, really hard. 29 | > 30 | > So, maybe I just sound like an (censored) right now. 31 | > 32 | > But it's just so frustrating that, as a consumer, I have to have this heavyweight thing that wants to know about my games. 33 | > 34 | > It wants me to launch my games from it. 35 | > 36 | > I'm not gonna do that. 37 | > 38 | > I already have Epic Game Store. 39 | > 40 | > I already have Steam, 41 | > 42 | > I already have all these other dumb utilities. 43 | > 44 | > My GPU drivers want to launch my games too. 45 | > 46 | > Leave me alone. 47 | 48 | Well, I'm a CS student and I agree. It shouldn't be that hard to make software that does exactly what it's supposed to do and nothing more. 49 | 50 | The Windows build currently produces a single 5 MB executable that runs without installation. 51 | 52 | ![FiHYR5DXwAUffEt](https://github.com/sidit77/headset-controller/assets/5053369/fe792bd9-cfc7-4b9c-bea2-41248bd1714b) 53 | 54 | ## Features 55 | * Read Battery Status 56 | * Read Chat Mix (currently purely visual) 57 | * Modify Equalizer 58 | * Modify Side Tone 59 | * Modify Microphone Volume 60 | * Toggle Volume Limiter 61 | * Modify Inactive Time 62 | * Modify Mute Light 63 | * Toggle Auto Enable Bluetooth 64 | * Change Call Action 65 | * Automatically switch audio when the headset connects (windows only) 66 | 67 | ## Supported Devices 68 | * SteelSeries Arctis Nova 7 (X/P) 69 | 70 | *It shouldn't be too hard to add support for more devices, but I only own this one headset.* 71 | 72 | ## Installation 73 | 74 | ### Prebuilt Binaries 75 | 76 | Prebuilt binaries can be found in the [**GitHub Release Section**](https://github.com/sidit77/headset-controller/releases). 77 | 78 | Simply download the binary, copy it to your preferred directory, and run it. 79 | 80 | #### Linux 81 | To run this program under a non-root user you also have to install the udev rules 82 | ```bash 83 | sudo ./headset-control --print-udev-rules > /etc/udev/rules.d/70-headset-controller.rules 84 | sudo udevadm control --reload-rules && sudo udevadm trigger 85 | ``` 86 | 87 | ### Building Yourself 88 | 89 | This app is built using 🦀*Rust*🦀, so you have to install it to build this project on your own. 90 | 91 | After that, simply clone the source and build it: 92 | ```bash 93 | git clone https://github.com/sidit77/headset-controller.git 94 | cd headset-controller 95 | cargo build --release 96 | ``` 97 | 98 | #### Windows 99 | 100 | On Windows, this program can be configured to use DirectX 11 for rendering instead of OpenGL. To build the DirectX version, run this command instead. 101 | ```bash 102 | cargo build --release --no-default-features --features directx 103 | ``` 104 | 105 | #### Linux 106 | 107 | On Linux, some additional packages are required. 108 | 109 | ```bash 110 | sudo apt install libgtk-3-dev libayatana-appindicator3-dev 111 | ``` 112 | 113 | *Don't forget to add the udev rules as described in the prebuilt binaries section.* 114 | 115 | #### macOS 116 | 117 | I don't own a Mac, so I can't test this. It might work or not. 118 | 119 | ## Todo 120 | 121 | - [ ] Panic popup 122 | - [ ] Normal error handling (show notification) 123 | - [ ] more tooltips (language file) 124 | - [ ] handling device disconnects 125 | - [x] udev rules generator for linux 126 | - [x] improve look of the equalizer 127 | - [x] Device selection 128 | - [x] Implement the remaining functions for arctis 129 | - [x] log file? 130 | - [x] better system tray 131 | - [x] Linux support 132 | 133 | ## License 134 | MIT License 135 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | #[cfg(windows)] 2 | fn main() { 3 | let icon_path = std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("icon.ico"); 4 | println!("cargo:rerun-if-changed=resources/icon.png"); 5 | let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon); 6 | let file = std::fs::File::open("resources/icon.png").unwrap(); 7 | let image = ico::IconImage::read_png(file).unwrap(); 8 | icon_dir.add_entry(ico::IconDirEntry::encode(&image).unwrap()); 9 | icon_dir 10 | .write(std::fs::File::create(&icon_path).unwrap()) 11 | .unwrap(); 12 | 13 | let mut res = tauri_winres::WindowsResource::new(); 14 | res.set_icon(icon_path.to_str().unwrap()); 15 | res.compile().unwrap(); 16 | } 17 | 18 | #[cfg(not(windows))] 19 | fn main() {} 20 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidit77/headset-controller/6f904a5dd8be89ea568ba2e4ebbdb286e0773270/resources/icon.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | trailing_comma = "Never" 2 | fn_params_layout = "Compressed" 3 | imports_granularity = "Module" 4 | group_imports = "StdExternalCrate" 5 | unstable_features = true 6 | max_width = 150 7 | version = "Two" 8 | chain_width=60 -------------------------------------------------------------------------------- /src/audio/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "windows")] 2 | #[path = "platforms/windows.rs"] 3 | mod platform; 4 | 5 | #[cfg(not(target_os = "windows"))] 6 | #[path = "platforms/dummy.rs"] 7 | mod platform; 8 | 9 | pub use platform::{AudioDevice, AudioLoopback, AudioManager}; 10 | 11 | use crate::config::OsAudio; 12 | 13 | pub struct AudioSystem { 14 | manager: Option, 15 | devices: Vec, 16 | default_device: Option, 17 | loopback: Option 18 | } 19 | 20 | impl AudioSystem { 21 | pub fn new() -> Self { 22 | let manager = AudioManager::new() 23 | .map_err(|err| tracing::warn!("Failed to initialize the audio manager: {}", err)) 24 | .ok(); 25 | let mut result = Self { 26 | manager, 27 | devices: Vec::new(), 28 | default_device: None, 29 | loopback: None 30 | }; 31 | result.refresh_devices(); 32 | result 33 | } 34 | 35 | pub fn is_running(&self) -> bool { 36 | self.manager.is_some() 37 | } 38 | 39 | pub fn refresh_devices(&mut self) { 40 | if let Some(manager) = &self.manager { 41 | self.devices.clear(); 42 | self.devices.extend(manager.devices()); 43 | self.default_device = manager.get_default_device(); 44 | } 45 | } 46 | 47 | pub fn devices(&self) -> &Vec { 48 | &self.devices 49 | } 50 | 51 | pub fn default_device(&self) -> Option<&AudioDevice> { 52 | self.default_device.as_ref() 53 | } 54 | 55 | pub fn apply(&mut self, audio_config: &OsAudio, connected: bool) { 56 | self.refresh_devices(); 57 | self.loopback = None; 58 | if let Some(manager) = &self.manager { 59 | match audio_config { 60 | OsAudio::Disabled => {} 61 | OsAudio::ChangeDefault { on_connect, on_disconnect } => { 62 | let target = match connected { 63 | true => on_connect, 64 | false => on_disconnect 65 | }; 66 | if let Some(device) = self.devices().iter().find(|dev| dev.name() == target) { 67 | match self.default_device().map_or(false, |dev| dev == device) { 68 | true => tracing::info!("Device \"{}\" is already active", device.name()), 69 | false => { 70 | manager 71 | .set_default_device(device) 72 | .unwrap_or_else(|err| tracing::warn!("Could not change default audio device: {:?}", err)); 73 | self.default_device = manager.get_default_device(); 74 | } 75 | } 76 | } 77 | } 78 | OsAudio::RouteAudio { src, dst } => { 79 | if !connected { 80 | let src = self.devices().iter().find(|dev| dev.name() == src); 81 | let dst = self.devices().iter().find(|dev| dev.name() == dst); 82 | match (src, dst) { 83 | (Some(src), Some(dst)) => { 84 | self.loopback = AudioLoopback::new(src, dst) 85 | .map_err(|err| tracing::warn!("Could not start audio routing: {:?}", err)) 86 | .ok(); 87 | } 88 | _ => tracing::warn!("Could not find both audio devices") 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/audio/platforms/dummy.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::bail; 2 | use color_eyre::Result; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct AudioManager; 6 | impl AudioManager { 7 | pub fn new() -> Result { 8 | bail!("Not supported on this platform!") 9 | } 10 | 11 | pub fn devices(&self) -> impl Iterator { 12 | std::iter::empty::() 13 | } 14 | 15 | pub fn get_default_device(&self) -> Option { 16 | None 17 | } 18 | 19 | pub fn set_default_device(&self, _: &AudioDevice) -> Result<()> { 20 | bail!("not supported!"); 21 | } 22 | } 23 | 24 | #[derive(Debug, Clone, Eq, PartialEq)] 25 | pub struct AudioDevice; 26 | impl AudioDevice { 27 | pub fn name(&self) -> &str { 28 | unimplemented!() 29 | } 30 | } 31 | 32 | pub struct AudioLoopback; 33 | 34 | impl AudioLoopback { 35 | pub fn new(_: &AudioDevice, _: &AudioDevice) -> Result { 36 | bail!("not supported!") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/audio/platforms/windows.rs: -------------------------------------------------------------------------------- 1 | use std::iter::FusedIterator; 2 | use std::marker::PhantomData; 3 | use std::ops::Deref; 4 | use std::thread::JoinHandle; 5 | use std::{ptr, thread}; 6 | 7 | use color_eyre::eyre::ensure; 8 | use color_eyre::Result; 9 | use com_policy_config::{IPolicyConfig, PolicyConfigClient}; 10 | use widestring::U16CString; 11 | use windows::core::{implement, Interface, GUID, HRESULT, PCWSTR, PWSTR}; 12 | use windows::w; 13 | use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; 14 | use windows::Win32::Foundation::*; 15 | use windows::Win32::Media::Audio::Endpoints::*; 16 | use windows::Win32::Media::Audio::*; 17 | use windows::Win32::System::Com::StructuredStorage::PropVariantClear; 18 | use windows::Win32::System::Com::*; 19 | use windows::Win32::System::Threading::*; 20 | 21 | #[derive(Default)] 22 | struct ComWrapper { 23 | _ptr: PhantomData<*mut ()> 24 | } 25 | 26 | thread_local!(static COM_INITIALIZED: ComWrapper = { 27 | unsafe { 28 | CoInitializeEx(None, COINIT_MULTITHREADED) 29 | .expect("Could not initialize COM"); 30 | let thread = std::thread::current(); 31 | tracing::trace!("Initialized COM on thread \"{}\"", thread.name().unwrap_or("")); 32 | ComWrapper::default() 33 | } 34 | }); 35 | 36 | impl Drop for ComWrapper { 37 | fn drop(&mut self) { 38 | unsafe { 39 | CoUninitialize(); 40 | } 41 | } 42 | } 43 | 44 | #[inline] 45 | pub fn com_initialized() { 46 | COM_INITIALIZED.with(|_| {}); 47 | } 48 | 49 | #[derive(Debug, Clone)] 50 | pub struct AudioManager { 51 | enumerator: IMMDeviceEnumerator, 52 | policy_config: IPolicyConfig 53 | } 54 | 55 | impl AudioManager { 56 | pub fn new() -> Result { 57 | unsafe { 58 | com_initialized(); 59 | let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; 60 | let policy_config: IPolicyConfig = CoCreateInstance(&PolicyConfigClient, None, CLSCTX_ALL)?; 61 | 62 | Ok(Self { enumerator, policy_config }) 63 | } 64 | } 65 | 66 | pub fn devices(&self) -> impl Iterator { 67 | unsafe { 68 | let device_collection = self 69 | .enumerator 70 | .EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) 71 | .expect("Unexpected error"); 72 | let count = device_collection.GetCount().expect("Unexpected error"); 73 | AudioDeviceIterator { 74 | device_collection, 75 | count, 76 | index: 0 77 | } 78 | } 79 | } 80 | 81 | pub fn get_default_device(&self) -> Option { 82 | unsafe { 83 | match self.enumerator.GetDefaultAudioEndpoint(eRender, eConsole) { 84 | Ok(dev) => Some(AudioDevice::new(dev)), 85 | Err(err) if err.code() == HRESULT::from(ERROR_NOT_FOUND) => None, 86 | Err(err) => Err(err).expect("Unexpected error") 87 | } 88 | } 89 | } 90 | 91 | pub fn set_default_device(&self, device: &AudioDevice) -> Result<()> { 92 | unsafe { 93 | self.policy_config 94 | .SetDefaultEndpoint(device.id(), eConsole)?; 95 | Ok(()) 96 | } 97 | } 98 | } 99 | 100 | #[derive(Debug, Clone)] 101 | struct AudioDeviceIterator { 102 | device_collection: IMMDeviceCollection, 103 | count: u32, 104 | index: u32 105 | } 106 | 107 | impl Iterator for AudioDeviceIterator { 108 | type Item = AudioDevice; 109 | 110 | fn next(&mut self) -> Option { 111 | unsafe { 112 | if self.index < self.count { 113 | let item = self 114 | .device_collection 115 | .Item(self.index) 116 | .expect("Unexpected error"); 117 | self.index += 1; 118 | Some(AudioDevice::new(item)) 119 | } else { 120 | None 121 | } 122 | } 123 | } 124 | 125 | fn size_hint(&self) -> (usize, Option) { 126 | let remaining = (self.count - self.index) as usize; 127 | (remaining, Some(remaining)) 128 | } 129 | } 130 | 131 | impl ExactSizeIterator for AudioDeviceIterator {} 132 | impl FusedIterator for AudioDeviceIterator {} 133 | 134 | #[derive(Debug, Clone)] 135 | pub struct AudioDevice { 136 | device: IMMDevice, 137 | name: String, 138 | id: U16CString 139 | } 140 | 141 | impl AudioDevice { 142 | fn new(device: IMMDevice) -> Self { 143 | unsafe { 144 | let id = { 145 | let ptr = ComPtr(device.GetId().expect("Unexpected error").0); 146 | U16CString::from_ptr_str(ptr.ptr()) 147 | }; 148 | let name = { 149 | let property_store = device 150 | .OpenPropertyStore(STGM_READ) 151 | .expect("Unexpected error"); 152 | let mut prop = property_store 153 | .GetValue(&PKEY_Device_FriendlyName) 154 | .expect("Unexpected error"); 155 | let dynamic_type = &prop.Anonymous.Anonymous; 156 | assert_eq!(dynamic_type.vt, VT_LPWSTR); 157 | let name: PWSTR = dynamic_type.Anonymous.pwszVal; 158 | let result = String::from_utf16_lossy(name.as_wide()); 159 | PropVariantClear(&mut prop).expect("Unexpected error"); 160 | result 161 | }; 162 | Self { device, name, id } 163 | } 164 | } 165 | 166 | pub fn name(&self) -> &str { 167 | &self.name 168 | } 169 | 170 | fn id(&self) -> PCWSTR { 171 | PCWSTR::from_raw(self.id.as_ptr()) 172 | } 173 | } 174 | 175 | impl PartialEq for AudioDevice { 176 | fn eq(&self, other: &Self) -> bool { 177 | self.id.eq(&other.id) 178 | } 179 | } 180 | impl Eq for AudioDevice {} 181 | 182 | #[derive(Clone)] 183 | struct ComObj(T); 184 | unsafe impl Send for ComObj {} 185 | unsafe impl Sync for ComObj {} 186 | 187 | impl Deref for ComObj { 188 | type Target = T; 189 | 190 | fn deref(&self) -> &Self::Target { 191 | &self.0 192 | } 193 | } 194 | 195 | struct ComPtr(*mut T); 196 | 197 | impl ComPtr { 198 | fn ptr(&self) -> *mut T { 199 | self.0 200 | } 201 | } 202 | 203 | impl Drop for ComPtr { 204 | fn drop(&mut self) { 205 | if !self.0.is_null() { 206 | unsafe { 207 | CoTaskMemFree(Some(self.0 as _)); 208 | } 209 | } 210 | } 211 | } 212 | 213 | #[implement(IAudioEndpointVolumeCallback)] 214 | struct AudioEndpointVolumeCallback(ISimpleAudioVolume); 215 | 216 | impl IAudioEndpointVolumeCallback_Impl for AudioEndpointVolumeCallback { 217 | fn OnNotify(&self, pnotify: *mut AUDIO_VOLUME_NOTIFICATION_DATA) -> windows::core::Result<()> { 218 | unsafe { 219 | let notify = pnotify.read(); 220 | self.0 221 | .SetMasterVolume(notify.fMasterVolume, ¬ify.guidEventContext)?; 222 | self.0.SetMute(notify.bMuted, ¬ify.guidEventContext)?; 223 | } 224 | Ok(()) 225 | } 226 | } 227 | 228 | struct VolumeSync { 229 | callback: IAudioEndpointVolumeCallback, 230 | audio_volume: IAudioEndpointVolume 231 | } 232 | 233 | impl VolumeSync { 234 | fn new(src_volume: IAudioEndpointVolume, dst_volume: ISimpleAudioVolume) -> Result { 235 | unsafe { 236 | dst_volume.SetMasterVolume(src_volume.GetMasterVolumeLevelScalar()?, &GUID::default())?; 237 | dst_volume.SetMute(src_volume.GetMute()?, &GUID::default())?; 238 | let callback: IAudioEndpointVolumeCallback = AudioEndpointVolumeCallback(dst_volume).into(); 239 | src_volume.RegisterControlChangeNotify(&callback)?; 240 | Ok(Self { 241 | callback, 242 | audio_volume: src_volume 243 | }) 244 | } 245 | } 246 | } 247 | 248 | impl Drop for VolumeSync { 249 | fn drop(&mut self) { 250 | unsafe { 251 | self.audio_volume 252 | .UnregisterControlChangeNotify(&self.callback) 253 | .unwrap_or_else(|err| tracing::warn!("Failed to unregister volume control handler: {}", err)) 254 | } 255 | } 256 | } 257 | 258 | struct AudioThreadHandle { 259 | handle: HANDLE, 260 | _task_id: u32 261 | } 262 | 263 | impl Drop for AudioThreadHandle { 264 | fn drop(&mut self) { 265 | unsafe { 266 | AvRevertMmThreadCharacteristics(self.handle) 267 | .ok() 268 | .unwrap_or_else(|err| tracing::warn!("Could not revert to normal thread: {}", err)) 269 | } 270 | } 271 | } 272 | 273 | fn mark_audio_thread() -> Result { 274 | let mut task_id = 0; 275 | let handle = unsafe { AvSetMmThreadCharacteristicsW(w!("Audio"), &mut task_id)? }; 276 | Ok(AudioThreadHandle { handle, _task_id: task_id }) 277 | } 278 | 279 | pub struct AudioLoopback { 280 | stop_event: HANDLE, 281 | _volume_sync: VolumeSync, 282 | audio_thread: Option> 283 | } 284 | 285 | impl AudioLoopback { 286 | pub fn new(src: &AudioDevice, dst: &AudioDevice) -> Result { 287 | Ok(unsafe { 288 | let src_audio_client = ComObj::(src.device.Activate(CLSCTX_ALL, None)?); 289 | let dst_audio_client = ComObj::(dst.device.Activate(CLSCTX_ALL, None)?); 290 | 291 | let format = ComPtr(src_audio_client.GetMixFormat()?); 292 | ensure!(!format.ptr().is_null(), "Could not retrieve current format"); 293 | let bytes_per_frame = format.ptr().read_unaligned().nBlockAlign as u32; 294 | let sound_buffer_duration = 10000000; 295 | 296 | src_audio_client.Initialize( 297 | AUDCLNT_SHAREMODE_SHARED, 298 | AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_NOPERSIST | AUDCLNT_SESSIONFLAGS_DISPLAY_HIDE, 299 | sound_buffer_duration, 300 | 0, 301 | format.ptr(), 302 | None 303 | )?; 304 | 305 | dst_audio_client.Initialize( 306 | AUDCLNT_SHAREMODE_SHARED, 307 | AUDCLNT_STREAMFLAGS_RATEADJUST | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, 308 | sound_buffer_duration, 309 | 0, 310 | format.ptr(), 311 | None 312 | )?; 313 | 314 | let dst_audio_volume: ISimpleAudioVolume = src_audio_client.GetService()?; 315 | let src_volume: IAudioEndpointVolume = src.device.Activate(CLSCTX_ALL, None)?; 316 | let volume_sync = VolumeSync::new(src_volume, dst_audio_volume)?; 317 | 318 | let capture_client = ComObj::(src_audio_client.GetService()?); 319 | let render_client = ComObj::(dst_audio_client.GetService()?); 320 | 321 | let stop_event = CreateEventExW(None, None, CREATE_EVENT(0), (EVENT_MODIFY_STATE | SYNCHRONIZATION_SYNCHRONIZE).0)?; 322 | let buffer_event = CreateEventExW(None, None, CREATE_EVENT(0), (EVENT_MODIFY_STATE | SYNCHRONIZATION_SYNCHRONIZE).0)?; 323 | src_audio_client.SetEventHandle(buffer_event)?; 324 | 325 | let audio_thread = Some( 326 | thread::Builder::new() 327 | .name("loopback audio router".to_string()) 328 | .spawn(move || { 329 | com_initialized(); 330 | let _handle = mark_audio_thread().map_err(|err| tracing::warn!("Could not mark as audio thread: {:?}", err)); 331 | 332 | src_audio_client.Start().unwrap(); 333 | dst_audio_client.Start().unwrap(); 334 | loop { 335 | let wait_result = WaitForMultipleObjects(&[buffer_event, stop_event], false, INFINITE); 336 | match wait_result.0 - WAIT_OBJECT_0.0 { 337 | 0 => copy_data(&capture_client, &render_client, bytes_per_frame).unwrap(), 338 | 1 => break, 339 | _ => wait_result.ok().unwrap() 340 | } 341 | } 342 | CloseHandle(buffer_event) 343 | .ok() 344 | .unwrap_or_else(|err| tracing::warn!("Could not delete buffer event: {}", err)); 345 | src_audio_client.Stop().unwrap(); 346 | dst_audio_client.Stop().unwrap(); 347 | })? 348 | ); 349 | AudioLoopback { 350 | stop_event, 351 | _volume_sync: volume_sync, 352 | audio_thread 353 | } 354 | }) 355 | } 356 | 357 | pub fn stop(&self) { 358 | unsafe { 359 | SetEvent(self.stop_event) 360 | .ok() 361 | .unwrap_or_else(|err| tracing::warn!("Could not set stop event: {}", err)); 362 | } 363 | } 364 | } 365 | 366 | impl Drop for AudioLoopback { 367 | fn drop(&mut self) { 368 | self.stop(); 369 | if let Some(thread) = self.audio_thread.take() { 370 | thread.join().unwrap(); 371 | } 372 | unsafe { 373 | CloseHandle(self.stop_event) 374 | .ok() 375 | .unwrap_or_else(|err| tracing::warn!("Could not delete stop event: {}", err)); 376 | } 377 | } 378 | } 379 | 380 | unsafe fn copy_data(src: &IAudioCaptureClient, dst: &IAudioRenderClient, bytes_per_frame: u32) -> Result<()> { 381 | let mut packet_length = src.GetNextPacketSize()?; 382 | while packet_length != 0 { 383 | let mut buffer = ptr::null_mut(); 384 | let mut flags = 0; 385 | let mut frames_available = 0; 386 | src.GetBuffer(&mut buffer, &mut frames_available, &mut flags, None, None)?; 387 | let silence = flags & AUDCLNT_BUFFERFLAGS_SILENT.0 as u32 != 0; 388 | { 389 | let play_buffer = dst.GetBuffer(frames_available)?; 390 | let buffer_len = (frames_available * bytes_per_frame) as usize; 391 | if !silence { 392 | ptr::copy(buffer, play_buffer, buffer_len); 393 | } 394 | flags &= AUDCLNT_BUFFERFLAGS_SILENT.0 as u32; 395 | dst.ReleaseBuffer(frames_available, flags)?; 396 | } 397 | 398 | src.ReleaseBuffer(frames_available)?; 399 | packet_length = src.GetNextPacketSize()?; 400 | } 401 | Ok(()) 402 | } 403 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fs::File; 3 | use std::io::Write; 4 | use std::path::{Path, PathBuf}; 5 | 6 | use color_eyre::Result; 7 | use directories_next::BaseDirs; 8 | use once_cell::sync::Lazy; 9 | use ron::ser::{to_string_pretty, PrettyConfig}; 10 | use serde::{Deserialize, Serialize}; 11 | 12 | use crate::util::EscapeStripper; 13 | 14 | #[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 15 | pub enum OsAudio { 16 | #[default] 17 | Disabled, 18 | ChangeDefault { 19 | on_connect: String, 20 | on_disconnect: String 21 | }, 22 | RouteAudio { 23 | src: String, 24 | dst: String 25 | } 26 | } 27 | 28 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 29 | pub enum EqualizerConfig { 30 | Preset(u32), 31 | Custom(Vec) 32 | } 33 | 34 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)] 35 | pub enum CallAction { 36 | Nothing, 37 | ReduceVolume, 38 | Mute 39 | } 40 | 41 | #[derive(Debug, Clone, Serialize, Deserialize)] 42 | pub struct Profile { 43 | pub name: String, 44 | pub side_tone: u8, 45 | pub volume_limiter: bool, 46 | pub microphone_volume: u8, 47 | pub equalizer: EqualizerConfig 48 | } 49 | 50 | impl Profile { 51 | pub(crate) fn new(name: String) -> Self { 52 | Self { 53 | name, 54 | side_tone: 0, 55 | volume_limiter: true, 56 | microphone_volume: 0, 57 | equalizer: EqualizerConfig::Preset(0) 58 | } 59 | } 60 | } 61 | 62 | #[derive(Debug, Clone, Serialize, Deserialize)] 63 | pub struct HeadsetConfig { 64 | pub os_audio: OsAudio, 65 | pub mic_light: u8, 66 | pub bluetooth_call: CallAction, 67 | pub auto_enable_bluetooth: bool, 68 | pub inactive_time: u8, 69 | pub selected_profile_index: u32, 70 | pub profiles: Vec 71 | } 72 | 73 | impl Default for HeadsetConfig { 74 | fn default() -> Self { 75 | Self { 76 | os_audio: Default::default(), 77 | mic_light: 0, 78 | bluetooth_call: CallAction::Nothing, 79 | auto_enable_bluetooth: false, 80 | inactive_time: 30, 81 | selected_profile_index: 0, 82 | profiles: vec![Profile::new(String::from("Default"))] 83 | } 84 | } 85 | } 86 | 87 | #[derive(Debug, Clone, Serialize, Deserialize)] 88 | pub struct Config { 89 | headsets: HashMap, 90 | pub auto_apply_changes: bool, 91 | pub preferred_device: Option 92 | } 93 | 94 | impl Default for Config { 95 | fn default() -> Self { 96 | Self { 97 | headsets: HashMap::new(), 98 | auto_apply_changes: true, 99 | preferred_device: None 100 | } 101 | } 102 | } 103 | 104 | static BASE_PATH: Lazy = Lazy::new(|| BaseDirs::new().expect("can not get directories")); 105 | static CONFIG_PATH: Lazy = Lazy::new(|| BASE_PATH.config_dir().join("HeadsetController.ron")); 106 | static LOG_PATH: Lazy = Lazy::new(|| BASE_PATH.config_dir().join("HeadsetController.log")); 107 | 108 | pub fn log_file() -> impl Write { 109 | let file = File::create(LOG_PATH.as_path()).expect("Can not open file"); 110 | EscapeStripper::new(file) 111 | } 112 | 113 | impl Config { 114 | pub fn path() -> &'static Path { 115 | CONFIG_PATH.as_path() 116 | } 117 | 118 | pub fn load() -> Result { 119 | let config: Self = match Self::path().exists() { 120 | true => { 121 | let file = std::fs::read_to_string(Self::path())?; 122 | 123 | ron::from_str(&file)? 124 | } 125 | false => { 126 | let conf = Self::default(); 127 | conf.save()?; 128 | conf 129 | } 130 | }; 131 | Ok(config) 132 | } 133 | 134 | pub fn save(&self) -> Result<()> { 135 | let pretty = PrettyConfig::new(); 136 | Ok(std::fs::write(Self::path(), to_string_pretty(self, pretty)?)?) 137 | } 138 | 139 | pub fn get_headset(&mut self, name: &str) -> &mut HeadsetConfig { 140 | if !self.headsets.contains_key(name) { 141 | self.headsets 142 | .insert(String::from(name), HeadsetConfig::default()); 143 | } 144 | self.headsets 145 | .get_mut(name) 146 | .expect("Key should always exist") 147 | } 148 | } 149 | 150 | impl HeadsetConfig { 151 | pub fn selected_profile(&mut self) -> &mut Profile { 152 | if self.profiles.is_empty() { 153 | tracing::debug!("No profile creating a new one"); 154 | self.profiles.push(Profile::new(String::from("Default"))); 155 | } 156 | if self.selected_profile_index >= self.profiles.len() as u32 { 157 | tracing::debug!("profile index out of bounds"); 158 | self.selected_profile_index = self.profiles.len() as u32 - 1; 159 | } 160 | &mut self.profiles[self.selected_profile_index as usize] 161 | } 162 | } 163 | 164 | pub static START_QUIET: Lazy = Lazy::new(|| std::env::args().any(|arg| arg.eq("--quiet"))); 165 | pub static CLOSE_IMMEDIATELY: Lazy = Lazy::new(|| std::env::args().any(|arg| arg.eq("--close-on-quit"))); 166 | pub static DUMMY_DEVICE: Lazy = Lazy::new(|| std::env::args().any(|arg| arg.eq("--dummy-device"))); 167 | pub static PRINT_UDEV_RULES: Lazy = Lazy::new(|| std::env::args().any(|arg| arg.eq("--print-udev-rules"))); 168 | -------------------------------------------------------------------------------- /src/debouncer.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use fixed_map::{Key, Map}; 4 | use tracing::instrument; 5 | 6 | use crate::util::PeekExt; 7 | 8 | #[derive(Debug, Clone, Copy, Key, Eq, PartialEq)] 9 | pub enum Action { 10 | SaveConfig, 11 | 12 | UpdateSideTone, 13 | UpdateEqualizer, 14 | UpdateMicrophoneVolume, 15 | UpdateVolumeLimit, 16 | 17 | UpdateInactiveTime, 18 | UpdateMicrophoneLight, 19 | UpdateBluetoothCall, 20 | UpdateAutoBluetooth, 21 | 22 | UpdateSystemAudio, 23 | UpdateTray, 24 | UpdateTrayTooltip, 25 | UpdateDeviceStatus, 26 | RefreshDeviceList, 27 | SwitchDevice 28 | } 29 | 30 | impl Action { 31 | fn timeout(self) -> Duration { 32 | match self { 33 | Action::SaveConfig => Duration::from_secs(10), 34 | Action::SwitchDevice | Action::RefreshDeviceList => Duration::from_millis(10), 35 | //Action::UpdateDeviceStatus => Duration::from_millis(250), 36 | _ => Duration::from_millis(500) 37 | } 38 | } 39 | } 40 | 41 | #[derive(Debug, Clone)] 42 | pub struct Debouncer(Map); 43 | 44 | impl Debouncer { 45 | pub fn new() -> Self { 46 | Self(Map::new()) 47 | } 48 | 49 | #[instrument(skip(self))] 50 | pub fn submit(&mut self, action: Action) { 51 | let now = Instant::now(); 52 | let old = self.0.insert(action, now); 53 | debug_assert!(old.map_or(true, |old| old <= now)); 54 | tracing::trace!("Received new action"); 55 | } 56 | 57 | pub fn submit_all(&mut self, actions: impl IntoIterator) { 58 | for action in actions { 59 | self.submit(action); 60 | } 61 | } 62 | 63 | pub fn next_action(&self) -> Option { 64 | self.0.iter().map(|(k, v)| *v + k.timeout()).min() 65 | } 66 | 67 | #[instrument(skip(self))] 68 | pub fn force(&mut self, action: Action) { 69 | if let Some(time) = self.0.get_mut(action) { 70 | *time -= action.timeout(); 71 | tracing::trace!("Skipped timeout"); 72 | } 73 | } 74 | 75 | pub fn force_all(&mut self, actions: impl IntoIterator) { 76 | for action in actions { 77 | self.force(action); 78 | } 79 | } 80 | } 81 | 82 | impl Iterator for Debouncer { 83 | type Item = Action; 84 | 85 | fn next(&mut self) -> Option { 86 | let now = Instant::now(); 87 | let elapsed = self 88 | .0 89 | .iter() 90 | .find(|(k, b)| { 91 | now.checked_duration_since(**b) 92 | .map_or(true, |dur| dur >= k.timeout()) 93 | }) 94 | .map(|(k, _)| k); 95 | elapsed.peek(|k| self.0.remove(*k)) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/devices/arctis_nova_7.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::time::Duration; 3 | 4 | use async_hid::{AccessMode, Device as HidDevice, HidResult}; 5 | use crossbeam_utils::atomic::AtomicCell; 6 | use static_assertions::const_assert; 7 | use tokio::spawn; 8 | use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; 9 | use tokio::task::JoinHandle; 10 | use tokio::time::timeout; 11 | use tracing::instrument; 12 | 13 | use crate::config::CallAction; 14 | use crate::devices::*; 15 | use crate::util::{AtomicCellExt, SenderExt, VecExt}; 16 | 17 | const VID_STEELSERIES: u16 = 0x1038; 18 | 19 | const PID_ARCTIS_NOVA_7: u16 = 0x2202; 20 | const PID_ARCTIS_NOVA_7X: u16 = 0x2206; 21 | const PID_ARCTIS_NOVA_7P: u16 = 0x220a; 22 | 23 | const USAGE_ID: u16 = 0x1; 24 | const NOTIFICATION_USAGE_PAGE: u16 = 0xFF00; 25 | const CONFIGURATION_USAGE_PAGE: u16 = 0xFFC0; 26 | 27 | pub const ARCTIS_NOVA_7: SupportedDevice = SupportedDevice { 28 | strings: DeviceStrings::new("Steelseries Arctis Nova 7", "Steelseries", "Arctis Nova 7"), 29 | required_interfaces: &[ 30 | Interface::new(NOTIFICATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, PID_ARCTIS_NOVA_7), 31 | Interface::new(CONFIGURATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, PID_ARCTIS_NOVA_7) 32 | ], 33 | open: ArctisNova7::open_pc 34 | }; 35 | 36 | pub const ARCTIS_NOVA_7X: SupportedDevice = SupportedDevice { 37 | strings: DeviceStrings::new("Steelseries Arctis Nova 7X", "Steelseries", "Arctis Nova 7X"), 38 | required_interfaces: &[ 39 | Interface::new(NOTIFICATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, PID_ARCTIS_NOVA_7X), 40 | Interface::new(CONFIGURATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, PID_ARCTIS_NOVA_7X) 41 | ], 42 | open: ArctisNova7::open_xbox 43 | }; 44 | 45 | pub const ARCTIS_NOVA_7P: SupportedDevice = SupportedDevice { 46 | strings: DeviceStrings::new("Steelseries Arctis Nova 7P", "Steelseries", "Arctis Nova 7P"), 47 | required_interfaces: &[ 48 | Interface::new(NOTIFICATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, PID_ARCTIS_NOVA_7P), 49 | Interface::new(CONFIGURATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, PID_ARCTIS_NOVA_7P) 50 | ], 51 | open: ArctisNova7::open_playstation 52 | }; 53 | 54 | #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] 55 | #[repr(u8)] 56 | enum PowerState { 57 | #[default] 58 | Offline, 59 | Charging, 60 | Discharging 61 | } 62 | 63 | impl PowerState { 64 | fn from_u8(byte: u8) -> Self { 65 | match byte { 66 | 0x0 => Self::Offline, 67 | 0x1 => Self::Charging, 68 | 0x3 => Self::Discharging, 69 | _ => Self::default() 70 | } 71 | } 72 | } 73 | 74 | const_assert!(AtomicCell::::is_lock_free()); 75 | #[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] 76 | #[repr(align(8))] //So that AtomicCell becomes lock-free 77 | struct State { 78 | power_state: PowerState, 79 | battery: u8, 80 | chat_mix: ChatMix 81 | } 82 | 83 | impl State { 84 | fn is_connected(self) -> bool { 85 | self.power_state != PowerState::Offline 86 | } 87 | fn battery(self) -> BatteryLevel { 88 | match self.power_state { 89 | PowerState::Offline => BatteryLevel::Unknown, 90 | PowerState::Charging => BatteryLevel::Charging, 91 | PowerState::Discharging => BatteryLevel::Level(self.battery) 92 | } 93 | } 94 | } 95 | 96 | pub struct ArctisNova7 { 97 | pub strings: DeviceStrings, 98 | update_task: JoinHandle<()>, 99 | config_task: JoinHandle<()>, 100 | config_channel: UnboundedSender, 101 | state: Arc> 102 | } 103 | 104 | impl ArctisNova7 { 105 | async fn open(strings: DeviceStrings, pid: u16, update_channel: UpdateChannel, interfaces: &InterfaceMap) -> DeviceResult { 106 | let config_interface = interfaces 107 | .get(&Interface::new(CONFIGURATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, pid)) 108 | .expect("Failed to find interface in map") 109 | .open(AccessMode::ReadWrite) 110 | .await?; 111 | 112 | let state = Arc::new(AtomicCell::new(load_state(&config_interface).await?)); 113 | 114 | //TODO open as read-only 115 | let notification_interface = interfaces 116 | .get(&Interface::new(NOTIFICATION_USAGE_PAGE, USAGE_ID, VID_STEELSERIES, pid)) 117 | .expect("Failed to find interface in map") 118 | .open(AccessMode::Read) 119 | .await?; 120 | 121 | let (config_channel, command_receiver) = unbounded_channel(); 122 | let config_task = spawn(configuration_handler(config_interface, update_channel.clone(), command_receiver)); 123 | let update_task = spawn(update_handler(notification_interface, update_channel.clone(), state.clone())); 124 | 125 | Ok(Box::new(Self { 126 | update_task, 127 | config_task, 128 | config_channel, 129 | strings, 130 | state 131 | })) 132 | } 133 | 134 | pub fn open_xbox(update_channel: UpdateChannel, interfaces: &InterfaceMap) -> BoxedDeviceFuture { 135 | Box::pin(Self::open(ARCTIS_NOVA_7X.strings, PID_ARCTIS_NOVA_7X, update_channel, interfaces)) 136 | } 137 | 138 | pub fn open_playstation(update_channel: UpdateChannel, interfaces: &InterfaceMap) -> BoxedDeviceFuture { 139 | Box::pin(Self::open(ARCTIS_NOVA_7P.strings, PID_ARCTIS_NOVA_7P, update_channel, interfaces)) 140 | } 141 | 142 | pub fn open_pc(update_channel: UpdateChannel, interfaces: &InterfaceMap) -> BoxedDeviceFuture { 143 | Box::pin(Self::open(ARCTIS_NOVA_7.strings, PID_ARCTIS_NOVA_7, update_channel, interfaces)) 144 | } 145 | 146 | fn request_config_action(&self, action: ConfigAction) { 147 | self.config_channel 148 | .send(action) 149 | .unwrap_or_else(|_| tracing::warn!("config channel close unexpectedly")) 150 | } 151 | } 152 | 153 | const STATUS_BUF_SIZE: usize = 8; 154 | 155 | #[instrument(skip_all)] 156 | async fn load_state(config_interface: &HidDevice) -> DeviceResult { 157 | let mut state = State::default(); 158 | config_interface.write_output_report(&[0x0, 0xb0]).await?; 159 | let mut buffer = [0u8; STATUS_BUF_SIZE]; 160 | //TODO add a timeout 161 | let size = config_interface.read_input_report(&mut buffer).await?; 162 | let buffer = &buffer[..size]; 163 | 164 | state.power_state = PowerState::from_u8(buffer[3]); 165 | state.battery = (state.power_state == PowerState::Discharging) 166 | .then(|| normalize_battery_level(buffer[2])) 167 | .unwrap_or_default(); 168 | state.chat_mix = (state.power_state != PowerState::Offline) 169 | .then_some(ChatMix { 170 | game: buffer[4], 171 | chat: buffer[5] 172 | }) 173 | .unwrap_or_default(); 174 | 175 | Ok(state) 176 | } 177 | 178 | #[instrument(skip_all)] 179 | async fn configuration_handler(config_interface: HidDevice, events: UpdateChannel, mut config_requests: UnboundedReceiver) { 180 | let mut config_interface = MaybeHidDevice::from(config_interface); 181 | 182 | loop { 183 | let duration = match config_interface.is_connected() { 184 | true => Duration::from_secs(20), 185 | false => Duration::MAX 186 | }; 187 | match timeout(duration, config_requests.recv()).await { 188 | Ok(Some(request)) => { 189 | tracing::debug!("Attempting apply config request: {:?}", request); 190 | let data = match request { 191 | ConfigAction::SetSideTone(level) => vec![0x00, 0x39, level], 192 | ConfigAction::SetMicrophoneVolume(level) => vec![0x00, 0x37, level], 193 | ConfigAction::EnableVolumeLimiter(enabled) => vec![0x00, 0x3a, u8::from(enabled)], 194 | ConfigAction::SetEqualizerLevels(mut levels) => { 195 | levels.prepend([0x00, 0x33]); 196 | levels 197 | } 198 | ConfigAction::SetBluetoothCallAction(action) => { 199 | let v = match action { 200 | CallAction::Nothing => 0x00, 201 | CallAction::ReduceVolume => 0x01, 202 | CallAction::Mute => 0x02 203 | }; 204 | vec![0x00, 0xb3, v] 205 | } 206 | ConfigAction::EnableAutoBluetoothActivation(enabled) => vec![0x00, 0xb2, u8::from(enabled)], 207 | ConfigAction::SetMicrophoneLightStrength(level) => vec![0x00, 0xae, level], 208 | ConfigAction::SetInactiveTime(minutes) => vec![0x00, 0xa3, minutes] 209 | }; 210 | match config_interface.connected(AccessMode::Write).await { 211 | Ok(device) => device 212 | .write_output_report(&data) 213 | .await 214 | .unwrap_or_else(|err| events.send_log(DeviceUpdate::DeviceError(err))), 215 | Err(err) => events.send_log(DeviceUpdate::DeviceError(err)) 216 | } 217 | } 218 | Ok(None) => break, 219 | Err(_) => config_interface.disconnect() 220 | } 221 | } 222 | tracing::warn!("Request channel close unexpectedly"); 223 | } 224 | 225 | #[instrument(skip_all)] 226 | async fn update_handler(notification_interface: HidDevice, events: UpdateChannel, state: Arc>) { 227 | let mut buf = [0u8; STATUS_BUF_SIZE]; 228 | loop { 229 | match notification_interface.read_input_report(&mut buf).await { 230 | Ok(size) => { 231 | let buf = &buf[..size]; 232 | //debug_assert_eq!(size, buf.len()); 233 | if let Some(update) = parse_status_update(buf) { 234 | state.update(|state| match update { 235 | StatusUpdate::PowerState(ps) => state.power_state = ps, 236 | StatusUpdate::Battery(level) => state.battery = level, 237 | StatusUpdate::ChatMix(mix) => state.chat_mix = mix 238 | }); 239 | events.send_log(DeviceUpdate::from(update)); 240 | } 241 | } 242 | Err(err) => events.send_log(DeviceUpdate::DeviceError(err)) 243 | } 244 | } 245 | } 246 | 247 | #[derive(Debug, Copy, Clone)] 248 | enum StatusUpdate { 249 | PowerState(PowerState), 250 | Battery(u8), 251 | ChatMix(ChatMix) 252 | } 253 | 254 | impl From for DeviceUpdate { 255 | fn from(value: StatusUpdate) -> Self { 256 | //This mapping is not fully correct but it's good enough 257 | match value { 258 | StatusUpdate::PowerState(_) => Self::ConnectionChanged, 259 | StatusUpdate::Battery(_) => Self::BatteryLevel, 260 | StatusUpdate::ChatMix(_) => Self::ChatMixChanged 261 | } 262 | } 263 | } 264 | 265 | fn parse_status_update(data: &[u8]) -> Option { 266 | const POWER_STATE_CHANGED: u8 = 0xbb; 267 | const BATTERY_LEVEL_CHANGED: u8 = 0xb7; 268 | const CHAT_MIX_CHANGED: u8 = 0x45; 269 | match data[0] { 270 | CHAT_MIX_CHANGED => Some(StatusUpdate::ChatMix(ChatMix { 271 | game: data[1], 272 | chat: data[2] 273 | })), 274 | POWER_STATE_CHANGED => Some(StatusUpdate::PowerState(PowerState::from_u8(data[1]))), 275 | BATTERY_LEVEL_CHANGED => Some(StatusUpdate::Battery(normalize_battery_level(data[1]))), 276 | _ => None 277 | } 278 | } 279 | 280 | fn normalize_battery_level(byte: u8) -> u8 { 281 | const BATTERY_MAX: u8 = 0x04; 282 | const BATTERY_MIN: u8 = 0x00; 283 | let level = byte.clamp(BATTERY_MIN, BATTERY_MAX); 284 | (level - BATTERY_MIN) * (100 / (BATTERY_MAX - BATTERY_MIN)) 285 | } 286 | 287 | impl Drop for ArctisNova7 { 288 | fn drop(&mut self) { 289 | tracing::trace!("Stopping background tasks for {}", self.name()); 290 | self.update_task.abort(); 291 | self.config_task.abort(); 292 | } 293 | } 294 | 295 | impl Device for ArctisNova7 { 296 | fn strings(&self) -> DeviceStrings { 297 | self.strings 298 | } 299 | 300 | fn is_connected(&self) -> bool { 301 | self.state.load().is_connected() 302 | } 303 | 304 | fn get_battery_status(&self) -> Option { 305 | Some(self.state.load().battery()) 306 | } 307 | 308 | fn get_chat_mix(&self) -> Option { 309 | Some(self.state.load().chat_mix) 310 | } 311 | 312 | fn get_side_tone(&self) -> Option<&dyn SideTone> { 313 | Some(self) 314 | } 315 | 316 | fn get_mic_volume(&self) -> Option<&dyn MicrophoneVolume> { 317 | Some(self) 318 | } 319 | 320 | fn get_volume_limiter(&self) -> Option<&dyn VolumeLimiter> { 321 | Some(self) 322 | } 323 | 324 | fn get_equalizer(&self) -> Option<&dyn Equalizer> { 325 | Some(self) 326 | } 327 | 328 | fn get_bluetooth_config(&self) -> Option<&dyn BluetoothConfig> { 329 | Some(self) 330 | } 331 | 332 | fn get_inactive_time(&self) -> Option<&dyn InactiveTime> { 333 | Some(self) 334 | } 335 | 336 | fn get_mic_light(&self) -> Option<&dyn MicrophoneLight> { 337 | Some(self) 338 | } 339 | } 340 | 341 | impl SideTone for ArctisNova7 { 342 | fn levels(&self) -> u8 { 343 | 4 344 | } 345 | 346 | fn set_level(&self, level: u8) { 347 | assert!(level < SideTone::levels(self)); 348 | self.request_config_action(ConfigAction::SetSideTone(level)); 349 | } 350 | } 351 | 352 | impl MicrophoneVolume for ArctisNova7 { 353 | fn levels(&self) -> u8 { 354 | 8 355 | } 356 | 357 | fn set_level(&self, level: u8) { 358 | assert!(level < MicrophoneVolume::levels(self)); 359 | self.request_config_action(ConfigAction::SetMicrophoneVolume(level)) 360 | } 361 | } 362 | 363 | impl VolumeLimiter for ArctisNova7 { 364 | fn set_enabled(&self, enabled: bool) { 365 | self.request_config_action(ConfigAction::EnableVolumeLimiter(enabled)); 366 | } 367 | } 368 | 369 | impl Equalizer for ArctisNova7 { 370 | fn bands(&self) -> u8 { 371 | 10 372 | } 373 | 374 | fn base_level(&self) -> u8 { 375 | 0x14 376 | } 377 | 378 | fn variance(&self) -> u8 { 379 | 0x14 380 | } 381 | 382 | fn presets(&self) -> &[(&str, &[u8])] { 383 | &[ 384 | ("Flat", &[0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14]), 385 | ("Bass", &[0x1b, 0x1f, 0x1c, 0x16, 0x11, 0x11, 0x12, 0x12, 0x12, 0x12]), 386 | ("Focus", &[0x0a, 0x0d, 0x12, 0x0d, 0x0f, 0x1c, 0x20, 0x1b, 0x0d, 0x14]), 387 | ("Smiley", &[0x1a, 0x1b, 0x17, 0x11, 0x0c, 0x0c, 0x0f, 0x17, 0x1a, 0x1c]) 388 | ] 389 | } 390 | 391 | fn set_levels(&self, levels: &[u8]) { 392 | assert_eq!(levels.len(), Equalizer::bands(self) as usize); 393 | assert!( 394 | levels 395 | .iter() 396 | .all(|i| *i >= self.base_level() - self.variance() && *i <= self.base_level() + self.variance()) 397 | ); 398 | self.request_config_action(ConfigAction::SetEqualizerLevels(levels.to_vec())); 399 | } 400 | } 401 | 402 | impl BluetoothConfig for ArctisNova7 { 403 | fn set_call_action(&self, action: CallAction) { 404 | self.request_config_action(ConfigAction::SetBluetoothCallAction(action)); 405 | } 406 | 407 | fn set_auto_enabled(&self, enabled: bool) { 408 | self.request_config_action(ConfigAction::EnableAutoBluetoothActivation(enabled)); 409 | } 410 | } 411 | 412 | impl MicrophoneLight for ArctisNova7 { 413 | fn levels(&self) -> u8 { 414 | 4 415 | } 416 | 417 | fn set_light_strength(&self, level: u8) { 418 | assert!(level < MicrophoneLight::levels(self)); 419 | self.request_config_action(ConfigAction::SetMicrophoneLightStrength(level)); 420 | } 421 | } 422 | 423 | impl InactiveTime for ArctisNova7 { 424 | fn set_inactive_time(&self, minutes: u8) { 425 | assert!(minutes > 0); 426 | //This should be correct, but I'm honestly to scared to test it 427 | //self.request_config_action(ConfigAction::SetInactiveTime(minutes)); 428 | let _ = ConfigAction::SetInactiveTime(minutes); 429 | } 430 | } 431 | 432 | enum MaybeHidDevice { 433 | Connected(HidDevice), 434 | Disconnected(DeviceInfo) 435 | } 436 | 437 | impl From for MaybeHidDevice { 438 | fn from(value: HidDevice) -> Self { 439 | Self::Connected(value) 440 | } 441 | } 442 | 443 | impl MaybeHidDevice { 444 | fn is_connected(&self) -> bool { 445 | matches!(self, MaybeHidDevice::Connected(_)) 446 | } 447 | 448 | fn disconnect(&mut self) { 449 | if let MaybeHidDevice::Connected(device) = self { 450 | let info = device.info().clone(); 451 | *self = MaybeHidDevice::Disconnected(info); 452 | tracing::debug!("Disconnecting from the device"); 453 | } 454 | } 455 | 456 | async fn connected(&mut self, mode: AccessMode) -> HidResult<&HidDevice> { 457 | match self { 458 | MaybeHidDevice::Connected(device) => Ok(device), 459 | MaybeHidDevice::Disconnected(info) => { 460 | tracing::debug!("Reconnecting to the device"); 461 | let device = info.open(mode).await?; 462 | *self = MaybeHidDevice::Connected(device); 463 | match self { 464 | MaybeHidDevice::Connected(device) => Ok(device), 465 | MaybeHidDevice::Disconnected(_) => unreachable!() 466 | } 467 | } 468 | } 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /src/devices/dummy.rs: -------------------------------------------------------------------------------- 1 | use std::future::ready; 2 | 3 | use tracing::instrument; 4 | 5 | use crate::config::CallAction; 6 | use crate::devices::*; 7 | 8 | pub const DUMMY_DEVICE: SupportedDevice = SupportedDevice { 9 | strings: DeviceStrings::new("DummyDevice", "DummyCorp", "DummyDevice"), 10 | required_interfaces: &[], 11 | open: create_dummy 12 | }; 13 | 14 | fn create_dummy(_: UpdateChannel, _: &InterfaceMap) -> BoxedDeviceFuture { 15 | let dummy: BoxedDevice = Box::new(DummyDevice); 16 | Box::pin(ready(Ok(dummy))) 17 | } 18 | 19 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 20 | pub struct DummyDevice; 21 | 22 | impl Device for DummyDevice { 23 | fn strings(&self) -> DeviceStrings { 24 | DUMMY_DEVICE.strings 25 | } 26 | 27 | fn is_connected(&self) -> bool { 28 | true 29 | } 30 | 31 | fn get_battery_status(&self) -> Option { 32 | Some(BatteryLevel::Charging) 33 | } 34 | 35 | fn get_chat_mix(&self) -> Option { 36 | Some(ChatMix::default()) 37 | } 38 | 39 | fn get_side_tone(&self) -> Option<&dyn SideTone> { 40 | Some(self) 41 | } 42 | 43 | fn get_mic_volume(&self) -> Option<&dyn MicrophoneVolume> { 44 | Some(self) 45 | } 46 | 47 | fn get_volume_limiter(&self) -> Option<&dyn VolumeLimiter> { 48 | Some(self) 49 | } 50 | 51 | fn get_equalizer(&self) -> Option<&dyn Equalizer> { 52 | Some(self) 53 | } 54 | 55 | fn get_bluetooth_config(&self) -> Option<&dyn BluetoothConfig> { 56 | Some(self) 57 | } 58 | 59 | fn get_inactive_time(&self) -> Option<&dyn InactiveTime> { 60 | Some(self) 61 | } 62 | 63 | fn get_mic_light(&self) -> Option<&dyn MicrophoneLight> { 64 | Some(self) 65 | } 66 | } 67 | 68 | impl SideTone for DummyDevice { 69 | fn levels(&self) -> u8 { 70 | 6 71 | } 72 | 73 | #[instrument(skip(self))] 74 | fn set_level(&self, level: u8) { 75 | tracing::info!("Updated sidetone"); 76 | } 77 | } 78 | 79 | impl MicrophoneVolume for DummyDevice { 80 | fn levels(&self) -> u8 { 81 | 12 82 | } 83 | 84 | #[instrument(skip(self))] 85 | fn set_level(&self, level: u8) { 86 | tracing::info!("Updated microphone volume"); 87 | } 88 | } 89 | 90 | impl MicrophoneLight for DummyDevice { 91 | fn levels(&self) -> u8 { 92 | 2 93 | } 94 | 95 | #[instrument(skip(self))] 96 | fn set_light_strength(&self, level: u8) { 97 | tracing::info!("Updated microphone light"); 98 | } 99 | } 100 | 101 | impl Equalizer for DummyDevice { 102 | fn bands(&self) -> u8 { 103 | 13 104 | } 105 | 106 | fn base_level(&self) -> u8 { 107 | 8 108 | } 109 | 110 | fn variance(&self) -> u8 { 111 | 3 112 | } 113 | 114 | fn presets(&self) -> &[(&str, &[u8])] { 115 | &[("Default", &[8; 13])] 116 | } 117 | 118 | #[instrument(skip(self))] 119 | fn set_levels(&self, levels: &[u8]) { 120 | tracing::info!("Updated equalizer"); 121 | } 122 | } 123 | 124 | impl VolumeLimiter for DummyDevice { 125 | #[instrument(skip(self))] 126 | fn set_enabled(&self, enabled: bool) { 127 | tracing::info!("Updated volume limiter"); 128 | } 129 | } 130 | 131 | impl BluetoothConfig for DummyDevice { 132 | #[instrument(skip(self))] 133 | fn set_call_action(&self, action: CallAction) { 134 | tracing::info!("Updated call action"); 135 | } 136 | 137 | #[instrument(skip(self))] 138 | fn set_auto_enabled(&self, enabled: bool) { 139 | tracing::info!("Updated auto enable"); 140 | } 141 | } 142 | 143 | impl InactiveTime for DummyDevice { 144 | #[instrument(skip(self))] 145 | fn set_inactive_time(&self, minutes: u8) { 146 | tracing::info!("Updated inactive time"); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/devices/mod.rs: -------------------------------------------------------------------------------- 1 | mod arctis_nova_7; 2 | mod dummy; 3 | 4 | use std::collections::{HashMap, HashSet}; 5 | use std::fmt::{Debug, Display, Formatter, Write}; 6 | use std::future::Future; 7 | use std::pin::Pin; 8 | 9 | use async_hid::{DeviceInfo, HidError}; 10 | use color_eyre::eyre::Error as EyreError; 11 | use futures_lite::stream::StreamExt; 12 | use tao::event_loop::EventLoopProxy; 13 | use tracing::instrument; 14 | 15 | use crate::config::{CallAction, DUMMY_DEVICE as DUMMY_DEVICE_ENABLED}; 16 | use crate::devices::arctis_nova_7::{ARCTIS_NOVA_7, ARCTIS_NOVA_7P, ARCTIS_NOVA_7X}; 17 | use crate::devices::dummy::DUMMY_DEVICE; 18 | 19 | pub const SUPPORTED_DEVICES: &[SupportedDevice] = &[ARCTIS_NOVA_7, ARCTIS_NOVA_7X, ARCTIS_NOVA_7P]; 20 | 21 | #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] 22 | #[repr(u16)] 23 | pub enum BatteryLevel { 24 | #[default] 25 | Unknown, 26 | Charging, 27 | Level(u8) 28 | } 29 | 30 | impl Display for BatteryLevel { 31 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 32 | match self { 33 | BatteryLevel::Unknown => write!(f, "Error"), 34 | BatteryLevel::Charging => write!(f, "Charging"), 35 | BatteryLevel::Level(level) => write!(f, "{}%", level) 36 | } 37 | } 38 | } 39 | 40 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 41 | pub struct ChatMix { 42 | pub game: u8, 43 | pub chat: u8 44 | } 45 | 46 | impl Default for ChatMix { 47 | fn default() -> Self { 48 | Self { game: 100, chat: 100 } 49 | } 50 | } 51 | 52 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] 53 | pub struct Interface { 54 | pub product_id: u16, 55 | pub vendor_id: u16, 56 | pub usage_id: u16, 57 | pub usage_page: u16 58 | } 59 | 60 | impl Interface { 61 | pub const fn new(usage_page: u16, usage_id: u16, vendor_id: u16, product_id: u16) -> Self { 62 | Self { 63 | product_id, 64 | vendor_id, 65 | usage_id, 66 | usage_page 67 | } 68 | } 69 | } 70 | 71 | impl From<&DeviceInfo> for Interface { 72 | fn from(value: &DeviceInfo) -> Self { 73 | Interface::new(value.usage_page, value.usage_id, value.vendor_id, value.product_id) 74 | } 75 | } 76 | 77 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 78 | pub struct DeviceStrings { 79 | pub name: &'static str, 80 | pub manufacturer: &'static str, 81 | pub product: &'static str 82 | } 83 | 84 | impl DeviceStrings { 85 | pub const fn new(name: &'static str, manufacturer: &'static str, product: &'static str) -> Self { 86 | Self { name, manufacturer, product } 87 | } 88 | } 89 | 90 | pub type InterfaceMap = HashMap; 91 | pub type UpdateChannel = EventLoopProxy; 92 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 93 | pub struct SupportedDevice { 94 | pub strings: DeviceStrings, 95 | required_interfaces: &'static [Interface], 96 | open: fn(channel: UpdateChannel, interfaces: &InterfaceMap) -> BoxedDeviceFuture 97 | } 98 | 99 | impl Display for SupportedDevice { 100 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 101 | f.write_str(self.name()) 102 | } 103 | } 104 | 105 | impl SupportedDevice { 106 | pub const fn name(&self) -> &'static str { 107 | self.strings.name 108 | } 109 | } 110 | 111 | #[derive(Debug)] 112 | pub enum DeviceUpdate { 113 | ConnectionChanged, 114 | ChatMixChanged, 115 | BatteryLevel, 116 | DeviceError(HidError) 117 | } 118 | 119 | #[derive(Debug, Clone, Default)] 120 | pub struct DeviceManager { 121 | interfaces: InterfaceMap, 122 | devices: Vec 123 | } 124 | 125 | impl DeviceManager { 126 | pub async fn new() -> DeviceResult { 127 | let mut result = Self::default(); 128 | result.refresh().await?; 129 | Ok(result) 130 | } 131 | 132 | #[instrument(skip_all)] 133 | pub async fn refresh(&mut self) -> DeviceResult<()> { 134 | self.interfaces.clear(); 135 | DeviceInfo::enumerate() 136 | .await? 137 | .map(|dev| (Interface::from(&dev), dev)) 138 | .for_each(|(info, dev)| _ = self.interfaces.insert(info, dev)) 139 | .await; 140 | 141 | self.devices.clear(); 142 | self.devices.extend( 143 | SUPPORTED_DEVICES 144 | .iter() 145 | .chain(DUMMY_DEVICE_ENABLED.then_some(&DUMMY_DEVICE)) 146 | .filter(|dev| { 147 | dev.required_interfaces 148 | .iter() 149 | .all(|i| self.interfaces.contains_key(i)) 150 | }) 151 | .inspect(|dev| tracing::trace!("Found {}", dev.strings.name)) 152 | ); 153 | 154 | Ok(()) 155 | } 156 | 157 | pub fn supported_devices(&self) -> &Vec { 158 | &self.devices 159 | } 160 | 161 | pub async fn open(&self, supported: &SupportedDevice, update_channel: UpdateChannel) -> DeviceResult { 162 | tracing::trace!("Attempting to open {}", supported.strings.name); 163 | let dev = (supported.open)(update_channel, &self.interfaces).await?; 164 | 165 | Ok(dev) 166 | } 167 | 168 | #[instrument(skip(self, update_channel))] 169 | pub async fn find_preferred_device(&self, preference: &Option, update_channel: UpdateChannel) -> Option { 170 | let device_iter = preference 171 | .iter() 172 | .flat_map(|pref| { 173 | self.devices 174 | .iter() 175 | .filter(move |dev| dev.strings.name == pref) 176 | }) 177 | .chain(self.devices.iter()); 178 | for device in device_iter { 179 | match self.open(device, update_channel.clone()).await { 180 | Ok(dev) => return Some(dev), 181 | Err(err) => tracing::error!("Failed to open device: {:?}", err) 182 | } 183 | } 184 | None 185 | } 186 | } 187 | 188 | pub fn generate_udev_rules() -> DeviceResult { 189 | let mut rules = String::new(); 190 | 191 | writeln!(rules, r#"ACTION!="add|change", GOTO="headsets_end""#)?; 192 | writeln!(rules, "")?; 193 | 194 | for device in SUPPORTED_DEVICES { 195 | writeln!(rules, "# {}", device.strings.name)?; 196 | let codes: HashSet<_> = device 197 | .required_interfaces 198 | .iter() 199 | .map(|i| (i.vendor_id, i.product_id)) 200 | .collect(); 201 | for (vid, pid) in codes { 202 | writeln!(rules, r#"KERNEL=="hidraw*", ATTRS{{idVendor}}=="{vid:04x}", ATTRS{{idProduct}}=="{pid:04x}", TAG+="uaccess""#)?; 203 | } 204 | writeln!(rules, "")?; 205 | } 206 | 207 | writeln!(rules, r#"LABEL="headsets_end""#)?; 208 | Ok(rules) 209 | } 210 | 211 | #[derive(Debug, Clone, Eq, PartialEq)] 212 | #[non_exhaustive] 213 | enum ConfigAction { 214 | SetSideTone(u8), 215 | EnableVolumeLimiter(bool), 216 | SetMicrophoneVolume(u8), 217 | SetEqualizerLevels(Vec), 218 | SetBluetoothCallAction(CallAction), 219 | EnableAutoBluetoothActivation(bool), 220 | SetMicrophoneLightStrength(u8), 221 | SetInactiveTime(u8) 222 | } 223 | 224 | pub type DeviceResult = Result; 225 | pub type BoxedDevice = Box; 226 | pub type BoxedDeviceFuture<'a> = Pin> + 'a>>; 227 | 228 | pub trait Device { 229 | fn strings(&self) -> DeviceStrings; 230 | fn is_connected(&self) -> bool; 231 | 232 | fn name(&self) -> &'static str { 233 | self.strings().name 234 | } 235 | fn get_battery_status(&self) -> Option { 236 | None 237 | } 238 | fn get_chat_mix(&self) -> Option { 239 | None 240 | } 241 | fn get_side_tone(&self) -> Option<&dyn SideTone> { 242 | None 243 | } 244 | fn get_mic_volume(&self) -> Option<&dyn MicrophoneVolume> { 245 | None 246 | } 247 | fn get_volume_limiter(&self) -> Option<&dyn VolumeLimiter> { 248 | None 249 | } 250 | fn get_equalizer(&self) -> Option<&dyn Equalizer> { 251 | None 252 | } 253 | fn get_bluetooth_config(&self) -> Option<&dyn BluetoothConfig> { 254 | None 255 | } 256 | fn get_inactive_time(&self) -> Option<&dyn InactiveTime> { 257 | None 258 | } 259 | fn get_mic_light(&self) -> Option<&dyn MicrophoneLight> { 260 | None 261 | } 262 | } 263 | 264 | pub trait SideTone { 265 | fn levels(&self) -> u8; 266 | fn set_level(&self, level: u8); 267 | } 268 | 269 | pub trait VolumeLimiter { 270 | fn set_enabled(&self, enabled: bool); 271 | } 272 | 273 | pub trait MicrophoneVolume { 274 | fn levels(&self) -> u8; 275 | fn set_level(&self, level: u8); 276 | } 277 | 278 | pub trait Equalizer { 279 | fn bands(&self) -> u8; 280 | fn base_level(&self) -> u8; 281 | fn variance(&self) -> u8; 282 | fn presets(&self) -> &[(&str, &[u8])]; 283 | fn set_levels(&self, levels: &[u8]); 284 | } 285 | 286 | pub trait BluetoothConfig { 287 | fn set_call_action(&self, action: CallAction); 288 | fn set_auto_enabled(&self, enabled: bool); 289 | } 290 | 291 | pub trait MicrophoneLight { 292 | fn levels(&self) -> u8; 293 | fn set_light_strength(&self, level: u8); 294 | } 295 | 296 | pub trait InactiveTime { 297 | fn set_inactive_time(&self, minutes: u8); 298 | } 299 | 300 | /* 301 | #[derive(Debug)] 302 | pub enum DeviceError { 303 | Hid(HidError), 304 | Other(EyreError) 305 | } 306 | 307 | 308 | impl From for DeviceError { 309 | fn from(value: HidError) -> Self { 310 | Self::Hid(value) 311 | } 312 | } 313 | 314 | impl From for DeviceError { 315 | fn from(value: EyreError) -> Self { 316 | Self::Other(value) 317 | } 318 | } 319 | 320 | impl Display for DeviceError { 321 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 322 | match self { 323 | DeviceError::Hid(err) => Display::fmt(err, f), 324 | DeviceError::Other(err) => Display::fmt(err, f) 325 | } 326 | } 327 | } 328 | 329 | impl Error for DeviceError { 330 | fn source(&self) -> Option<&(dyn Error + 'static)> { 331 | match self { 332 | DeviceError::Hid(err) => Some(err), 333 | DeviceError::Other(err) => err.source() 334 | } 335 | } 336 | } 337 | */ 338 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | /* 3 | mod devices; 4 | mod config; 5 | mod util; 6 | 7 | use color_eyre::Result; 8 | use tokio::runtime::Builder; 9 | use tokio::sync::mpsc::unbounded_channel; 10 | use crate::devices::DeviceManager; 11 | 12 | 13 | 14 | fn main() -> Result<()> { 15 | let runtime = Builder::new_multi_thread() 16 | .enable_all() 17 | .build()?; 18 | 19 | let (sender, mut receiver) = unbounded_channel(); 20 | 21 | runtime.block_on(async { 22 | let manager = DeviceManager::new().await?; 23 | let dev = manager 24 | .supported_devices() 25 | .first() 26 | .unwrap(); 27 | 28 | let dev = manager.open(dev, sender.clone()).await?; 29 | println!("{:?}", dev.is_connected()); 30 | println!("{:?}", dev.get_battery_status()); 31 | println!("{:?}", dev.get_chat_mix()); 32 | while let Some(_) = receiver.recv().await { 33 | println!("{:?}", dev.is_connected()); 34 | println!("{:?}", dev.get_battery_status()); 35 | println!("{:?}", dev.get_chat_mix()); 36 | } 37 | Ok(()) 38 | }) 39 | } 40 | */ 41 | 42 | mod audio; 43 | mod config; 44 | mod debouncer; 45 | mod devices; 46 | mod notification; 47 | mod renderer; 48 | mod tray; 49 | mod ui; 50 | mod util; 51 | 52 | use std::ops::Not; 53 | use std::sync::Mutex; 54 | use std::time::{Duration, Instant}; 55 | 56 | use color_eyre::Result; 57 | use tao::event::Event; 58 | use tao::event_loop::{ControlFlow, EventLoop}; 59 | use tao::platform::run_return::EventLoopExtRunReturn; 60 | use tokio::runtime::Builder; 61 | use tracing::instrument; 62 | use tracing_error::ErrorLayer; 63 | use tracing_subscriber::filter::{LevelFilter, Targets}; 64 | use tracing_subscriber::fmt::layer; 65 | use tracing_subscriber::layer::SubscriberExt; 66 | use tracing_subscriber::util::SubscriberInitExt; 67 | 68 | use crate::audio::AudioSystem; 69 | use crate::config::{log_file, Config, EqualizerConfig, HeadsetConfig, CLOSE_IMMEDIATELY, START_QUIET, PRINT_UDEV_RULES}; 70 | use crate::debouncer::{Action, Debouncer}; 71 | use crate::devices::{BatteryLevel, BoxedDevice, Device, DeviceManager, DeviceUpdate, generate_udev_rules}; 72 | use crate::renderer::EguiWindow; 73 | use crate::tray::{AppTray, TrayEvent}; 74 | 75 | fn main() -> Result<()> { 76 | if *PRINT_UDEV_RULES { return Ok(println!("{}", generate_udev_rules()?)); } 77 | color_eyre::install()?; 78 | let logfile = Mutex::new(log_file()); 79 | tracing_subscriber::registry() 80 | .with(ErrorLayer::default()) 81 | .with(Targets::new().with_default(LevelFilter::TRACE)) 82 | .with(layer().without_time()) 83 | .with(layer().with_ansi(false).with_writer(logfile)) 84 | .init(); 85 | let runtime = Builder::new_multi_thread().enable_all().build()?; 86 | 87 | let span = tracing::info_span!("init").entered(); 88 | 89 | let mut config = Config::load()?; 90 | 91 | let mut event_loop = EventLoop::with_user_event(); 92 | let event_loop_proxy = event_loop.create_proxy(); 93 | 94 | let mut audio_system = AudioSystem::new(); 95 | 96 | let mut device_manager = runtime.block_on(DeviceManager::new())?; 97 | let mut device = runtime.block_on(async { 98 | device_manager 99 | .find_preferred_device(&config.preferred_device, event_loop_proxy.clone()) 100 | .await 101 | }); 102 | 103 | let mut tray = AppTray::new(&event_loop); 104 | 105 | let mut window: Option = START_QUIET.not().then(|| EguiWindow::new(&event_loop)); 106 | 107 | let mut debouncer = Debouncer::new(); 108 | let mut last_connected = false; 109 | let mut last_battery = Default::default(); 110 | debouncer.submit_all([Action::UpdateSystemAudio, Action::UpdateTrayTooltip, Action::UpdateTray]); 111 | 112 | span.exit(); 113 | event_loop.run_return(move |event, event_loop, control_flow| { 114 | if window 115 | .as_mut() 116 | .map(|w| { 117 | w.handle_events(&event, |egui_ctx| match &device { 118 | Some(device) => ui::config_ui( 119 | egui_ctx, 120 | &mut debouncer, 121 | &mut config, 122 | device.as_ref(), 123 | device_manager.supported_devices(), 124 | &mut audio_system 125 | ), 126 | None => ui::no_device_ui(egui_ctx, &mut debouncer) 127 | }) 128 | }) 129 | .unwrap_or(false) 130 | { 131 | debouncer.force(Action::SaveConfig); 132 | window.take(); 133 | if *CLOSE_IMMEDIATELY { 134 | *control_flow = ControlFlow::Exit; 135 | } 136 | } 137 | 138 | match event { 139 | Event::MenuEvent { menu_id, .. } => { 140 | let _span = tracing::info_span!("tray_menu_event").entered(); 141 | match tray.handle_event(menu_id) { 142 | Some(TrayEvent::Open) => { 143 | audio_system.refresh_devices(); 144 | match &mut window { 145 | None => window = Some(EguiWindow::new(event_loop)), 146 | Some(window) => { 147 | window.focus(); 148 | } 149 | } 150 | } 151 | Some(TrayEvent::Quit) => { 152 | *control_flow = ControlFlow::Exit; 153 | } 154 | Some(TrayEvent::Profile(id)) => { 155 | let _span = tracing::info_span!("profile_change", id).entered(); 156 | if let Some(device) = &device { 157 | let headset = config.get_headset(device.name()); 158 | if id as u32 != headset.selected_profile_index { 159 | let len = headset.profiles.len(); 160 | if id < len { 161 | headset.selected_profile_index = id as u32; 162 | submit_profile_change(&mut debouncer); 163 | debouncer.submit_all([Action::SaveConfig, Action::UpdateTray]); 164 | } else { 165 | tracing::warn!(len, "Profile id out of range") 166 | } 167 | } else { 168 | tracing::trace!("Profile already selected"); 169 | } 170 | } 171 | } 172 | _ => {} 173 | } 174 | } 175 | Event::NewEvents(_) | Event::LoopDestroyed => { 176 | while let Some(action) = debouncer.next() { 177 | let _span = tracing::info_span!("debouncer_event", ?action).entered(); 178 | tracing::trace!("Processing event"); 179 | match action { 180 | Action::UpdateDeviceStatus => { 181 | if let Some(device) = &device { 182 | let current_connection = device.is_connected(); 183 | let current_battery = device.get_battery_status(); 184 | if current_connection != last_connected { 185 | let msg = build_notification_text(current_connection, &[current_battery, last_battery]); 186 | notification::notify(device.name(), &msg, Duration::from_secs(2)) 187 | .unwrap_or_else(|err| tracing::warn!("Can not create notification: {:?}", err)); 188 | debouncer.submit_all([Action::UpdateSystemAudio, Action::UpdateTrayTooltip]); 189 | debouncer.force(Action::UpdateSystemAudio); 190 | last_connected = current_connection; 191 | } 192 | if last_battery != current_battery { 193 | debouncer.submit(Action::UpdateTrayTooltip); 194 | last_battery = current_battery; 195 | } 196 | } 197 | } 198 | Action::RefreshDeviceList => runtime.block_on(async { 199 | device = None; 200 | device_manager 201 | .refresh() 202 | .await 203 | .unwrap_or_else(|err| tracing::warn!("Failed to refresh devices: {}", err)) 204 | }), 205 | Action::SwitchDevice => { 206 | if config.preferred_device != device.as_ref().map(|d| d.name().to_string()) { 207 | device = runtime.block_on(async { 208 | device_manager 209 | .find_preferred_device(&config.preferred_device, event_loop_proxy.clone()) 210 | .await 211 | }); 212 | submit_full_change(&mut debouncer); 213 | debouncer.submit_all([Action::UpdateTray, Action::UpdateTrayTooltip]); 214 | } else { 215 | tracing::debug!("Preferred device is already active") 216 | } 217 | } 218 | Action::UpdateSystemAudio => { 219 | if let Some(device) = &device { 220 | let headset = config.get_headset(device.name()); 221 | audio_system.apply(&headset.os_audio, device.is_connected()) 222 | } 223 | } 224 | Action::SaveConfig => { 225 | config 226 | .save() 227 | .unwrap_or_else(|err| tracing::warn!("Could not save config: {:?}", err)); 228 | } 229 | Action::UpdateTray => update_tray(&mut tray, &mut config, device.as_ref().map(|d| d.name())), 230 | Action::UpdateTrayTooltip => update_tray_tooltip(&mut tray, &device), 231 | action => { 232 | if let Some(device) = &device { 233 | let headset = config.get_headset(device.name()); 234 | apply_config_to_device(action, device.as_ref(), headset) 235 | } 236 | } 237 | } 238 | } 239 | } 240 | Event::UserEvent(event) => match event { 241 | DeviceUpdate::ConnectionChanged | DeviceUpdate::BatteryLevel => debouncer.submit(Action::UpdateDeviceStatus), 242 | DeviceUpdate::DeviceError(err) => tracing::error!("The device return an error: {}", err), 243 | DeviceUpdate::ChatMixChanged => {} 244 | }, 245 | _ => () 246 | } 247 | if !matches!(*control_flow, ControlFlow::ExitWithCode(_)) { 248 | let next_window_update = window.as_ref().and_then(|w| w.next_repaint()); 249 | let next_update = [next_window_update, debouncer.next_action()] 250 | .into_iter() 251 | .flatten() 252 | .min(); 253 | *control_flow = match next_update { 254 | Some(next_update) => match next_update <= Instant::now() { 255 | true => ControlFlow::Poll, 256 | false => ControlFlow::WaitUntil(next_update) 257 | }, 258 | None => ControlFlow::Wait 259 | }; 260 | } 261 | }); 262 | Ok(()) 263 | } 264 | 265 | #[instrument(skip_all)] 266 | fn submit_profile_change(debouncer: &mut Debouncer) { 267 | let actions = [ 268 | Action::UpdateSideTone, 269 | Action::UpdateEqualizer, 270 | Action::UpdateMicrophoneVolume, 271 | Action::UpdateVolumeLimit 272 | ]; 273 | debouncer.submit_all(actions); 274 | debouncer.force_all(actions); 275 | } 276 | 277 | #[instrument(skip_all)] 278 | fn submit_full_change(debouncer: &mut Debouncer) { 279 | submit_profile_change(debouncer); 280 | let actions = [ 281 | Action::UpdateMicrophoneLight, 282 | Action::UpdateInactiveTime, 283 | Action::UpdateBluetoothCall, 284 | Action::UpdateAutoBluetooth, 285 | Action::UpdateSystemAudio 286 | ]; 287 | debouncer.submit_all(actions); 288 | debouncer.force_all(actions); 289 | } 290 | 291 | #[instrument(skip_all, fields(name = %device.name()))] 292 | fn apply_config_to_device(action: Action, device: &dyn Device, headset: &mut HeadsetConfig) { 293 | if device.is_connected() { 294 | match action { 295 | Action::UpdateSideTone => { 296 | if let Some(sidetone) = device.get_side_tone() { 297 | let _span = tracing::info_span!("sidetone").entered(); 298 | sidetone.set_level(headset.selected_profile().side_tone); 299 | } 300 | } 301 | Action::UpdateEqualizer => { 302 | if let Some(equalizer) = device.get_equalizer() { 303 | let _span = tracing::info_span!("equalizer").entered(); 304 | let levels = match headset.selected_profile().equalizer.clone() { 305 | EqualizerConfig::Preset(i) => equalizer 306 | .presets() 307 | .get(i as usize) 308 | .expect("Unknown preset") 309 | .1 310 | .to_vec(), 311 | EqualizerConfig::Custom(levels) => levels 312 | }; 313 | equalizer.set_levels(&levels); 314 | } 315 | } 316 | Action::UpdateMicrophoneVolume => { 317 | if let Some(mic_volume) = device.get_mic_volume() { 318 | let _span = tracing::info_span!("mic_volume").entered(); 319 | mic_volume.set_level(headset.selected_profile().microphone_volume); 320 | } 321 | } 322 | Action::UpdateVolumeLimit => { 323 | if let Some(volume_limiter) = device.get_volume_limiter() { 324 | let _span = tracing::info_span!("volume_limiter").entered(); 325 | volume_limiter.set_enabled(headset.selected_profile().volume_limiter); 326 | } 327 | } 328 | Action::UpdateInactiveTime => { 329 | if let Some(inactive_time) = device.get_inactive_time() { 330 | let _span = tracing::info_span!("inactive time").entered(); 331 | inactive_time.set_inactive_time(headset.inactive_time); 332 | } 333 | } 334 | Action::UpdateMicrophoneLight => { 335 | if let Some(mic_light) = device.get_mic_light() { 336 | let _span = tracing::info_span!("mic_light").entered(); 337 | mic_light.set_light_strength(headset.mic_light); 338 | } 339 | } 340 | Action::UpdateAutoBluetooth => { 341 | if let Some(bluetooth_config) = device.get_bluetooth_config() { 342 | let _span = tracing::info_span!("bluetooth").entered(); 343 | bluetooth_config.set_auto_enabled(headset.auto_enable_bluetooth); 344 | } 345 | } 346 | Action::UpdateBluetoothCall => { 347 | if let Some(bluetooth_config) = device.get_bluetooth_config() { 348 | let _span = tracing::info_span!("bluetooth").entered(); 349 | bluetooth_config.set_call_action(headset.bluetooth_call); 350 | } 351 | } 352 | _ => tracing::warn!("{:?} is not related to the device", action) 353 | } 354 | } 355 | } 356 | 357 | #[instrument(skip_all)] 358 | pub fn update_tray(tray: &mut AppTray, config: &mut Config, device_name: Option<&str>) { 359 | match device_name { 360 | None => { 361 | tray.build_menu(0, |_| ("", false)); 362 | } 363 | Some(device_name) => { 364 | let headset = config.get_headset(device_name); 365 | let selected = headset.selected_profile_index as usize; 366 | let profiles = &headset.profiles; 367 | tray.build_menu(profiles.len(), |id| (profiles[id].name.as_str(), id == selected)); 368 | } 369 | } 370 | } 371 | 372 | #[instrument(skip_all)] 373 | pub fn update_tray_tooltip(tray: &mut AppTray, device: &Option) { 374 | match device { 375 | None => { 376 | tray.set_tooltip("No Device"); 377 | } 378 | Some(device) => { 379 | let name = device.name().to_string(); 380 | let tooltip = match device.is_connected() { 381 | true => match device.get_battery_status() { 382 | Some(BatteryLevel::Charging) => format!("{name}\nBattery: Charging"), 383 | Some(BatteryLevel::Level(level)) => format!("{name}\nBattery: {level}%"), 384 | _ => format!("{name}\nConnected") 385 | }, 386 | false => format!("{name}\nDisconnected") 387 | }; 388 | tray.set_tooltip(&tooltip); 389 | } 390 | } 391 | tracing::trace!("Updated tooltip"); 392 | } 393 | 394 | fn build_notification_text(connected: bool, battery_levels: &[Option]) -> String { 395 | let msg = match connected { 396 | true => "Connected", 397 | false => "Disconnected" 398 | }; 399 | battery_levels 400 | .iter() 401 | .filter_map(|b| match b { 402 | Some(BatteryLevel::Level(l)) => Some(*l), 403 | _ => None 404 | }) 405 | .min() 406 | .map(|level| format!("{} (Battery: {}%)", msg, level)) 407 | .unwrap_or_else(|| msg.to_string()) 408 | } 409 | -------------------------------------------------------------------------------- /src/notification.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use color_eyre::Result; 4 | 5 | #[cfg(target_os = "windows")] 6 | pub fn notify(msg_title: &str, msg_body: &str, duration: Duration) -> Result<()> { 7 | use std::thread; 8 | 9 | use windows::core::HSTRING; 10 | use windows::UI::Notifications::{ToastNotification, ToastNotificationManager, ToastTemplateType}; 11 | 12 | let toast_xml = ToastNotificationManager::GetTemplateContent(ToastTemplateType::ToastText02)?; 13 | let toast_text_elements = toast_xml.GetElementsByTagName(&HSTRING::from("text"))?; 14 | 15 | toast_text_elements 16 | .GetAt(0)? 17 | .AppendChild(&toast_xml.CreateTextNode(&HSTRING::from(msg_title))?)?; 18 | 19 | toast_text_elements 20 | .GetAt(1)? 21 | .AppendChild(&toast_xml.CreateTextNode(&HSTRING::from(msg_body))?)?; 22 | 23 | let toast = ToastNotification::CreateToastNotification(&toast_xml)?; 24 | 25 | let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("HeadsetController"))?; 26 | notifier.Show(&toast)?; 27 | thread::spawn(move || { 28 | thread::sleep(duration); 29 | notifier 30 | .Hide(&toast) 31 | .unwrap_or_else(|err| tracing::warn!("Can not hide notification: {}", err)); 32 | }); 33 | Ok(()) 34 | } 35 | 36 | #[cfg(not(target_os = "windows"))] 37 | pub fn notify(msg_title: &str, msg_body: &str, duration: Duration) -> Result<()> { 38 | notify_rust::Notification::new() 39 | .summary(msg_title) 40 | .body(msg_body) 41 | .timeout(duration) 42 | .show()?; 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/d3d11.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | 3 | pub use egui_d3d11::Painter; 4 | use egui_d3d11::{Device, DeviceContext}; 5 | use tao::dpi::PhysicalSize; 6 | use tao::event_loop::EventLoopWindowTarget; 7 | use tao::platform::windows::WindowExtWindows; 8 | use tao::window::{Window, WindowBuilder}; 9 | use tracing::instrument; 10 | use windows::Win32::Foundation::{FALSE, HWND}; 11 | use windows::Win32::Graphics::Direct3D::*; 12 | use windows::Win32::Graphics::Direct3D11::*; 13 | use windows::Win32::Graphics::Dxgi::Common::*; 14 | use windows::Win32::Graphics::Dxgi::*; 15 | 16 | pub struct GraphicsWindow { 17 | window: Window, 18 | device: Device, 19 | context: DeviceContext, 20 | swap_chain: IDXGISwapChain1, 21 | render_target: Cell> 22 | } 23 | 24 | impl GraphicsWindow { 25 | #[instrument(skip_all, name = "d3d11_window_new")] 26 | pub fn new(window_builder: WindowBuilder, event_loop: &EventLoopWindowTarget) -> Self { 27 | let window = window_builder 28 | .build(event_loop) 29 | .expect("Failed to create window"); 30 | 31 | let (device, context) = unsafe { 32 | let mut device = None; 33 | let mut context = None; 34 | D3D11CreateDevice( 35 | None, 36 | D3D_DRIVER_TYPE_HARDWARE, 37 | None, 38 | D3D11_CREATE_DEVICE_FLAG::default(), 39 | Some(&[D3D_FEATURE_LEVEL_11_1]), 40 | D3D11_SDK_VERSION, 41 | Some(&mut device), 42 | None, 43 | Some(&mut context) 44 | ) 45 | .expect("Failed to create d3d11 device"); 46 | (device.unwrap(), context.unwrap()) 47 | }; 48 | 49 | let dxgi_factory: IDXGIFactory2 = unsafe { CreateDXGIFactory1().expect("Failed to create dxgi factory") }; 50 | let window_size = window.inner_size(); 51 | let desc = DXGI_SWAP_CHAIN_DESC1 { 52 | Width: window_size.width, 53 | Height: window_size.height, 54 | Format: DXGI_FORMAT_R8G8B8A8_UNORM, 55 | Stereo: FALSE, 56 | SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, 57 | BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, 58 | BufferCount: 2, 59 | Scaling: DXGI_SCALING_NONE, 60 | SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD, 61 | AlphaMode: DXGI_ALPHA_MODE_IGNORE, 62 | Flags: 0 63 | }; 64 | 65 | let window_handle = HWND(window.hwnd() as _); 66 | let swap_chain = unsafe { 67 | dxgi_factory 68 | .CreateSwapChainForHwnd(&device, window_handle, &desc, None, None) 69 | .expect("Failed to create swapchain") 70 | }; 71 | 72 | unsafe { 73 | swap_chain 74 | .SetBackgroundColor(&get_background_color()) 75 | .unwrap_or_else(|err| tracing::warn!("Failed to set swapchain color: {}", err)); 76 | } 77 | 78 | Self { 79 | window, 80 | device, 81 | context, 82 | swap_chain, 83 | render_target: Cell::new(None) 84 | } 85 | } 86 | 87 | #[instrument(skip_all)] 88 | fn render_target(&self) -> ID3D11RenderTargetView { 89 | let target = self.render_target.take().unwrap_or_else(|| unsafe { 90 | let buffer: ID3D11Texture2D = self 91 | .swap_chain 92 | .GetBuffer(0) 93 | .expect("Can not get a valid back buffer"); 94 | let mut target = None; 95 | self.device 96 | .CreateRenderTargetView(&buffer, None, Some(&mut target)) 97 | .expect("Can not create a render target"); 98 | target.expect("Render target is none") 99 | }); 100 | self.render_target.set(Some(target.clone())); 101 | target 102 | } 103 | 104 | #[instrument(skip_all)] 105 | pub fn make_painter(&self) -> Painter { 106 | Painter::new(self.device.clone(), self.context.clone()) 107 | } 108 | 109 | pub fn window(&self) -> &Window { 110 | &self.window 111 | } 112 | 113 | #[instrument(skip(self))] 114 | pub fn resize(&self, size: PhysicalSize) { 115 | unsafe { 116 | self.render_target.set(None); 117 | self.context.ClearState(); 118 | self.swap_chain 119 | .ResizeBuffers(0, size.width, size.height, DXGI_FORMAT_UNKNOWN, 0) 120 | .expect("Failed to resize swapchain"); 121 | } 122 | } 123 | 124 | #[instrument(skip(self))] 125 | pub fn clear(&self) { 126 | let render_target = self.render_target(); 127 | unsafe { 128 | self.context 129 | .OMSetRenderTargets(Some(&[Some(render_target)]), None); 130 | } 131 | } 132 | 133 | #[instrument(skip(self))] 134 | pub fn swap_buffers(&self) { 135 | unsafe { 136 | self.swap_chain 137 | .Present(1, 0) 138 | .ok() 139 | .expect("Could not present swapchain"); 140 | } 141 | } 142 | } 143 | 144 | fn get_background_color() -> DXGI_RGBA { 145 | let [r, g, b, a] = egui::Visuals::light().window_fill.to_normalized_gamma_f32(); 146 | DXGI_RGBA { r, g, b, a } 147 | } 148 | -------------------------------------------------------------------------------- /src/renderer/gl.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroU32; 2 | use std::sync::Arc; 3 | 4 | pub use egui_glow::Painter; 5 | use glow::{Context, HasContext, COLOR_BUFFER_BIT}; 6 | use glutin::config::ConfigTemplateBuilder; 7 | use glutin::context::{ContextApi, ContextAttributesBuilder, NotCurrentGlContextSurfaceAccessor, PossiblyCurrentContext}; 8 | use glutin::display::{Display, GetGlDisplay, GlDisplay}; 9 | use glutin::surface::{GlSurface, Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface}; 10 | use glutin_tao::{finalize_window, ApiPreference, DisplayBuilder, GlWindow}; 11 | use raw_window_handle::HasRawWindowHandle; 12 | use tao::dpi::PhysicalSize; 13 | use tao::event_loop::EventLoopWindowTarget; 14 | use tao::window::{Window, WindowBuilder}; 15 | use tracing::instrument; 16 | 17 | pub struct GraphicsWindow { 18 | window: Window, 19 | gl_context: PossiblyCurrentContext, 20 | _gl_display: Display, 21 | gl_surface: Surface, 22 | gl: Arc 23 | } 24 | 25 | impl GraphicsWindow { 26 | #[instrument(skip_all, name = "gl_window_new")] 27 | pub fn new(window_builder: WindowBuilder, event_loop: &EventLoopWindowTarget) -> Self { 28 | let template = ConfigTemplateBuilder::new() 29 | .with_depth_size(0) 30 | .with_stencil_size(0) 31 | .with_transparency(false) 32 | .prefer_hardware_accelerated(None); 33 | 34 | tracing::debug!("trying to get gl_config"); 35 | let (mut window, gl_config) = DisplayBuilder::new() 36 | .with_preference(ApiPreference::FallbackEgl) 37 | .with_window_builder(Some(window_builder.clone())) 38 | .build(event_loop, template, |mut configs| { 39 | configs 40 | .next() 41 | .expect("failed to find a matching configuration for creating glutin config") 42 | }) 43 | .expect("failed to create gl_config"); 44 | 45 | tracing::debug!("found gl_config: {:?}", &gl_config); 46 | 47 | let raw_window_handle = window.as_ref().map(|window| window.raw_window_handle()); 48 | tracing::debug!("raw window handle: {:?}", raw_window_handle); 49 | let gl_display = gl_config.display(); 50 | 51 | let context_attributes = ContextAttributesBuilder::new().build(raw_window_handle); 52 | 53 | let fallback_context_attributes = ContextAttributesBuilder::new() 54 | .with_context_api(ContextApi::Gles(None)) 55 | .build(raw_window_handle); 56 | 57 | let mut not_current_gl_context = Some(unsafe { 58 | gl_display 59 | .create_context(&gl_config, &context_attributes) 60 | .unwrap_or_else(|_| { 61 | tracing::debug!( 62 | "failed to create gl_context with attributes: {:?}. retrying with fallback context attributes: {:?}", 63 | &context_attributes, 64 | &fallback_context_attributes 65 | ); 66 | gl_display 67 | .create_context(&gl_config, &fallback_context_attributes) 68 | .expect("failed to create context") 69 | }) 70 | }); 71 | 72 | let window = window.take().unwrap_or_else(|| { 73 | tracing::debug!("window doesn't exist yet. creating one now with finalize_window"); 74 | finalize_window(event_loop, window_builder, &gl_config).expect("failed to finalize glutin window") 75 | }); 76 | 77 | let attrs = window.build_surface_attributes(SurfaceAttributesBuilder::default()); 78 | tracing::debug!("creating surface with attributes: {:?}", &attrs); 79 | let gl_surface = unsafe { 80 | gl_config 81 | .display() 82 | .create_window_surface(&gl_config, &attrs) 83 | .expect("Failed to create window surface") 84 | }; 85 | tracing::debug!("surface created successfully: {:?}.making context current", gl_surface); 86 | let gl_context = not_current_gl_context 87 | .take() 88 | .unwrap() 89 | .make_current(&gl_surface) 90 | .expect("Could not make context current"); 91 | 92 | gl_surface 93 | .set_swap_interval(&gl_context, SwapInterval::Wait(NonZeroU32::new(1).unwrap())) 94 | .expect("Failed to activate vsync"); 95 | 96 | let gl = Arc::new(unsafe { 97 | Context::from_loader_function(|s| { 98 | let s = std::ffi::CString::new(s).expect("failed to construct C string from string for gl proc address"); 99 | gl_display.get_proc_address(&s) 100 | }) 101 | }); 102 | 103 | Self { 104 | window, 105 | gl_context, 106 | _gl_display: gl_display, 107 | gl_surface, 108 | gl 109 | } 110 | } 111 | 112 | #[instrument(skip_all)] 113 | pub fn make_painter(&self) -> Painter { 114 | Painter::new(self.gl.clone(), "", None).unwrap() 115 | } 116 | 117 | pub fn window(&self) -> &Window { 118 | &self.window 119 | } 120 | 121 | #[instrument(skip(self))] 122 | pub fn resize(&self, physical_size: PhysicalSize) { 123 | self.gl_surface.resize( 124 | &self.gl_context, 125 | physical_size.width.try_into().unwrap(), 126 | physical_size.height.try_into().unwrap() 127 | ); 128 | } 129 | 130 | #[instrument(skip(self))] 131 | pub fn clear(&self) { 132 | let clear_color = [0.1, 0.1, 0.1]; 133 | unsafe { 134 | self.gl 135 | .clear_color(clear_color[0], clear_color[1], clear_color[2], 1.0); 136 | self.gl.clear(COLOR_BUFFER_BIT); 137 | } 138 | } 139 | 140 | #[instrument(skip(self))] 141 | pub fn swap_buffers(&self) { 142 | self.gl_surface 143 | .swap_buffers(&self.gl_context) 144 | .expect("Failed to swap buffers") 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/renderer/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(all(not(windows), feature = "directx"))] 2 | compile_error!("DirectX is only supported on windows."); 3 | 4 | #[cfg(not(any(feature = "directx", feature = "opengl")))] 5 | compile_error!("You must select a backend. Use --feature directx/opengl"); 6 | 7 | #[cfg(feature = "directx")] 8 | #[path = "d3d11.rs"] 9 | mod backend; 10 | 11 | #[cfg(feature = "opengl")] 12 | #[path = "gl.rs"] 13 | mod backend; 14 | 15 | use std::time::Instant; 16 | 17 | use egui::{Context, FullOutput, Rounding, Visuals}; 18 | use egui_tao::State; 19 | use tao::dpi::LogicalSize; 20 | use tao::event::{Event, WindowEvent}; 21 | use tao::event_loop::EventLoopWindowTarget; 22 | #[cfg(any( 23 | target_os = "linux", 24 | target_os = "dragonfly", 25 | target_os = "freebsd", 26 | target_os = "netbsd", 27 | target_os = "openbsd" 28 | ))] 29 | use tao::platform::unix::WindowBuilderExtUnix; 30 | #[cfg(windows)] 31 | use tao::platform::windows::WindowBuilderExtWindows; 32 | use tao::window::WindowBuilder; 33 | use tracing::instrument; 34 | 35 | use crate::renderer::backend::{GraphicsWindow, Painter}; 36 | 37 | pub struct EguiWindow { 38 | window: GraphicsWindow, 39 | painter: Painter, 40 | ctx: Context, 41 | state: State, 42 | 43 | next_repaint: Option 44 | } 45 | 46 | impl EguiWindow { 47 | #[instrument(skip_all, name = "egui_window_new")] 48 | pub fn new(event_loop: &EventLoopWindowTarget) -> Self { 49 | let window_builder = WindowBuilder::new() 50 | .with_resizable(true) 51 | .with_inner_size(LogicalSize { width: 800.0, height: 600.0 }) 52 | .with_window_icon(Some(crate::ui::WINDOW_ICON.clone())) 53 | .with_title("Headset Controller"); 54 | 55 | #[cfg(windows)] 56 | let window_builder = window_builder.with_drag_and_drop(false); 57 | 58 | #[cfg(any( 59 | target_os = "linux", 60 | target_os = "dragonfly", 61 | target_os = "freebsd", 62 | target_os = "netbsd", 63 | target_os = "openbsd" 64 | ))] 65 | let window_builder = window_builder 66 | .with_double_buffered(false) 67 | .with_app_paintable(true); 68 | 69 | let window = GraphicsWindow::new(window_builder, event_loop); 70 | 71 | let painter = window.make_painter(); 72 | 73 | let ctx = Context::default(); 74 | set_theme(&ctx); 75 | //ctx.set_visuals(Visuals::light()); 76 | 77 | Self { 78 | window, 79 | painter, 80 | ctx, 81 | state: State::new(), 82 | next_repaint: Some(Instant::now()) 83 | } 84 | } 85 | 86 | pub fn next_repaint(&self) -> Option { 87 | self.next_repaint 88 | } 89 | 90 | pub fn focus(&self) { 91 | self.window.window().set_focus(); 92 | } 93 | 94 | #[instrument(skip_all)] 95 | fn redraw(&mut self, gui: impl FnMut(&Context)) { 96 | let window = self.window.window(); 97 | let raw_input = self.state.take_egui_input(window); 98 | let FullOutput { 99 | platform_output, 100 | repaint_after, 101 | mut textures_delta, 102 | shapes 103 | } = self.ctx.run(raw_input, gui); 104 | 105 | self.state 106 | .handle_platform_output(window, &self.ctx, platform_output); 107 | 108 | self.next_repaint = Instant::now().checked_add(repaint_after); 109 | { 110 | self.window.clear(); 111 | 112 | for (id, image_delta) in textures_delta.set { 113 | self.painter.set_texture(id, &image_delta); 114 | } 115 | 116 | let clipped_primitives = self.ctx.tessellate(shapes); 117 | let dimensions: [u32; 2] = window.inner_size().into(); 118 | self.painter 119 | .paint_primitives(dimensions, self.ctx.pixels_per_point(), &clipped_primitives); 120 | 121 | for id in textures_delta.free.drain(..) { 122 | self.painter.free_texture(id); 123 | } 124 | 125 | self.window.swap_buffers(); 126 | } 127 | } 128 | 129 | #[instrument(skip_all)] 130 | pub fn handle_events(&mut self, event: &Event, gui: impl FnMut(&Context)) -> bool { 131 | if self 132 | .next_repaint 133 | .map(|t| Instant::now().checked_duration_since(t)) 134 | .is_some() 135 | { 136 | self.window.window().request_redraw(); 137 | } 138 | match event { 139 | Event::RedrawEventsCleared if cfg!(windows) => self.redraw(gui), 140 | Event::RedrawRequested(_) if !cfg!(windows) => self.redraw(gui), 141 | Event::WindowEvent { event, .. } => { 142 | match &event { 143 | WindowEvent::CloseRequested | WindowEvent::Destroyed => { 144 | return true; 145 | } 146 | WindowEvent::Resized(physical_size) => { 147 | self.window.resize(*physical_size); 148 | } 149 | WindowEvent::ScaleFactorChanged { new_inner_size, .. } => { 150 | self.window.resize(**new_inner_size); 151 | } 152 | _ => {} 153 | } 154 | 155 | let event_response = self.state.on_event(&self.ctx, event); 156 | if event_response.repaint { 157 | self.window.window().request_redraw(); 158 | } 159 | } 160 | _ => () 161 | } 162 | false 163 | } 164 | } 165 | 166 | impl Drop for EguiWindow { 167 | #[instrument(skip_all)] 168 | fn drop(&mut self) { 169 | self.painter.destroy(); 170 | } 171 | } 172 | 173 | pub fn set_theme(ctx: &Context) { 174 | ctx.set_visuals(Visuals::light()); 175 | 176 | let mut style = (*ctx.style()).clone(); 177 | style.spacing.slider_width = 200_f32; // slider width can only be set globally 178 | //style.spacing.item_spacing = egui::vec2(15.0, 15.0); 179 | //style.spacing.button_padding = egui::vec2(10.0, 10.0); 180 | style.spacing.button_padding = egui::vec2(5.0, 5.0); 181 | 182 | let visuals = &mut style.visuals; 183 | //let mut visuals = Visuals::light(); 184 | 185 | let rounding = Rounding::same(7.0); 186 | 187 | //visuals.widgets.active.bg_fill = ACCENT; 188 | //visuals.widgets.active.fg_stroke = Stroke::new(1.0, FG); 189 | visuals.widgets.active.rounding = rounding; 190 | 191 | //visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, FG); 192 | visuals.widgets.inactive.rounding = rounding; 193 | 194 | visuals.widgets.hovered.rounding = rounding; 195 | 196 | // visuals.widgets.open.bg_fill = SEPARATOR_BG; 197 | visuals.widgets.open.rounding = rounding; 198 | 199 | //visuals.selection.bg_fill = SELECTED; 200 | //visuals.selection.stroke = Stroke::new(1.0, BG); 201 | 202 | //visuals.widgets.noninteractive.bg_fill = BG; 203 | //visuals.faint_bg_color = DARKER_BG; 204 | //visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, FG); 205 | //visuals.widgets.noninteractive.bg_stroke = Stroke::new(0.5, SEPARATOR_BG); 206 | visuals.widgets.noninteractive.rounding = rounding; 207 | 208 | ctx.set_style(style); 209 | } 210 | -------------------------------------------------------------------------------- /src/tray.rs: -------------------------------------------------------------------------------- 1 | use tao::event_loop::EventLoopWindowTarget; 2 | use tao::menu::{ContextMenu, CustomMenuItem, MenuId, MenuItem, MenuItemAttributes}; 3 | use tao::system_tray::{SystemTray, SystemTrayBuilder}; 4 | 5 | use crate::ui::WINDOW_ICON; 6 | 7 | pub struct AppTray { 8 | tray: SystemTray, 9 | menu: TrayMenu 10 | } 11 | 12 | impl AppTray { 13 | pub fn new(event_loop: &EventLoopWindowTarget) -> Self { 14 | let (m, menu) = TrayMenu::new(0, |_| ("", false)); 15 | let tray = SystemTrayBuilder::new(WINDOW_ICON.clone(), Some(m)) 16 | .build(event_loop) 17 | .expect("Could not build tray icon"); 18 | Self { tray, menu } 19 | } 20 | 21 | pub fn build_menu<'a, F>(&mut self, profile_count: usize, func: F) 22 | where 23 | F: Fn(usize) -> (&'a str, bool) 24 | { 25 | self.menu.update(&mut self.tray, profile_count, func) 26 | } 27 | 28 | pub fn set_tooltip(&mut self, tooltip: &str) { 29 | self.tray.set_tooltip(tooltip) 30 | } 31 | 32 | pub fn handle_event(&self, id: MenuId) -> Option { 33 | self.menu.handle_event(id) 34 | } 35 | } 36 | 37 | struct TrayMenu { 38 | profile_buttons: Vec, 39 | quit_button: CustomMenuItem, 40 | open_button: CustomMenuItem 41 | } 42 | 43 | fn next(id: &mut MenuId) -> MenuId { 44 | id.0 += 1; 45 | *id 46 | } 47 | 48 | impl TrayMenu { 49 | pub fn new<'a, F>(profile_count: usize, func: F) -> (ContextMenu, Self) 50 | where 51 | F: Fn(usize) -> (&'a str, bool) 52 | { 53 | let mut id = MenuId::EMPTY; 54 | let mut menu = ContextMenu::new(); 55 | let mut profiles = ContextMenu::new(); 56 | let mut profile_buttons = Vec::new(); 57 | for i in 0..profile_count { 58 | let (name, selected) = func(i); 59 | let item = MenuItemAttributes::new(name) 60 | .with_id(next(&mut id)) 61 | .with_selected(selected); 62 | profile_buttons.push(profiles.add_item(item)); 63 | } 64 | menu.add_submenu("Profiles", profile_count > 0, profiles); 65 | menu.add_native_item(MenuItem::Separator); 66 | let open_button = menu.add_item(MenuItemAttributes::new("Open").with_id(next(&mut id))); 67 | let quit_button = menu.add_item(MenuItemAttributes::new("Quit").with_id(next(&mut id))); 68 | ( 69 | menu, 70 | Self { 71 | profile_buttons, 72 | quit_button, 73 | open_button 74 | } 75 | ) 76 | } 77 | 78 | pub fn update<'a, F>(&mut self, tray: &mut SystemTray, profile_count: usize, func: F) 79 | where 80 | F: Fn(usize) -> (&'a str, bool) 81 | { 82 | if profile_count == self.profile_buttons.len() { 83 | tracing::trace!("Reusing existing menu"); 84 | for (i, button) in self.profile_buttons.iter_mut().enumerate() { 85 | let (name, selected) = func(i); 86 | tracing::trace!(name, selected); 87 | button.set_title(name); 88 | button.set_selected(selected); 89 | } 90 | } else { 91 | tracing::trace!("Creating new menu"); 92 | let (m, menu) = Self::new(profile_count, func); 93 | tray.set_menu(&m); 94 | *self = menu; 95 | } 96 | } 97 | 98 | pub fn handle_event(&self, id: MenuId) -> Option { 99 | if self.open_button.clone().id() == id { 100 | return Some(TrayEvent::Open); 101 | } 102 | if self.quit_button.clone().id() == id { 103 | return Some(TrayEvent::Quit); 104 | } 105 | for (i, profile) in self.profile_buttons.iter().enumerate() { 106 | if profile.clone().id() == id { 107 | return Some(TrayEvent::Profile(i)); 108 | } 109 | } 110 | None 111 | } 112 | } 113 | 114 | #[derive(Debug, Clone, Eq, PartialEq)] 115 | pub enum TrayEvent { 116 | Open, 117 | Quit, 118 | Profile(usize) 119 | } 120 | -------------------------------------------------------------------------------- /src/ui/central_panel/headset.rs: -------------------------------------------------------------------------------- 1 | use egui::*; 2 | use tracing::instrument; 3 | 4 | use crate::audio::{AudioDevice, AudioSystem}; 5 | use crate::config::{CallAction, HeadsetConfig, OsAudio}; 6 | use crate::debouncer::{Action, Debouncer}; 7 | use crate::devices::Device; 8 | use crate::ui::ResponseExt; 9 | 10 | #[instrument(skip_all)] 11 | pub fn headset_section( 12 | ui: &mut Ui, debouncer: &mut Debouncer, auto_update: bool, headset: &mut HeadsetConfig, device: &dyn Device, audio_system: &mut AudioSystem 13 | ) { 14 | if device.get_inactive_time().is_some() { 15 | ui.horizontal(|ui| { 16 | DragValue::new(&mut headset.inactive_time) 17 | .clamp_range(5..=120) 18 | .ui(ui) 19 | .submit(debouncer, auto_update, Action::UpdateInactiveTime); 20 | ui.label("Inactive Time"); 21 | }); 22 | ui.add_space(10.0); 23 | } 24 | 25 | if let Some(mic_light) = device.get_mic_light() { 26 | Slider::new(&mut headset.mic_light, 0..=(mic_light.levels() - 1)) 27 | .text("Microphone Light") 28 | .ui(ui) 29 | .submit(debouncer, auto_update, Action::UpdateMicrophoneLight); 30 | ui.add_space(10.0); 31 | } 32 | 33 | if device.get_bluetooth_config().is_some() { 34 | Checkbox::new(&mut headset.auto_enable_bluetooth, "Auto Enable Bluetooth") 35 | .ui(ui) 36 | .submit(debouncer, auto_update, Action::UpdateAutoBluetooth); 37 | ui.add_space(10.0); 38 | let actions = [ 39 | (CallAction::Nothing, "Nothing"), 40 | (CallAction::ReduceVolume, "Reduce Volume"), 41 | (CallAction::Mute, "Mute") 42 | ]; 43 | let mut current_index = actions 44 | .iter() 45 | .position(|(a, _)| *a == headset.bluetooth_call) 46 | .unwrap_or(0); 47 | ComboBox::from_label("Bluetooth Call Action") 48 | .width(120.0) 49 | .show_index(ui, &mut current_index, actions.len(), |i| actions[i].1.to_string()) 50 | .submit(debouncer, auto_update, Action::UpdateBluetoothCall); 51 | headset.bluetooth_call = actions[current_index].0; 52 | ui.add_space(10.0); 53 | } 54 | 55 | if audio_system.is_running() { 56 | let switch = &mut headset.os_audio; 57 | if audio_output_switch_selector(ui, switch, audio_system) { 58 | debouncer.submit(Action::SaveConfig); 59 | if auto_update { 60 | debouncer.submit(Action::UpdateSystemAudio); 61 | debouncer.force(Action::UpdateSystemAudio); 62 | } 63 | } 64 | ui.add_space(10.0); 65 | } 66 | } 67 | 68 | fn get_name(switch: &OsAudio) -> &str { 69 | match switch { 70 | OsAudio::Disabled => "Disabled", 71 | OsAudio::ChangeDefault { .. } => "Change Default Device", 72 | OsAudio::RouteAudio { .. } => "Route Audio When Disconnected" 73 | } 74 | } 75 | 76 | fn audio_output_switch_selector(ui: &mut Ui, switch: &mut OsAudio, audio_system: &mut AudioSystem) -> bool { 77 | let mut dirty = false; 78 | let resp = ComboBox::from_label("Audio Action") 79 | .selected_text(get_name(switch)) 80 | .width(250.0) 81 | .show_ui(ui, |ui| { 82 | let default_device = audio_system 83 | .default_device() 84 | .or_else(|| audio_system.devices().first()) 85 | .map(|d| d.name().to_string()) 86 | .unwrap_or_else(|| String::from("")); 87 | let options = [ 88 | OsAudio::Disabled, 89 | OsAudio::ChangeDefault { 90 | on_connect: default_device.clone(), 91 | on_disconnect: default_device.clone() 92 | }, 93 | OsAudio::RouteAudio { 94 | src: default_device.clone(), 95 | dst: default_device 96 | } 97 | ]; 98 | for option in options { 99 | let current = std::mem::discriminant(switch) == std::mem::discriminant(&option); 100 | if ui.selectable_label(current, get_name(&option)).clicked() && !current { 101 | *switch = option; 102 | dirty = true; 103 | } 104 | } 105 | }); 106 | if resp.response.clicked() { 107 | audio_system.refresh_devices(); 108 | } 109 | if let OsAudio::ChangeDefault { on_connect, on_disconnect } = switch { 110 | dirty |= audio_device_selector(ui, "On Connect", on_connect, audio_system.devices()); 111 | dirty |= audio_device_selector(ui, "On Disconnect", on_disconnect, audio_system.devices()); 112 | } 113 | if let OsAudio::RouteAudio { src, dst } = switch { 114 | dirty |= audio_device_selector(ui, "From", src, audio_system.devices()); 115 | dirty |= audio_device_selector(ui, "To", dst, audio_system.devices()); 116 | } 117 | dirty 118 | } 119 | 120 | fn audio_device_selector(ui: &mut Ui, label: &str, selected: &mut String, audio_devices: &[AudioDevice]) -> bool { 121 | let mut changed = false; 122 | ComboBox::from_label(label) 123 | .width(300.0) 124 | .selected_text(selected.as_str()) 125 | .show_ui(ui, |ui| { 126 | for dev in audio_devices { 127 | let current = dev.name() == selected; 128 | if ui.selectable_label(current, dev.name()).clicked() && !current { 129 | *selected = dev.name().to_string(); 130 | changed = true; 131 | } 132 | } 133 | }); 134 | changed 135 | } 136 | -------------------------------------------------------------------------------- /src/ui/central_panel/mod.rs: -------------------------------------------------------------------------------- 1 | mod headset; 2 | mod profile; 3 | 4 | use egui::*; 5 | use tracing::instrument; 6 | 7 | use crate::audio::AudioSystem; 8 | use crate::config::Config; 9 | use crate::debouncer::{Action, Debouncer}; 10 | use crate::devices::Device; 11 | use crate::submit_full_change; 12 | use crate::ui::central_panel::headset::headset_section; 13 | use crate::ui::central_panel::profile::profile_section; 14 | 15 | #[instrument(skip_all)] 16 | pub fn central_panel(ui: &mut Ui, debouncer: &mut Debouncer, config: &mut Config, device: &dyn Device, audio_system: &mut AudioSystem) { 17 | ui.style_mut() 18 | .text_styles 19 | .get_mut(&TextStyle::Heading) 20 | .unwrap() 21 | .size = 25.0; 22 | ui.style_mut() 23 | .text_styles 24 | .get_mut(&TextStyle::Body) 25 | .unwrap() 26 | .size = 14.0; 27 | ui.style_mut() 28 | .text_styles 29 | .get_mut(&TextStyle::Button) 30 | .unwrap() 31 | .size = 14.0; 32 | ScrollArea::both().auto_shrink([false; 2]).show(ui, |ui| { 33 | let auto_update = config.auto_apply_changes; 34 | let headset = config.get_headset(device.name()); 35 | ui.heading("Profile"); 36 | ui.add_space(7.0); 37 | profile_section(ui, debouncer, auto_update, headset.selected_profile(), device); 38 | ui.add_space(10.0); 39 | ui.separator(); 40 | ui.add_space(10.0); 41 | ui.heading("Headset"); 42 | ui.add_space(7.0); 43 | headset_section(ui, debouncer, auto_update, headset, device, audio_system); 44 | ui.add_space(10.0); 45 | ui.separator(); 46 | ui.add_space(10.0); 47 | ui.heading("Application"); 48 | ui.add_space(7.0); 49 | if ui 50 | .checkbox(&mut config.auto_apply_changes, "Auto Apply Changes") 51 | .changed() 52 | { 53 | debouncer.submit(Action::SaveConfig); 54 | } 55 | ui.with_layout(Layout::default().with_main_align(Align::Center), |ui| { 56 | if ui 57 | .add_sized([200.0, 20.0], Button::new("Apply Now")) 58 | .clicked() 59 | { 60 | submit_full_change(debouncer); 61 | } 62 | }); 63 | ui.add_space(10.0); 64 | #[cfg(target_os = "windows")] 65 | { 66 | let mut auto_start = autostart::is_enabled() 67 | .map_err(|err| tracing::warn!("Can not get autostart status: {}", err)) 68 | .unwrap_or(false); 69 | if ui.checkbox(&mut auto_start, "Run On Startup").changed() { 70 | if auto_start { 71 | autostart::enable().unwrap_or_else(|err| tracing::warn!("Can not enable auto start: {:?}", err)); 72 | } else { 73 | autostart::disable().unwrap_or_else(|err| tracing::warn!("Can not disable auto start: {:?}", err)); 74 | } 75 | } 76 | } 77 | 78 | ui.add_space(20.0); 79 | ui.separator(); 80 | ui.add_space(10.0); 81 | ui.heading("Information"); 82 | ui.add_space(7.0); 83 | ui.label(concat!("Version: ", env!("CARGO_PKG_VERSION"))); 84 | ui.add_space(7.0); 85 | ui.horizontal(|ui| { 86 | ui.label("Repository: "); 87 | ui.hyperlink("https://github.com/sidit77/headset-controller"); 88 | }); 89 | ui.add_space(7.0); 90 | ui.label(format!("Config Location: {}", Config::path().display())); 91 | ui.add_space(12.0); 92 | }); 93 | } 94 | 95 | #[cfg(target_os = "windows")] 96 | mod autostart { 97 | use std::ffi::OsString; 98 | 99 | use color_eyre::Result; 100 | use winreg::enums::HKEY_CURRENT_USER; 101 | use winreg::types::FromRegValue; 102 | use winreg::RegKey; 103 | 104 | fn directory() -> Result { 105 | let hkcu = RegKey::predef(HKEY_CURRENT_USER); 106 | let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?; 107 | Ok(key) 108 | } 109 | 110 | fn reg_key() -> &'static str { 111 | "HeadsetController" 112 | } 113 | 114 | fn start_cmd() -> Result { 115 | let mut cmd = OsString::from("\""); 116 | let exe_dir = dunce::canonicalize(std::env::current_exe()?)?; 117 | cmd.push(exe_dir); 118 | cmd.push("\" --quiet"); 119 | Ok(cmd) 120 | } 121 | 122 | pub fn is_enabled() -> Result { 123 | let cmd = start_cmd()?; 124 | let result = directory()? 125 | .enum_values() 126 | .filter_map(|r| { 127 | r.map_err(|err| tracing::warn!("Problem enumerating registry key: {}", err)) 128 | .ok() 129 | }) 130 | .any(|(key, value)| { 131 | key.eq(reg_key()) 132 | && OsString::from_reg_value(&value) 133 | .map_err(|err| tracing::warn!("Can not decode registry value: {}", err)) 134 | .map(|v| v.eq(&cmd)) 135 | .unwrap_or(false) 136 | }); 137 | Ok(result) 138 | } 139 | 140 | pub fn enable() -> Result<()> { 141 | let key = directory()?; 142 | let cmd = start_cmd()?; 143 | key.set_value(reg_key(), &cmd)?; 144 | Ok(()) 145 | } 146 | 147 | pub fn disable() -> Result<()> { 148 | let key = directory()?; 149 | key.delete_value(reg_key())?; 150 | Ok(()) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/ui/central_panel/profile.rs: -------------------------------------------------------------------------------- 1 | use egui::*; 2 | use tracing::instrument; 3 | 4 | use crate::config::{EqualizerConfig, Profile}; 5 | use crate::debouncer::{Action, Debouncer}; 6 | use crate::devices::{Device, Equalizer}; 7 | use crate::ui::ResponseExt; 8 | 9 | #[instrument(skip_all)] 10 | pub fn profile_section(ui: &mut Ui, debouncer: &mut Debouncer, auto_update: bool, profile: &mut Profile, device: &dyn Device) { 11 | if let Some(equalizer) = device.get_equalizer() { 12 | equalizer_ui(ui, debouncer, auto_update, &mut profile.equalizer, equalizer); 13 | ui.add_space(10.0); 14 | } 15 | if let Some(side_tone) = device.get_side_tone() { 16 | Slider::new(&mut profile.side_tone, 0..=(side_tone.levels() - 1)) 17 | .text("Side Tone Level") 18 | .ui(ui) 19 | .on_hover_text("This setting controls how much of your voice is played back over the headset when you speak.\nSet to 0 to turn off.") 20 | .submit(debouncer, auto_update, Action::UpdateSideTone); 21 | ui.add_space(10.0); 22 | } 23 | if let Some(mic_volume) = device.get_mic_volume() { 24 | Slider::new(&mut profile.microphone_volume, 0..=(mic_volume.levels() - 1)) 25 | .text("Microphone Level") 26 | .ui(ui) 27 | .submit(debouncer, auto_update, Action::UpdateMicrophoneVolume); 28 | ui.add_space(10.0); 29 | } 30 | if device.get_volume_limiter().is_some() { 31 | Checkbox::new(&mut profile.volume_limiter, "Limit Volume") 32 | .ui(ui) 33 | .submit(debouncer, auto_update, Action::UpdateVolumeLimit); 34 | ui.add_space(10.0); 35 | } 36 | } 37 | 38 | fn equalizer_ui(ui: &mut Ui, debouncer: &mut Debouncer, auto_update: bool, conf: &mut EqualizerConfig, equalizer: &dyn Equalizer) { 39 | let range = (equalizer.base_level() - equalizer.variance())..=(equalizer.base_level() + equalizer.variance()); 40 | let mut presets = equalizer 41 | .presets() 42 | .iter() 43 | .map(|(s, _)| s.to_string()) 44 | .collect::>(); 45 | let custom_index = presets.len(); 46 | presets.push("Custom".to_string()); 47 | let (mut current_index, mut levels) = match conf { 48 | EqualizerConfig::Preset(i) => (*i as usize, equalizer.presets()[*i as usize].1.to_vec()), 49 | EqualizerConfig::Custom(levels) => (custom_index, levels.clone()) 50 | }; 51 | let preset = ComboBox::from_label("Equalizer").show_index(ui, &mut current_index, presets.len(), |i| presets[i].clone()); 52 | let mut dirty = preset.changed(); 53 | ui.horizontal(|ui| { 54 | ui.style_mut().spacing.slider_width = 150.0; 55 | ui.style_mut().spacing.button_padding = vec2(5.0, 2.0); 56 | ui.style_mut().spacing.interact_size = vec2(30.0, 20.0); 57 | for i in levels.iter_mut() { 58 | let resp = Slider::new(i, range.clone()) 59 | .vertical() 60 | .trailing_fill(true) 61 | .ui(ui); 62 | if resp.changed() { 63 | dirty |= true; 64 | current_index = custom_index; 65 | } 66 | if resp.drag_released() { 67 | debouncer.force(Action::UpdateEqualizer); 68 | } 69 | } 70 | }); 71 | if dirty { 72 | *conf = if current_index == custom_index { 73 | EqualizerConfig::Custom(levels) 74 | } else { 75 | EqualizerConfig::Preset(current_index as u32) 76 | }; 77 | debouncer.submit(Action::SaveConfig); 78 | if auto_update { 79 | debouncer.submit(Action::UpdateEqualizer); 80 | } 81 | } 82 | if preset.changed() { 83 | debouncer.force(Action::UpdateEqualizer); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod central_panel; 2 | mod side_panel; 3 | 4 | use egui::panel::Side; 5 | use egui::{CentralPanel, Context, Response, RichText, SidePanel}; 6 | use once_cell::sync::Lazy; 7 | use tao::window::Icon; 8 | use tracing::instrument; 9 | 10 | use crate::audio::AudioSystem; 11 | use crate::config::Config; 12 | use crate::debouncer::{Action, Debouncer}; 13 | use crate::devices::{Device, SupportedDevice}; 14 | use crate::ui::central_panel::central_panel; 15 | use crate::ui::side_panel::side_panel; 16 | 17 | #[cfg(windows)] 18 | pub static WINDOW_ICON: Lazy = Lazy::new(|| { 19 | use tao::platform::windows::IconExtWindows; 20 | Icon::from_resource(32512, None).unwrap() 21 | }); 22 | 23 | #[cfg(not(windows))] 24 | pub static WINDOW_ICON: Lazy = Lazy::new(|| { 25 | let mut decoder = png::Decoder::new(include_bytes!("../../resources/icon.png").as_slice()); 26 | decoder.set_transformations(png::Transformations::EXPAND); 27 | let mut reader = decoder.read_info().unwrap(); 28 | let mut buf = vec![0u8; reader.output_buffer_size()]; 29 | let info = reader.next_frame(&mut buf).unwrap(); 30 | Icon::from_rgba(buf, info.width, info.height).unwrap() 31 | }); 32 | 33 | #[instrument(skip_all)] 34 | pub fn config_ui( 35 | ctx: &Context, debouncer: &mut Debouncer, config: &mut Config, device: &dyn Device, device_list: &[SupportedDevice], 36 | audio_system: &mut AudioSystem 37 | ) { 38 | SidePanel::new(Side::Left, "Profiles") 39 | .resizable(true) 40 | .width_range(175.0..=400.0) 41 | .show(ctx, |ui| side_panel(ui, debouncer, config, device, device_list)); 42 | CentralPanel::default().show(ctx, |ui| central_panel(ui, debouncer, config, device, audio_system)); 43 | } 44 | 45 | #[instrument(skip_all)] 46 | pub fn no_device_ui(ctx: &Context, debouncer: &mut Debouncer) { 47 | CentralPanel::default().show(ctx, |ctx| { 48 | ctx.vertical_centered(|ctx| { 49 | ctx.add_space(ctx.available_height() / 3.0); 50 | ctx.label(RichText::new("No supported device detected!").size(20.0)); 51 | ctx.add_space(10.0); 52 | if ctx.button(RichText::new("Refresh").size(15.0)).clicked() { 53 | debouncer.submit_all([Action::RefreshDeviceList, Action::SwitchDevice]); 54 | } 55 | }); 56 | }); 57 | } 58 | 59 | trait ResponseExt { 60 | fn submit(self, debouncer: &mut Debouncer, auto_update: bool, action: Action) -> Self; 61 | } 62 | 63 | impl ResponseExt for Response { 64 | #[instrument(skip(self, debouncer, action), name = "submit_response")] 65 | fn submit(self, debouncer: &mut Debouncer, auto_update: bool, action: Action) -> Self { 66 | if self.changed() { 67 | debouncer.submit(Action::SaveConfig); 68 | if auto_update { 69 | debouncer.submit(action); 70 | } 71 | } 72 | if self.drag_released() { 73 | debouncer.force(action); 74 | } 75 | self 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ui/side_panel.rs: -------------------------------------------------------------------------------- 1 | use egui::*; 2 | use tracing::instrument; 3 | 4 | use crate::config::{Config, Profile}; 5 | use crate::debouncer::{Action, Debouncer}; 6 | use crate::devices::{Device, SupportedDevice}; 7 | use crate::submit_profile_change; 8 | 9 | #[instrument(skip_all)] 10 | pub fn side_panel(ui: &mut Ui, debouncer: &mut Debouncer, config: &mut Config, device: &dyn Device, device_list: &[SupportedDevice]) { 11 | ui.style_mut() 12 | .text_styles 13 | .get_mut(&TextStyle::Body) 14 | .unwrap() 15 | .size = 14.0; 16 | ui.label( 17 | RichText::from(device.strings().manufacturer) 18 | .heading() 19 | .size(30.0) 20 | ) 21 | .union( 22 | ui.label( 23 | RichText::from(device.strings().product) 24 | .heading() 25 | .size(20.0) 26 | ) 27 | ) 28 | .context_menu(|ui| { 29 | for device in device_list.iter() { 30 | let resp = ui 31 | .with_layout(Layout::default().with_cross_justify(true), |ui| { 32 | let active = config 33 | .preferred_device 34 | .as_ref() 35 | .map_or(false, |pref| pref.eq(device.name())); 36 | ui.selectable_label(active, device.name()) 37 | }) 38 | .inner; 39 | if resp.clicked() { 40 | ui.close_menu(); 41 | config.preferred_device = Some(device.name().to_string()); 42 | debouncer.submit_all([Action::SaveConfig, Action::SwitchDevice]); 43 | } 44 | } 45 | ui.separator(); 46 | if ui.button(" Refresh ").clicked() { 47 | debouncer.submit_all([Action::RefreshDeviceList, Action::SwitchDevice]); 48 | } 49 | }); 50 | ui.separator(); 51 | if device.is_connected() { 52 | if let Some(battery) = device.get_battery_status() { 53 | ui.label(format!("Battery: {}", battery)); 54 | } 55 | ui.add_space(10.0); 56 | if let Some(mix) = device.get_chat_mix() { 57 | ui.label("Chat Mix:") 58 | .on_hover_text("Currently doesn't do anything"); 59 | ProgressBar::new(mix.chat as f32 / 100.0) 60 | .text("Chat") 61 | .ui(ui); 62 | ProgressBar::new(mix.game as f32 / 100.0) 63 | .text("Game") 64 | .ui(ui); 65 | } 66 | } else { 67 | ui.label("Not Connected"); 68 | } 69 | ui.separator(); 70 | let headset = config.get_headset(device.name()); 71 | ui.horizontal(|ui| { 72 | ui.heading("Profiles"); 73 | let resp = ui 74 | .with_layout(Layout::right_to_left(Align::Center), |ui| { 75 | ui.style_mut().spacing.button_padding = vec2(6.0, 0.0); 76 | ui.selectable_label(false, RichText::from("+").heading()) 77 | }) 78 | .inner; 79 | if resp.clicked() { 80 | headset 81 | .profiles 82 | .push(Profile::new(String::from("New Profile"))); 83 | debouncer.submit_all([Action::SaveConfig, Action::UpdateTray]); 84 | } 85 | }); 86 | ScrollArea::vertical() 87 | .auto_shrink([false; 2]) 88 | .show(ui, |ui| { 89 | let old_profile_index = headset.selected_profile_index; 90 | let mut deleted = None; 91 | let profile_count = headset.profiles.len(); 92 | for (i, profile) in headset.profiles.iter_mut().enumerate() { 93 | let resp = ui 94 | .with_layout(Layout::default().with_cross_justify(true), |ui| { 95 | ui.selectable_label(i as u32 == headset.selected_profile_index, &profile.name) 96 | }) 97 | .inner; 98 | let resp = resp.context_menu(|ui| { 99 | if ui.text_edit_singleline(&mut profile.name).changed() { 100 | debouncer.submit_all([Action::SaveConfig, Action::UpdateTray]); 101 | } 102 | ui.add_space(4.0); 103 | if ui 104 | .add_enabled(profile_count > 1, Button::new("Delete")) 105 | .clicked() 106 | { 107 | deleted = Some(i); 108 | ui.close_menu(); 109 | } 110 | }); 111 | if resp.clicked() { 112 | headset.selected_profile_index = i as u32; 113 | } 114 | } 115 | if let Some(i) = deleted { 116 | headset.profiles.remove(i); 117 | debouncer.submit_all([Action::SaveConfig, Action::UpdateTray]); 118 | if i as u32 <= headset.selected_profile_index && headset.selected_profile_index > 0 { 119 | headset.selected_profile_index -= 1; 120 | } 121 | } 122 | if headset.selected_profile_index != old_profile_index { 123 | submit_profile_change(debouncer); 124 | debouncer.submit_all([Action::SaveConfig, Action::UpdateTray]); 125 | } 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Error, ErrorKind, Write}; 2 | 3 | use crossbeam_utils::atomic::AtomicCell; 4 | use tao::event_loop::EventLoopProxy; 5 | 6 | pub trait CopySlice { 7 | fn cloned(self) -> Box<[T]>; 8 | } 9 | 10 | impl CopySlice for &[T] { 11 | fn cloned(self) -> Box<[T]> { 12 | self.to_vec().into_boxed_slice() 13 | } 14 | } 15 | 16 | pub trait PeekExt { 17 | fn peek(self, func: impl FnOnce(&T) -> R) -> Self; 18 | } 19 | 20 | impl PeekExt for Option { 21 | fn peek(self, func: impl FnOnce(&T) -> R) -> Self { 22 | if let Some(inner) = self.as_ref() { 23 | func(inner); 24 | } 25 | self 26 | } 27 | } 28 | 29 | pub struct EscapeStripper { 30 | inner: T, 31 | escape_sequence: bool, 32 | buffer: String 33 | } 34 | 35 | impl EscapeStripper { 36 | pub fn new(inner: T) -> Self { 37 | Self { 38 | inner, 39 | escape_sequence: false, 40 | buffer: String::new() 41 | } 42 | } 43 | } 44 | 45 | impl Write for EscapeStripper { 46 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 47 | let str = std::str::from_utf8(buf).map_err(|e| Error::new(ErrorKind::InvalidData, e))?; 48 | self.buffer.clear(); 49 | for c in str.chars() { 50 | match self.escape_sequence { 51 | true if c == 'm' => self.escape_sequence = false, 52 | true => {} 53 | false if c == '\u{001b}' => self.escape_sequence = true, 54 | false => self.buffer.push(c) 55 | } 56 | } 57 | self.inner.write_all(self.buffer.as_bytes())?; 58 | Ok(str.as_bytes().len()) 59 | } 60 | 61 | fn flush(&mut self) -> std::io::Result<()> { 62 | self.inner.flush() 63 | } 64 | } 65 | 66 | pub trait AtomicCellExt { 67 | fn update(&self, func: F); 68 | } 69 | 70 | impl AtomicCellExt for AtomicCell { 71 | fn update(&self, func: F) { 72 | let mut previous_state = self.load(); 73 | loop { 74 | let mut current_state = previous_state; 75 | func(&mut current_state); 76 | 77 | match self.compare_exchange(previous_state, current_state) { 78 | Ok(_) => break, 79 | Err(current) => { 80 | previous_state = current; 81 | tracing::trace!("compare exchange failed!") 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | pub trait SenderExt { 89 | fn send_log(&self, update: T); 90 | } 91 | 92 | impl SenderExt for EventLoopProxy { 93 | fn send_log(&self, update: T) { 94 | self.send_event(update) 95 | .unwrap_or_else(|_| tracing::warn!("Could not send message because the receiver is closed")) 96 | } 97 | } 98 | 99 | pub trait VecExt { 100 | fn prepend>(&mut self, iter: I); 101 | } 102 | 103 | impl VecExt for Vec { 104 | fn prepend>(&mut self, iter: I) { 105 | let prev = self.len(); 106 | self.extend(iter); 107 | let offset = self.len() - prev; 108 | self.rotate_right(offset); 109 | } 110 | } 111 | --------------------------------------------------------------------------------