From 9b1f6bd14c6d885a7d8088e8b34e0dc86962af6c Mon Sep 17 00:00:00 2001 From: Emre Demir <274893088+EmreO33@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:16:40 +0300 Subject: [PATCH] Tray: show the connected mouse and its battery in the right-click menu The tray menu was a static Show / Quit. It now leads with two disabled label lines, the device name and "Battery: NN% (state)", the way Razer Synapse's tray does, plus a matching icon tooltip. The battery line is removed rather than shown blank when nothing is connected or the mouse reports no cell. The text is pushed from the frontend (new `tray_set_device_status` command) whenever the cached MouseStatus name/battery changes, since the protocol drivers run in the webview. While the window is hidden or unfocused the background refresh used to skip entirely; it now still runs once a minute so the tray value does not freeze at whatever it was when the window was closed. Also resolve the DeathAdder V3 Pro to the DeathAdder V3 render (same shell, minus the cable; the V2 family already shares one render). It was deliberately excluded while test-needed and has since been verified on hardware (mouse-protocol 0x00b7, firmware 2.1). --- src-tauri/src/lib.rs | 95 +++++++++--------- src-tauri/src/tray.rs | 161 ++++++++++++++++++++++++++++++ src/hooks/use-mouse-connection.ts | 36 ++++++- src/native-hid/device-images.ts | 5 +- 4 files changed, 243 insertions(+), 54 deletions(-) create mode 100644 src-tauri/src/tray.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dae2673..4c5be57 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,6 @@ -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; -use tauri::tray::TrayIconBuilder; +use tauri::tray::TrayIcon; use tauri::webview::WebviewWindowBuilder; -use tauri::{AppHandle, LogicalSize, Manager, Size, WebviewWindow, WindowEvent}; +use tauri::{AppHandle, LogicalSize, Manager, Size, WebviewWindow, WindowEvent, Wry}; #[macro_use] mod applog; @@ -11,6 +10,7 @@ mod games; mod hid; mod linux_permissions; mod resource_monitor; +mod tray; use hid::{HidApiHandle, HidRegistry}; use resource_monitor::ResourceMonitorState; @@ -74,6 +74,46 @@ fn show_main_window(app: &AppHandle) { } } +fn on_tray_menu(app: &AppHandle, id: &str) { + match id { + "show" => show_main_window(app), + "quit" => app.exit(0), + _ => {} + } +} + +fn on_tray_icon_event(tray: &TrayIcon, event: tauri::tray::TrayIconEvent) { + // Left click toggles show/hide (only meaningful when the window still + // exists — nothing to hide otherwise, so that case just shows/recreates + // it, same as double-click). Double-click (Windows only — tray-icon + // doesn't report this on macOS/Linux) always shows rather than + // toggling: a double-click is two rapid single clicks first, so without + // this arm the pair would show-then-hide the window right back out from + // under the user. Right click opens the menu (device + battery lines, + // Show, Quit — see tray.rs), which tray-icon handles itself. + match event { + tauri::tray::TrayIconEvent::Click { + button: tauri::tray::MouseButton::Left, + button_state: tauri::tray::MouseButtonState::Up, + .. + } => { + let app = tray.app_handle(); + match app.get_webview_window("main").map(|w| w.is_visible()) { + Some(Ok(true)) => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } + _ => show_main_window(app), + } + } + tauri::tray::TrayIconEvent::DoubleClick { .. } => { + show_main_window(tray.app_handle()); + } + _ => {} + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -103,55 +143,10 @@ pub fn run() { conflicting_apps::detect_conflicting_apps, resource_monitor::sample_resource_usage, linux_permissions::install_udev_rules, + tray::tray_set_device_status, ]) .setup(|app| { - let show = MenuItem::with_id(app, "show", "Show OpenMouse", true, None::<&str>)?; - let separator = PredefinedMenuItem::separator(app)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show, &separator, &quit])?; - - TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "show" => show_main_window(app), - "quit" => app.exit(0), - _ => {} - }) - .on_tray_icon_event(|tray, event| { - // Left click toggles show/hide (only meaningful when - // the window still exists — nothing to hide otherwise, - // so that case just shows/recreates it, same as - // double-click). Double-click (Windows only — - // tray-icon doesn't report this on macOS/Linux) always - // shows rather than toggling: a double-click is two - // rapid single clicks first, so without this arm the - // pair would show-then-hide the window right back out - // from under the user. - match event { - tauri::tray::TrayIconEvent::Click { - button: tauri::tray::MouseButton::Left, - button_state: tauri::tray::MouseButtonState::Up, - .. - } => { - let app = tray.app_handle(); - match app.get_webview_window("main").map(|w| w.is_visible()) { - Some(Ok(true)) => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.hide(); - } - } - _ => show_main_window(app), - } - } - tauri::tray::TrayIconEvent::DoubleClick { .. } => { - show_main_window(tray.app_handle()); - } - _ => {} - } - }) - .build(app)?; + tray::build(app, on_tray_menu, on_tray_icon_event)?; if let Some(window) = app.get_webview_window("main") { size_window(&window); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..91998b3 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,161 @@ +//! System tray menu, and the device/battery lines at the top of it. +//! +//! The menu is built once at startup with two disabled "label" items above +//! the Show/Quit actions, the way Razer Synapse's tray shows the paired mouse +//! and its charge. Their text is rewritten from the frontend through the +//! `tray_set_device_status` command whenever the cached `MouseStatus` in +//! `use-mouse-connection.ts` changes, so right-clicking the tray icon shows +//! the last-read battery level without bringing the window back. +//! +//! Only the text lives here. Reading the battery is the frontend's job (the +//! protocol drivers run in the webview, see `native-hid/`), so a webview that +//! is hidden to the tray still owns the refresh cadence; the frontend just +//! reads on a slower interval while hidden instead of skipping entirely. + +use std::sync::Mutex; + +use serde::Deserialize; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{TrayIcon, TrayIconBuilder}; +use tauri::{App, AppHandle, Manager, Wry}; + +/// Text shown while nothing is connected, and the tooltip's base. +const NO_DEVICE: &str = "No device connected"; +const APP_NAME: &str = "OpenMouse"; + +/// What the frontend knows about the connected device that is worth showing +/// in a two-line tray menu. `None` battery means the mouse has no cell (wired, +/// or a driver that does not report one). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrayDeviceStatus { + pub name: String, + pub battery_percent: Option, + /// The protocol's `MouseStatus.batteryState` label, e.g. "Charging", + /// "Discharging", "Full", "Unknown". + pub battery_state: String, +} + +struct TrayHandles { + icon: TrayIcon, + menu: Menu, + device: MenuItem, + battery: MenuItem, + /// Whether `battery` is currently inserted in `menu`. It is removed + /// rather than left reading "Battery: —" when nothing is connected, so + /// the menu collapses to one label line. + battery_shown: bool, +} + +#[derive(Default)] +pub struct TrayState(Mutex>); + +/// Builds the tray icon and menu. `on_show`/`on_quit` are what the existing +/// Show/Quit items and the left-click/double-click handlers already did in +/// lib.rs; they stay there so this module owns only the menu text. +pub fn build( + app: &App, + on_menu: fn(&AppHandle, &str), + on_tray_icon: fn(&TrayIcon, tauri::tray::TrayIconEvent), +) -> tauri::Result<()> { + let device = MenuItem::with_id(app, "tray-device", NO_DEVICE, false, None::<&str>)?; + let battery = MenuItem::with_id(app, "tray-battery", "Battery", false, None::<&str>)?; + let show = MenuItem::with_id(app, "show", "Show OpenMouse", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[ + &device, + &PredefinedMenuItem::separator(app)?, + &show, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?; + + let icon = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .tooltip(APP_NAME) + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| on_menu(app, event.id.as_ref())) + .on_tray_icon_event(move |tray, event| on_tray_icon(tray, event)) + .build(app)?; + + app.manage(TrayState(Mutex::new(Some(TrayHandles { + icon, + menu, + device, + battery, + battery_shown: false, + })))); + Ok(()) +} + +/// One line for the menu and a shorter one for the icon tooltip. +fn battery_lines(status: &TrayDeviceStatus) -> Option<(String, String)> { + let percent = status.battery_percent?; + let state = status.battery_state.trim(); + let menu = if state.is_empty() || state.eq_ignore_ascii_case("unknown") { + format!("Battery: {percent}%") + } else { + format!("Battery: {percent}% ({state})") + }; + let tooltip = if state.eq_ignore_ascii_case("charging") + || state.eq_ignore_ascii_case("charging slowly") + || state.eq_ignore_ascii_case("almost full") + { + format!("{percent}%, charging") + } else { + format!("{percent}%") + }; + Some((menu, tooltip)) +} + +/// Rewrites the device and battery lines. Called by the frontend every time +/// its cached status changes; `None` means the device went away. +#[tauri::command] +pub fn tray_set_device_status( + state: tauri::State, + status: Option, +) -> Result<(), String> { + let mut guard = state.0.lock().map_err(|e| e.to_string())?; + let Some(handles) = guard.as_mut() else { + // The tray failed to build at startup; nothing to update. + return Ok(()); + }; + + let (device_text, battery_text, tooltip) = match &status { + Some(status) => { + let name = if status.name.trim().is_empty() { "Connected device" } else { status.name.trim() }; + match battery_lines(status) { + Some((menu, tip)) => (name.to_string(), Some(menu), format!("{APP_NAME} · {name} · {tip}")), + None => (name.to_string(), None, format!("{APP_NAME} · {name}")), + } + } + None => (NO_DEVICE.to_string(), None, APP_NAME.to_string()), + }; + + handles.device.set_text(&device_text).map_err(|e| e.to_string())?; + match (&battery_text, handles.battery_shown) { + (Some(text), _) => { + handles.battery.set_text(text).map_err(|e| e.to_string())?; + if !handles.battery_shown { + // Directly under the device line. + handles.menu.insert(&handles.battery, 1).map_err(|e| e.to_string())?; + handles.battery_shown = true; + } + } + (None, true) => { + handles.menu.remove(&handles.battery).map_err(|e| e.to_string())?; + handles.battery_shown = false; + } + (None, false) => {} + } + // Tooltip failures are cosmetic (unsupported on some Linux trays), so + // they are logged rather than failing the whole update. + if let Err(e) = handles.icon.set_tooltip(Some(&tooltip)) { + applog!("[tray] set_tooltip failed: {e}"); + } + Ok(()) +} diff --git a/src/hooks/use-mouse-connection.ts b/src/hooks/use-mouse-connection.ts index 50881b7..9e57e9c 100644 --- a/src/hooks/use-mouse-connection.ts +++ b/src/hooks/use-mouse-connection.ts @@ -36,6 +36,13 @@ import { showToast } from "../lib/toast"; // manual refresh is still running just silently skips (isHidBusyError) // rather than piling up or corrupting anything. const AUTO_REFRESH_INTERVAL_MS = 5000; +// How often to re-read while nobody can see the window (minimized, hidden to +// the tray, or just not the focused app). The tray menu shows the battery +// from the cached status (see tray.rs), and a level frozen at whatever it was +// when the window was last looked at is what Synapse's tray notably does NOT +// do. One full walk a minute is cheap enough to keep that line honest +// without paying the 5 s cadence for a panel nobody is watching. +const HIDDEN_REFRESH_INTERVAL_MS = 60_000; interface ConflictingApp { process: string; @@ -115,6 +122,9 @@ export function useMouseConnection() { // it. Assume focused until told otherwise — the event may not have fired // yet on first mount. const windowFocusedRef = useRef(true); + // When the last successful walk finished, so the hidden-window cadence + // below can be measured from real reads rather than from interval ticks. + const lastReadAtRef = useRef(0); const connect = useCallback(async (candidate: CandidateInterface, opts?: { silent?: boolean }) => { const key = candidate.info.key; @@ -126,6 +136,7 @@ export function useMouseConnection() { setConnectingKey(key); try { const device = await connectToInterface(candidate.info); + lastReadAtRef.current = Date.now(); setConnected(device); lastCandidateRef.current = candidate; rememberDevice(candidate.info, device.brand); @@ -264,15 +275,34 @@ export function useMouseConnection() { // A full readStatus() walk (5 splits opened, 20-30 HID++ round // trips) every 5s adds up if it keeps running while nobody can even // see the result — minimized, hidden to the tray, occluded, or just - // not the focused window right now. Skip the tick entirely rather - // than spend that on a window nobody's actively looking at. - if (document.hidden || !windowFocusedRef.current) return; + // not the focused window right now. Drop to the slow cadence there + // (the tray menu still shows the battery from this cache) rather + // than spend the full rate on a window nobody's actively looking at. + if (document.hidden || !windowFocusedRef.current) { + if (Date.now() - lastReadAtRef.current < HIDDEN_REFRESH_INTERVAL_MS) return; + } if (lastCandidateRef.current) void connect(lastCandidateRef.current, { silent: true }); }, AUTO_REFRESH_INTERVAL_MS); return () => clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps }, [connected?.key, connect]); + // Mirror the cached device name and battery into the tray menu (tray.rs), + // so a right-click on the tray icon shows the charge without bringing the + // window back. Keyed on the three fields the menu shows, not on `connected` + // itself, so a patchStatus() that changes only DPI doesn't re-send it. + const trayName = connected?.status.name ?? null; + const trayBattery = connected?.status.batteryPercent ?? null; + const trayBatteryState = connected?.status.batteryState ?? null; + useEffect(() => { + const status = trayName === null + ? null + : { name: trayName, batteryPercent: trayBattery, batteryState: trayBatteryState ?? "Unknown" }; + // Tray text is cosmetic — a failure here (tray failed to build at + // startup, say) shouldn't surface as a device error. + void invoke("tray_set_device_status", { status }).catch(() => {}); + }, [trayName, trayBattery, trayBatteryState]); + // Just switches back to the list — the snapshot stays cached (see module // docs above). Re-scans in the background so a newly plugged-in device // shows up, same as a manual Refresh would. diff --git a/src/native-hid/device-images.ts b/src/native-hid/device-images.ts index 12b8d26..71369db 100644 --- a/src/native-hid/device-images.ts +++ b/src/native-hid/device-images.ts @@ -185,7 +185,10 @@ export function deviceImage(key: string | null | undefined, displayName = ""): s if (/\bmx\s*anywhere\s*3\b/i.test(displayName)) return "/devices/logitech-mx-anywhere-3.png"; if (/\bmx\s*ergo\b/i.test(displayName)) return "/devices/logitech-mx-ergo-s.png"; if (/\bdeathadder\s*v4\b/i.test(displayName)) return "/devices/razer-deathadder-v4-pro.png"; - if (/\bdeathadder\s*v3\b(?!\s*pro\b)/i.test(displayName)) return "/devices/razer-deathadder-v3.png"; + // V3 and V3 Pro are one shell (the Pro drops the cable), so the Pro shares + // the V3 render like the V2 family does below. It was excluded while still + // test-needed; verified on hardware since (mouse-protocol `0x00b7`). + if (/\bdeathadder\s*v3\b/i.test(displayName)) return "/devices/razer-deathadder-v3.png"; if (/\bdeathadder\s*v2\b(?!\s*x\s*hyperspeed\b)/i.test(displayName)) return "/devices/razer-deathadder-v2.png"; if (/\bdeathadder\s*essential\b/i.test(displayName)) return "/devices/razer-deathadder-v2.png"; if (/\bviper\s*v3\s*hyperspeed\b/i.test(displayName)) return "/devices/razer-viper-v3-hyperspeed.png";