diff --git a/src/api.rs b/src/api.rs index 428235a..e92039f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -5,7 +5,7 @@ use axum::{ body::Body, extract::{Path, State, ws::WebSocketUpgrade}, http::{HeaderMap, HeaderValue, Method, Response, StatusCode, header}, - routing::{get, put}, + routing::{delete, get, put}, }; use serde::{Deserialize, Serialize}; use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer}; @@ -53,7 +53,7 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { ); let cors = CorsLayer::new() .allow_origin(allowed) - .allow_methods([Method::GET, Method::PUT]) + .allow_methods([Method::GET, Method::PUT, Method::DELETE]) .allow_headers([axum::http::header::CONTENT_TYPE]) .max_age(Duration::from_secs(3600)); Router::new() @@ -67,6 +67,7 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .route("/v1/default-profile", put(set_default_profile)) .route("/v1/native/settings", put(apply_native_settings)) .route("/v1/battery", put(record_battery)) + .route("/v1/battery/{device_id}", delete(forget_battery)) .route("/v1/autostart", put(set_autostart)) .route("/v1/panel", put(show_panel)) .merge(hid_route) @@ -180,6 +181,14 @@ async fn record_battery( Ok(Json(ApiResult { ok: true })) } +async fn forget_battery( + State(service): State, + Path(device_id): Path, +) -> Json { + service.forget_battery(&device_id).await; + Json(ApiResult { ok: true }) +} + async fn set_autostart( Json(payload): Json, ) -> Result, (StatusCode, String)> { diff --git a/src/desktop.rs b/src/desktop.rs index c429c7a..7cd238f 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -254,6 +254,7 @@ enum BridgeCommand { CheckForUpdates, InstallUpdate(UpdateInfo), AcknowledgeTrayHint, + ForgetBattery(String), } enum Startup { @@ -774,7 +775,21 @@ impl TrayApp { }; // 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); + if battery.stale { + row(ui, &battery.device_name, |ui| { + if link_button(ui, "Remove", MUTED) { + let _ = self + .commands + .send(BridgeCommand::ForgetBattery(battery.device_id.clone())); + } + ui.add( + egui::Label::new(RichText::new(&value).size(12.0).color(color)) + .truncate(), + ); + }); + } else { + value_row(ui, &battery.device_name, &value, color); + } divider(ui); } } @@ -1398,6 +1413,9 @@ fn run_server( BridgeCommand::InstallUpdate(update) => { install_update(update, &update_events).await; } + BridgeCommand::ForgetBattery(device_id) => { + command_service.forget_battery(&device_id).await; + } BridgeCommand::AcknowledgeTrayHint => { if let Err(error) = command_service.acknowledge_tray_hint().await { tracing::error!(%error, "could not save Bridge settings"); diff --git a/src/service.rs b/src/service.rs index f3e51ac..32b080e 100644 --- a/src/service.rs +++ b/src/service.rs @@ -78,6 +78,22 @@ const BATTERY_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); /// A reading older than this is shown as stale: more than two missed polls. const BATTERY_STALE_AFTER: Duration = Duration::from_secs(12 * 60); +/// A reading this old belongs to a mouse that is gone, not just asleep: it is +/// dropped from the list instead of lingering as a ghost device. +const BATTERY_EVICT_AFTER: Duration = Duration::from_secs(24 * 60 * 60); + +/// Drops battery entries that can no longer describe a live mouse: anything +/// past `BATTERY_EVICT_AFTER`, and anything outside the saved profiles that +/// has also gone stale (a renamed or removed device, or an id a client pushed +/// once and never again). +fn prune_batteries(battery: &mut HashMap, known: &[(String, String)]) { + battery.retain(|device_id, entry| { + let age = entry.updated_at.elapsed(); + age < BATTERY_EVICT_AFTER + && (age < BATTERY_STALE_AFTER || known.iter().any(|(id, _)| id == device_id)) + }); +} + /// The distinct devices named by the saved profiles, as `(id, name)`. fn profile_devices(config: &BridgeConfig) -> Vec<(String, String)> { let mut devices: Vec<(String, String)> = Vec::new(); @@ -431,6 +447,7 @@ impl BridgeService { let mut batteries = state .battery .iter() + .filter(|(_, entry)| entry.updated_at.elapsed() < BATTERY_EVICT_AFTER) .map(|(device_id, entry)| DeviceBattery { device_id: device_id.clone(), device_name: entry.device_name.clone(), @@ -588,6 +605,12 @@ impl BridgeService { config::save(&self.config_path, &config) } + /// Forgets a lingering battery entry so a dead device leaves the list. + /// Returns whether an entry was removed. + pub async fn forget_battery(&self, device_id: &str) -> bool { + self.inner.write().await.battery.remove(device_id).is_some() + } + pub async fn record_battery(&self, reading: BatteryReading) -> Result { let percent = reading.percent.min(100); let mut reading = reading; @@ -646,6 +669,7 @@ impl BridgeService { continue; } let devices = profile_devices(&service.inner.read().await.config); + prune_batteries(&mut service.inner.write().await.battery, &devices); for (device_id, device_name) in devices { let id = device_id.clone(); let outcome = @@ -1057,6 +1081,70 @@ mod tests { ); } + fn battery_entry(age: Duration) -> BatteryState { + BatteryState { + last_alert: None, + device_name: "Mouse".into(), + percent: 50, + charging: false, + updated_at: Instant::now() - age, + } + } + + #[test] + fn prune_evicts_old_entries_and_unknown_stale_ones() { + let mut battery = HashMap::new(); + battery.insert("fresh-unknown".to_string(), battery_entry(Duration::ZERO)); + battery.insert( + "stale-known".to_string(), + battery_entry(BATTERY_STALE_AFTER), + ); + battery.insert( + "stale-unknown".to_string(), + battery_entry(BATTERY_STALE_AFTER), + ); + battery.insert( + "ancient-known".to_string(), + battery_entry(BATTERY_EVICT_AFTER), + ); + let known = vec![ + ("stale-known".to_string(), "Mouse".to_string()), + ("ancient-known".to_string(), "Mouse".to_string()), + ]; + + prune_batteries(&mut battery, &known); + + let mut kept = battery.keys().cloned().collect::>(); + kept.sort(); + assert_eq!(kept, ["fresh-unknown", "stale-known"]); + } + + #[tokio::test] + async fn forgetting_a_battery_removes_only_that_device() { + let bridge = service(BridgeConfig::default()); + for id in ["a", "b"] { + bridge + .record_battery(BatteryReading { + device_id: id.into(), + device_name: id.into(), + percent: 80, + charging: false, + }) + .await + .unwrap(); + } + assert!(bridge.forget_battery("a").await); + assert!(!bridge.forget_battery("a").await); + let ids = bridge + .snapshot() + .await + .batteries + .into_iter() + .map(|battery| battery.device_id) + .collect::>(); + assert_eq!(ids, ["b"]); + } + #[test] fn application_picker_only_accepts_catalog_games() { let games = vec![GameConfig {