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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "openmouse-bridge"
version = "1.0.0-beta.18"
version = "1.0.0-beta.19"
edition = "2024"
description = "Local native HID and desktop companion for the OpenMouse control panel"
license = "MIT"
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,33 @@ portable so additional desktop adapters do not require changes to the web app.

The initial service provides:

- a compact tray panel for status, startup, battery, and update controls;
- a compact tray panel for status, startup, battery, and update controls,
with a device summary row set showing the mouse's current DPI, polling
rate, and switch mode as read from its onboard memory;
- a live tray icon that shows the connected mouse's battery, drawn square at
the taskbar's real pixel size with either the native Segoe Fluent Icons
battery glyph (Segoe MDL2 Assets on Windows 10) or the percentage number in
bold text — a control-panel choice, since the Windows 11 taskbar only
renders square tray icons faithfully — tinted by charge state: green while
charging (the variant with the built-in bolt) or above half, amber through
the mid band, alert red at or below the low-battery threshold, gray when
the reading is stale, and a level glyph for every 10% — with a tooltip that
spells everything out — `Device — 87% · Charging · full in 45 m` —
including the estimated time remaining from the recent trend;
- process-based detection for configured game executables;
- startup discovery: Bridge scans the HID bus for mice whose brands it has
drivers for (the same vendor table the native-hid helper drives — Pulsar,
Logitech, Razer, Delux, VGN, and more), so the tray shows the battery from
the first second — no web panel connection and no saved profile required;
- hybrid device mode (toggle in the panel): while the OpenMouse web app is
disconnected, Bridge drives the mouse itself in onboard memory mode — the
active profile's stored DPI, polling rate, and switch mode are pushed
natively (switch mode wherever a driver supports it, e.g. Logitech
LightForce), and the battery is read directly (including a native Pulsar
battery reader) — and the moment the web app connects, it takes the mouse
over live, with Bridge restoring the stored settings and reading the
battery again on disconnect; without a saved profile Bridge leaves the
mouse's own onboard memory untouched and just monitors it;
- discovery of visible applications and the foreground application (on Linux, foreground detection needs X11; on Wayland every entry reports `foreground: false`);
- persistent application profiles tied to a specific mouse;
- low-battery notifications with a configurable threshold and cooldown, from
Expand Down
56 changes: 54 additions & 2 deletions native-hid/src/apply.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
// Usage: node apply.mjs < request.json
// stdin: {"brand": "Razer", "dpi": 800, "pollingRateHz": 1000}
// (dpi / pollingRateHz are each optional — omit to leave that setting alone)
// stdin: {"brand": "Razer", "dpi": 800, "settings": {"lightforceSwitchMode": "Optical"}}
// (settings carries the panel's stored MouseStatus fields; ones the driver
// supports are applied, the rest are reported and skipped)
// stdin: {"brand": "Razer", "action": "status"}
// reads the mouse's status instead and prints its battery fields as JSON on
// stdout: {"batteryPercent": 41, "batteryState": "Discharging"}
// reads the mouse's status instead and prints it as JSON on stdout:
// {"batteryPercent": 41, "batteryState": "Discharging",
// "deviceName": "G Pro X Superlight 2", "dpi": 800, "pollingRateHz": 1000,
// "switchMode": "Optical"} (settings fields null when unreadable)
//
// Exit codes (Bridge's src/drivers/native_hid.rs depends on this contract):
// 0 — a device was found and the requested settings were applied (or, in
Expand Down Expand Up @@ -138,6 +143,16 @@ async function main() {
JSON.stringify({
batteryPercent: status?.batteryPercent ?? null,
batteryState: status?.batteryState ?? null,
// The driver's own model name (MouseStatus.name) — better than the
// receiver-level name Bridge's discovery gets from the HID
// descriptor (a LIGHTSPEED receiver reports "USB Receiver").
deviceName: status?.name ?? null,
// The settings currently in the device's onboard memory, for the
// tray panel's device summary. MouseStatus reports them whenever
// the driver can read them.
dpi: status?.dpi ?? null,
pollingRateHz: status?.pollingRateHz ?? null,
switchMode: status?.lightforceSwitchMode ?? null,
}),
);
await client.close().catch(() => undefined);
Expand Down Expand Up @@ -205,6 +220,43 @@ async function main() {
process.exit(EXIT_FAILED);
}

