Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 45 additions & 50 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,6 +10,7 @@ mod games;
mod hid;
mod linux_permissions;
mod resource_monitor;
mod tray;
use hid::{HidApiHandle, HidRegistry};
use resource_monitor::ResourceMonitorState;

Expand Down Expand Up @@ -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<Wry>, 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()
Expand Down Expand Up @@ -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);
Expand Down
161 changes: 161 additions & 0 deletions src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
@@ -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<u8>,
/// The protocol's `MouseStatus.batteryState` label, e.g. "Charging",
/// "Discharging", "Full", "Unknown".
pub battery_state: String,
}

struct TrayHandles {
icon: TrayIcon<Wry>,
menu: Menu<Wry>,
device: MenuItem<Wry>,
battery: MenuItem<Wry>,
/// 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<Option<TrayHandles>>);

/// 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<Wry>, 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<TrayState>,
status: Option<TrayDeviceStatus>,
) -> 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(())
}
36 changes: 33 additions & 3 deletions src/hooks/use-mouse-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/native-hid/device-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down