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";