├── .github └── workflows │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── Tauri.toml ├── build.rs ├── icons ├── 128x128.png ├── 128x128@2x.png ├── 32x32.png ├── Square107x107Logo.png ├── Square142x142Logo.png ├── Square150x150Logo.png ├── Square284x284Logo.png ├── Square30x30Logo.png ├── Square310x310Logo.png ├── Square44x44Logo.png ├── Square71x71Logo.png ├── Square89x89Logo.png ├── StoreLogo.png ├── app-icon.png ├── icon.icns ├── icon.ico └── icon.png ├── rustfmt.toml └── src ├── app.rs ├── config.rs ├── lib.rs ├── main.rs └── wootility.rs /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [ created ] 4 | 5 | jobs: 6 | release: 7 | env: 8 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 9 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - name: Linux-x86_64 16 | target: x86_64-unknown-linux-gnu 17 | runner: ubuntu-latest 18 | 19 | - name: macOS-universal 20 | target: universal-apple-darwin 21 | runner: macos-latest 22 | 23 | - name: Windows-x86_64 24 | target: x86_64-pc-windows-msvc 25 | runner: windows-latest 26 | 27 | name: ${{ matrix.name }} 28 | runs-on: ${{ matrix.runner }} 29 | steps: 30 | - name: Fetch Repository 31 | uses: actions/checkout@v3 32 | 33 | - name: Update and Install Dependencies (Linux) 34 | if: ${{ matrix.runner == 'ubuntu-latest' }} 35 | run: | 36 | sudo apt-get update 37 | sudo apt-get install -y libwebkit2gtk-4.1-dev libhidapi-dev libayatana-appindicator3-dev libsoup-3.0-dev javascriptcoregtk-4.1-dev librsvg2-dev 38 | 39 | - name: Install libffi6 40 | if: ${{ matrix.runner == 'ubuntu-latest' }} 41 | run: | 42 | curl -LO http://archive.ubuntu.com/ubuntu/pool/main/libf/libffi/libffi6_3.2.1-8_amd64.deb 43 | sudo dpkg -i libffi6_3.2.1-8_amd64.deb 44 | 45 | - name: Install libcroco3 46 | if: ${{ matrix.runner == 'ubuntu-latest' }} 47 | run: | 48 | curl -LO https://archive.ubuntu.com/ubuntu/pool/main/libc/libcroco/libcroco3_0.6.13-1ubuntu0.1_amd64.deb 49 | sudo dpkg -i libcroco3_0.6.13-1ubuntu0.1_amd64.deb 50 | 51 | - name: Install Protoc 52 | uses: arduino/setup-protoc@v2 53 | with: 54 | repo-token: ${{ secrets.GITHUB_TOKEN }} 55 | 56 | - name: Update Rust Toolchain 57 | run: rustup update stable 58 | 59 | - name: Add Rust Target (macOS) 60 | if: ${{ matrix.runner == 'macos-latest' }} 61 | run: rustup target add x86_64-apple-darwin 62 | 63 | - name: Add Rust Target (Other) 64 | if: ${{ matrix.runner != 'macos-latest' }} 65 | run: rustup target add ${{ matrix.target }} 66 | 67 | - name: Install Tauri CLI 68 | run: | 69 | rustup toolchain install 1.79 70 | rustup run 1.79 cargo install --locked --git https://github.com/Shays-Forks/tauri.git tauri-cli 71 | 72 | - name: Build Tauri Installers/Bundles/Images 73 | uses: tauri-apps/tauri-action@dev 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 77 | NO_STRIP: true 78 | with: 79 | args: --target ${{ matrix.target }} --verbose 80 | releaseId: ${{ github.event.release.id }} 81 | tagName: ${{ github.ref_name }} 82 | tauriScript: "cargo tauri" 83 | 84 | - name: Build Portable Binary (Windows) 85 | if: ${{ matrix.runner == 'windows-latest' }} 86 | run: | 87 | cargo build --target ${{ matrix.target }} 88 | mv ./target/${{ matrix.target }}/debug/wooting-profile-switcher.exe ./target/${{ matrix.target }}/debug/wooting-profile-switcher_${{ github.ref_name }}_x64-portable.exe 89 | 90 | - name: Upload Portable Binary (Windows) 91 | if: ${{ matrix.runner == 'windows-latest' }} 92 | run: bash -c 'gh release upload ${{ github.ref_name }} ./target/${{ matrix.target }}/debug/wooting-profile-switcher_${{ github.ref_name }}_x64-portable.exe --clobber' 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | target/ 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wooting-profile-switcher" 3 | description = "Wooting Profile Switcher" 4 | version = "2.4.0" 5 | authors = ["Shayne Hartford ", "Tony Langhammer"] 6 | edition = "2021" 7 | readme = "README.md" 8 | repository = "https://github.com/ShayBox/Wooting-Profile-Switcher" 9 | license = "MIT" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [build-dependencies] 14 | tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "bd29b05", features = ["config-toml"] } 15 | 16 | [dependencies] 17 | active-win-pos-rs = "0.8" 18 | anyhow = "1" 19 | clap = { version = "4", features = ["derive"] } 20 | ctrlc = { version = "3", features = ["termination"] } 21 | derive_more = { version = "1", features = ["full"] } 22 | dirs = "5" 23 | egui_extras = "=0.22" 24 | encoding_rs = "0.8" 25 | game-scanner = { git = "https://github.com/Shays-Forks/game-scanner.git" } 26 | image = "0.25" 27 | lazy_static = "1" 28 | open = "5" 29 | parking_lot = "0.12" 30 | regex = "1" 31 | rusty-leveldb = "3" 32 | serde = { version = "1", features = ["derive"] } 33 | serde_json = "1" 34 | serde_with = { version = "3", features = ["json"] } 35 | structstruck = "0.4" 36 | strum = { version = "0.26", features = ["derive"] } 37 | tauri = { git = "https://github.com/tauri-apps/tauri", rev = "bd29b05", features = ["system-tray"] } 38 | tauri-egui = { git = "https://github.com/Shays-Forks/tauri-egui", branch = "0.22" } 39 | tauri-plugin-autostart = { git = "https://github.com/Shays-Forks/plugins-workspace" } 40 | tauri-plugin-single-instance = { git = "https://github.com/Shays-Forks/plugins-workspace" } 41 | tauri-plugin-updater = { git = "https://github.com/Shays-Forks/plugins-workspace" } 42 | tauri-utils = { git = "https://github.com/tauri-apps/tauri", rev = "bd29b05" } 43 | wildflower = "0.3" 44 | wooting-rgb-sys = "0.3" 45 | 46 | [target.'cfg(windows)'.dependencies] 47 | windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_Console"] } 48 | 49 | [features] 50 | # this feature is used for production builds or when `devPath` points to the filesystem 51 | # DO NOT REMOVE!! 52 | custom-protocol = ["tauri/custom-protocol"] 53 | 54 | # https://tauri.app/v1/guides/building/app-size/#rust-build-time-optimizations 55 | [profile.release] 56 | lto = true # Enables link to optimizations 57 | opt-level = "s" # Optimize for binary size 58 | strip = true # Remove debug symbols 59 | 60 | [lints.clippy] 61 | pedantic = { level = "warn", priority = -1 } 62 | nursery = { level = "warn", priority = -1 } 63 | multiple_crate_versions = "allow" 64 | missing_errors_doc = "allow" 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shayne Hartford 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Discord 4 | 5 | 6 | Downloads 7 | 8 |
9 | 10 | # Wooting Profile Switcher 11 | 12 | Automatically switch Wooting keyboard profiles based on focused window 13 | 14 | ## Installation 15 | 16 | [Download the latest release](https://github.com/ShayBox/Wooting-Profile-Switcher/releases/latest) 17 | 18 | You must install and run [Wootility](https://wooting.io/wootility) once, but must not have it running at the same time. 19 | 20 | ## Screenshots 21 | 22 | ![MainApp](https://github.com/ShayBox/Wooting-Profile-Switcher/assets/9505196/2dabd348-2b5c-49b1-8a51-e9cc3fcdf6a9) 23 | 24 | ## System Tray Icon 25 | 26 | The system tray icon allows you to pause/resume, reload, quit, and set the active profile 27 | 28 | ## Configuration 29 | 30 | The config file is generated on first-run in the following location and format 31 | 32 | | Platform | Location | 33 | | -------- | ---------------------------------------- | 34 | | Portable | Same location as the binary | 35 | | Windows | `C:\Users\...\AppData\Roaming` | 36 | | macOS | `/Users/.../Library/Application Support` | 37 | | Linux | `/home/.../.config` | 38 | 39 | ```json5 40 | { 41 | // Auto launch at startup 42 | "auto_launch": null, 43 | // Auto update at startup 44 | "auto_update": null, 45 | // List of connected devices, their serial number properties, and profile names 46 | "devices": { 47 | "A02B2106W031H00418": { 48 | "model_name": "Wooting Two LE", 49 | "supplier": 2, 50 | "year": 21, 51 | "week": 6, 52 | "product": 3, 53 | "revision": 1, 54 | "product_id": 418, 55 | "production": true, 56 | "profiles": [ 57 | "Typing Profile", 58 | "Rapid Profile", 59 | "Racing Profile", 60 | "Mixed Movement" 61 | ] 62 | } 63 | }, 64 | // Sleep duration for the loop checking the active window 65 | "loop_sleep_ms": 250, 66 | // Sleep duration between sending Wooting USB commands 67 | "send_sleep_ms": 250, 68 | // Show the serial number instead of the model name 69 | "show_serial": false, 70 | // Swap the lighting effects with the keyboard profile 71 | "swap_lighting": true, 72 | // List of rule objects, all match rules support Wildcard and Regex 73 | "rules": [ 74 | { 75 | "alias": "The Binding of Isaac", 76 | "device_indices": { 77 | "A02B2106W031H00418": 0 78 | }, 79 | "match_app_name": null, 80 | "match_bin_name": null, 81 | "match_bin_path": "C:\\Program Files (x86)\\Steam\\steamapps\\common\\The Binding of Isaac Rebirth*", 82 | "match_win_name": null 83 | }, 84 | { 85 | "alias": "Default Fallback", 86 | "device_indices": { 87 | "A02B2106W031H00418": 0 88 | }, 89 | "match_app_name": "*", 90 | "match_bin_name": "*", 91 | "match_bin_path": "*", 92 | "match_win_name": "*" 93 | } 94 | ], 95 | "ui": { 96 | "scale": 1.25, 97 | "theme": "Dark" 98 | } 99 | } 100 | ``` 101 | 102 | ### Examples: 103 | 104 | #### Matching a window title with a date variable 105 | 106 | ```json5 107 | { 108 | "alias": "VRCX", 109 | "match_app_name": null, 110 | "match_bin_name": null, 111 | "match_bin_path": null, 112 | "match_win_name": "VRCX ????.??.??", 113 | "device_indices": { 114 | "A02B2106W031H00418": 0 115 | }, 116 | } 117 | ``` 118 | 119 | #### Matching a window title with a version variable 120 | 121 | ```json5 122 | { 123 | "alias": "Minecraft", 124 | "match_app_name": null, 125 | "match_bin_name": null, 126 | "match_bin_path": null, 127 | "match_win_name": "Minecraft [\d]+.[\d]+.[\d]+", 128 | "device_indices": { 129 | "A02B2106W031H00418": 0 130 | }, 131 | } 132 | ``` 133 | 134 | #### Wayland Support 135 | 136 | This program does not officially support Wayland. 137 | This is because most compositors don't support the `foreign-toplevel-management` protocol. 138 | There's no way for programs to detect the active focused Wayland window, only X11 / Xwayland. 139 | The version of Tauri (`tauri-egui`) this program uses didn't yet support Wayland either, so the window doesn't appear. 140 | You can use this program to detect Xwayland windows headlessly using only the config and tray icon, but it's not great. 141 | -------------------------------------------------------------------------------- /Tauri.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | productName = "wooting-profile-switcher" 3 | version = "2.4.0" 4 | 5 | [tauri.bundle] 6 | active = true 7 | targets = "all" 8 | identifier = "com.shaybox.wooting-profile-switcher" 9 | icon = ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] 10 | copyright = "Copyright (c) 2021 Shayne Hartford" 11 | category = "Utility" 12 | shortDescription = "Automatically switch Wooting keyboard profiles based on focused window" 13 | 14 | [tauri.bundle.updater] 15 | active = true 16 | pubkey = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCMTZBMEZBOEQ0NDNCQUEKUldTcU8wU04rcUFXaXlVdEhyWG9ZRi9vUGNPekVMWmVOL2dMcG40dEpXUzUwdkxkczVBU2JaOWwK" 17 | 18 | [tauri.systemTray] 19 | iconPath = "icons/icon.png" 20 | iconAsTemplate = true 21 | title = "Wooting Profile Switcher" 22 | 23 | [build] 24 | devPath = "src" 25 | distDir = "src" 26 | 27 | [plugins.updater] 28 | endpoints = ["https://github.com/ShayBox/Wooting-Profile-Switcher/releases/latest/download/latest.json"] 29 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build(); 3 | } 4 | -------------------------------------------------------------------------------- /icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/128x128.png -------------------------------------------------------------------------------- /icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/128x128@2x.png -------------------------------------------------------------------------------- /icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/32x32.png -------------------------------------------------------------------------------- /icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/StoreLogo.png -------------------------------------------------------------------------------- /icons/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/app-icon.png -------------------------------------------------------------------------------- /icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/icon.icns -------------------------------------------------------------------------------- /icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/icon.ico -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShayBox/Wooting-Profile-Switcher/ed01bad9ae95303c9445ed370f74caadda26d44b/icons/icon.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | enum_discrim_align_threshold = 10 2 | group_imports = "StdExternalCrate" 3 | imports_granularity = "Crate" 4 | imports_layout = "HorizontalVertical" 5 | struct_field_align_threshold = 10 6 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Not; 2 | 3 | use egui_extras::{Column, TableBuilder}; 4 | use game_scanner::prelude::*; 5 | use image::DynamicImage; 6 | use lazy_static::lazy_static; 7 | use parking_lot::RwLock; 8 | use tauri::{AppHandle, Manager}; 9 | use tauri_egui::{ 10 | eframe::{App, CreationContext, Frame, IconData, NativeOptions}, 11 | egui::{ 12 | self, menu::bar as MenuBar, Align, Button, CentralPanel, Color32, Context, Layout, 13 | ScrollArea, SidePanel, Slider, Stroke, TopBottomPanel, Vec2, Visuals, Window, 14 | }, 15 | EguiPluginHandle, Error, 16 | }; 17 | use tauri_plugin_autostart::ManagerExt; 18 | use wooting_profile_switcher as wps; 19 | use wps::{DeviceIndices, ProfileIndex}; 20 | 21 | use crate::{ 22 | config::{Config, Rule, Theme}, 23 | Args, 24 | }; 25 | 26 | const CARGO_PKG_AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); 27 | const CARGO_PKG_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); 28 | const CARGO_PKG_NAME: &str = env!("CARGO_PKG_NAME"); 29 | const CARGO_PKG_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); 30 | const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); 31 | 32 | #[derive(Clone, Debug)] 33 | struct SelectedRule { 34 | alias: String, 35 | device_indices: DeviceIndices, 36 | match_app_name: String, 37 | match_bin_name: String, 38 | match_bin_path: String, 39 | match_win_name: String, 40 | rule_index: usize, 41 | } 42 | 43 | impl SelectedRule { 44 | fn new(rule: Rule, i: usize) -> Self { 45 | Self { 46 | alias: rule.alias, 47 | device_indices: rule.device_indices, 48 | match_app_name: rule.match_app_name.unwrap_or_default(), 49 | match_bin_name: rule.match_bin_name.unwrap_or_default(), 50 | match_bin_path: rule.match_bin_path.unwrap_or_default(), 51 | match_win_name: rule.match_win_name.unwrap_or_default(), 52 | rule_index: i, 53 | } 54 | } 55 | } 56 | 57 | impl From for Rule { 58 | fn from(rule: SelectedRule) -> Self { 59 | Self { 60 | alias: rule.alias, 61 | device_indices: rule.device_indices, 62 | match_app_name: rule 63 | .match_app_name 64 | .is_empty() 65 | .not() 66 | .then_some(rule.match_app_name), 67 | match_bin_name: rule 68 | .match_bin_name 69 | .is_empty() 70 | .not() 71 | .then_some(rule.match_bin_name), 72 | match_bin_path: rule 73 | .match_bin_path 74 | .is_empty() 75 | .not() 76 | .then_some(rule.match_bin_path), 77 | match_win_name: rule 78 | .match_win_name 79 | .is_empty() 80 | .not() 81 | .then_some(rule.match_win_name), 82 | } 83 | } 84 | } 85 | 86 | #[allow(clippy::module_name_repetitions)] 87 | #[allow(clippy::struct_excessive_bools)] 88 | pub struct MainApp { 89 | app_handle: AppHandle, 90 | open_about: bool, 91 | open_auto_launch: bool, 92 | open_auto_update: bool, 93 | open_new_rule_setup: bool, 94 | open_confirm_delete: bool, 95 | selected_rule: Option, 96 | } 97 | 98 | impl MainApp { 99 | pub fn open(app: &AppHandle) -> Result<(), Error> { 100 | let egui_handle = app.state::(); 101 | 102 | let native_options = NativeOptions { 103 | initial_window_size: Some(Vec2::new(720.0, 480.0)), 104 | icon_data: Self::get_icon_data(), 105 | ..Default::default() 106 | }; 107 | 108 | let app = app.clone(); 109 | egui_handle.create_window( 110 | CARGO_PKG_NAME.into(), 111 | Box::new(|cc| Box::new(Self::new(cc, app))), 112 | CARGO_PKG_DESCRIPTION.into(), 113 | native_options, 114 | )?; 115 | 116 | Ok(()) 117 | } 118 | 119 | fn new(cc: &CreationContext<'_>, app: AppHandle) -> Self { 120 | let config = app.state::>().read().clone(); 121 | let visuals = match config.ui.theme { 122 | Theme::Dark => Visuals::dark(), 123 | Theme::Light => Visuals::light(), 124 | }; 125 | 126 | cc.egui_ctx.set_visuals(visuals); 127 | cc.egui_ctx.set_pixels_per_point(config.ui.scale); 128 | 129 | Self { 130 | app_handle: app, 131 | open_about: false, 132 | open_auto_launch: config.auto_launch.is_none(), 133 | open_auto_update: config.auto_update.is_none(), 134 | open_new_rule_setup: false, 135 | open_confirm_delete: false, 136 | selected_rule: None, 137 | } 138 | } 139 | 140 | /// 141 | fn get_icon_data() -> Option { 142 | let buffer = include_bytes!("../icons/icon.png"); 143 | let Ok(image) = image::load_from_memory(buffer).map(DynamicImage::into_rgba8) else { 144 | return None; 145 | }; 146 | 147 | let (width, height) = image.dimensions(); 148 | let rgba = image.into_raw(); 149 | 150 | Some(IconData { 151 | rgba, 152 | width, 153 | height, 154 | }) 155 | } 156 | } 157 | 158 | impl App for MainApp { 159 | #[allow(clippy::too_many_lines)] 160 | fn update(&mut self, ctx: &Context, _frame: &mut Frame) { 161 | let Self { 162 | app_handle: app, 163 | open_about, 164 | open_auto_launch, 165 | open_auto_update, 166 | open_new_rule_setup, 167 | open_confirm_delete, 168 | selected_rule, 169 | } = self; 170 | 171 | let args = app.state::>(); 172 | let config = app.state::>(); 173 | 174 | Window::new("About") 175 | .collapsible(false) 176 | .open(open_about) 177 | .resizable(false) 178 | .show(ctx, |ui| { 179 | ui.heading(CARGO_PKG_DESCRIPTION); 180 | ui.heading(CARGO_PKG_VERSION); 181 | ui.label(CARGO_PKG_AUTHORS.split(':').collect::>().join("\n")); 182 | ui.hyperlink_to("Source Code Repository", CARGO_PKG_REPOSITORY); 183 | }); 184 | 185 | if *open_auto_launch { 186 | let auto_launch = app.autolaunch(); 187 | Window::new("Auto Startup") 188 | .collapsible(false) 189 | .resizable(false) 190 | .show(ctx, |ui| { 191 | ui.label("Would you like to enable automatic startup?"); 192 | ui.horizontal(|ui| { 193 | if ui.button("Yes").clicked() { 194 | *open_auto_launch = false; 195 | let _ = auto_launch.enable(); 196 | 197 | let mut config = config.write(); 198 | config.auto_launch = Some(true); 199 | config.save().expect("Failed to save config"); 200 | } 201 | if ui.button("No").clicked() { 202 | *open_auto_launch = false; 203 | let _ = auto_launch.disable(); 204 | 205 | let mut config = config.write(); 206 | config.auto_launch = Some(false); 207 | config.save().expect("Failed to save config"); 208 | } 209 | }); 210 | }); 211 | } 212 | 213 | if *open_auto_update { 214 | Window::new("Auto Update") 215 | .collapsible(false) 216 | .resizable(false) 217 | .show(ctx, |ui| { 218 | ui.label("Would you like to enable automatic updates?"); 219 | ui.horizontal(|ui| { 220 | if ui.button("Yes").clicked() { 221 | *open_auto_update = false; 222 | 223 | let mut config = config.write(); 224 | config.auto_update = Some(true); 225 | config.save().expect("Failed to save config"); 226 | } 227 | if ui.button("No").clicked() { 228 | *open_auto_update = false; 229 | 230 | let mut config = config.write(); 231 | config.auto_update = Some(false); 232 | config.save().expect("Failed to save config"); 233 | } 234 | }); 235 | }); 236 | } 237 | 238 | if *open_new_rule_setup { 239 | Window::new("New Rule Setup") 240 | .collapsible(false) 241 | .resizable(true) 242 | .show(ctx, |ui| { 243 | ui.label("Select a game or blank to create a rule"); 244 | ui.vertical_centered_justified(|ui| { 245 | ScrollArea::vertical().id_source("rules").show(ui, |ui| { 246 | lazy_static! { 247 | static ref GAMES: Vec = { 248 | let mut games = [ 249 | game_scanner::amazon::games(), 250 | game_scanner::blizzard::games(), 251 | game_scanner::epicgames::games(), 252 | game_scanner::gog::games(), 253 | game_scanner::origin::games(), 254 | game_scanner::riotgames::games(), 255 | game_scanner::steam::games(), 256 | game_scanner::ubisoft::games(), 257 | ] 258 | .into_iter() 259 | .filter_map(Result::ok) 260 | .flatten() 261 | .collect::>(); 262 | 263 | games.sort_by(|a, b| String::cmp(&a.name, &b.name)); 264 | games.insert( 265 | 0, 266 | Game { 267 | name: String::from("Blank"), 268 | ..Default::default() 269 | }, 270 | ); 271 | 272 | games 273 | }; 274 | } 275 | 276 | for game in GAMES.iter() { 277 | if ui.button(&game.name).clicked() { 278 | let mut config = config.write(); 279 | let rule = Rule { 280 | alias: game.name.clone(), 281 | match_bin_path: game 282 | .path 283 | .clone() 284 | .map(|path| path.display().to_string() + "*"), 285 | ..Default::default() 286 | }; 287 | *selected_rule = Some(SelectedRule::new(rule.clone(), 0)); 288 | *open_new_rule_setup = false; 289 | 290 | config.rules.insert(0, rule); 291 | config.save().expect("Failed to save config"); 292 | } 293 | } 294 | 295 | if ui.button("Cancel").clicked() { 296 | *open_new_rule_setup = false; 297 | } 298 | }); 299 | }); 300 | }); 301 | }; 302 | 303 | if *open_confirm_delete { 304 | Window::new("Confirm Deletion") 305 | .collapsible(false) 306 | .resizable(false) 307 | .show(ctx, |ui| { 308 | ui.label("Are you sure you want to delete this rule?"); 309 | ui.horizontal(|ui| { 310 | if ui.button("Yes").clicked() { 311 | if let Some(rule) = &selected_rule { 312 | let mut config = config.write(); 313 | config.rules.remove(rule.rule_index); 314 | config.save().expect("Failed to save config"); 315 | } 316 | *selected_rule = None; 317 | *open_confirm_delete = false; 318 | } 319 | if ui.button("No").clicked() { 320 | *open_confirm_delete = false; 321 | } 322 | }); 323 | }); 324 | } 325 | 326 | TopBottomPanel::top("top_panel").show(ctx, |ui| { 327 | MenuBar(ui, |ui| { 328 | ui.menu_button("File", |ui| { 329 | let devices = config.read().devices.clone(); 330 | for (device_serial, device) in devices { 331 | let serial_number = device_serial.to_string(); 332 | let text = if config.read().show_serial { 333 | &serial_number 334 | } else { 335 | &device.model_name 336 | }; 337 | 338 | ui.label(text); 339 | 340 | for (profile_index, profile_name) in device.profiles.iter().enumerate() { 341 | if ui.button(profile_name).clicked() { 342 | ui.close_menu(); 343 | 344 | if wps::select_device_serial(&device_serial).is_err() { 345 | return; 346 | } 347 | 348 | #[allow(clippy::cast_possible_truncation)] 349 | let profile_index = profile_index as ProfileIndex; 350 | let _ = wps::set_active_profile_index( 351 | profile_index, 352 | config.read().send_sleep_ms, 353 | config.read().swap_lighting, 354 | ); 355 | 356 | let mut args = args.write(); 357 | args.device_serial = Some(device_serial.clone()); 358 | args.profile_index = Some(profile_index); 359 | } 360 | } 361 | 362 | ui.separator(); 363 | } 364 | 365 | let paused = args.read().paused; 366 | let text = if paused { 367 | "Resume Scanning" 368 | } else { 369 | "Pause Scanning" 370 | }; 371 | if ui.button(text).clicked() { 372 | ui.close_menu(); 373 | 374 | args.write().paused = !paused; 375 | } 376 | 377 | ui.separator(); 378 | 379 | if ui.button("Quit Program").clicked() { 380 | ui.close_menu(); 381 | 382 | app.exit(0); 383 | } 384 | }); 385 | ui.menu_button("Edit", |ui| { 386 | if ui.button("Open Config File").clicked() { 387 | ui.close_menu(); 388 | 389 | let config_path = Config::get_path().expect("Failed to get config path"); 390 | open::that(config_path).expect("Failed to open config file"); 391 | } 392 | if ui.button("Reload Config File").clicked() { 393 | ui.close_menu(); 394 | 395 | *config.write() = Config::load().expect("Failed to reload config"); 396 | } 397 | }); 398 | ui.menu_button("View", |ui| { 399 | if ui.button("Swap Theme").clicked() { 400 | ui.close_menu(); 401 | 402 | let mut config = config.write(); 403 | let theme = config.ui.theme.clone(); 404 | config.ui.theme = match theme { 405 | Theme::Dark => Theme::Light, 406 | Theme::Light => Theme::Dark, 407 | }; 408 | ctx.set_visuals(match theme { 409 | Theme::Dark => Visuals::dark(), 410 | Theme::Light => Visuals::light(), 411 | }); 412 | config.save().expect("Failed to save config"); 413 | } 414 | }); 415 | ui.menu_button("Help", |ui| { 416 | if ui.button("About").clicked() { 417 | ui.close_menu(); 418 | 419 | *open_about = true; 420 | } 421 | }); 422 | 423 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 424 | egui::warn_if_debug_build(ui); 425 | }) 426 | }); 427 | }); 428 | 429 | SidePanel::left("side_panel") 430 | .resizable(false) 431 | .show(ctx, |ui| { 432 | ui.horizontal(|ui| { 433 | ui.heading("Rules"); 434 | 435 | let add_button = Button::new("+").small(); 436 | if ui.add(add_button).clicked() { 437 | *open_new_rule_setup = true; 438 | } 439 | 440 | let enabled = selected_rule.is_some(); 441 | let del_button = Button::new("-").small(); 442 | if ui.add_enabled(enabled, del_button).clicked() { 443 | *open_confirm_delete = true; 444 | } 445 | }); 446 | 447 | ScrollArea::vertical().id_source("rules").show(ui, |ui| { 448 | let rules = config.read().rules.clone(); 449 | for (i, rule) in rules.into_iter().enumerate() { 450 | ui.horizontal(|ui| { 451 | if ui.button("⬆").clicked() { 452 | let mut config = config.write(); 453 | let end = config.rules.len() - 1; 454 | config.rules.swap(i, if i == 0 { end } else { i - 1 }); 455 | config.save().expect("Failed to move rule up"); 456 | } 457 | 458 | if ui.button("⬇").clicked() { 459 | let mut config = config.write(); 460 | let end = config.rules.len() - 1; 461 | config.rules.swap(i, if i == end { 0 } else { i + 1 }); 462 | config.save().expect("Failed to move rule down"); 463 | } 464 | 465 | let mut button = Button::new(&rule.alias).wrap(false); 466 | if let Some(rule) = selected_rule { 467 | if rule.rule_index == i { 468 | let color = ui.visuals().strong_text_color(); 469 | button = button.stroke(Stroke::new(1.0, color)); 470 | } 471 | } 472 | if ui.add(button).clicked() { 473 | *selected_rule = Some(SelectedRule::new(rule, i)); 474 | } 475 | }); 476 | } 477 | }); 478 | }); 479 | 480 | CentralPanel::default().show(ctx, |ui| { 481 | let Some(selected_rule) = selected_rule else { 482 | ui.heading("No rule selected"); 483 | return; 484 | }; 485 | 486 | ui.colored_label(Color32::KHAKI, "Match variables support Wildcard and Regex"); 487 | 488 | let height = 18.0; 489 | TableBuilder::new(ui) 490 | .column(Column::exact(140.0)) 491 | .column(Column::remainder()) 492 | .body(|mut body| { 493 | body.row(height, |mut row| { 494 | row.col(|ui| { 495 | ui.label("Rule Alias/Name"); 496 | }); 497 | row.col(|ui| { 498 | ui.text_edit_singleline(&mut selected_rule.alias); 499 | }); 500 | }); 501 | 502 | body.row(height, |mut row| { 503 | row.col(|ui| { 504 | ui.label("Match App Name"); 505 | }); 506 | row.col(|ui| { 507 | ui.text_edit_singleline(&mut selected_rule.match_app_name); 508 | }); 509 | }); 510 | 511 | body.row(height, |mut row| { 512 | row.col(|ui| { 513 | ui.label("Match Bin Name"); 514 | }); 515 | row.col(|ui| { 516 | ui.text_edit_singleline(&mut selected_rule.match_bin_name); 517 | }); 518 | }); 519 | 520 | body.row(height, |mut row| { 521 | row.col(|ui| { 522 | ui.label("Match Bin Path"); 523 | }); 524 | row.col(|ui| { 525 | ui.text_edit_singleline(&mut selected_rule.match_bin_path); 526 | }); 527 | }); 528 | 529 | body.row(height, |mut row| { 530 | row.col(|ui| { 531 | ui.label("Match Win Name"); 532 | }); 533 | row.col(|ui| { 534 | ui.text_edit_singleline(&mut selected_rule.match_win_name); 535 | }); 536 | }); 537 | 538 | body.row(height, |mut row| { 539 | row.col(|ui| { 540 | ui.label(if config.read().show_serial { 541 | "Serial Numbers" 542 | } else { 543 | "Model Names" 544 | }); 545 | }); 546 | row.col(|ui| { 547 | ui.label("Profile Indices (Skip: -1)"); 548 | }); 549 | }); 550 | 551 | let devices = config.read().devices.clone(); 552 | for (device_serial, device) in devices { 553 | let profile_index = selected_rule.device_indices.get_mut(&device_serial); 554 | if profile_index.is_none() { 555 | selected_rule 556 | .device_indices 557 | .insert(device_serial.clone(), 0); 558 | continue; 559 | } 560 | 561 | body.row(height, |mut row| { 562 | row.col(|ui| { 563 | let serial_number = device_serial.to_string(); 564 | ui.label(if config.read().show_serial { 565 | &serial_number 566 | } else { 567 | &device.model_name 568 | }); 569 | }); 570 | row.col(|ui| { 571 | let slider = Slider::new(&mut *profile_index.unwrap(), -1..=3) 572 | .clamp_to_range(true); 573 | ui.add(slider); 574 | }); 575 | }); 576 | } 577 | 578 | body.row(height, |mut row| { 579 | row.col(|ui| { 580 | if ui.button("Save").clicked() { 581 | let rule = selected_rule.clone().into(); 582 | let mut config = config.write(); 583 | config.rules[selected_rule.rule_index] = rule; 584 | config.save().expect("Failed to save config"); 585 | } 586 | }); 587 | }); 588 | }); 589 | }); 590 | } 591 | } 592 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | ffi::OsStr, 4 | fs::File, 5 | io::{Read, Write}, 6 | path::PathBuf, 7 | }; 8 | 9 | use anyhow::{bail, Result}; 10 | use serde::{Deserialize, Serialize}; 11 | use wooting_profile_switcher as wps; 12 | use wps::{Device, DeviceIndices, DeviceSerial}; 13 | 14 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 15 | pub enum Theme { 16 | #[default] 17 | Dark, 18 | Light, 19 | } 20 | 21 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 22 | #[serde(default)] 23 | pub struct Rule { 24 | pub alias: String, 25 | pub device_indices: DeviceIndices, 26 | #[serde(alias = "app_name")] 27 | pub match_app_name: Option, 28 | #[serde(alias = "process_name")] 29 | pub match_bin_name: Option, 30 | #[serde(alias = "process_path")] 31 | pub match_bin_path: Option, 32 | #[serde(alias = "title")] 33 | pub match_win_name: Option, 34 | } 35 | 36 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 37 | #[serde(default)] 38 | pub struct Ui { 39 | pub scale: f32, 40 | pub theme: Theme, 41 | } 42 | 43 | #[derive(Clone, Debug, Deserialize, Serialize)] 44 | #[serde(default)] 45 | pub struct Config { 46 | pub auto_launch: Option, 47 | pub auto_update: Option, 48 | pub devices: HashMap, 49 | pub loop_sleep_ms: u64, 50 | pub send_sleep_ms: u64, 51 | pub show_serial: bool, 52 | pub swap_lighting: bool, 53 | pub rules: Vec, 54 | pub ui: Ui, 55 | } 56 | 57 | impl Default for Config { 58 | fn default() -> Self { 59 | Self { 60 | auto_launch: None, 61 | auto_update: None, 62 | devices: HashMap::new(), 63 | loop_sleep_ms: 250, 64 | send_sleep_ms: 250, 65 | show_serial: false, 66 | swap_lighting: true, 67 | rules: vec![Rule { 68 | alias: String::from("The Binding of Isaac"), 69 | device_indices: DeviceIndices::new(), 70 | match_app_name: None, 71 | match_bin_name: None, 72 | match_bin_path: Some(String::from("C:\\Program Files (x86)\\Steam\\steamapps\\common\\The Binding of Isaac Rebirth*")), 73 | match_win_name: None, 74 | }, 75 | Rule { 76 | alias: String::from("Default Fallback"), 77 | device_indices: wps::get_device_indices().unwrap_or_default(), 78 | match_app_name: Some(String::from("*")), 79 | match_bin_name: Some(String::from("*")), 80 | match_bin_path: Some(String::from("*")), 81 | match_win_name: Some(String::from("*")), 82 | }], 83 | ui: Ui { 84 | scale: 1.25, 85 | theme: Theme::Dark, 86 | }, 87 | } 88 | } 89 | } 90 | 91 | impl Config { 92 | pub fn get_path() -> Result { 93 | let mut path = std::env::current_exe()?; 94 | path.set_extension("json"); 95 | 96 | let config_path = if cfg!(debug_assertions) { 97 | path 98 | } else { 99 | let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { 100 | bail!("Could not get current executable file name") 101 | }; 102 | 103 | let Some(path) = dirs::config_dir() else { 104 | bail!("Could not get config directory path"); 105 | }; 106 | 107 | path.join(file_name) 108 | }; 109 | 110 | Ok(config_path) 111 | } 112 | 113 | pub fn load() -> Result { 114 | let path = Self::get_path()?; 115 | let config = if let Ok(mut file) = File::open(&path) { 116 | let mut text = String::new(); 117 | file.read_to_string(&mut text)?; 118 | 119 | match serde_json::from_str(&text) { 120 | Ok(config) => config, 121 | Err(error) => { 122 | eprintln!("There was an error parsing the config: {error}"); 123 | eprintln!("Temporarily using the default config"); 124 | Self::default() 125 | } 126 | } 127 | } else { 128 | if path.exists() { 129 | // Rename the existing config file 130 | let new_path = path.join(".bak"); 131 | std::fs::rename(&path, &new_path)?; 132 | eprintln!("Config file renamed to: {new_path:?}"); 133 | } 134 | 135 | // Create a new config file and write default config 136 | let mut file = File::create(&path)?; 137 | let config = Self::default(); 138 | let text = serde_json::to_string_pretty(&config)?; 139 | file.write_all(text.as_bytes())?; 140 | config 141 | }; 142 | 143 | Ok(config) 144 | } 145 | 146 | pub fn save(&mut self) -> Result<()> { 147 | let path = Self::get_path()?; 148 | let mut file = File::options() 149 | .write(true) 150 | .create(true) 151 | .truncate(true) 152 | .open(path)?; 153 | 154 | let content = serde_json::to_string_pretty(&self)?; 155 | file.write_all(content.as_bytes())?; 156 | 157 | Ok(()) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, ffi::CStr, time::Duration}; 2 | 3 | use anyhow::{bail, Error, Result}; 4 | use derive_more::{Display, FromStr}; 5 | use serde::{Deserialize, Serialize}; 6 | use strum::FromRepr; 7 | use wooting_rgb_sys as rgb; 8 | 9 | /* Constants */ 10 | 11 | // https://gist.github.com/BigBrainAFK/0ba454a1efb43f7cb6301cda8838f432 12 | #[allow(clippy::cast_possible_truncation)] // Max is 10 13 | const MAX_DEVICES: u8 = rgb::WOOTING_MAX_RGB_DEVICES as u8; 14 | const MAX_LENGTH: usize = u8::MAX as usize + 1; 15 | const MAGIC_WORD: u16 = 56016; 16 | const GET_SERIAL: u8 = 3; 17 | const RELOAD_PROFILE: u8 = 7; 18 | const GET_CURRENT_KEYBOARD_PROFILE_INDEX: u8 = 11; 19 | const ACTIVATE_PROFILE: u8 = 23; 20 | const REFRESH_RGB_COLORS: u8 = 29; 21 | const WOOT_DEV_RESET_ALL: u8 = 32; 22 | 23 | /* Typings */ 24 | 25 | pub type ProfileIndex = i8; 26 | pub type DeviceIndices = HashMap; 27 | 28 | /* Structures */ 29 | 30 | #[derive(Clone, Debug, Default, Display, Deserialize, FromRepr, Eq, Hash, PartialEq, Serialize)] 31 | pub enum Stage { 32 | #[default] 33 | H = 0, // Mass 34 | P = 1, // PVT 35 | T = 2, // DVT 36 | E = 3, // EVT 37 | X = 4, // Prototype 38 | } 39 | 40 | #[derive(Clone, Debug, Default, Display, Deserialize, Eq, FromStr, Hash, PartialEq, Serialize)] 41 | pub struct U32(u32); 42 | 43 | #[derive(Clone, Debug, Default, Display, Deserialize, Eq, FromStr, Hash, PartialEq, Serialize)] 44 | pub struct DeviceID(String); 45 | 46 | #[derive(Clone, Debug, Default, Display, Deserialize, Eq, FromStr, Hash, PartialEq, Serialize)] 47 | pub struct DeviceSerial(String); 48 | 49 | #[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] 50 | pub struct Device { 51 | pub model_name: String, 52 | pub supplier: u32, 53 | pub year: u32, 54 | pub week: u32, 55 | pub product: u32, 56 | pub revision: u32, 57 | pub product_id: u32, 58 | pub stage: Stage, 59 | pub variant: Option, 60 | pub pcb_design: Option, 61 | pub minor_rev: Option, 62 | pub profiles: Vec, 63 | } 64 | 65 | /* Implementations */ 66 | 67 | /// Reverse engineered from Wootility 68 | impl TryFrom> for U32 { 69 | type Error = Error; 70 | 71 | fn try_from(bytes: Vec) -> Result { 72 | let mut result: u32 = 0; 73 | let mut shift: u32 = 0; 74 | 75 | for byte in &bytes { 76 | result |= u32::from(byte & 0x7F) << shift; 77 | shift += 7; 78 | 79 | if byte & 0x80 == 0 { 80 | return Ok(Self(result)); 81 | } 82 | 83 | if shift > 28 { 84 | bail!("Integer too large"); 85 | } 86 | } 87 | 88 | println!("Incomplete integer"); 89 | Ok(Self(result)) 90 | } 91 | } 92 | 93 | /// Reverse engineered from Wootility 94 | impl TryFrom> for Device { 95 | type Error = Error; 96 | 97 | fn try_from(buffer: Vec) -> Result { 98 | const OFFSET: usize = 5; 99 | let length = buffer[4] as usize; 100 | let mut index = OFFSET; 101 | let mut device = Self::default(); 102 | while index < length + OFFSET { 103 | let field = buffer[index]; 104 | index += 1; 105 | 106 | match field >> 3 { 107 | 1 => { 108 | let bytes = vec![buffer[index]]; 109 | device.supplier = U32::try_from(bytes)?.0; 110 | index += 1; 111 | } 112 | 2 => { 113 | let bytes = vec![buffer[index]]; 114 | device.year = U32::try_from(bytes)?.0; 115 | index += 1; 116 | } 117 | 3 => { 118 | let bytes = vec![buffer[index]]; 119 | device.week = U32::try_from(bytes)?.0; 120 | index += 1; 121 | } 122 | 4 => { 123 | let bytes = vec![buffer[index]]; 124 | device.product = U32::try_from(bytes)?.0; 125 | index += 1; 126 | } 127 | 5 => { 128 | let bytes = vec![buffer[index]]; 129 | device.revision = U32::try_from(bytes)?.0; 130 | index += 1; 131 | } 132 | 6 => { 133 | let mut bytes = vec![buffer[index]]; 134 | while buffer[index] >> 3 != 0 { 135 | index += 1; 136 | bytes.push(buffer[index]); 137 | } 138 | device.product_id = U32::try_from(bytes)?.0; 139 | index += 1; 140 | } 141 | 7 => { 142 | let bytes = vec![buffer[index]]; 143 | let discriminant = U32::try_from(bytes)?.0 as usize; 144 | device.stage = Stage::from_repr(discriminant).unwrap_or_default(); 145 | index += 1; 146 | } 147 | 9 => { 148 | let bytes = vec![buffer[index]]; 149 | device.variant = Some(U32::try_from(bytes)?.0); 150 | index += 1; 151 | } 152 | 10 => { 153 | let bytes = vec![buffer[index]]; 154 | device.pcb_design = Some(U32::try_from(bytes)?.0); 155 | index += 1; 156 | } 157 | 11 => { 158 | let bytes = vec![buffer[index]]; 159 | device.minor_rev = Some(U32::try_from(bytes)?.0); 160 | index += 1; 161 | } 162 | _ => { 163 | // Skip unknown field 164 | let wire_type = field & 7; 165 | match wire_type { 166 | 0 => index += 1, 167 | 1 => index += 8, 168 | 2 => { 169 | let length = buffer[index] as usize; 170 | index += length + 1; 171 | } 172 | 3 => { 173 | // Skip nested fields 174 | while buffer[index] & 7 != 4 { 175 | index += 1; 176 | } 177 | index += 1; 178 | } 179 | 5 => index += 4, 180 | _ => bail!("Invalid wire type"), 181 | } 182 | } 183 | } 184 | } 185 | 186 | Ok(device) 187 | } 188 | } 189 | 190 | impl From<&Device> for DeviceID { 191 | fn from(device: &Device) -> Self { 192 | // These must match exactly what the Wooting firmware reports (Wootility var KeyboardType) 193 | let keyboard_type = match device.model_name.as_str() { 194 | "Wooting One" => 0, 195 | "Wooting Two" => 1, 196 | "Wooting Two LE" => 2, 197 | "Wooting Two HE" => 3, 198 | "Wooting 60HE" => 4, 199 | "Wooting 60HE (ARM)" => 5, 200 | "Wooting Two HE (ARM)" => 6, 201 | "Wooting UwU" => 7, 202 | "Wooting UwU RGB" => 8, 203 | "Wooting 60HE+" => 9, 204 | "Wooting 80HE" => 10, 205 | &_ => 11, 206 | }; 207 | 208 | let device_id = format!( 209 | "{}{}{}{}{}{}{}{}{}", 210 | keyboard_type, 211 | device.product_id, 212 | device.product, 213 | device.revision, 214 | device.week, 215 | device.year, 216 | device.pcb_design.map_or(String::new(), |v| v.to_string()), 217 | device.minor_rev.map_or(String::new(), |v| v.to_string()), 218 | device.variant.map_or(String::new(), |v| v.to_string()), 219 | ); 220 | 221 | Self(device_id) 222 | } 223 | } 224 | 225 | impl From<&Device> for DeviceSerial { 226 | fn from(device: &Device) -> Self { 227 | Self(format!( 228 | "A{:02X}B{:02}{:02}W{:02X}{}{}{}{}{}{:05}", 229 | device.supplier, 230 | device.year, 231 | device.week, 232 | device.product, 233 | device 234 | .pcb_design 235 | .map_or_else(String::new, |pcb_design| format!("T{pcb_design:02}")), 236 | device.revision, 237 | device 238 | .minor_rev 239 | .map_or_else(String::new, |minor_rev| format!("{minor_rev:02}")), 240 | device 241 | .variant 242 | .map_or_else(String::new, |variant| format!("S{variant:02}")), 243 | device.stage, 244 | device.product_id 245 | )) 246 | } 247 | } 248 | 249 | impl TryFrom for Device { 250 | type Error = anyhow::Error; 251 | 252 | fn try_from(device_id: DeviceID) -> Result { 253 | unsafe { 254 | rgb::wooting_usb_disconnect(false); 255 | rgb::wooting_usb_find_keyboard(); 256 | 257 | for device_index in 0..MAX_DEVICES { 258 | if !rgb::wooting_usb_select_device(device_index) { 259 | continue; 260 | } 261 | 262 | let device = get_active_device()?; 263 | if device_id == DeviceID::from(&device) { 264 | return Ok(device); 265 | } 266 | } 267 | } 268 | 269 | bail!("Device ({device_id}) not found") 270 | } 271 | } 272 | 273 | impl TryFrom for Device { 274 | type Error = anyhow::Error; 275 | 276 | fn try_from(device_serial: DeviceSerial) -> Result { 277 | unsafe { 278 | rgb::wooting_usb_disconnect(false); 279 | rgb::wooting_usb_find_keyboard(); 280 | 281 | for device_index in 0..MAX_DEVICES { 282 | if !rgb::wooting_usb_select_device(device_index) { 283 | continue; 284 | } 285 | 286 | let device = get_active_device()?; 287 | if device_serial == DeviceSerial::from(&device) { 288 | return Ok(device); 289 | } 290 | } 291 | } 292 | 293 | bail!("Device ({device_serial}) not found") 294 | } 295 | } 296 | 297 | /* Getters */ 298 | 299 | #[allow(clippy::too_many_lines)] 300 | pub fn get_active_device() -> Result { 301 | unsafe { 302 | /* Response Bytes 303 | * 0-1 Magic Word 304 | * 2 Command 305 | * 3 Unknown 306 | * 4 Length 307 | * 5-L Buffer 308 | */ 309 | let mut buffer = vec![0u8; MAX_LENGTH]; 310 | let response = rgb::wooting_usb_send_feature_with_response( 311 | buffer.as_mut_ptr(), 312 | MAX_LENGTH, 313 | GET_SERIAL, 314 | 0, 315 | 0, 316 | 0, 317 | 2, 318 | ); 319 | 320 | #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] 321 | if response != MAX_LENGTH as i32 { 322 | bail!("Invalid response length"); 323 | } 324 | 325 | let magic_word = u16::from_le_bytes([buffer[0], buffer[1]]); 326 | if magic_word != MAGIC_WORD { 327 | bail!("Invalid response type"); 328 | } 329 | 330 | let command = buffer[2]; 331 | if command != GET_SERIAL { 332 | bail!("Invalid response command"); 333 | } 334 | 335 | println!("Serial Buffer: {:?}", &buffer[5..5 + buffer[4] as usize]); 336 | let wooting_usb_meta = *rgb::wooting_usb_get_meta(); 337 | let c_str_model_name = CStr::from_ptr(wooting_usb_meta.model); 338 | let mut device = Device::try_from(buffer)?; 339 | device.model_name = c_str_model_name.to_str()?.replace("Lekker Edition", "LE"); 340 | 341 | Ok(device) 342 | } 343 | } 344 | 345 | pub fn get_all_devices() -> Result> { 346 | let mut devices = Vec::new(); 347 | 348 | unsafe { 349 | rgb::wooting_usb_disconnect(false); 350 | if !rgb::wooting_usb_find_keyboard() { 351 | bail!("Failed to find keyboard(s)") 352 | }; 353 | 354 | for device_index in 0..MAX_DEVICES { 355 | if !rgb::wooting_usb_select_device(device_index) { 356 | continue; 357 | } 358 | 359 | let device = get_active_device()?; 360 | devices.push(device); 361 | } 362 | 363 | rgb::wooting_rgb_reset_rgb(); 364 | } 365 | 366 | Ok(devices) 367 | } 368 | 369 | #[must_use] 370 | pub fn get_active_profile_index() -> ProfileIndex { 371 | unsafe { 372 | let mut buff = vec![0u8; MAX_LENGTH]; 373 | let response = rgb::wooting_usb_send_feature_with_response( 374 | buff.as_mut_ptr(), 375 | MAX_LENGTH, 376 | GET_CURRENT_KEYBOARD_PROFILE_INDEX, 377 | 0, 378 | 0, 379 | 0, 380 | 0, 381 | ); 382 | 383 | #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] 384 | if response == MAX_LENGTH as i32 { 385 | let is_v2 = rgb::wooting_usb_use_v2_interface(); 386 | buff[if is_v2 { 5 } else { 4 }] as ProfileIndex 387 | } else { 388 | ProfileIndex::MAX 389 | } 390 | } 391 | } 392 | 393 | pub fn get_device_indices() -> Result { 394 | let mut device_indices = DeviceIndices::new(); 395 | 396 | unsafe { 397 | rgb::wooting_usb_disconnect(false); 398 | rgb::wooting_usb_find_keyboard(); 399 | 400 | for device_index in 0..MAX_DEVICES { 401 | if !rgb::wooting_usb_select_device(device_index) { 402 | continue; 403 | } 404 | 405 | let device = get_active_device()?; 406 | let device_serial = DeviceSerial::from(&device); 407 | let profile_index = get_active_profile_index(); 408 | device_indices.insert(device_serial, profile_index); 409 | } 410 | 411 | rgb::wooting_rgb_reset_rgb(); 412 | } 413 | 414 | Ok(device_indices) 415 | } 416 | 417 | /* Setters */ 418 | 419 | pub fn set_active_profile_index( 420 | profile_index: ProfileIndex, 421 | send_sleep_ms: u64, 422 | swap_lighting: bool, 423 | ) -> Result<()> { 424 | let profile_index = u8::try_from(profile_index)?; 425 | 426 | unsafe { 427 | rgb::wooting_usb_send_feature(ACTIVATE_PROFILE, 0, 0, 0, profile_index); 428 | std::thread::sleep(Duration::from_millis(send_sleep_ms)); 429 | rgb::wooting_usb_send_feature(RELOAD_PROFILE, 0, 0, 0, profile_index); 430 | 431 | if swap_lighting { 432 | std::thread::sleep(Duration::from_millis(send_sleep_ms)); 433 | rgb::wooting_usb_send_feature(WOOT_DEV_RESET_ALL, 0, 0, 0, 0); 434 | std::thread::sleep(Duration::from_millis(send_sleep_ms)); 435 | rgb::wooting_usb_send_feature(REFRESH_RGB_COLORS, 0, 0, 0, profile_index); 436 | } 437 | } 438 | 439 | Ok(()) 440 | } 441 | 442 | pub fn set_device_indices( 443 | mut device_indices: DeviceIndices, 444 | send_sleep_ms: u64, 445 | swap_lighting: bool, 446 | ) -> Result<()> { 447 | unsafe { 448 | rgb::wooting_usb_disconnect(false); 449 | rgb::wooting_usb_find_keyboard(); 450 | 451 | for device_index in 0..MAX_DEVICES { 452 | if !rgb::wooting_usb_select_device(device_index) { 453 | continue; 454 | } 455 | 456 | let device = get_active_device()?; 457 | let device_serial = DeviceSerial::from(&device); 458 | if let Some(profile_index) = device_indices.remove(&device_serial) { 459 | // Silently ignore negative profile indexes as a way to skip updating devices 460 | let _ = set_active_profile_index(profile_index, send_sleep_ms, swap_lighting); 461 | }; 462 | } 463 | 464 | rgb::wooting_rgb_reset_rgb(); 465 | } 466 | 467 | Ok(()) 468 | } 469 | 470 | /* Helpers */ 471 | 472 | pub fn select_device_serial(device_serial: &DeviceSerial) -> Result { 473 | unsafe { 474 | rgb::wooting_usb_disconnect(false); 475 | rgb::wooting_usb_find_keyboard(); 476 | 477 | for device_index in 0..MAX_DEVICES { 478 | if !rgb::wooting_usb_select_device(device_index) { 479 | continue; 480 | } 481 | 482 | let device = get_active_device()?; 483 | if device_serial == &DeviceSerial::from(&device) { 484 | return Ok(device); 485 | } 486 | } 487 | } 488 | 489 | bail!("Device ({device_serial}) not found") 490 | } 491 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | use std::{ffi::OsStr, str::FromStr, time::Duration}; 5 | 6 | use active_win_pos_rs::ActiveWindow; 7 | use anyhow::Result; 8 | use app::MainApp; 9 | use clap::Parser; 10 | use parking_lot::RwLock; 11 | use regex::Regex; 12 | use tauri::{ 13 | AppHandle, Builder, CustomMenuItem, Manager, RunEvent, SystemTray, SystemTrayEvent, 14 | SystemTrayMenu, SystemTrayMenuItem, 15 | }; 16 | use tauri_egui::EguiPluginBuilder; 17 | use tauri_plugin_autostart::{MacosLauncher::LaunchAgent, ManagerExt}; 18 | use tauri_plugin_updater::UpdaterExt; 19 | use wildflower::Pattern; 20 | #[cfg(target_os = "windows")] 21 | use windows::Win32::System::Console::{AttachConsole, FreeConsole, ATTACH_PARENT_PROCESS}; 22 | use wootility::Wootility; 23 | use wooting_profile_switcher as wps; 24 | use wooting_rgb_sys as rgb; 25 | use wps::{DeviceID, DeviceIndices, DeviceSerial, ProfileIndex}; 26 | 27 | use crate::config::{Config, Rule}; 28 | 29 | mod app; 30 | mod config; 31 | mod wootility; 32 | 33 | #[derive(Debug, Parser)] 34 | #[command(author, version, about)] 35 | struct Args { 36 | /// One-shot command line service for automation scripting. 37 | /// Select an active profile index to apply to a device and exit. 38 | /// You can specify which device with the serial number argument. 39 | #[arg(short, long)] 40 | profile_index: Option, 41 | 42 | /// Select which device to apply the profile. 43 | /// Defaults to the first found device. 44 | #[arg(short, long)] 45 | device_serial: Option, 46 | 47 | /// Pause the active window scanning at startup. 48 | #[arg(long, default_value_t = false)] 49 | paused: bool, 50 | } 51 | 52 | #[allow(clippy::too_many_lines)] 53 | fn main() -> Result<()> { 54 | // Reset the keyboard if the program panics 55 | std::panic::set_hook(Box::new(|_| unsafe { 56 | rgb::wooting_rgb_reset(); 57 | std::process::exit(1); 58 | })); 59 | 60 | // Reset the keyboard if the program is killed/terminated 61 | ctrlc::set_handler(move || unsafe { 62 | rgb::wooting_rgb_reset(); 63 | std::process::exit(1); 64 | })?; 65 | 66 | Builder::default() 67 | .plugin(tauri_plugin_autostart::init(LaunchAgent, None)) 68 | .plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {})) 69 | .plugin(tauri_plugin_updater::Builder::new().build()) 70 | .system_tray(SystemTray::new()) 71 | .setup(|app| { 72 | #[cfg(target_os = "macos")] // Hide the macOS dock icon 73 | app.set_activation_policy(tauri::ActivationPolicy::Accessory); 74 | app.wry_plugin(EguiPluginBuilder::new(app.handle())); 75 | app.manage(RwLock::new(Args::parse())); 76 | app.manage(RwLock::new(Config::load()?)); 77 | 78 | let args = app.state::>(); 79 | let config = app.state::>(); 80 | println!("{:#?}\n{:#?}", args.read(), config.read()); 81 | 82 | // One-shot command line argument to set the device and profile index 83 | if let Some(profile_index) = args.read().profile_index { 84 | if let Some(device_serial) = args.read().device_serial.clone() { 85 | wps::select_device_serial(&device_serial)?; 86 | } 87 | 88 | let _ = wps::set_active_profile_index( 89 | profile_index, 90 | config.read().send_sleep_ms, 91 | config.read().swap_lighting, 92 | ); 93 | 94 | println!("Profile Index Updated"); 95 | std::process::exit(0); 96 | } 97 | 98 | println!("Scanning Wootility for devices and profiles to save"); 99 | if let Ok(mut wootility) = Wootility::load() { 100 | let devices = match wps::get_all_devices() { 101 | Ok(devices) => devices, 102 | Err(error) => { 103 | eprintln!("{error}"); 104 | std::process::exit(1); 105 | // TODO: Add a GUI popup 106 | } 107 | }; 108 | println!("Found Devices: {devices:#?}"); 109 | 110 | let mut config = config.write(); 111 | config.devices = devices 112 | .into_iter() 113 | .filter_map(|mut device| { 114 | let device_id = DeviceID::from(&device); 115 | let device_serial = DeviceSerial::from(&device); 116 | println!("Device ID: {device_id}"); 117 | println!("Device Serial: {device_serial}"); 118 | println!("Found Profiles: {:#?}", wootility.profiles); 119 | 120 | device.profiles = wootility 121 | .profiles 122 | .devices 123 | .remove(&device_id)? 124 | .into_iter() 125 | .map(|profile| profile.details.name) 126 | .collect(); 127 | 128 | Some((device_serial, device)) 129 | }) 130 | .collect(); 131 | config.save()?; 132 | } else { 133 | eprintln!("Failed to access Wootility local storage"); 134 | eprintln!("Please make sure Wootility isn't running"); 135 | // TODO: Add a GUI popup 136 | }; 137 | 138 | // Enable or disable auto-launch on startup 139 | let auto_launch_manager = app.autolaunch(); 140 | if let Some(auto_launch) = config.read().auto_launch { 141 | let _ = if auto_launch { 142 | println!("Auto Launch Enabled"); 143 | auto_launch_manager.enable() 144 | } else { 145 | println!("Auto Launch Disabled"); 146 | auto_launch_manager.disable() 147 | }; 148 | } 149 | 150 | // Check for and install updates automatically 151 | // TODO: Add an option to ask prior to installing 152 | let updater = app.updater(); 153 | if let Some(auto_update) = config.read().auto_update { 154 | if auto_update { 155 | tauri::async_runtime::block_on(async move { 156 | match updater.check().await { 157 | Ok(update) => { 158 | println!("Checking for updates..."); 159 | if !update.is_update_available() { 160 | println!("No updates found."); 161 | return; 162 | } 163 | 164 | println!("Update found, please wait..."); 165 | if let Err(error) = update.download_and_install(|_event| {}).await { 166 | eprintln!("{error}"); 167 | } 168 | } 169 | Err(error) => { 170 | eprintln!("{error}"); 171 | } 172 | } 173 | }); 174 | } 175 | } 176 | 177 | let tray_handle = app.tray_handle(); 178 | let mut system_tray_menu = SystemTrayMenu::new() 179 | .add_item(CustomMenuItem::new("show", "Show Window")) 180 | .add_native_item(SystemTrayMenuItem::Separator); 181 | 182 | let devices = config.read().devices.clone(); 183 | for (device_serial, device) in devices { 184 | let serial_number = device_serial.to_string(); 185 | let title = if config.read().show_serial { 186 | &serial_number 187 | } else { 188 | &device.model_name 189 | }; 190 | 191 | let menu_item = CustomMenuItem::new(&serial_number, title).disabled(); 192 | system_tray_menu = system_tray_menu.add_item(menu_item); 193 | 194 | for (i, title) in device.profiles.iter().enumerate() { 195 | let id = format!("{device_serial}|{i}"); 196 | let menu_item = CustomMenuItem::new(id, title).selected(); 197 | system_tray_menu = system_tray_menu.add_item(menu_item); 198 | } 199 | 200 | system_tray_menu = system_tray_menu.add_native_item(SystemTrayMenuItem::Separator); 201 | } 202 | 203 | system_tray_menu = system_tray_menu 204 | .add_item(CustomMenuItem::new(String::from("pause"), "Pause Scanning")) 205 | .add_item(CustomMenuItem::new(String::from("reload"), "Reload Config")) 206 | .add_native_item(SystemTrayMenuItem::Separator) 207 | .add_item(CustomMenuItem::new(String::from("quit"), "Quit Program")); 208 | 209 | tray_handle.set_menu(system_tray_menu)?; 210 | 211 | // Attempt to hide the Windows console 212 | #[cfg(target_os = "windows")] 213 | unsafe { 214 | let _ = FreeConsole(); 215 | let _ = AttachConsole(ATTACH_PARENT_PROCESS); 216 | } 217 | 218 | Ok(()) 219 | }) 220 | .on_system_tray_event(move |app, event| { 221 | let args = app.state::>(); 222 | let config = app.state::>(); 223 | match event { 224 | SystemTrayEvent::LeftClick { .. } => { 225 | MainApp::open(app).expect("Failed to open main app"); 226 | } 227 | SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() { 228 | "show" => { 229 | MainApp::open(app).expect("Failed to open main app"); 230 | } 231 | "quit" => { 232 | app.exit(0); 233 | } 234 | "reload" => { 235 | *config.write() = Config::load().expect("Failed to reload config"); 236 | } 237 | "pause" => { 238 | let paused = args.read().paused; 239 | let title = if paused { 240 | "Pause Scanning" 241 | } else { 242 | "Resume Scanning" 243 | }; 244 | args.write().paused = !paused; 245 | 246 | let tray_handle = app.tray_handle(); 247 | let item_handle = tray_handle.get_item(&id); 248 | item_handle.set_title(title).unwrap(); 249 | } 250 | _ => { 251 | let Some((serial_number, profile_index)) = id.split_once('|') else { 252 | return; 253 | }; 254 | 255 | let Ok(device_serial) = DeviceSerial::from_str(serial_number) else { 256 | return; 257 | }; 258 | 259 | let Ok(profile_index) = profile_index.parse::() else { 260 | return; 261 | }; 262 | 263 | if wps::select_device_serial(&device_serial).is_err() { 264 | return; 265 | } 266 | 267 | let _ = wps::set_active_profile_index( 268 | profile_index, 269 | config.read().send_sleep_ms, 270 | config.read().swap_lighting, 271 | ); 272 | 273 | let mut args = args.write(); 274 | args.device_serial = Some(device_serial); 275 | args.profile_index = Some(profile_index); 276 | } 277 | }, 278 | _ => {} 279 | } 280 | }) 281 | .build(tauri::generate_context!())? 282 | .run(move |app, event| { 283 | if matches!(event, RunEvent::Ready) { 284 | let app = app.clone(); 285 | std::thread::spawn(move || { 286 | active_window_polling_task(&app).unwrap(); 287 | }); 288 | } 289 | }); 290 | 291 | Ok(()) 292 | } 293 | 294 | // Polls the active window to matching rules and applies the keyboard profile 295 | fn active_window_polling_task(app: &AppHandle) -> Result<()> { 296 | let args = app.state::>(); 297 | let config = app.state::>(); 298 | 299 | let mut last_active_window = ActiveWindow::default(); 300 | let mut last_device_indices = wps::get_device_indices()?; 301 | 302 | loop { 303 | std::thread::sleep(Duration::from_millis(config.read().loop_sleep_ms)); 304 | 305 | // Update the selected profile system tray menu item 306 | if let Some(active_profile_index) = args.read().profile_index { 307 | if let Some(active_device_serial) = args.read().device_serial.clone() { 308 | let devices = config.read().devices.clone(); 309 | for (device_serial, device) in devices { 310 | if device_serial != active_device_serial { 311 | continue; 312 | } 313 | 314 | for i in 0..device.profiles.len() { 315 | let id = format!("{device_serial}|{i}"); 316 | let tray_handle = app.tray_handle(); 317 | let item_handle = tray_handle.get_item(&id); 318 | #[allow(clippy::cast_sign_loss)] 319 | let _ = item_handle.set_selected(i == active_profile_index as usize); 320 | } 321 | } 322 | } 323 | } 324 | 325 | if args.read().paused { 326 | continue; 327 | } 328 | 329 | let Ok(active_window) = active_win_pos_rs::get_active_window() else { 330 | continue; 331 | }; 332 | 333 | if active_window == last_active_window { 334 | continue; 335 | } 336 | 337 | last_active_window = active_window.clone(); 338 | 339 | let rules = config.read().rules.clone(); 340 | let Some(device_indices) = find_match(active_window, rules) else { 341 | continue; 342 | }; 343 | 344 | if device_indices == last_device_indices { 345 | continue; 346 | } 347 | 348 | last_device_indices.clone_from(&device_indices); 349 | 350 | println!("Updated Device Indices: {device_indices:#?}"); 351 | wps::set_device_indices( 352 | device_indices, 353 | config.read().send_sleep_ms, 354 | config.read().swap_lighting, 355 | )?; 356 | } 357 | } 358 | 359 | // Find the first matching device indices for the given active window 360 | #[allow(clippy::uninlined_format_args)] 361 | fn find_match(active_window: ActiveWindow, rules: Vec) -> Option { 362 | type RulePropFn = fn(Rule) -> Option; 363 | 364 | let active_window_bin_path = active_window.process_path.display().to_string(); 365 | let active_window_bin_name = active_window 366 | .process_path 367 | .file_name() 368 | .and_then(OsStr::to_str) 369 | .map(String::from) 370 | .unwrap_or_default(); 371 | 372 | println!("Updated Active Window:"); 373 | println!("- App Name: {}", active_window.app_name); 374 | println!("- Bin Name: {}", active_window_bin_name); 375 | println!("- Bin Path: {}", active_window_bin_path); 376 | println!("- Win Name: {}", active_window.title); 377 | 378 | let match_active_window: Vec<(RulePropFn, String)> = vec![ 379 | (|rule| rule.match_app_name, active_window.app_name), 380 | (|rule| rule.match_bin_name, active_window_bin_name), 381 | (|rule| rule.match_bin_path, active_window_bin_path), 382 | (|rule| rule.match_win_name, active_window.title), 383 | ]; 384 | 385 | for rule in rules { 386 | for (rule_prop_fn, active_prop) in &match_active_window { 387 | if let Some(rule_prop) = rule_prop_fn(rule.clone()) { 388 | if Pattern::new(&rule_prop.replace('\\', "\\\\")).matches(active_prop) { 389 | return Some(rule.device_indices); 390 | } else if let Ok(re) = Regex::new(&rule_prop) { 391 | if re.is_match(active_prop) { 392 | return Some(rule.device_indices); 393 | } 394 | } 395 | } 396 | } 397 | } 398 | 399 | None 400 | } 401 | -------------------------------------------------------------------------------- /src/wootility.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, collections::HashMap, path::PathBuf}; 2 | 3 | use anyhow::{anyhow, bail, Result}; 4 | use encoding_rs::{UTF_16LE, WINDOWS_1252}; 5 | use rusty_leveldb::{compressor::SnappyCompressor, CompressorId, Options, DB}; 6 | use serde::{Deserialize, Serialize}; 7 | use serde_with::{json::JsonString, serde_as}; 8 | use wooting_profile_switcher::DeviceID; 9 | 10 | // This isn't exactly pretty, but it reduces a lot of duplicated code 11 | structstruck::strike! { 12 | #[strikethrough[serde_as]] 13 | #[strikethrough[derive(Clone, Debug, Default, Deserialize, Serialize)]] 14 | #[strikethrough[serde(rename_all = "camelCase")]] 15 | pub struct Wootility { 16 | #[serde_as(as = "JsonString")] 17 | pub profiles: struct { 18 | pub devices: HashMap>, 24 | } 25 | } 26 | } 27 | 28 | impl Wootility { 29 | pub fn get_path() -> Result { 30 | ["", "-lekker", "-lekker-beta", "-lekker-alpha"] 31 | .into_iter() 32 | .map(|path| format!("wootility{path}/Local Storage/leveldb")) 33 | .map(|path| dirs::config_dir().unwrap().join(path)) 34 | .find(|path| path.exists()) 35 | .ok_or_else(|| anyhow!("Couldn't find Wootility path")) 36 | } 37 | 38 | pub fn load() -> Result { 39 | const KEY: &[u8; 22] = b"_file://\x00\x01persist:root"; 40 | 41 | let path = Self::get_path()?; 42 | let opts = Options { 43 | compressor: SnappyCompressor::ID, 44 | create_if_missing: false, 45 | paranoid_checks: true, 46 | ..Default::default() 47 | }; 48 | 49 | let mut db = DB::open(path, opts)?; 50 | let encoded = db 51 | .get(KEY) 52 | .ok_or_else(|| anyhow!("Couldn't find Wootility data"))?; 53 | let decoded = Self::decode_string(&encoded)?; 54 | 55 | Ok(serde_json::from_str(&decoded)?) 56 | } 57 | 58 | /// 59 | pub fn decode_string(bytes: &[u8]) -> Result> { 60 | let prefix = bytes.first().ok_or_else(|| anyhow!("Invalid length"))?; 61 | match prefix { 62 | 0 => Ok(UTF_16LE.decode(&bytes[1..]).0), 63 | 1 => Ok(WINDOWS_1252.decode(&bytes[1..]).0), 64 | _ => bail!("Invalid prefix"), 65 | } 66 | } 67 | } 68 | --------------------------------------------------------------------------------