diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b67e51..a1cd6c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = "Stop" - npm install --prefix native-hid --omit=dev + npm ci --prefix native-hid --omit=dev # Trim node-hid's other-platform prebuilt bindings — this archive # only ever runs on win32-x64. Get-ChildItem native-hid/node_modules/node-hid/prebuilds -Directory | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f323ef8..1fb9423 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = "Stop" - npm install --prefix native-hid --omit=dev + npm ci --prefix native-hid --omit=dev # Trim node-hid's other-platform prebuilt bindings — this archive # only ever runs on win32-x64. Get-ChildItem native-hid/node_modules/node-hid/prebuilds -Directory | @@ -84,7 +84,7 @@ jobs: - name: Prepare native-hid (deps + bundled Node.js runtime) run: | set -euo pipefail - npm install --prefix native-hid --omit=dev + npm ci --prefix native-hid --omit=dev # Trim node-hid's non-macOS prebuilt bindings; keep both Apple # silicon and Intel since the Bridge binary itself is universal. find native-hid/node_modules/node-hid/prebuilds -mindepth 1 -maxdepth 1 -type d \ @@ -131,7 +131,7 @@ jobs: - name: Prepare native-hid (deps + bundled Node.js runtime) run: | set -euo pipefail - npm install --prefix native-hid --omit=dev + npm ci --prefix native-hid --omit=dev # Trim node-hid's non-Linux prebuilt bindings; this archive only # ever runs on linux-x64. find native-hid/node_modules/node-hid/prebuilds -mindepth 1 -maxdepth 1 -type d \ diff --git a/README.md b/README.md index 4600a10..8e11ba9 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,9 @@ enumeration details. ## Loopback API -- `GET /v1/status` reports the Bridge version, platform, active games, battery +- `GET /v1/status` reports the Bridge version, platform, active games, the + devices the control panel currently holds open over native HID + (`activeDevices`, most recently opened first), battery levels, battery threshold, autostart and automatic-update preferences, and whether an OpenMouse client has completed a recent handshake. - `PUT /v1/handshake` renews OpenMouse's 20-second connection lease. The client diff --git a/native-hid/package-lock.json b/native-hid/package-lock.json index 806d57a..1c4e773 100644 --- a/native-hid/package-lock.json +++ b/native-hid/package-lock.json @@ -8,6 +8,7 @@ "name": "openmouse-bridge-native-hid", "version": "0.1.0", "dependencies": { + "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.10.0", "node-hid": "^3.1.0" }, @@ -15,6 +16,14 @@ "node": ">=20" } }, + "node_modules/@openmouse/keyboard-protocol": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/OpenMouse-Project/keyboard-protocol.git#cae3d7a74e8256a9a3cc5c7fe2311db1425bb977", + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=20" + } + }, "node_modules/@openmouse/protocol": { "version": "0.1.0", "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#b69cd44b2498dcaaa65fd6f77ba10bf109e49322", diff --git a/native-hid/package.json b/native-hid/package.json index b35e2dc..222c01b 100644 --- a/native-hid/package.json +++ b/native-hid/package.json @@ -12,10 +12,12 @@ }, "dependencies": { "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.10.0", - "@openmouse/keyboard-protocol": "file:../../../keyboard-protocol", + "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", "node-hid": "^3.1.0" }, "allowScripts": { + "github:OpenMouse-Project/keyboard-protocol#v1.1.0": true, + "github:OpenMouse-Project/keyboard-protocol#cae3d7a74e8256a9a3cc5c7fe2311db1425bb977": true, "github:OpenMouse-Project/mouse-protocol#v0.10.0": true, "node-hid@3.4.0": true, "github:OpenMouse-Project/mouse-protocol#b69cd44b2498dcaaa65fd6f77ba10bf109e49322": true diff --git a/src/desktop.rs b/src/desktop.rs index 224fd2c..da508b6 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -655,9 +655,15 @@ impl TrayApp { }); let device_name = snapshot.and_then(|snapshot| { snapshot - .batteries + .active_devices .first() - .map(|battery| battery.device_name.clone()) + .map(|device| device.name.clone()) + .or_else(|| { + snapshot + .batteries + .first() + .map(|battery| battery.device_name.clone()) + }) .or_else(|| { snapshot .active_profile @@ -687,7 +693,6 @@ impl TrayApp { divider(ui); match snapshot.map(|snapshot| snapshot.batteries.as_slice()) { Some(batteries) if !batteries.is_empty() => { - let single = batteries.len() == 1; for battery in batteries.iter().take(MAX_BATTERY_ROWS) { let value = if battery.charging { format!("{}% · Charging", battery.percent) @@ -701,12 +706,9 @@ impl TrayApp { } else { TEXT }; - let label = if single { - "Battery" - } else { - battery.device_name.as_str() - }; - value_row(ui, label, &value, color); + // Battery history can belong to a different mouse than + // the active HID device, even when there is only one row. + value_row(ui, &battery.device_name, &value, color); divider(ui); } } diff --git a/src/hid.rs b/src/hid.rs index 7b9e5b2..8345370 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -2,7 +2,7 @@ use std::{ collections::{HashMap, HashSet}, ffi::CString, sync::{ - Arc, Mutex, PoisonError, + Arc, Mutex, OnceLock, PoisonError, atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, }, thread, @@ -26,6 +26,90 @@ const RAZER_VENDOR_ID: u16 = 0x1532; const RAZER_FEATURE_BUFFER_LEN: usize = 91; static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); static ACTIVE_SESSIONS: AtomicUsize = AtomicUsize::new(0); +/// Monotonic touch order for open HID devices, so the most recently opened +/// device sorts first without relying on clock resolution. +static ACTIVE_DEVICE_SEQ: AtomicU64 = AtomicU64::new(1); + +/// A HID device the control panel currently holds open, as the status view +/// sees it. Device keys are allocated per websocket session (`hid-N`), so the +/// registry is keyed by session: one panel tab re-listing must not clear +/// another tab's open device. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveDeviceInfo { + pub key: String, + pub name: String, + pub vendor_id: u16, + pub product_id: u16, +} + +struct ActiveDeviceEntry { + info: ActiveDeviceInfo, + seq: u64, +} + +static ACTIVE_DEVICES: OnceLock>>> = + OnceLock::new(); + +fn active_device_registry() -> &'static Mutex>> { + ACTIVE_DEVICES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn note_device_opened(session_id: u64, summary: &DeviceSummary) { + let mut registry = active_device_registry() + .lock() + .unwrap_or_else(PoisonError::into_inner); + registry.entry(session_id).or_default().insert( + summary.key.clone(), + ActiveDeviceEntry { + info: ActiveDeviceInfo { + key: summary.key.clone(), + name: summary.product_name.clone(), + vendor_id: summary.vendor_id, + product_id: summary.product_id, + }, + seq: ACTIVE_DEVICE_SEQ.fetch_add(1, Ordering::Relaxed), + }, + ); +} + +fn note_device_closed(session_id: u64, key: &str) { + let mut registry = active_device_registry() + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(devices) = registry.get_mut(&session_id) { + devices.remove(key); + if devices.is_empty() { + registry.remove(&session_id); + } + } +} + +fn note_session_ended(session_id: u64) { + active_device_registry() + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&session_id); +} + +/// The devices the control panel currently holds open over native HID, most +/// recently opened first. Empty when no HID session has a device open — e.g. +/// a wired mouse that was never opened still leaves no trace here, which is +/// why callers fall back to the battery list and the active profile. +pub fn active_devices() -> Vec { + let registry = active_device_registry() + .lock() + .unwrap_or_else(PoisonError::into_inner); + let mut devices = registry + .values() + .flat_map(|devices| devices.values()) + .collect::>(); + devices.sort_by_key(|entry| std::cmp::Reverse(entry.seq)); + devices + .into_iter() + .map(|entry| entry.info.clone()) + .collect() +} #[derive(Debug, Deserialize)] struct ClientMessage { @@ -431,13 +515,19 @@ impl HidSession { return Err("too many HID vendor ids were requested".into()); } let vendors: HashSet = vendor_ids.into_iter().collect(); - if vendors.is_empty() { - return Ok(Vec::new()); - } self.api .refresh_devices() .map_err(|error| format!("could not refresh HID devices: {error}"))?; + // Reconcile against the full inventory, not the requested vendor filter: + // a picker scanning one brand must not close another brand's device. + let present = self.api.device_list().map(device_group_key).collect(); + self.reconcile_inventory(&present)?; + + if vendors.is_empty() { + return Ok(Vec::new()); + } + let mut by_device = HashMap::, Vec>::new(); for info in self.api.device_list() { if vendors.contains(&info.vendor_id()) { @@ -479,8 +569,29 @@ impl HidSession { Ok(summaries) } + fn reconcile_inventory(&mut self, present: &HashSet>) -> Result<(), String> { + let removed = self + .device_keys + .iter() + .filter(|(identity, _)| !present.contains(*identity)) + .map(|(identity, key)| (identity.clone(), key.clone())) + .collect::>(); + for (identity, key) in removed { + self.close(&key)?; + self.listeners.remove(&key); + self.candidates.remove(&key); + self.device_keys.remove(&identity); + } + Ok(()) + } + fn open(&mut self, key: &str) -> Result<(), String> { if self.open_devices.contains_key(key) { + // Re-selecting an already-open device still makes it the current + // one for status purposes. + if let Some(candidate) = self.candidates.get(key) { + note_device_opened(self.session_id, &candidate.summary); + } return Ok(()); } let candidate = self @@ -528,6 +639,9 @@ impl HidSession { if self.listeners.contains(key) { self.start_reader(key)?; } + if let Some(candidate) = self.candidates.get(key) { + note_device_opened(self.session_id, &candidate.summary); + } Ok(()) } @@ -536,6 +650,7 @@ impl HidSession { return Ok(()); }; open.stop_reader(); + note_device_closed(self.session_id, key); Ok(()) } @@ -1171,6 +1286,7 @@ pub async fn serve(mut socket: WebSocket) { } drop(session); + note_session_ended(session_id); tracing::info!(session_id, "HID websocket disconnected"); } @@ -1265,6 +1381,116 @@ mod tests { assert!(report_payload(&full, 0).is_empty()); } + #[test] + fn active_devices_prefer_the_most_recently_opened_device() { + // A session id no other test uses, so the shared registry is ours. + let session = u64::MAX - 41_137; + note_session_ended(session); + let summary = |key: &str, name: &str| DeviceSummary { + key: key.into(), + vendor_id: RAZER_VENDOR_ID, + product_id: 0x00b0, + product_name: name.into(), + collections: Vec::new(), + }; + note_device_opened(session, &summary("hid-1", "PRO SUPERLIGHT 2C")); + note_device_opened(session, &summary("hid-2", "DeathAdder V3")); + let names = active_devices() + .iter() + .map(|device| device.name.clone()) + .collect::>(); + let newer = names.iter().position(|name| name == "DeathAdder V3"); + let older = names.iter().position(|name| name == "PRO SUPERLIGHT 2C"); + assert!(newer.is_some() && older.is_some() && newer < older); + + // Closing the current device falls back to the previous one, and a + // dead session leaves no stale entry behind. + note_device_closed(session, "hid-2"); + let names = active_devices() + .iter() + .map(|device| device.name.clone()) + .collect::>(); + assert!(!names.iter().any(|name| name == "DeathAdder V3")); + assert!(names.iter().any(|name| name == "PRO SUPERLIGHT 2C")); + note_session_ended(session); + assert!( + active_devices() + .iter() + .all(|device| device.name != "PRO SUPERLIGHT 2C") + ); + } + + #[test] + fn inventory_removal_cleans_handles_and_preserves_other_sessions() { + let session_id = u64::MAX - 41_138; + let other_session_id = u64::MAX - 41_139; + let (input_tx, _) = mpsc::unbounded_channel(); + let mut session = HidSession::new(session_id, input_tx).unwrap(); + for (key, vendor_id, name) in [ + ("hid-1", RAZER_VENDOR_ID, "unplugged-test-mouse"), + ("hid-2", LOGITECH_VENDOR_ID, "remaining-test-mouse"), + ] { + let summary = DeviceSummary { + key: key.into(), + vendor_id, + product_id: 1, + product_name: name.into(), + collections: Vec::new(), + }; + session + .device_keys + .insert(key.as_bytes().to_vec(), key.into()); + session.candidates.insert( + key.into(), + Candidate { + paths: Vec::new(), + summary: summary.clone(), + }, + ); + session.open_devices.insert( + key.into(), + OpenDevice { + paths: Vec::new(), + vendor_id, + listening: false, + stop: None, + readers: Vec::new(), + }, + ); + session.listeners.insert(key.into()); + note_device_opened(session_id, &summary); + if key == "hid-1" { + note_device_opened(other_session_id, &summary); + } + } + + session + .reconcile_inventory(&HashSet::from([b"hid-2".to_vec()])) + .unwrap(); + assert!(!session.open_devices.contains_key("hid-1")); + assert!(!session.candidates.contains_key("hid-1")); + assert!(!session.listeners.contains("hid-1")); + assert!(!session.device_keys.contains_key(b"hid-1".as_slice())); + assert!(session.open("hid-1").is_err()); + assert!(session.open_devices.contains_key("hid-2")); + { + let registry = active_device_registry().lock().unwrap(); + assert!(!registry[&session_id].contains_key("hid-1")); + assert!(registry[&session_id].contains_key("hid-2")); + assert!(registry[&other_session_id].contains_key("hid-1")); + } + // Even an empty vendor filter must refresh the inventory and retire + // the remaining synthetic device, while returning no devices. + assert!(session.list(Vec::new()).unwrap().is_empty()); + assert!( + !active_device_registry() + .lock() + .unwrap() + .contains_key(&session_id) + ); + note_session_ended(other_session_id); + } + #[test] fn descriptor_preserves_nested_collections() { let descriptor = [ diff --git a/src/service.rs b/src/service.rs index 78610f8..5a21bf2 100644 --- a/src/service.rs +++ b/src/service.rs @@ -280,6 +280,20 @@ pub struct DeviceBattery { pub stale: bool, } +/// A mouse the control panel currently holds open over native HID, most +/// recently opened first. Unlike the battery list — which accumulates every +/// device that ever reported a charge level — this reflects the live HID +/// session, so the status view can follow device switches (including wired +/// mice that never report battery). +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveDevice { + pub key: String, + pub name: String, + pub vendor_id: u16, + pub product_id: u16, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameActivity { @@ -308,6 +322,7 @@ pub struct BridgeSnapshot { pub visible_application_count: usize, pub profile_count: usize, pub client_connected: bool, + pub active_devices: Vec, pub batteries: Vec, } @@ -391,6 +406,15 @@ impl BridgeService { }) .collect::>(); batteries.sort_by(|a, b| a.device_id.cmp(&b.device_id)); + let active_devices = crate::hid::active_devices() + .into_iter() + .map(|device| ActiveDevice { + key: device.key, + name: device.name, + vendor_id: device.vendor_id, + product_id: device.product_id, + }) + .collect::>(); BridgeSnapshot { version: crate::BRIDGE_VERSION, platform: platform::platform_name(), @@ -418,6 +442,7 @@ impl BridgeService { visible_application_count: state.applications.len(), profile_count: state.config.profiles.len(), client_connected, + active_devices, batteries, } }