Skip to content
Draft
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
4 changes: 4 additions & 0 deletions Cargo.lock

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

11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sysinfo = "0.37"
tokio = { version = "1.47", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tokio = { version = "1.47", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tower-http = { version = "0.6", features = ["cors", "set-header", "trace"] }
tracing = "0.1"
tracing-appender = "0.2"
Expand All @@ -48,6 +48,8 @@ gtk = "0.18"
# X11 overlay banner and foreground-window lookup. Pure Rust; no system
# libraries beyond what eframe/tray-icon already require.
x11rb = "0.13"
# Virtual keyboard and mouse for the button listener (/dev/uinput).
libc = "0.2"

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
Expand All @@ -70,6 +72,9 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [
"NSWorkspace",
] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSArray", "NSData", "NSGeometry", "NSString"] }
# Posts the key and button events the button listener presses.
objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFCGTypes"] }
objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["std", "CGEvent", "CGEventTypes", "CGRemoteOperation"] }

[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = [
Expand All @@ -82,6 +87,10 @@ windows-sys = { version = "0.61", features = [
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,16 @@ enumeration details.
OpenMouse client has completed a recent handshake.
- `PUT /v1/handshake` renews OpenMouse's 20-second connection lease. The client
sends this heartbeat every five seconds while connected.
- `GET /v1/games` returns the full executable catalog currently being tracked.
- `GET /v1/games` returns the full executable catalog currently being tracked,
including the user's custom games.
- `GET /v1/custom-games` returns the games the user added by hand;
`PUT /v1/custom-games` replaces them (`[{ name, executables }]`), persists
them under `customGames` in `config.json` and answers with the saved list.
They live apart from the catalog, which is replaced on every start.
- `GET /v1/custom-games/image?name=…` returns the image saved for a custom
game (404 when it has none); `PUT` with a PNG, JPEG or WebP body (at most
512 KB) saves one. Images live in a `custom-game-images` folder beside
`config.json` and go away with the game.
- `GET /v1/applications` lists running games and identifies the foreground
game. Only applications from the registered catalog are
returned. Each item includes an `iconId`; requesting
Expand Down
9 changes: 8 additions & 1 deletion install-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ write_udev_rule() {
for vid in $VENDOR_IDS; do
printf 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="%s", MODE="0666", TAG+="uaccess"\n' "$vid"
done
echo "# The button listener presses keys and side buttons through a virtual keyboard and mouse."
# No SUBSYSTEM match here: the installer test counts those, one per vendor id.
echo 'KERNEL=="uinput", MODE="0660", TAG+="uaccess", OPTIONS+="static_node=uinput"'
} >"$path"
}

Expand Down Expand Up @@ -280,7 +283,7 @@ do_uninstall() {
if [ "$sudo" = "none" ]; then
die "cannot remove $rule without root; delete it manually"
fi
$sudo rm -f "$rule"
$sudo rm -f "$rule" /etc/modules-load.d/openmouse-bridge.conf
$sudo udevadm control --reload-rules 2>/dev/null || true
fi
update_desktop_database
Expand Down Expand Up @@ -419,8 +422,12 @@ main() {
log "Installing hidraw udev rule..."
write_udev_rule "$tmp/69-openmouse-bridge.rules"
$sudo cp "$tmp/69-openmouse-bridge.rules" "$rule"
# /dev/uinput only exists once the uinput module is loaded.
echo uinput | $sudo tee /etc/modules-load.d/openmouse-bridge.conf >/dev/null
$sudo modprobe uinput 2>/dev/null || true
$sudo udevadm control --reload-rules 2>/dev/null || true
$sudo udevadm trigger --subsystem-match=hidraw --action=change 2>/dev/null || true
$sudo udevadm trigger --subsystem-match=misc --sysname-match=uinput --action=change 2>/dev/null || true
log "Unplug and replug the mouse (or reboot) so the new rule applies."
fi
else
Expand Down
10 changes: 10 additions & 0 deletions native-hid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ fall through to the generic `AttackSharkHidClient` path, which drives the 0x04
six-stage table for model `0x55` (mouse-protocol #174), preserving the stages
the request does not touch.

## Button listener

`button-listener.mjs` is the one long-running helper. While a game's profile assigns
the side buttons of a Logitech mouse, Bridge (`src/buttons.rs`) starts it with
`{"brand": "Logitech"}` on stdin. It puts the mouse on the Host profile if it was
on an onboard one, turns the mouse's button events on, and prints
`{"event": "buttons", "mask": 16}` lines; Bridge presses the assigned key. Closing
stdin stops it: the events go off and the mouse returns to the mode it was in.
It needs an `@openmouse/protocol` release that has `startButtonListener`.

## Exit code contract (Bridge depends on this — see `src/drivers/native_hid.rs`)

- `0` — a device was found and the requested settings were applied. In
Expand Down
159 changes: 159 additions & 0 deletions native-hid/src/button-listener.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env node
// Long-running helper Bridge spawns while a game profile has button
// assignments for a Logitech mouse running from software (Host mode).
//
// stdin: one JSON line to start, e.g. {"brand": "Logitech", "device": "PRO X 3 SUPERSTRIKE"}
// ("device" picks that mouse when several of the brand are connected); add
// "requireHost": true to listen only to a mouse already on Host. Closing stdin (or
// SIGTERM/SIGINT) stops it: the listener is switched off and the mouse goes back
// to the mode it was in, if this helper changed it.
// stdout: one JSON object per line.
// {"event": "ready", "priorMode": "Onboard", "buttons": [1, 2, 3, 4, 5]}
// {"event": "buttons", "mask": 16} — the buttons held right now
// {"event": "error", "message": "..."}
// {"event": "stopped"}
//
// Exit codes: 0 stopped cleanly, 3 no driver for the brand, 1 anything else.
// See apply.mjs for why `window` is aliased before any driver is imported.
globalThis.window ??= globalThis;

import { createInterface } from "node:readline";

import { BRAND_DRIVERS, brandKey } from "./brands.mjs";
import { candidateDevices } from "./hid-device-adapter.mjs";

const EXIT_OK = 0;
const EXIT_FAILED = 1;
const EXIT_NO_DRIVER = 3;
const STEP_TIMEOUT_MS = 3000;

const emit = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);

function withTimeout(promise, ms, label) {
let timer;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

/** Opens a candidate class on one device and reads its status, or rejects and closes it. */
async function probe(device, candidate) {
const module = await import(candidate.module);
const client = new module[candidate.exportName](device);
try {
await withTimeout(client.open(), STEP_TIMEOUT_MS, `${candidate.exportName}.open()`);
const status = await withTimeout(client.readStatus(), STEP_TIMEOUT_MS, `${candidate.exportName}.readStatus()`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/button-listener.mjs:46

Valid Logitech mice that answer only on HID++ index 0x01 are rejected as “no mouse answered”: this 3-second timeout closes the device before readStatus() can finish its fallback from 0xFF, even though each probe allows 6 seconds. Let discovery complete within the driver's full budget, or use a suitably bounded listener-specific probe.

🤖 Copy this AI Prompt to have your agent fix this:
In file @native-hid/src/button-listener.mjs around line 46:

Valid Logitech mice that answer only on HID++ index `0x01` are rejected as “no mouse answered”: this 3-second timeout closes the device before `readStatus()` can finish its fallback from `0xFF`, even though each probe allows 6 seconds. Let discovery complete within the driver's full budget, or use a suitably bounded listener-specific probe.

return { client, status };
} catch (error) {
await device.close().catch(() => undefined);
throw error;
}
}

// `wantedName` is the mouse the profile names. With several mice of the brand
// connected only that one is used; with a single one it is used whatever its
// name, so a name spelled differently never leaves it without a listener.
async function findClient(entry, wantedName) {
const attempts = [];
const others = [];
const closeOthers = () => Promise.all(others.map((other) => other.client.close().catch(() => undefined)));
for (const vendorId of entry.vendorIds) {
for (const device of candidateDevices(vendorId)) {
for (const candidate of entry.classes) {
try {
const found = await probe(device, candidate);
// The listener only exists on a client built from a protocol release that has it.
if (typeof found.client.startButtonListener !== "function") {
await found.client.close().catch(() => undefined);
attempts.push(`${candidate.exportName}: this protocol version has no button listener`);
continue;
}
if (!wantedName || String(found.status?.name ?? "").toLowerCase() === wantedName.toLowerCase()) {
await closeOthers();
return found;
}
others.push(found);
break;
} catch (error) {
attempts.push(`${candidate.exportName} on ${device.productName || "unknown device"}: ${error.message}`);
}
}
}
}
if (others.length === 1) return others[0];
await closeOthers();
if (others.length > 1) throw new Error(`none of the ${others.length} connected mice is named "${wantedName}"`);
throw new Error(`no mouse answered. ${attempts.join("; ")}`);
}

async function main(input) {
const entry = BRAND_DRIVERS[brandKey(typeof input.brand === "string" ? input.brand : "")];
if (!entry) {
emit({ event: "error", message: `no driver registered for brand "${input.brand}"` });
process.exit(EXIT_NO_DRIVER);
}

const { client, status } = await findClient(entry, typeof input.device === "string" ? input.device.trim() : "");
const priorMode = status?.deviceMode ?? "Unknown";
// The default profile only listens to a mouse already on Host; it never
// switches one over, so a mouse on an onboard profile is left alone.
if (input.requireHost === true && priorMode !== "Host") {
emit({ event: "error", message: "the mouse is not on the Host profile" });
await client.close().catch(() => undefined);
process.exit(EXIT_FAILED);
}
let changedMode = false;
let stopping = false;

// `code` is how the helper ends: a failed start is a failure, not a clean stop.
async function shutdown(code = EXIT_OK) {
if (stopping) return;
stopping = true;
await withTimeout(client.stopButtonListener(), STEP_TIMEOUT_MS, "stopButtonListener").catch(() => undefined);
if (changedMode) {
await withTimeout(client.setOnboardMode(priorMode), STEP_TIMEOUT_MS, "setOnboardMode").catch(() => undefined);
}
await client.close().catch(() => undefined);
emit({ event: "stopped" });
process.exit(code);
}

// Event handlers pass their own arguments (a signal name), which are not an exit code.
const stop = () => void shutdown();
process.stdin.on("end", stop);
process.stdin.on("close", stop);
process.on("SIGTERM", stop);
process.on("SIGINT", stop);

try {
// The events only flow while the mouse runs from software; an unknown mode
// is left alone and the listener is tried as it is.
if (priorMode === "Onboard") {
// Marked first: the mouse may switch and then fail to confirm it, and it still has to go back.
changedMode = true;
await withTimeout(client.setOnboardMode("Host"), STEP_TIMEOUT_MS, "setOnboardMode");
}
client.onButtonChange((mask) => emit({ event: "buttons", mask }));
const buttons = await withTimeout(client.startButtonListener(), STEP_TIMEOUT_MS, "startButtonListener");
emit({ event: "ready", priorMode, buttons });
} catch (error) {
emit({ event: "error", message: error.message });
await shutdown(EXIT_FAILED);
}
}

const lines = createInterface({ input: process.stdin });
lines.once("line", (line) => {
let input;
try {
input = JSON.parse(line);
} catch {
emit({ event: "error", message: "the first line on stdin must be JSON" });
process.exit(EXIT_FAILED);
}
main(input).catch((error) => {
emit({ event: "error", message: error.message });
process.exit(EXIT_FAILED);
});
});
Loading
Loading