// ── Panel snapshot fields ────────────────────────────────────────────
// Bridge stores the panel's opaque MouseStatus fields alongside the
// numeric settings and hands them back here for onboard-memory mode.
// Only fields with a feature-detected driver method are applied; the
// rest are reported and skipped.
if (input.settings && typeof input.settings === "object" && !Array.isArray(input.settings)) {
const settings = input.settings;
const skipped = [];
if (
typeof settings.lightforceSwitchMode === "string" &&
typeof client.setLightforceSwitchMode === "function"
) {
try {
await withTimeout(
client.setLightforceSwitchMode(settings.lightforceSwitchMode),
PROBE_TIMEOUT_MS,
"setLightforceSwitchMode",
);
} catch (error) {
console.error(`[native-hid] could not apply lightforceSwitchMode: ${error.message}`);
await client.close().catch(() => undefined);
process.exit(EXIT_FAILED);
}
} else if (typeof settings.lightforceSwitchMode === "string") {
skipped.push("lightforceSwitchMode");
}
const known = ["lightforceSwitchMode"];
for (const field of Object.keys(settings)) {
if (!known.includes(field) && settings[field] !== null && settings[field] !== undefined) {
skipped.push(field);
}
}
if (skipped.length > 0) {
console.error(`[native-hid] no native support for: ${skipped.join(", ")} — skipped.`);
}
}

await client.close().catch(() => undefined);
process.exit(EXIT_APPLIED);
}
Expand Down
36 changes: 36 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ pub struct BridgeConfig {
pub notification_sound: bool,
#[serde(default = "default_notification_volume")]
pub notification_volume: u8,
/// What the tray battery icon shows: `"glyph"` (the native battery icon)
/// or `"percentage"` (just the number). Other values read as `"glyph"`.
#[serde(default = "default_tray_battery_style")]
pub tray_battery_style: String,
/// Hybrid device mode: while the OpenMouse web panel is disconnected,
/// Bridge drives the mouse itself — the active profile's stored settings
/// (DPI, polling rate, switch mode) go to the onboard memory and the
/// battery is read natively — and hands the mouse over to the panel the
/// moment it connects, applying the stored settings again on disconnect.
#[serde(default = "enabled_by_default")]
pub hybrid_mode: bool,
#[serde(default)]
pub games: Vec<GameConfig>,
/// Games the user added by hand. Kept apart from `games` because the
Expand Down Expand Up @@ -184,6 +195,8 @@ impl Default for BridgeConfig {
automatic_updates: false,
notification_sound: true,
notification_volume: DEFAULT_NOTIFICATION_VOLUME,
tray_battery_style: default_tray_battery_style(),
hybrid_mode: true,
games: Vec::new(),
custom_games: Vec::new(),
local_applications: Vec::new(),
Expand All @@ -201,6 +214,13 @@ impl BridgeConfig {
self.battery_threshold_percent = self.battery_threshold_percent.min(100);
self.notification_volume = self.notification_volume.min(100);
self.alert_cooldown_minutes = self.alert_cooldown_minutes.max(1);
// Only the two known tray styles are honored; anything else (a
// hand-edited config, an older panel) reads as the default glyph.
self.tray_battery_style = match self.tray_battery_style.trim().to_ascii_lowercase().as_str()
{
"percentage" => "percentage".to_owned(),
_ => "glyph".to_owned(),
};
normalize_games(&mut self.games, &CATALOG_LIMITS);
normalize_games(&mut self.custom_games, &CUSTOM_GAME_LIMITS);
for profile in &mut self.profiles {
Expand Down Expand Up @@ -423,6 +443,10 @@ const fn default_notification_volume() -> u8 {
DEFAULT_NOTIFICATION_VOLUME
}

fn default_tray_battery_style() -> String {
"glyph".to_owned()
}

fn default_origins() -> Vec<String> {
vec![
"https://control.openmouse.app".to_owned(),
Expand Down Expand Up @@ -581,6 +605,8 @@ mod tests {
notification_sound: true,
notification_volume: 180,
custom_games: Vec::new(),
tray_battery_style: " percentage ".to_owned(),
hybrid_mode: true,
profiles: Vec::new(),
default_profile: None,
allowed_origins: Vec::new(),
Expand All @@ -592,6 +618,7 @@ mod tests {
assert_eq!(config.battery_threshold_percent, 100);
assert_eq!(config.alert_cooldown_minutes, 1);
assert_eq!(config.notification_volume, 100);
assert_eq!(config.tray_battery_style, "percentage");
let valorant = config
.games
.iter()
Expand Down Expand Up @@ -630,6 +657,15 @@ mod tests {
assert_eq!(config.custom_games[0].executables, ["mygame.exe"]);
}

#[test]
fn hybrid_mode_defaults_to_on_and_normalizes_to_a_bool() {
let config: BridgeConfig = serde_json::from_str("{}").expect("empty config parses");
assert!(config.hybrid_mode);
let config: BridgeConfig =
serde_json::from_str(r#"{"hybridMode": false}"#).expect("off config parses");
assert!(!config.hybrid_mode);
}

#[test]
fn profile_settings_keep_the_snapshot_and_accept_profiles_without_one() {
let json =
Expand Down
Loading
Loading