diff --git a/Cargo.toml b/Cargo.toml index 2e8f47fe..8549e379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ desktop = [ "dep:v4l2r", "dep:alsa", "dep:gpio-cdev", + "dep:one-kvm-bluetooth-hid", "dep:cpal", "dep:windows-sys", ] @@ -179,6 +180,8 @@ turbojpeg = { version = "1.3", optional = true } audiopus = { version = "0.2", optional = true } [target.'cfg(target_os = "linux")'.dependencies] +one-kvm-bluetooth-hid = { path = "libs/bluetooth-hid", optional = true } + # Utilities nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true } diff --git a/build/Dockerfile.runtime b/build/Dockerfile.runtime index 56b6dd7f..c334977f 100644 --- a/build/Dockerfile.runtime +++ b/build/Dockerfile.runtime @@ -17,6 +17,8 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \ apt-get install -y --no-install-recommends \ # Core runtime (all platforms) - no codec libs needed ca-certificates \ + # Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter. + bluez \ libudev1 \ libasound2 \ # OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus) diff --git a/build/Dockerfile.runtime-full b/build/Dockerfile.runtime-full index 422f1d1b..fb5ae61e 100644 --- a/build/Dockerfile.runtime-full +++ b/build/Dockerfile.runtime-full @@ -17,6 +17,8 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \ apt-get install -y --no-install-recommends \ # Core runtime (all platforms) - no codec libs needed ca-certificates \ + # Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter. + bluez \ libudev1 \ libasound2 \ # OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus) diff --git a/build/debian/control.tpl b/build/debian/control.tpl index fc3c7e00..a1c7e0de 100644 --- a/build/debian/control.tpl +++ b/build/debian/control.tpl @@ -6,6 +6,7 @@ Maintainer: SilentWind Package: one-kvm Architecture: {arch} Depends: ${{auto}}, ca-certificates{distsuffix} +Recommends: bluez Description: A open and lightweight IP-KVM solution written in Rust Enables BIOS-level remote management of servers and workstations. . @@ -17,6 +18,7 @@ Description: A open and lightweight IP-KVM solution written in Rust * Hardware-accelerated video encoding (VAAPI, QSV, RKMPP) * WebRTC and MJPEG streaming with low latency * USB HID emulation via OTG gadget + * Classic Bluetooth keyboard and mouse via BlueZ * Mass storage device for ISO/IMG mounting * ATX power control via GPIO or USB relay Homepage: https://github.com/mofeng-git/One-KVM diff --git a/build/one-kvm.service b/build/one-kvm.service index 97c03848..0f41e399 100644 --- a/build/one-kvm.service +++ b/build/one-kvm.service @@ -1,7 +1,7 @@ [Unit] Description=One-KVM IP-KVM Service Documentation=https://github.com/mofeng-git/One-KVM -After=network-online.target +After=network-online.target bluetooth.service Wants=network-online.target [Service] diff --git a/build/package-deb.sh b/build/package-deb.sh index a3647967..5c8277c5 100755 --- a/build/package-deb.sh +++ b/build/package-deb.sh @@ -139,6 +139,7 @@ Section: admin Priority: optional Architecture: $DEB_ARCH Depends: $DEPS +Recommends: bluez Maintainer: SilentWind Description: A open and lightweight IP-KVM solution Enables BIOS-level remote management of servers and workstations. diff --git a/libs/bluetooth-hid/Cargo.toml b/libs/bluetooth-hid/Cargo.toml new file mode 100644 index 00000000..86cfcb51 --- /dev/null +++ b/libs/bluetooth-hid/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "one-kvm-bluetooth-hid" +version = "0.1.0" +edition = "2021" +license = "GPL-2.0" + +[dependencies] +bluer = { version = "0.17", features = ["bluetoothd", "l2cap"] } +dbus = { version = "0.9", features = ["vendored"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dbus-crossroads = "0.5" +dbus-tokio = "0.7" +libc = "0.2" +tracing = "0.1" diff --git a/libs/bluetooth-hid/examples/pairing_probe.rs b/libs/bluetooth-hid/examples/pairing_probe.rs new file mode 100644 index 00000000..c6756ccc --- /dev/null +++ b/libs/bluetooth-hid/examples/pairing_probe.rs @@ -0,0 +1,49 @@ +//! Explicit hardware regression: opens a 10-second pairing window without +//! removing existing bonds or sending input. Stop the production HID first. +use one_kvm_bluetooth_hid::{Action, Config, Peripheral}; +use std::time::Duration; + +#[tokio::main] +async fn main() -> Result<(), String> { + let peripheral = Peripheral::start(Config { + adapter: "hci0".into(), + name: "One-KVM HID".into(), + peer: None, + })?; + let result = async { + for _ in 0..50 { + if peripheral.status().initialized { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let before = peripheral.status(); + if !before.initialized { + return Err(format!("Initialization failed: {:?}", before.error)); + } + if !before + .devices + .iter() + .any(|d| Some(&d.address) == before.peer.as_ref() && d.paired) + { + return Err("This regression needs an existing bonded computer".into()); + } + peripheral.action(Action::Pair(10)).await?; + tokio::time::sleep(Duration::from_secs(1)).await; + let open = peripheral.status(); + println!("open: {}", serde_json::to_string(&open).unwrap()); + if open.pairing_seconds == 0 { + return Err("Existing bond prematurely closed pairing".into()); + } + tokio::time::sleep(Duration::from_secs(10)).await; + let expired = peripheral.status(); + println!("expired: {}", serde_json::to_string(&expired).unwrap()); + if expired.pairing_seconds != 0 || expired.peer != before.peer { + return Err("Window did not expire while preserving the bonded peer".into()); + } + Ok(()) + } + .await; + let cleanup = peripheral.shutdown().await; + result.and(cleanup) +} diff --git a/libs/bluetooth-hid/examples/probe.rs b/libs/bluetooth-hid/examples/probe.rs new file mode 100644 index 00000000..85fc8634 --- /dev/null +++ b/libs/bluetooth-hid/examples/probe.rs @@ -0,0 +1,24 @@ +//! Native hardware smoke check. Registers for 12 seconds, sends no input, +//! opens no pairing window, then restores the adapter configuration. +use one_kvm_bluetooth_hid::{Config, Peripheral}; +use std::time::Duration; +#[tokio::main] +async fn main() -> Result<(), String> { + let peripheral = Peripheral::start(Config { + adapter: "hci0".into(), + name: "One-KVM HID".into(), + peer: None, + })?; + let mut initialized = false; + for _ in 0..12 { + tokio::time::sleep(Duration::from_secs(1)).await; + let status = peripheral.status(); + initialized |= status.initialized; + println!("{}", serde_json::to_string(&status).unwrap()); + } + peripheral.shutdown().await?; + if !initialized { + return Err("Native BlueZ peripheral never initialized".into()); + } + Ok(()) +} diff --git a/libs/bluetooth-hid/src/agent.rs b/libs/bluetooth-hid/src/agent.rs new file mode 100644 index 00000000..3317127b --- /dev/null +++ b/libs/bluetooth-hid/src/agent.rs @@ -0,0 +1,236 @@ +use crate::Shared; +use dbus::{channel::MatchingReceiver, message::MatchRule, nonblock::Proxy, Path}; +use dbus_crossroads::Crossroads; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +const PATH: &str = "/org/onekvm/bluetooth/agent"; +pub struct Agent { + connection: Arc, + task: tokio::task::JoinHandle<()>, +} +impl Agent { + pub async fn register(shared: Arc, adapter: String) -> Result { + let (resource, connection) = + dbus_tokio::connection::new_system_sync().map_err(|e| e.to_string())?; + let task = tokio::spawn(async move { + let _ = resource.await; + }); + let mut cr = Crossroads::new(); + let iface = cr.register("org.bluez.Agent1", |b| { + b.method("Release", (), (), |_, _: &mut (), ()| Ok(())); + b.method("Cancel", (), (), |_, _: &mut (), ()| Ok(())); + let state = shared.clone(); + let name = adapter.clone(); + b.method( + "RequestAuthorization", + ("device",), + (), + move |_, _: &mut (), (path,): (Path<'static>,)| { + authorize(&state, &name, &path, true) + }, + ); + let state = shared.clone(); + let name = adapter.clone(); + b.method( + "RequestConfirmation", + ("device", "passkey"), + (), + move |_, _: &mut (), (path, _): (Path<'static>, u32)| { + authorize(&state, &name, &path, true) + }, + ); + b.method( + "AuthorizeService", + ("device", "uuid"), + (), + move |_, _: &mut (), (path, uuid): (Path<'static>, String)| { + if !matches!(uuid.as_str(), "00001124-0000-1000-8000-00805f9b34fb") { + return Err(dbus::MethodErr::from(( + "org.bluez.Error.Rejected", + "Not an HID service", + ))); + } + authorize(&shared, &adapter, &path, false) + }, + ); + }); + cr.insert(PATH, &[iface], ()); + let profile = cr.register("org.bluez.Profile1", |b| { + b.method("Release", (), (), |_, _: &mut (), ()| Ok(())); + b.method( + "RequestDisconnection", + ("device",), + (), + |_, _: &mut (), (_device,): (Path<'static>,)| Ok(()), + ); + b.method( + "NewConnection", + ("device", "fd", "properties"), + (), + |_, + _: &mut (), + (_device, _fd, _props): ( + Path<'static>, + dbus::arg::OwnedFd, + dbus::arg::PropMap, + )| { + // This profile only publishes SDP. Our L2CAP sockets own both HID channels. + Err::<(), _>(dbus::MethodErr::from(( + "org.bluez.Error.Rejected", + "Unexpected profile connection", + ))) + }, + ); + }); + cr.insert("/org/onekvm/bluetooth/profile", &[profile], ()); + connection.start_receive( + MatchRule::new_method_call(), + Box::new(move |msg, conn| { + let _ = cr.handle_message(msg, conn); + true + }), + ); + let agent = Self { connection, task }; + let proxy = Proxy::new( + "org.bluez", + "/org/bluez", + Duration::from_secs(5), + agent.connection.clone(), + ); + let result: Result<(), dbus::Error> = proxy + .method_call( + "org.bluez.AgentManager1", + "RegisterAgent", + (Path::from(PATH), "NoInputNoOutput"), + ) + .await; + if let Err(error) = result { + return Err(error.to_string()); + } + let result: Result<(), dbus::Error> = proxy + .method_call( + "org.bluez.AgentManager1", + "RequestDefaultAgent", + (Path::from(PATH),), + ) + .await; + result.map_err(|e| e.to_string())?; + let mut options: dbus::arg::PropMap = std::collections::HashMap::new(); + options.insert( + "ServiceRecord".into(), + dbus::arg::Variant(Box::new(crate::protocol::sdp())), + ); + options.insert( + "Role".into(), + dbus::arg::Variant(Box::new("server".to_string())), + ); + options.insert( + "RequireAuthentication".into(), + dbus::arg::Variant(Box::new(true)), + ); + options.insert( + "RequireAuthorization".into(), + dbus::arg::Variant(Box::new(false)), + ); + let result: Result<(), dbus::Error> = proxy + .method_call( + "org.bluez.ProfileManager1", + "RegisterProfile", + ( + Path::from("/org/onekvm/bluetooth/profile"), + crate::protocol::HID_UUID, + options, + ), + ) + .await; + result.map_err(|e| format!("Register HID SDP: {e}"))?; + Ok(agent) + } +} +impl Drop for Agent { + fn drop(&mut self) { + self.task.abort(); + } +} +fn authorize( + shared: &Shared, + adapter: &str, + path: &str, + pairing: bool, +) -> Result<(), dbus::MethodErr> { + let rejected = || { + dbus::MethodErr::from(( + "org.bluez.Error.Rejected", + "Open pairing in One-KVM or select the paired computer", + )) + }; + let prefix = format!("/org/bluez/{adapter}/dev_"); + let address = path + .strip_prefix(&prefix) + .ok_or_else(rejected)? + .replace('_', ":"); + let mut state = shared.state.lock().unwrap(); + if pairing + && !state + .pairing_until + .is_some_and(|until| until > Instant::now()) + { + return Err(rejected()); + } + if state.peer.as_ref().is_some_and(|peer| peer != &address) { + return Err(rejected()); + } + if state.peer.is_none() { + if !pairing || state.bonded_before_pairing.contains(&address) { + return Err(rejected()); + } + state.peer = Some(address); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn existing_unrelated_bond_cannot_be_adopted_by_agent_callback() { + let shared = Shared::new(None); + { + let mut state = shared.state.lock().unwrap(); + state.pairing_until = Some(Instant::now() + Duration::from_secs(120)); + state + .bonded_before_pairing + .insert("10:6F:D9:66:97:88".into()); + } + assert!(authorize( + &shared, + "hci0", + "/org/bluez/hci0/dev_10_6F_D9_66_97_88", + true + ) + .is_err()); + assert!(shared.state.lock().unwrap().peer.is_none()); + } + #[test] + fn only_pair_during_window_and_pin_first_peer() { + let state = Shared::new(None); + let first = "/org/bluez/hci0/dev_10_6F_D9_66_97_88"; + assert!(authorize(&state, "hci0", first, true).is_err()); + state.state.lock().unwrap().pairing_until = Some(Instant::now() + Duration::from_secs(60)); + assert!(authorize(&state, "hci0", first, true).is_ok()); + assert!(authorize( + &state, + "hci0", + "/org/bluez/hci0/dev_10_6F_D9_66_97_89", + true + ) + .is_err()); + assert!(authorize(&state, "hci1", first, true).is_err()); + state.state.lock().unwrap().pairing_until = None; + assert!(authorize(&state, "hci0", first, false).is_ok()); + assert!(authorize(&state, "hci0", first, true).is_err()); + } +} diff --git a/libs/bluetooth-hid/src/bonds.rs b/libs/bluetooth-hid/src/bonds.rs new file mode 100644 index 00000000..bcf5e7fe --- /dev/null +++ b/libs/bluetooth-hid/src/bonds.rs @@ -0,0 +1,145 @@ +//! Only bonds explicitly owned by this HID peripheral may be removed. +use bluer::{Adapter, Session}; +use std::{future::Future, pin::Pin}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bond { + pub adapter: String, + pub peer: String, + pub pending: bool, +} +pub type Operation<'a, T> = Pin> + Send + 'a>>; +pub trait BondStore: Send + Sync { + fn list(&self) -> Operation<'_, Vec>; + fn save(&self, bond: Bond) -> Operation<'_, ()>; + fn remove(&self, bond: Bond) -> Operation<'_, ()>; +} + +pub async fn clean_adapter(store: &dyn BondStore, adapter: &Adapter) -> Result<(), String> { + let address = adapter + .address() + .await + .map_err(|e| e.to_string())? + .to_string(); + clean_owned(store, &address, |peer| async move { + let peer = peer.parse().map_err(|_| "Invalid stored HID peer")?; + if adapter + .device_addresses() + .await + .map_err(|e| e.to_string())? + .contains(&peer) + { + adapter + .remove_device(peer) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) + }) + .await +} + +async fn clean_owned( + store: &dyn BondStore, + address: &str, + mut remove: F, +) -> Result<(), String> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + for bond in store + .list() + .await? + .into_iter() + .filter(|b| b.pending && b.adapter == address) + { + remove(bond.peer.clone()).await?; + // Keep the tombstone until BlueZ confirms removal; retry safely after interruption. + store.remove(bond).await?; + } + Ok(()) +} + +pub async fn reset(store: &dyn BondStore, explicit: Vec) -> Result<(), String> { + let session = Session::new().await.map_err(|e| e.to_string())?; + let mut owned = store.list().await?; + owned.extend(explicit); + for mut bond in owned { + bond.pending = true; + store.save(bond).await?; + } + for name in session.adapter_names().await.map_err(|e| e.to_string())? { + clean_adapter(store, &session.adapter(&name).map_err(|e| e.to_string())?).await?; + } + // Records for absent adapters remain pending and are cleaned before their next use. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + #[derive(Default)] + struct Memory(Mutex>); + impl BondStore for Memory { + fn list(&self) -> Operation<'_, Vec> { + Box::pin(async { Ok(self.0.lock().unwrap().clone()) }) + } + fn save(&self, bond: Bond) -> Operation<'_, ()> { + Box::pin(async move { + let mut rows = self.0.lock().unwrap(); + rows.retain(|b| b.adapter != bond.adapter || b.peer != bond.peer); + rows.push(bond); + Ok(()) + }) + } + fn remove(&self, bond: Bond) -> Operation<'_, ()> { + Box::pin(async move { + self.0.lock().unwrap().retain(|b| b != &bond); + Ok(()) + }) + } + } + fn bond(adapter: &str, peer: &str, pending: bool) -> Bond { + Bond { + adapter: adapter.into(), + peer: peer.into(), + pending, + } + } + #[tokio::test] + async fn cleanup_only_removes_owned_pending_bonds_on_the_selected_hardware() { + let current = bond("adapter-A", "host-1", true); + let absent = bond("adapter-B", "host-2", true); + let retained = bond("adapter-A", "host-3", false); + let store = Memory(Mutex::new(vec![current, absent.clone(), retained.clone()])); + let removed = Mutex::new(Vec::new()); + clean_owned(&store, "adapter-A", |peer| { + removed.lock().unwrap().push(peer); + async { Ok(()) } + }) + .await + .unwrap(); + assert_eq!(*removed.lock().unwrap(), vec!["host-1"]); + assert_eq!(store.list().await.unwrap(), vec![absent, retained]); + } + #[tokio::test] + async fn cleanup_failure_keeps_tombstone_and_is_reported() { + let record = bond("adapter-A", "host-1", true); + let store = Memory(Mutex::new(vec![record.clone()])); + assert_eq!( + clean_owned(&store, "adapter-A", |_| async { + Err("BlueZ denied removal".into()) + }) + .await + .unwrap_err(), + "BlueZ denied removal" + ); + assert_eq!(store.list().await.unwrap(), vec![record]); + clean_owned(&store, "adapter-A", |_| async { Ok(()) }) + .await + .unwrap(); + assert!(store.list().await.unwrap().is_empty()); + } +} diff --git a/libs/bluetooth-hid/src/controller.rs b/libs/bluetooth-hid/src/controller.rs new file mode 100644 index 00000000..3de88c5b --- /dev/null +++ b/libs/bluetooth-hid/src/controller.rs @@ -0,0 +1,135 @@ +//! Exclusive controller ownership. No BLE switching or power cycling. +use std::{ + fs::{File, OpenOptions}, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, + process::Stdio, + time::Duration, +}; +use tokio::process::Command; +pub struct Controller { + _lock: File, + adapter: String, + old_class: (u8, u8), + old_connectable: bool, +} +async fn run_once(adapter: &str, args: &[&str]) -> Result { + let mut cmd = Command::new("btmgmt"); + cmd.arg("--index") + .arg(adapter.trim_start_matches("hci")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd + .spawn() + .map_err(|e| format!("Install bluez/btmgmt: {e}"))?; + // BlueZ 5.55's btmgmt exits on stdin EOF, even with a command argument. + // Keep a pipe open until it finishes; systemd normally supplies /dev/null. + let stdin = child.stdin.take(); + let result = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output()) + .await + .map_err(|_| "btmgmt timeout")? + .map_err(|e| format!("Install bluez/btmgmt: {e}"))?; + drop(stdin); + let text = String::from_utf8_lossy(&result.stdout).into_owned(); + if !result.status.success() + || !result.stderr.is_empty() + || text.to_lowercase().contains("failed") + { + return Err(format!( + "btmgmt {}: {text} {}", + args.join(" "), + String::from_utf8_lossy(&result.stderr) + )); + } + Ok(text) +} +async fn run(adapter: &str, args: &[&str]) -> Result { + // BlueZ updates the EIR/class asynchronously after registering SDP. + // Kernel management rejects a simultaneous class change with Busy. + for attempt in 0..5 { + match run_once(adapter, args).await { + Err(error) if error.contains("(Busy)") && attempt < 4 => { + tokio::time::sleep(Duration::from_millis(200)).await; + } + result => return result, + } + } + unreachable!() +} +impl Controller { + pub async fn acquire(adapter: &str) -> Result { + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(format!("/run/one-kvm-bluetooth-{adapter}.lock")) + .map_err(|e| e.to_string())?; + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err("Bluetooth adapter already owned by One-KVM".into()); + } + let info = run(adapter, &["info"]).await?; + let settings = info + .lines() + .find_map(|l| l.trim().strip_prefix("current settings:")) + .ok_or("Cannot read Bluetooth settings")?; + if !settings.split_whitespace().any(|s| s == "br/edr") { + return Err( + "Classic Bluetooth disabled; enable BR/EDR with btmgmt before using HID".into(), + ); + } + let class = info + .split_whitespace() + .skip_while(|s| *s != "class") + .nth(1) + .ok_or("Missing Bluetooth class")?; + let class = + u32::from_str_radix(class.trim_start_matches("0x"), 16).map_err(|e| e.to_string())?; + Ok(Self { + _lock: lock, + adapter: adapter.into(), + old_class: (((class >> 8) & 0x1f) as u8, (class & 0xfc) as u8), + old_connectable: settings.split_whitespace().any(|s| s == "connectable"), + }) + } + pub async fn configure(&self) -> Result<(), String> { + run(&self.adapter, &["class", "5", "192"]).await?; + run(&self.adapter, &["connectable", "on"]).await?; + Ok(()) + } + pub async fn restore(&self) -> Result<(), String> { + let mut errors = vec![]; + if let Err(e) = run( + &self.adapter, + &[ + "class", + &self.old_class.0.to_string(), + &self.old_class.1.to_string(), + ], + ) + .await + { + errors.push(e); + } + if let Err(e) = run( + &self.adapter, + &[ + "connectable", + if self.old_connectable { "on" } else { "off" }, + ], + ) + .await + { + errors.push(e); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } +} diff --git a/libs/bluetooth-hid/src/lib.rs b/libs/bluetooth-hid/src/lib.rs new file mode 100644 index 00000000..31b65af7 --- /dev/null +++ b/libs/bluetooth-hid/src/lib.rs @@ -0,0 +1,964 @@ +//! Classic Bluetooth HID peripheral: SDP + L2CAP. Linux/BlueZ only. +mod agent; +pub mod bonds; +mod controller; +mod protocol; + +use bluer::{ + l2cap::{Security, SecurityLevel, SeqPacket, SeqPacketListener, Socket, SocketAddr}, + Adapter, Address, AddressType, Session, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::sync::{mpsc, oneshot, watch, Mutex as AsyncMutex}; + +#[derive(Debug, Clone)] +pub struct Config { + pub adapter: String, + pub name: String, + pub peer: Option, +} +impl Config { + pub fn validate(&self) -> Result<(), String> { + if !self + .adapter + .strip_prefix("hci") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit())) + { + return Err("Bluetooth adapter must be hci followed by an index".into()); + } + if self.name.is_empty() || self.name.len() > 64 || self.name.chars().any(char::is_control) { + return Err( + "Bluetooth name must contain 1–64 UTF-8 bytes without control characters".into(), + ); + } + if let Some(peer) = &self.peer { + peer.parse::
() + .map_err(|_| "Invalid Bluetooth peer address")?; + } + Ok(()) + } +} +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Status { + pub initialized: bool, + pub connected: bool, + pub ready: bool, + pub adapter: String, + pub adapter_address: String, + pub peer: Option, + pub pairing_seconds: u32, + pub control_connected: bool, + pub interrupt_connected: bool, + pub leds: u8, + pub generation: u64, + pub error: Option, + pub devices: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Device { + pub address: String, + pub name: String, + pub paired: bool, + pub connected: bool, +} +#[derive(Debug, Clone, Copy)] +pub enum Report { + Keyboard, + Mouse, + Consumer, +} +impl Report { + fn len(self) -> usize { + match self { + Self::Keyboard => 8, + Self::Mouse => 4, + Self::Consumer => 2, + } + } + fn id(self) -> u8 { + match self { + Self::Keyboard => 1, + Self::Mouse => 2, + Self::Consumer => 3, + } + } +} +struct State { + peer: Option, + pairing_until: Option, + bonded_before_pairing: HashSet, +} +impl State { + fn observe_bond(&mut self, address: &str, paired: bool) { + // Just Works may complete without an Agent1 authorization callback. + // Adopt only a new bond from our explicit window, respecting a pinned peer. + if paired + && self.peer.is_none() + && self + .pairing_until + .is_some_and(|until| until > Instant::now()) + && !self.bonded_before_pairing.contains(address) + { + self.peer = Some(address.to_owned()); + tracing::info!( + peer = address, + "Bluetooth HID selected newly bonded computer" + ); + } + } + fn close_pairing(&mut self, paired: bool, fallback: Option) { + self.pairing_until = None; + if !paired { + self.peer = fallback; + } + } + fn pairing_completed(&self, selected_paired: bool) -> bool { + selected_paired + && self + .peer + .as_ref() + .is_some_and(|peer| !self.bonded_before_pairing.contains(peer)) + } +} +struct Shared { + state: Mutex, +} +impl Shared { + fn new(peer: Option) -> Self { + Self { + state: Mutex::new(State { + peer, + pairing_until: None, + bonded_before_pairing: HashSet::new(), + }), + } + } + fn allowed(&self, address: Address) -> bool { + self.state.lock().unwrap().peer.as_deref() == Some(address.to_string().as_str()) + } +} +pub enum Action { + Pair(u32), + ClosePairing, + Forget, + Disconnect, + Reset, +} +enum Command { + Report(Report, Vec), + Action(Action), + Stop, +} +struct Request { + command: Command, + result: oneshot::Sender>, + expires: Instant, +} +pub struct Peripheral { + tx: mpsc::Sender, + status: watch::Receiver, + task: AsyncMutex>>, +} +impl Peripheral { + pub fn start(config: Config) -> Result { + Self::start_with_store(config, None) + } + pub fn start_with_store( + config: Config, + bonds: Option>, + ) -> Result { + config.validate()?; + let (tx, rx) = mpsc::channel(64); + let (state_tx, status) = watch::channel(Status { + adapter: config.adapter.clone(), + ..Default::default() + }); + let task = tokio::spawn(supervise(config, bonds, rx, state_tx)); + Ok(Self { + tx, + status, + task: AsyncMutex::new(Some(task)), + }) + } + pub fn status(&self) -> Status { + self.status.borrow().clone() + } + pub fn subscribe(&self) -> watch::Receiver { + self.status.clone() + } + async fn request(&self, command: Command, duration: Duration) -> Result<(), String> { + let (result, rx) = oneshot::channel(); + let request = Request { + command, + result, + expires: Instant::now() + duration, + }; + self.tx + .try_send(request) + .map_err(|_| "Bluetooth input queue unavailable or full".to_string())?; + tokio::time::timeout(duration, rx) + .await + .map_err(|_| "Bluetooth operation timed out".to_string())? + .map_err(|_| "Bluetooth worker stopped".to_string())? + } + pub async fn send(&self, report: Report, value: Vec) -> Result<(), String> { + if value.len() != report.len() { + return Err("Invalid HID report size".into()); + } + self.request(Command::Report(report, value), Duration::from_millis(500)) + .await + } + pub async fn action(&self, action: Action) -> Result<(), String> { + self.request(Command::Action(action), Duration::from_secs(15)) + .await + } + pub async fn shutdown(&self) -> Result<(), String> { + if let Some(task) = self.task.lock().await.take() { + let result = self.request(Command::Stop, Duration::from_secs(45)).await; + // Do not abort: the worker must restore the radio before a backend switch. + task.await.map_err(|e| e.to_string())?; + result + } else { + Ok(()) + } + } +} + +fn listen(address: Address, psm: u16) -> Result { + let socket = Socket::::new_seq_packet().map_err(|e| e.to_string())?; + socket + .set_security(Security { + level: SecurityLevel::Medium, + key_size: 0, + }) + .map_err(|e| e.to_string())?; + socket.bind(SocketAddr::new(address,AddressType::BrEdr,psm)) + .map_err(|e|format!("Cannot bind classic HID PSM 0x{psm:02x}: {e}. Disable BlueZ input plugin; see Bluetooth HID setup."))?; + socket.listen(2).map_err(|e| e.to_string()) +} +struct Runtime { + adapter: Adapter, + _session: Session, + shared: Arc, + agent: Option, + controller: controller::Controller, + listeners: [SeqPacketListener; 2], + channels: [Option>; 2], + channel_peer: Option
, + partial_since: Option, + hid: protocol::HidProtocol, + generation: u64, + old_pairable: bool, + old_powered: bool, + old_alias: String, + old_discoverable: bool, + old_discoverable_timeout: u32, + old_pairable_timeout: u32, + config: Config, + bonds: Option>, + recorded_peer: Option, +} +impl Runtime { + async fn open( + config: &Config, + bonds: Option>, + ) -> Result { + let controller = controller::Controller::acquire(&config.adapter).await?; + let session = Session::new().await.map_err(|e| e.to_string())?; + let adapter = session + .adapter(&config.adapter) + .map_err(|e| e.to_string())?; + let address = adapter.address().await.map_err(|e| e.to_string())?; + let mut config = config.clone(); + if let Some(store) = &bonds { + bonds::clean_adapter(store.as_ref(), &adapter).await?; + if config.peer.is_none() { + config.peer = store + .list() + .await? + .into_iter() + .find(|bond| bond.adapter == address.to_string() && !bond.pending) + .map(|bond| bond.peer); + } + } + // Acquire BOTH channels before changing adapter state. Conflict is a startup error. + let listeners = [listen(address, 0x11)?, listen(address, 0x13)?]; + let old_powered = adapter.is_powered().await.map_err(|e| e.to_string())?; + let old_pairable = adapter.is_pairable().await.map_err(|e| e.to_string())?; + let old_alias = adapter.alias().await.map_err(|e| e.to_string())?; + let old_discoverable = adapter.is_discoverable().await.map_err(|e| e.to_string())?; + let old_discoverable_timeout = adapter + .discoverable_timeout() + .await + .map_err(|e| e.to_string())?; + let old_pairable_timeout = adapter + .pairable_timeout() + .await + .map_err(|e| e.to_string())?; + let shared = Arc::new(Shared::new( + config.peer.as_ref().map(|p| p.to_ascii_uppercase()), + )); + let mut runtime = Self { + adapter, + _session: session, + shared, + agent: None, + controller, + listeners, + channels: [None, None], + channel_peer: None, + partial_since: None, + hid: Default::default(), + generation: 0, + old_pairable, + old_powered, + old_alias, + old_discoverable, + old_discoverable_timeout, + old_pairable_timeout, + config: config.clone(), + bonds, + recorded_peer: None, + }; + if let Err(e) = runtime.setup().await { + let restore = runtime.close().await; + return Err(format!("{e}; cleanup: {restore:?}")); + } + Ok(runtime) + } + async fn setup(&mut self) -> Result<(), String> { + self.adapter + .set_powered(true) + .await + .map_err(|e| e.to_string())?; + self.adapter + .set_discoverable(false) + .await + .map_err(|e| e.to_string())?; + self.adapter + .set_pairable(false) + .await + .map_err(|e| e.to_string())?; + self.adapter + .set_alias(self.config.name.clone()) + .await + .map_err(|e| e.to_string())?; + self.agent = + Some(agent::Agent::register(self.shared.clone(), self.config.adapter.clone()).await?); + self.controller.configure().await?; + self.public(None).await?; + Ok(()) + } + async fn public(&self, seconds: Option) -> Result<(), String> { + if let Some(seconds) = seconds { + self.adapter + .set_pairable_timeout(seconds) + .await + .map_err(|e| e.to_string())?; + self.adapter + .set_discoverable_timeout(seconds) + .await + .map_err(|e| e.to_string())?; + self.adapter + .set_pairable(true) + .await + .map_err(|e| e.to_string())?; + if let Err(e) = self.adapter.set_discoverable(true).await { + let _ = self.adapter.set_pairable(false).await; + return Err(e.to_string()); + } + } else { + self.adapter + .set_discoverable(false) + .await + .map_err(|e| e.to_string())?; + self.adapter + .set_pairable(false) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) + } + fn drop_channels(&mut self) { + for socket in self.channels.iter_mut().filter_map(Option::take) { + let _ = socket.shutdown(std::net::Shutdown::Both); + } + self.channel_peer = None; + self.partial_since = None; + self.hid = Default::default(); + self.generation = self.generation.wrapping_add(1); + } + async fn packet(&self, index: usize, data: &[u8]) -> Result<(), String> { + let socket = self.channels[index] + .as_ref() + .ok_or("Classic HID channel not connected")?; + let sent = tokio::time::timeout(Duration::from_millis(250), socket.send(data)) + .await + .map_err(|_| "HID send timeout")? + .map_err(|e| e.to_string())?; + if sent != data.len() { + return Err("Short HID packet write".into()); + } + Ok(()) + } + async fn release(&mut self) { + for packet in self.hid.release() { + if self.channels[1].is_some() && self.packet(1, &packet).await.is_err() { + self.drop_channels(); + break; + } + } + self.generation = self.generation.wrapping_add(1); + } + async fn accept( + &mut self, + index: usize, + socket: SeqPacket, + peer: SocketAddr, + ) -> Result<(), String> { + if peer.addr_type != AddressType::BrEdr { + return Ok(()); + } + // A host can open HID channels before the next status poll. + let paired = self + .adapter + .device(peer.addr) + .map_err(|e| e.to_string())? + .is_paired() + .await + .map_err(|e| e.to_string())?; + self.shared + .state + .lock() + .unwrap() + .observe_bond(&peer.addr.to_string(), paired); + if !self.shared.allowed(peer.addr) { + return Ok(()); + } + if self.channel_peer.is_some_and(|p| p != peer.addr) || self.channels[index].is_some() { + return Ok(()); + } + // Kernel BT_SECURITY_MEDIUM negotiates encryption before accept completes. + if socket + .as_ref() + .security() + .map_err(|e| e.to_string())? + .key_size + < 7 + { + return Ok(()); + } + self.channel_peer = Some(peer.addr); + self.channels[index] = Some(Arc::new(socket)); + if self.channels.iter().all(Option::is_some) { + self.partial_since = None; + self.release().await; + } else { + self.partial_since = Some(Instant::now()); + } + Ok(()) + } + async fn status(&mut self) -> Result { + if !self.adapter.is_powered().await.map_err(|e| e.to_string())? { + return Err("Bluetooth adapter powered off".into()); + } + // A daemon restart loses the SDP registration even if the adapter comes back powered. + if !self + .adapter + .uuids() + .await + .map_err(|e| e.to_string())? + .unwrap_or_default() + .iter() + .any(|id| id.to_string() == protocol::HID_UUID) + { + return Err("Classic HID SDP registration lost; restarting Bluetooth backend".into()); + } + let mut status = Status { + initialized: true, + adapter: self.config.adapter.clone(), + adapter_address: self + .adapter + .address() + .await + .map_err(|e| e.to_string())? + .to_string(), + ..Default::default() + }; + for address in self + .adapter + .device_addresses() + .await + .map_err(|e| e.to_string())? + { + let device = self.adapter.device(address).map_err(|e| e.to_string())?; + let connected = device.is_connected().await.unwrap_or(false); + let paired = device.is_paired().await.unwrap_or(false); + self.shared + .state + .lock() + .unwrap() + .observe_bond(&address.to_string(), paired); + if paired || connected { + status.devices.push(Device { + address: address.to_string(), + name: device.alias().await.unwrap_or_default(), + paired, + connected, + }); + } + } + status.devices.sort_by(|a, b| a.address.cmp(&b.address)); + let (peer, until) = { + let state = self.shared.state.lock().unwrap(); + (state.peer.clone(), state.pairing_until) + }; + status.peer = peer.clone(); + let selected = status + .devices + .iter() + .find(|device| Some(&device.address) == peer.as_ref()); + let selected_paired = selected.is_some_and(|device| device.paired); + if selected_paired && self.recorded_peer != peer { + if let (Some(store), Some(peer)) = (&self.bonds, &peer) { + store + .save(bonds::Bond { + adapter: status.adapter_address.clone(), + peer: peer.clone(), + pending: false, + }) + .await?; + self.recorded_peer = Some(peer.clone()); + } + } + status.connected = selected.is_some_and(|device| device.paired && device.connected); + if self.channels.iter().any(Option::is_some) + && (!status.connected + || self + .partial_since + .is_some_and(|time| time.elapsed() > Duration::from_secs(10))) + { + self.drop_channels(); + } + if let Some(until) = until { + let completed = self + .shared + .state + .lock() + .unwrap() + .pairing_completed(selected_paired); + if until <= Instant::now() || completed { + { + let mut state = self.shared.state.lock().unwrap(); + state.close_pairing( + selected_paired, + self.config.peer.as_ref().map(|p| p.to_ascii_uppercase()), + ); + status.peer = state.peer.clone(); + } + self.public(None).await?; + tracing::info!(reason = if completed { "bonded" } else { "expired" }, peer = ?status.peer, "Bluetooth HID pairing window closed"); + } else { + status.pairing_seconds = + until.saturating_duration_since(Instant::now()).as_secs() as u32 + 1; + } + } + status.control_connected = self.channels[0].is_some(); + status.interrupt_connected = self.channels[1].is_some(); + status.ready = status.connected + && status.control_connected + && status.interrupt_connected + && !self.hid.suspended; + status.leds = self.hid.leds; + status.generation = self.generation; + Ok(status) + } + async fn forget(&mut self) -> Result<(), String> { + self.release().await; + self.drop_channels(); + let peer = self.shared.state.lock().unwrap().peer.clone(); + if let Some(peer) = &peer { + let address = peer.parse().map_err(|_| "Invalid peer")?; + if self + .adapter + .device_addresses() + .await + .map_err(|e| e.to_string())? + .contains(&address) + { + self.adapter + .remove_device(address) + .await + .map_err(|e| e.to_string())?; + } + self.shared + .state + .lock() + .unwrap() + .bonded_before_pairing + .remove(peer); + tracing::info!(%peer, "Bluetooth HID forgot computer"); + } + if let Some(store) = &self.bonds { + let address = self + .adapter + .address() + .await + .map_err(|e| e.to_string())? + .to_string(); + for bond in store + .list() + .await? + .into_iter() + .filter(|b| b.adapter == address && Some(&b.peer) == peer.as_ref()) + { + store.remove(bond).await?; + } + } + self.recorded_peer = None; + self.config.peer = None; + self.shared.state.lock().unwrap().peer = None; + Ok(()) + } + async fn incoming(&mut self, index: usize, data: &[u8]) -> Result<(), String> { + if index == 1 { + self.hid.output(data); + return Ok(()); + } + let result = self.hid.control(data); + if let Some(reply) = result.reply { + self.packet(0, &reply).await?; + } + if result.reset { + self.release().await; + } + if result.unplug { + self.forget().await?; + } + Ok(()) + } + async fn execute(&mut self, command: Command) -> Result<(), String> { + match command { + Command::Report(kind, value) => { + let peer = self.channel_peer.ok_or("Classic HID is not connected")?; + if !self.shared.allowed(peer) || self.channels.iter().any(Option::is_none) { + return Err("Classic HID channels are not ready".into()); + } + let device = self.adapter.device(peer).map_err(|e| e.to_string())?; + if !device.is_paired().await.unwrap_or(false) + || !device.is_connected().await.unwrap_or(false) + { + self.drop_channels(); + return Err("Selected Bluetooth computer disconnected".into()); + } + let packet = self.hid.input(kind, &value)?; + if let Err(e) = self.packet(1, &packet).await { + self.drop_channels(); + return Err(e); + } + } + Command::Action(Action::Pair(seconds)) => { + if !(10..=300).contains(&seconds) { + return Err("Pairing window must be 10–300 seconds".into()); + } + let mut bonded = HashSet::new(); + for address in self + .adapter + .device_addresses() + .await + .map_err(|e| e.to_string())? + { + if self + .adapter + .device(address) + .map_err(|e| e.to_string())? + .is_paired() + .await + .map_err(|e| e.to_string())? + { + bonded.insert(address.to_string()); + } + } + { + let mut state = self.shared.state.lock().unwrap(); + // Preserve the original baseline when extending an open window. + if !state + .pairing_until + .is_some_and(|until| until > Instant::now()) + { + state.bonded_before_pairing = bonded; + } + state.pairing_until = + Some(Instant::now() + Duration::from_secs(seconds.into())); + } + if let Err(e) = self.public(Some(seconds)).await { + self.shared.state.lock().unwrap().pairing_until = None; + return Err(e); + } + tracing::info!(seconds, "Bluetooth HID pairing window opened"); + } + Command::Action(Action::ClosePairing) => { + self.public(None).await?; + let peer = self.shared.state.lock().unwrap().peer.clone(); + let paired = if let Some(peer) = peer { + self.adapter + .device(peer.parse().map_err(|_| "Invalid HID peer")?) + .map_err(|e| e.to_string())? + .is_paired() + .await + .map_err(|e| e.to_string())? + } else { + false + }; + self.shared + .state + .lock() + .unwrap() + .close_pairing(paired, self.config.peer.clone()); + tracing::info!(reason = "manual", "Bluetooth HID pairing window closed"); + } + Command::Action(Action::Forget) => self.forget().await?, + Command::Action(Action::Disconnect) => { + self.release().await; + self.drop_channels(); + let peer = self.shared.state.lock().unwrap().peer.clone(); + if let Some(peer) = peer { + self.adapter + .device(peer.parse().map_err(|_| "Invalid peer")?) + .map_err(|e| e.to_string())? + .disconnect() + .await + .map_err(|e| e.to_string())?; + } + } + Command::Action(Action::Reset) => self.release().await, + Command::Stop => {} + } + Ok(()) + } + async fn close(&mut self) -> Result<(), String> { + self.release().await; + self.drop_channels(); + self.agent.take(); + tokio::time::sleep(Duration::from_millis(100)).await; + let mut errors = vec![]; + if let Err(e) = self.adapter.set_discoverable(false).await { + errors.push(e.to_string()); + } + if let Err(e) = self.controller.restore().await { + errors.push(e); + } + if let Err(e) = self.adapter.set_alias(self.old_alias.clone()).await { + errors.push(e.to_string()); + } + if let Err(e) = self + .adapter + .set_discoverable_timeout(self.old_discoverable_timeout) + .await + { + errors.push(e.to_string()); + } + if let Err(e) = self + .adapter + .set_pairable_timeout(self.old_pairable_timeout) + .await + { + errors.push(e.to_string()); + } + if let Err(e) = self.adapter.set_pairable(self.old_pairable).await { + errors.push(e.to_string()); + } + if let Err(e) = self.adapter.set_discoverable(self.old_discoverable).await { + errors.push(e.to_string()); + } + if let Err(e) = self.adapter.set_powered(self.old_powered).await { + errors.push(e.to_string()); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } +} +async fn receive(socket: Option>, buffer: &mut [u8]) -> std::io::Result { + match socket { + Some(socket) => socket.recv(buffer).await, + None => std::future::pending().await, + } +} +async fn supervise( + config: Config, + bonds: Option>, + mut rx: mpsc::Receiver, + tx: watch::Sender, +) { + let mut generation = 0; + loop { + let mut runtime = match Runtime::open(&config, bonds.clone()).await { + Ok(runtime) => runtime, + Err(error) => { + tx.send_replace(Status { + adapter: config.adapter.clone(), + error: Some(error.clone()), + generation, + ..Default::default() + }); + let delay = tokio::time::sleep(Duration::from_secs(5)); + tokio::pin!(delay); + loop { + tokio::select! {_=&mut delay=>break,request=rx.recv()=>{ + let Some(request)=request else{return;}; + if matches!(request.command,Command::Stop){let _=request.result.send(Ok(()));return;} + let _=request.result.send(Err(error.clone())); + }} + } + continue; + } + }; + runtime.generation = generation; + let mut timer = tokio::time::interval(Duration::from_millis(500)); + let failure = loop { + let ctl = runtime.channels[0].clone(); + let intr = runtime.channels[1].clone(); + let mut ctl_buffer = [0; 1024]; + let mut intr_buffer = [0; 1024]; + tokio::select! { + accepted=runtime.listeners[0].accept()=>{match accepted{Ok((socket,peer))=>{if let Err(e)=runtime.accept(0,socket,peer).await{break e;}},Err(e)=>break e.to_string()}} + accepted=runtime.listeners[1].accept()=>{match accepted{Ok((socket,peer))=>{if let Err(e)=runtime.accept(1,socket,peer).await{break e;}},Err(e)=>break e.to_string()}} + read=receive(ctl,&mut ctl_buffer)=>{match read{Ok(n)if n>0=>{if runtime.incoming(0,&ctl_buffer[..n]).await.is_err(){runtime.drop_channels();}},_=>runtime.drop_channels()}} + read=receive(intr,&mut intr_buffer)=>{match read{Ok(n)if n>0=>{if runtime.incoming(1,&intr_buffer[..n]).await.is_err(){runtime.drop_channels();}},_=>runtime.drop_channels()}} + _=timer.tick()=>{match runtime.status().await{Ok(status)=>{tx.send_if_modified(|old|{if *old!=status{*old=status;true}else{false}});},Err(e)=>break e}} + request=rx.recv()=>{ + let Some(request)=request else{let _=runtime.close().await;return;}; + if matches!(request.command,Command::Stop){let result=runtime.close().await;tx.send_replace(Status{adapter:config.adapter.clone(),..Default::default()});let _=request.result.send(result);return;} + if request.expires<=Instant::now()||request.result.is_closed(){runtime.release().await;continue;} + let action = matches!(&request.command, Command::Action(_)); + let result=runtime.execute(request.command).await; + if result.is_err(){runtime.release().await;} + if action { + if let Ok(status) = runtime.status().await { tx.send_replace(status); } + } + let _=request.result.send(result); + } + } + }; + let restore = runtime.close().await; + generation = runtime.generation.wrapping_add(1); + tx.send_replace(Status { + adapter: config.adapter.clone(), + error: Some(format!("{failure}; cleanup: {restore:?}")), + generation, + ..Default::default() + }); + drop(runtime); + tokio::time::sleep(Duration::from_secs(2)).await; + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn closing_pairing_preserves_completed_bonds_but_releases_unpaired_targets() { + let shared = Shared::new(Some("10:6F:D9:66:97:88".into())); + let mut state = shared.state.lock().unwrap(); + state.pairing_until = Some(Instant::now() + Duration::from_secs(120)); + state.close_pairing(true, None); + assert_eq!(state.peer.as_deref(), Some("10:6F:D9:66:97:88")); + assert!(state.pairing_until.is_none()); + state.close_pairing(false, None); + assert!(state.peer.is_none()); + } + #[test] + fn configuration_validation() { + let mut c = Config { + adapter: "hci0".into(), + name: "One-KVM".into(), + peer: None, + }; + assert!(c.validate().is_ok()); + c.adapter = "hci0;reboot".into(); + assert!(c.validate().is_err()); + c.adapter = "hci1".into(); + c.peer = Some("bogus".into()); + assert!(c.validate().is_err()); + } + #[test] + fn peer_isolation() { + let state = Shared::new(Some("10:6F:D9:66:97:88".into())); + assert!(state.allowed("10:6F:D9:66:97:88".parse().unwrap())); + assert!(!state.allowed("10:6F:D9:66:97:89".parse().unwrap())); + } + #[test] + fn new_bond_without_agent_callback_completes_pairing() { + let shared = Shared::new(None); + let mut state = shared.state.lock().unwrap(); + state.pairing_until = Some(Instant::now() + Duration::from_secs(120)); + let peer = "10:6F:D9:66:97:88"; + state.observe_bond(peer, false); + assert_eq!(state.peer, None); + state.observe_bond(peer, true); + assert_eq!(state.peer.as_deref(), Some(peer)); + assert!(state.pairing_completed(true)); + // A second computer cannot replace the one selected in this window. + state.observe_bond("10:6F:D9:66:97:89", true); + assert_eq!(state.peer.as_deref(), Some(peer)); + } + #[test] + fn old_bond_does_not_finish_a_new_window() { + let peer = "10:6F:D9:66:97:88"; + let shared = Shared::new(Some(peer.into())); + let mut state = shared.state.lock().unwrap(); + state.pairing_until = Some(Instant::now() + Duration::from_secs(120)); + state.bonded_before_pairing.insert(peer.into()); + state.observe_bond(peer, true); + assert!(!state.pairing_completed(true)); + state.peer = None; + state.observe_bond(peer, true); + assert_eq!(state.peer, None); + } + #[test] + fn bond_observation_respects_closed_window_and_pinned_peer() { + let peer = "10:6F:D9:66:97:88"; + let shared = Shared::new(None); + let mut state = shared.state.lock().unwrap(); + state.observe_bond(peer, true); + assert_eq!(state.peer, None); + state.pairing_until = Some(Instant::now() - Duration::from_secs(1)); + state.observe_bond(peer, true); + assert_eq!(state.peer, None); + state.pairing_until = Some(Instant::now() + Duration::from_secs(120)); + state.peer = Some("10:6F:D9:66:97:89".into()); + state.observe_bond(peer, true); + assert_eq!(state.peer.as_deref(), Some("10:6F:D9:66:97:89")); + } +} + +#[derive(Serialize)] +pub struct AdapterInfo { + pub name: String, + pub address: String, + pub powered: bool, +} +pub async fn adapters() -> Result, String> { + let session = Session::new().await.map_err(|e| e.to_string())?; + let mut output = vec![]; + for name in session.adapter_names().await.map_err(|e| e.to_string())? { + let adapter = session.adapter(&name).map_err(|e| e.to_string())?; + output.push(AdapterInfo { + name, + address: adapter + .address() + .await + .map_err(|e| e.to_string())? + .to_string(), + powered: adapter.is_powered().await.map_err(|e| e.to_string())?, + }); + } + Ok(output) +} diff --git a/libs/bluetooth-hid/src/protocol.rs b/libs/bluetooth-hid/src/protocol.rs new file mode 100644 index 00000000..6bf3933e --- /dev/null +++ b/libs/bluetooth-hid/src/protocol.rs @@ -0,0 +1,267 @@ +//! Classic HIDP framing and report descriptor. +use crate::Report; +pub const MAP: &[u8] = &[ + 0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x01, 0x05, 0x07, 0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00, + 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x95, 0x01, 0x75, 0x08, 0x81, 0x01, 0x95, 0x05, + 0x75, 0x01, 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x91, 0x02, 0x95, 0x01, 0x75, 0x03, 0x91, 0x01, + 0x95, 0x06, 0x75, 0x08, 0x15, 0x00, 0x25, 0x65, 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x81, 0x00, + 0xc0, 0x05, 0x01, 0x09, 0x02, 0xa1, 0x01, 0x85, 0x02, 0x09, 0x01, 0xa1, 0x00, 0x05, 0x09, 0x19, + 0x01, 0x29, 0x05, 0x15, 0x00, 0x25, 0x01, 0x95, 0x05, 0x75, 0x01, 0x81, 0x02, 0x95, 0x01, 0x75, + 0x03, 0x81, 0x01, 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38, 0x15, 0x81, 0x25, 0x7f, 0x75, + 0x08, 0x95, 0x03, 0x81, 0x06, 0xc0, 0xc0, 0x05, 0x0c, 0x09, 0x01, 0xa1, 0x01, 0x85, 0x03, 0x15, + 0x00, 0x26, 0xff, 0x03, 0x19, 0x00, 0x2a, 0xff, 0x03, 0x75, 0x10, 0x95, 0x01, 0x81, 0x00, 0xc0, +]; + +pub const HID_UUID: &str = "00001124-0000-1000-8000-00805f9b34fb"; +pub fn sdp() -> String { + let hex: String = MAP.iter().map(|byte| format!("{byte:02x}")).collect(); + format!( + r#" + + + + + + + + + + + + + + + + + + + + + + + + +"# + ) +} +#[derive(Debug, Default)] +pub struct HidProtocol { + pub boot: bool, + pub suspended: bool, + pub leds: u8, + pub keyboard: [u8; 8], + pub buttons: u8, + pub consumer: [u8; 2], +} +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ControlResult { + pub reply: Option>, + pub unplug: bool, + pub reset: bool, +} +impl HidProtocol { + pub fn input(&mut self, kind: Report, value: &[u8]) -> Result, String> { + if value.len() != kind.len() { + return Err("Invalid HID report length".into()); + } + if self.suspended { + return Err("Bluetooth HID is suspended".into()); + } + let mut packet = vec![0xa1, kind.id()]; + match kind { + Report::Keyboard => { + self.keyboard.copy_from_slice(value); + packet.extend_from_slice(value); + } + Report::Mouse => { + self.buttons = value[0]; + packet.extend_from_slice(&value[..if self.boot { 3 } else { 4 }]); + if self.boot { + packet[2] &= 7; + } + } + Report::Consumer if !self.boot => { + self.consumer.copy_from_slice(value); + packet.extend_from_slice(value); + } + Report::Consumer => return Err("Consumer keys unavailable in boot protocol".into()), + } + Ok(packet) + } + pub fn release(&mut self) -> Vec> { + self.keyboard = [0; 8]; + self.buttons = 0; + self.consumer = [0; 2]; + let mut packets = vec![ + vec![0xa1, 1, 0, 0, 0, 0, 0, 0, 0, 0], + if self.boot { + vec![0xa1, 2, 0, 0, 0] + } else { + vec![0xa1, 2, 0, 0, 0, 0] + }, + ]; + if !self.boot { + packets.push(vec![0xa1, 3, 0, 0]); + } + packets + } + pub fn output(&mut self, data: &[u8]) -> bool { + if data.len() == 3 && data[0] == 0xa2 && data[1] == 1 { + self.leds = data[2] & 0x1f; + true + } else { + false + } + } + pub fn control(&mut self, data: &[u8]) -> ControlResult { + let mut result = ControlResult::default(); + let handshake = |code| ControlResult { + reply: Some(vec![code]), + ..Default::default() + }; + let Some(&header) = data.first() else { + return handshake(4); + }; + match header { + 0x13 if data.len() == 1 => { + self.suspended = true; + result.reset = true; + } + 0x14 if data.len() == 1 => { + self.suspended = false; + result.reset = true; + } + 0x15 if data.len() == 1 => { + result.unplug = true; + } + 0x11 | 0x12 if data.len() == 1 => { + self.suspended = false; + result.reset = true; + } + 0x60 if data.len() == 1 => { + result.reply = Some(vec![0xa0, if self.boot { 0 } else { 1 }]); + } + 0x70 | 0x71 if data.len() == 1 => { + self.boot = header == 0x70; + result.reply = Some(vec![0]); + result.reset = true; + } + 0x52 if data.len() == 3 && data[1] == 1 => { + self.leds = data[2] & 0x1f; + return handshake(0); + } + 0x41 | 0x49 | 0x42 | 0x4a => { + let sized = header & 8 != 0; + if data.len() != if sized { 4 } else { 2 } { + return handshake(4); + } + let mut report = vec![0xa0 | (header & 3), data[1]]; + match (header & 3, data[1]) { + (1, 1) => report.extend(self.keyboard), + (1, 2) => report.extend(if self.boot { + vec![self.buttons & 7, 0, 0] + } else { + vec![self.buttons, 0, 0, 0] + }), + (1, 3) if !self.boot => report.extend(self.consumer), + (2, 1) => report.push(self.leds), + _ => return handshake(2), + } + if sized { + report.truncate(1 + u16::from_le_bytes([data[2], data[3]]) as usize); + } + result.reply = Some(report); + } + _ => return handshake(3), // ERR_UNSUPPORTED_REQUEST + } + result + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn classic_frames_and_boot_protocol() { + let mut hid = HidProtocol::default(); + assert_eq!( + hid.input(Report::Keyboard, &[2, 0, 4, 0, 0, 0, 0, 0]) + .unwrap(), + [0xa1, 1, 2, 0, 4, 0, 0, 0, 0, 0] + ); + assert_eq!(hid.control(&[0x60]).reply.unwrap(), [0xa0, 1]); + assert_eq!(hid.control(&[0x70]).reply.unwrap(), [0]); + assert_eq!( + hid.input(Report::Mouse, &[0x1f, 127, 128, 1]).unwrap(), + [0xa1, 2, 7, 127, 128] + ); + assert!(hid.input(Report::Consumer, &[0, 0]).is_err()); + assert!(hid.input(Report::Keyboard, &[0]).is_err()); + } + #[test] + fn led_read_write_and_errors() { + let mut hid = HidProtocol::default(); + assert!(hid.output(&[0xa2, 1, 3])); + assert_eq!(hid.leds, 3); + assert_eq!(hid.control(&[0x42, 1]).reply.unwrap(), [0xa2, 1, 3]); + assert_eq!(hid.control(&[0x52, 1, 2]).reply.unwrap(), [0]); + assert_eq!(hid.leds, 2); + assert_eq!(hid.control(&[0x41, 99]).reply.unwrap(), [2]); + assert_eq!(hid.control(&[0x41]).reply.unwrap(), [4]); + assert_eq!(hid.control(&[0x90, 0]).reply.unwrap(), [3]); + assert_eq!( + hid.control(&[0x49, 1, 3, 0]).reply.unwrap(), + [0xa1, 1, 0, 0] + ); + } + #[test] + fn suspend_reset_and_virtual_unplug() { + let mut hid = HidProtocol::default(); + assert!(hid.control(&[0x13]).reset); + assert!(hid.input(Report::Mouse, &[0; 4]).is_err()); + assert!(hid.control(&[0x14]).reset); + assert!(hid.input(Report::Mouse, &[0; 4]).is_ok()); + assert!(hid.control(&[0x15]).unplug); + hid.keyboard[2] = 4; + hid.buttons = 1; + assert_eq!(hid.release().len(), 3); + assert_eq!(hid.keyboard, [0; 8]); + assert_eq!(hid.buttons, 0); + } + #[test] + fn sdp_describes_both_classic_channels() { + let record = sdp(); + assert!(record.contains("uuid value=\"0x1124\"")); + assert!(record.contains("uint16 value=\"0x0011\"")); + assert!(record.contains("uint16 value=\"0x0013\"")); + assert!(!record.contains("0x1812")); + } + #[test] + fn descriptor_report_sizes() { + let (mut pos, mut size, mut count, mut id, mut depth) = (0, 0, 0, 0, 0); + let mut input = [0; 4]; + let mut output = [0; 4]; + while pos < MAP.len() { + let prefix = MAP[pos]; + pos += 1; + let length = [0, 1, 2, 4][(prefix & 3) as usize]; + let mut value = 0; + for i in 0..length { + value |= (MAP[pos + i] as usize) << (8 * i); + } + pos += length; + match ((prefix >> 2) & 3, prefix >> 4) { + (1, 7) => size = value, + (1, 8) => id = value, + (1, 9) => count = value, + (0, 8) => input[id] += size * count, + (0, 9) => output[id] += size * count, + (0, 10) => depth += 1, + (0, 12) => depth -= 1, + _ => {} + } + } + assert_eq!(depth, 0); + assert_eq!(input, [0, 64, 32, 16]); + assert_eq!(output, [0, 8, 0, 0]); + } +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 13885088..ac32dc00 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -96,6 +96,7 @@ fn is_setup_public_endpoint(path: &str) -> bool { "/setup" | "/setup/init" | "/devices" + | "/hid/bluetooth/adapters" | "/video/input-status" | "/stream/codecs" | "/video/codecs" diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index 6c533566..296aa2fb 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -1,6 +1,54 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct BluetoothHidConfig { + pub adapter: String, + pub name: String, + pub peer: Option, +} +impl Default for BluetoothHidConfig { + fn default() -> Self { + Self { + adapter: "hci0".into(), + name: "One-KVM HID".into(), + peer: None, + } + } +} +impl BluetoothHidConfig { + pub fn validate(&self) -> crate::error::Result<()> { + let invalid = |reason: &str| crate::error::AppError::BadRequest(reason.into()); + if !self + .adapter + .strip_prefix("hci") + .is_some_and(|s| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit())) + { + return Err(invalid( + "Bluetooth adapter must be hci followed by an index", + )); + } + if self.name.is_empty() || self.name.len() > 64 || self.name.chars().any(char::is_control) { + return Err(invalid( + "Bluetooth name must contain 1–64 UTF-8 bytes without control characters", + )); + } + if let Some(peer) = &self.peer { + let parts: Vec<_> = peer.split(':').collect(); + if parts.len() != 6 + || parts + .iter() + .any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_hexdigit())) + { + return Err(invalid("Invalid Bluetooth peer address")); + } + } + Ok(()) + } +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] @@ -8,6 +56,7 @@ use typeshare::typeshare; pub enum HidBackend { Otg, Ch9329, + Bluetooth, #[default] None, } @@ -166,6 +215,7 @@ impl OtgHidProfile { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct HidConfig { + pub bluetooth: BluetoothHidConfig, pub backend: HidBackend, pub otg_udc: Option, #[serde(default)] @@ -189,6 +239,7 @@ impl Default for HidConfig { fn default() -> Self { Self { backend: HidBackend::None, + bluetooth: BluetoothHidConfig::default(), otg_udc: None, otg_descriptor: OtgDescriptorConfig::default(), otg_profile: OtgHidProfile::default(), @@ -252,3 +303,49 @@ impl HidConfig { }) } } + +#[cfg(test)] +mod bluetooth_tests { + use super::*; + #[test] + fn old_configs_keep_bluetooth_disabled_and_get_defaults() { + let config: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap(); + assert_eq!(config.backend, HidBackend::Otg); + assert_eq!(config.bluetooth, BluetoothHidConfig::default()); + } + #[test] + fn obsolete_ble_flag_is_ignored_and_not_saved() { + let config: BluetoothHidConfig = + serde_json::from_str(r#"{"adapter":"hci0","name":"My keyboard","le_only":true}"#) + .unwrap(); + config.validate().unwrap(); + assert!(serde_json::to_value(config) + .unwrap() + .get("le_only") + .is_none()); + } + #[test] + fn bluetooth_uses_relative_mouse_and_existing_usb_constraints() { + let mut config = crate::config::AppConfig::default(); + config.hid.backend = HidBackend::Bluetooth; + config.hid.mouse_absolute = true; + config.msd.enabled = true; + config.uac.enabled = true; + config.otg_network.enabled = true; + config.enforce_invariants(); + assert!(!config.hid.mouse_absolute); + assert!(!config.msd.enabled && !config.uac.enabled && !config.otg_network.enabled); + } + #[test] + fn reject_invalid_adapter_address_and_oversize_advertisement_name() { + let mut config = BluetoothHidConfig::default(); + config.adapter = "/dev/hci0".into(); + assert!(config.validate().is_err()); + config.adapter = "hci0".into(); + config.peer = Some("not-a-mac".into()); + assert!(config.validate().is_err()); + config.peer = None; + config.name = "蓝".repeat(24); + assert!(config.validate().is_err()); + } +} diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index 0828a9ff..c20e40e3 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -56,6 +56,9 @@ impl AppConfig { self.otg_network.enabled = false; self.uac.enabled = false; } + if self.hid.backend == HidBackend::Bluetooth { + self.hid.mouse_absolute = false; + } self.atx.normalize(); } diff --git a/src/config/store.rs b/src/config/store.rs index 040eeeb5..b8324a04 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -83,6 +83,11 @@ impl ConfigStore { Ok(()) } + #[cfg(target_os = "linux")] + pub fn hid_bonds(&self) -> crate::db::hid_bonds::HidBondStore { + crate::db::hid_bonds::HidBondStore(self.pool.clone()) + } + pub fn get(&self) -> Arc { self.cache.load_full() } diff --git a/src/db/hid_bonds.rs b/src/db/hid_bonds.rs new file mode 100644 index 00000000..7305237b --- /dev/null +++ b/src/db/hid_bonds.rs @@ -0,0 +1,80 @@ +use one_kvm_bluetooth_hid::bonds::{Bond, BondStore, Operation}; +use sqlx::{Pool, Sqlite}; + +#[derive(Clone)] +pub struct HidBondStore(pub Pool); +impl BondStore for HidBondStore { + fn list(&self) -> Operation<'_, Vec> { + Box::pin(async move { + let rows: Vec<(String, String, bool)> = sqlx::query_as( + "SELECT adapter, peer, pending FROM hid_bonds ORDER BY adapter, peer", + ) + .fetch_all(&self.0) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|(adapter, peer, pending)| Bond { + adapter, + peer, + pending, + }) + .collect()) + }) + } + fn save(&self, bond: Bond) -> Operation<'_, ()> { + Box::pin(async move { + sqlx::query("INSERT INTO hid_bonds(adapter, peer, pending) VALUES (?, ?, ?) ON CONFLICT(adapter, peer) DO UPDATE SET pending = MAX(pending, excluded.pending)") + .bind(bond.adapter.to_ascii_uppercase()).bind(bond.peer.to_ascii_uppercase()).bind(bond.pending) + .execute(&self.0).await.map_err(|e| e.to_string())?; + Ok(()) + }) + } + fn remove(&self, bond: Bond) -> Operation<'_, ()> { + Box::pin(async move { + sqlx::query("DELETE FROM hid_bonds WHERE adapter = ? AND peer = ?") + .bind(bond.adapter) + .bind(bond.peer) + .execute(&self.0) + .await + .map_err(|e| e.to_string())?; + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn ownership_and_pending_cleanup_survive_reopen() { + let dir = tempfile::tempdir().unwrap(); + let db = super::super::open_database_pool(dir.path()).await.unwrap(); + let store = HidBondStore(db.clone_pool()); + let bond = Bond { + adapter: "AA:BB:CC:DD:EE:FF".into(), + peer: "11:22:33:44:55:66".into(), + pending: false, + }; + store.save(bond.clone()).await.unwrap(); + store + .save(Bond { + pending: true, + ..bond.clone() + }) + .await + .unwrap(); + store.save(bond.clone()).await.unwrap(); // Late status cannot undo a pending reset. + let reopened = super::super::open_database_pool(dir.path()).await.unwrap(); + let records = HidBondStore(reopened.clone_pool()).list().await.unwrap(); + assert_eq!( + records, + vec![Bond { + pending: true, + ..bond.clone() + }] + ); + store.remove(bond).await.unwrap(); + assert!(store.list().await.unwrap().is_empty()); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 470c4288..d8cae180 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "linux")] +pub mod hid_bonds; mod pool; mod wol_history; diff --git a/src/db/pool.rs b/src/db/pool.rs index 5b309201..3a3c5ac4 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -144,4 +144,5 @@ const SCHEMA_MIGRATIONS: &[&[&str]] = &[ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_singleton ON users ((1)) "#], + &["CREATE TABLE hid_bonds (adapter TEXT NOT NULL, peer TEXT NOT NULL, pending INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(adapter, peer))"], ]; diff --git a/src/hid/backend.rs b/src/hid/backend.rs index 8ffa899c..6f02583e 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -18,6 +18,9 @@ fn default_ch9329_baud_rate() -> u32 { #[derive(Default)] pub enum HidBackendType { Otg, + Bluetooth { + config: crate::config::BluetoothHidConfig, + }, Ch9329 { port: String, #[serde(default = "default_ch9329_baud_rate")] @@ -33,6 +36,7 @@ impl HidBackendType { pub fn name_str(&self) -> &str { match self { Self::Otg => "otg", + Self::Bluetooth { .. } => "bluetooth", Self::Ch9329 { .. } => "ch9329", Self::None => "none", } @@ -81,6 +85,17 @@ pub trait HidBackend: Send + Sync { )) } + async fn bluetooth_status(&self) -> Result { + Err(crate::error::AppError::BadRequest( + "Bluetooth HID is not active".into(), + )) + } + async fn bluetooth_action(&self, _action: &str, _seconds: u32) -> Result<()> { + Err(crate::error::AppError::BadRequest( + "Bluetooth HID is not active".into(), + )) + } + async fn reset(&self) -> Result<()>; async fn prepare_rebuild(&self) -> Result<()> { diff --git a/src/hid/bluetooth.rs b/src/hid/bluetooth.rs new file mode 100644 index 00000000..5c248353 --- /dev/null +++ b/src/hid/bluetooth.rs @@ -0,0 +1,283 @@ +//! Translate canonical One-KVM input into the native BlueZ peripheral. +use super::{ + backend::{HidBackend, HidBackendRuntimeSnapshot}, + types::{ + ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType, + }, +}; +use crate::{ + config::BluetoothHidConfig, + error::{AppError, Result}, + events::LedState, +}; +use async_trait::async_trait; +use one_kvm_bluetooth_hid::{Action, Config, Peripheral, Report}; +use tokio::sync::{watch, Mutex}; + +fn error(message: String) -> AppError { + AppError::HidError { + backend: "bluetooth".into(), + error_code: "bluetooth_error".into(), + reason: message, + } +} +#[derive(Default)] +struct InputState { + keyboard: KeyboardReport, + buttons: u8, + generation: u64, +} +pub struct BluetoothBackend { + peripheral: Peripheral, + input: Mutex, + runtime: watch::Sender<()>, + worker: Mutex>>, +} +impl BluetoothBackend { + pub fn new( + config: BluetoothHidConfig, + bonds: Option>, + ) -> Result { + let peripheral = Peripheral::start_with_store( + Config { + adapter: config.adapter, + name: config.name, + peer: config.peer, + }, + bonds, + ) + .map_err(error)?; + let (runtime, _) = watch::channel(()); + Ok(Self { + peripheral, + input: Mutex::new(InputState::default()), + runtime, + worker: Mutex::new(None), + }) + } + fn check(&self, input: &mut InputState) -> Result<()> { + let status = self.peripheral.status(); + if input.generation != status.generation || !status.ready { + *input = InputState { + generation: status.generation, + ..Default::default() + }; + } + if !status.ready { + return Err(error(status.error.unwrap_or_else(|| { + "Pair and connect a computer; HID reports are not ready".into() + }))); + } + Ok(()) + } +} +#[async_trait] +impl HidBackend for BluetoothBackend { + async fn init(&self) -> Result<()> { + let mut status = self.peripheral.subscribe(); + let runtime = self.runtime.clone(); + *self.worker.lock().await = Some(tokio::spawn(async move { + let mut last_error = None; + while status.changed().await.is_ok() { + let error = status.borrow_and_update().error.clone(); + if error != last_error { + if let Some(reason) = &error { + tracing::warn!(%reason, "Bluetooth HID unavailable"); + } + last_error = error; + } + runtime.send_replace(()); + } + })); + Ok(()) + } + async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { + let mut input = self.input.lock().await; + self.check(&mut input)?; + apply_key(&mut input.keyboard, &event); + let result = self + .peripheral + .send(Report::Keyboard, input.keyboard.to_bytes().to_vec()) + .await + .map_err(error); + if result.is_err() { + *input = InputState::default(); + } + result + } + async fn send_mouse(&self, event: MouseEvent) -> Result<()> { + let mut input = self.input.lock().await; + self.check(&mut input)?; + let (mut x, mut y, wheel) = match event.event_type { + MouseEventType::Move => (event.x, event.y, 0), + MouseEventType::MoveAbs => { + return Err(AppError::BadRequest( + "Bluetooth HID supports relative mouse only".into(), + )) + } + MouseEventType::Down | MouseEventType::Up => { + if let Some(button) = event.button { + let bit = button.to_hid_bit(); + if event.event_type == MouseEventType::Down { + input.buttons |= bit; + } else { + input.buttons &= !bit; + } + } + (0, 0, 0) + } + MouseEventType::Scroll => (0, 0, event.scroll), + }; + // Bound malformed remote input without silently clipping normal relative movements. + if x.unsigned_abs() > 32767 || y.unsigned_abs() > 32767 { + return Err(AppError::BadRequest( + "Relative mouse displacement too large".into(), + )); + } + loop { + let dx = x.clamp(-127, 127); + let dy = y.clamp(-127, 127); + self.peripheral + .send( + Report::Mouse, + vec![input.buttons, dx as i8 as u8, dy as i8 as u8, wheel as u8], + ) + .await + .map_err(error)?; + x -= dx; + y -= dy; + if x == 0 && y == 0 { + break; + } + } + Ok(()) + } + async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { + let mut input = self.input.lock().await; + self.check(&mut input)?; + if event.usage > 0x3ff { + return Err(AppError::BadRequest( + "Consumer usage exceeds Bluetooth report range".into(), + )); + } + self.peripheral + .send(Report::Consumer, event.usage.to_le_bytes().to_vec()) + .await + .map_err(error) + } + async fn reset(&self) -> Result<()> { + *self.input.lock().await = InputState::default(); + self.peripheral.action(Action::Reset).await.map_err(error) + } + async fn shutdown(&self) -> Result<()> { + let result = self.peripheral.shutdown().await.map_err(error); + if let Some(worker) = self.worker.lock().await.take() { + worker.abort(); + } + result + } + fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot { + let state = self.peripheral.status(); + HidBackendRuntimeSnapshot { + initialized: state.initialized, + online: state.ready, + supports_absolute_mouse: false, + keyboard_leds_enabled: true, + led_state: LedState { + num_lock: state.leds & 1 != 0, + caps_lock: state.leds & 2 != 0, + scroll_lock: state.leds & 4 != 0, + compose: state.leds & 8 != 0, + kana: state.leds & 16 != 0, + }, + device: Some(state.peer.unwrap_or(state.adapter)), + screen_resolution: None, + error_code: state.error.as_ref().map(|_| "bluetooth_error".into()), + error: state.error, + } + } + fn subscribe_runtime(&self) -> watch::Receiver<()> { + self.runtime.subscribe() + } + async fn bluetooth_status(&self) -> Result { + serde_json::to_value(self.peripheral.status()).map_err(|e| error(e.to_string())) + } + async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> { + if action == "pair" && !(10..=300).contains(&seconds) { + return Err(AppError::BadRequest( + "Pairing window must be 10–300 seconds".into(), + )); + } + let action = match action { + "pair" => Action::Pair(seconds), + "close" => Action::ClosePairing, + "forget" => Action::Forget, + "disconnect" => Action::Disconnect, + _ => return Err(AppError::BadRequest("Unknown Bluetooth action".into())), + }; + self.peripheral.action(action).await.map_err(error) + } +} +fn apply_key(report: &mut KeyboardReport, event: &KeyboardEvent) { + if let Some(bit) = event.key.modifier_bit() { + match event.event_type { + KeyEventType::Down => report.modifiers |= bit, + KeyEventType::Up => report.modifiers &= !bit, + } + } else { + report.modifiers = event.modifiers.to_hid_byte(); + let usage = event.key.to_hid_usage(); + match event.event_type { + KeyEventType::Down if !report.keys.contains(&usage) => { + report.add_key(usage); + } + KeyEventType::Up => report.remove_key(usage), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hid::{CanonicalKey, KeyboardModifiers}; + #[test] + fn repeated_keydown_does_not_leave_stuck_keys() { + let mut report = KeyboardReport::default(); + let event = KeyboardEvent { + key: CanonicalKey::KeyA, + event_type: KeyEventType::Down, + modifiers: KeyboardModifiers::default(), + }; + apply_key(&mut report, &event); + apply_key(&mut report, &event); + assert_eq!(report.keys.iter().filter(|&&key| key == 4).count(), 1); + apply_key( + &mut report, + &KeyboardEvent { + event_type: KeyEventType::Up, + ..event + }, + ); + assert_eq!(report.to_bytes(), [0; 8]); + } + #[test] + fn modifier_press_and_release() { + let mut report = KeyboardReport::default(); + let event = KeyboardEvent { + key: CanonicalKey::ShiftRight, + event_type: KeyEventType::Down, + modifiers: KeyboardModifiers::default(), + }; + apply_key(&mut report, &event); + assert_eq!(report.modifiers, 0x20); + apply_key( + &mut report, + &KeyboardEvent { + event_type: KeyEventType::Up, + ..event + }, + ); + assert_eq!(report.modifiers, 0); + } +} diff --git a/src/hid/factory.rs b/src/hid/factory.rs index f92afbd5..ce9f7ac0 100644 --- a/src/hid/factory.rs +++ b/src/hid/factory.rs @@ -8,6 +8,8 @@ use crate::error::{AppError, Result}; use crate::otg::OtgService; pub struct HidBackendFactory { + #[cfg(target_os = "linux")] + pub bonds: std::sync::OnceLock>, #[cfg(unix)] otg_service: Option>, } @@ -15,7 +17,11 @@ pub struct HidBackendFactory { impl HidBackendFactory { #[cfg(unix)] pub fn new(otg_service: Option>) -> Self { - Self { otg_service } + Self { + otg_service, + #[cfg(target_os = "linux")] + bonds: Default::default(), + } } #[cfg(not(unix))] @@ -54,6 +60,22 @@ impl HidBackendFactory { *hybrid_mouse, )?))) } + HidBackendType::Bluetooth { config } => { + #[cfg(target_os = "linux")] + { + Ok(Some(Arc::new(super::bluetooth::BluetoothBackend::new( + config.clone(), + self.bonds.get().cloned(), + )?))) + } + #[cfg(not(target_os = "linux"))] + { + let _ = config; + Err(AppError::Config( + "Bluetooth HID requires Linux and BlueZ".into(), + )) + } + } HidBackendType::None => { warn!("HID backend disabled"); Ok(None) diff --git a/src/hid/mod.rs b/src/hid/mod.rs index ea643c97..34e8364d 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -1,6 +1,8 @@ //! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329. pub mod backend; +#[cfg(target_os = "linux")] +mod bluetooth; pub mod ch9329; mod ch9329_proto; pub mod consumer; @@ -132,6 +134,7 @@ pub struct HidController { hid_worker: Mutex>>, runtime_worker: Mutex>>, backend_available: Arc, + reset_requested: Arc, } impl HidController { @@ -153,6 +156,7 @@ impl HidController { hid_worker: Mutex::new(None), runtime_worker: Mutex::new(None), backend_available: Arc::new(AtomicBool::new(false)), + reset_requested: Arc::new(AtomicBool::new(false)), } } @@ -174,9 +178,15 @@ impl HidController { hid_worker: Mutex::new(None), runtime_worker: Mutex::new(None), backend_available: Arc::new(AtomicBool::new(false)), + reset_requested: Arc::new(AtomicBool::new(false)), } } + #[cfg(target_os = "linux")] + pub fn set_bond_store(&self, store: crate::db::hid_bonds::HidBondStore) { + let _ = self.backend_factory.bonds.set(Arc::new(store)); + } + pub async fn set_event_bus(&self, events: Arc) { *self.events.write().await = Some(events); } @@ -293,6 +303,25 @@ impl HidController { self.enqueue_event(QueuedHidEvent::Consumer(event)).await } + pub async fn bluetooth_status(&self) -> Result { + let backend = self + .backend + .read() + .await + .clone() + .ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?; + backend.bluetooth_status().await + } + pub async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> { + let backend = self + .backend + .read() + .await + .clone() + .ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?; + backend.bluetooth_action(action, seconds).await + } + pub async fn reset(&self) -> Result<()> { if !self.backend_available.load(Ordering::Acquire) { return Ok(()); @@ -349,6 +378,14 @@ impl HidController { if let Some(backend) = self.backend.write().await.take() { if let Err(e) = backend.shutdown().await { + // A Bluetooth shutdown may fail to restore adapter settings. Surface + // that failure so the config transaction can roll back. + if matches!( + *self.backend_type.read().await, + HidBackendType::Bluetooth { .. } + ) { + return Err(e); + } warn!("Error shutting down old HID backend: {}", e); } } @@ -437,6 +474,7 @@ impl HidController { let backend = self.backend.clone(); let pending_move = self.pending_move.clone(); let pending_move_flag = self.pending_move_flag.clone(); + let reset_requested = self.reset_requested.clone(); let handle = tokio::spawn(async move { let mut rx = rx; @@ -446,6 +484,15 @@ impl HidController { None => break, }; + if reset_requested.swap(false, Ordering::AcqRel) { + // A full input queue must not lose a key/button release and leave + // the host stuck. Discard the obsolete batch and send all-up. + while rx.try_recv().is_ok() {} + *pending_move.lock() = None; + pending_move_flag.store(false, Ordering::Release); + process_hid_event(QueuedHidEvent::Reset, &backend).await; + continue; + } process_hid_event(event, &backend).await; if pending_move_flag.swap(false, Ordering::AcqRel) { @@ -504,7 +551,7 @@ impl HidController { match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) { Ok(_) => Ok(()), Err(mpsc::error::TrySendError::Full(_)) => { - *self.pending_move.lock() = Some(event); + merge_pending_move(&mut self.pending_move.lock(), event); self.pending_move_flag.store(true, Ordering::Release); Ok(()) } @@ -524,11 +571,16 @@ impl HidController { tx.send(ev), ) .await; - if send_result.is_ok() { - Ok(()) - } else { - warn!("HID event queue full, dropping event"); - Ok(()) + match send_result { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(AppError::BadRequest("HID event queue closed".into())), + Err(_) => { + self.reset_requested.store(true, Ordering::Release); + warn!("HID event queue full; scheduling all-input release"); + Err(AppError::ServiceUnavailable( + "HID input queue full; input state will be reset".into(), + )) + } } } Err(mpsc::error::TrySendError::Closed(_)) => { @@ -620,3 +672,100 @@ async fn apply_runtime_state( events.mark_device_info_dirty(); } } + +fn merge_pending_move(pending: &mut Option, event: MouseEvent) { + if let Some(previous) = pending { + if previous.event_type == MouseEventType::Move && event.event_type == MouseEventType::Move { + previous.x = previous.x.saturating_add(event.x).clamp(-32767, 32767); + previous.y = previous.y.saturating_add(event.y).clamp(-32767, 32767); + return; + } + } + *pending = Some(event); +} + +#[cfg(test)] +mod queue_tests { + use super::*; + struct TestBackend { + pressed: Arc, + reset_done: Arc, + runtime: tokio::sync::watch::Sender<()>, + } + #[async_trait::async_trait] + impl HidBackend for TestBackend { + async fn init(&self) -> Result<()> { + Ok(()) + } + async fn send_keyboard(&self, _: KeyboardEvent) -> Result<()> { + self.pressed.store(true, Ordering::Release); + Ok(()) + } + async fn send_mouse(&self, _: MouseEvent) -> Result<()> { + Ok(()) + } + async fn reset(&self) -> Result<()> { + self.pressed.store(false, Ordering::Release); + self.reset_done.notify_one(); + Ok(()) + } + async fn shutdown(&self) -> Result<()> { + Ok(()) + } + fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot { + HidBackendRuntimeSnapshot::default() + } + fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> { + self.runtime.subscribe() + } + } + #[tokio::test] + async fn congested_queue_releases_host_instead_of_replaying_keydowns() { + #[cfg(unix)] + let controller = HidController::new(HidBackendType::None, None); + #[cfg(not(unix))] + let controller = HidController::new(HidBackendType::None); + let pressed = Arc::new(AtomicBool::new(true)); + let done = Arc::new(tokio::sync::Notify::new()); + let (runtime, _) = tokio::sync::watch::channel(()); + *controller.backend.write().await = Some(Arc::new(TestBackend { + pressed: pressed.clone(), + reset_done: done.clone(), + runtime, + })); + for _ in 0..HID_EVENT_QUEUE_CAPACITY { + controller + .enqueue_event(QueuedHidEvent::Keyboard(KeyboardEvent::key_down( + CanonicalKey::KeyA, + KeyboardModifiers::default(), + ))) + .await + .unwrap(); + } + assert!(controller + .enqueue_event(QueuedHidEvent::Reset) + .await + .is_err()); + controller.start_event_worker().await; + tokio::time::timeout(Duration::from_secs(1), done.notified()) + .await + .unwrap(); + assert!(!pressed.load(Ordering::Acquire)); + } + + #[test] + fn relative_motion_is_accumulated_but_absolute_is_replaced() { + let mut pending = Some(MouseEvent::move_rel(100, -20)); + merge_pending_move(&mut pending, MouseEvent::move_rel(80, 30)); + assert_eq!( + (pending.as_ref().unwrap().x, pending.as_ref().unwrap().y), + (180, 10) + ); + merge_pending_move(&mut pending, MouseEvent::move_abs(10, 20)); + merge_pending_move(&mut pending, MouseEvent::move_abs(30, 40)); + assert_eq!( + (pending.as_ref().unwrap().x, pending.as_ref().unwrap().y), + (30, 40) + ); + } +} diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index 45d4ed46..0add2d16 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -104,6 +104,8 @@ impl RuntimeBuilder { let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone()))); #[cfg(not(unix))] let hid = Arc::new(HidController::new(hid_backend)); + #[cfg(target_os = "linux")] + hid.set_bond_store(config_store.hid_bonds()); hid.set_event_bus(events.clone()).await; if let Err(error) = hid.init().await { tracing::warn!("Failed to initialize HID backend: {}", error); @@ -412,6 +414,9 @@ fn hid_backend_type(config: &AppConfig) -> HidBackendType { hybrid_mouse: config.hid.ch9329_hybrid_mouse, }, config::HidBackend::None => HidBackendType::None, + config::HidBackend::Bluetooth => HidBackendType::Bluetooth { + config: config.hid.bluetooth.clone(), + }, } } diff --git a/src/runtime/usb.rs b/src/runtime/usb.rs index 1c3db5c7..5cde110a 100644 --- a/src/runtime/usb.rs +++ b/src/runtime/usb.rs @@ -168,6 +168,7 @@ impl UsbCoordinator { options: ConfigApplyOptions, ) -> Result<()> { new_config.validate_otg_functions()?; + new_config.bluetooth.validate()?; let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; let hid_functions_changed = @@ -180,6 +181,7 @@ impl UsbCoordinator { if old_config.backend == new_config.backend && old_config.ch9329_port == new_config.ch9329_port && old_config.ch9329_baudrate == new_config.ch9329_baudrate + && old_config.bluetooth == new_config.bluetooth && !ch9329_runtime_changed && old_config.otg_udc == new_config.otg_udc && !descriptor_changed @@ -314,6 +316,9 @@ fn hid_backend_type(config: &HidConfig) -> HidBackendType { hybrid_mouse: config.ch9329_hybrid_mouse, }, HidBackend::None => HidBackendType::None, + HidBackend::Bluetooth => HidBackendType::Bluetooth { + config: config.bluetooth.clone(), + }, } } diff --git a/src/web/handlers/config/hid.rs b/src/web/handlers/config/hid.rs index 7e4d88db..9add4191 100644 --- a/src/web/handlers/config/hid.rs +++ b/src/web/handlers/config/hid.rs @@ -5,7 +5,7 @@ use crate::error::Result; use crate::web::state::UsbApiState; use super::types::HidConfigUpdate; -use super::usb_update::{stage_hid_config_update, update_usb_config}; +use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset}; pub async fn get_hid_config(State(state): State) -> Json { Json(state.config.get().hid.clone()) @@ -15,7 +15,8 @@ pub async fn update_hid_config( State(state): State, Json(req): Json, ) -> Result> { - let config = update_usb_config(&state, move |staged| { + let reset = req.bluetooth_reset_pairing.unwrap_or(false); + let config = update_usb_config_with_reset(&state, reset, move |staged| { stage_hid_config_update(&mut staged.hid, &req) }) .await?; diff --git a/src/web/handlers/config/otg.rs b/src/web/handlers/config/otg.rs index 9b9f4d56..4ba7004f 100644 --- a/src/web/handlers/config/otg.rs +++ b/src/web/handlers/config/otg.rs @@ -8,7 +8,7 @@ use crate::otg::OtgNetworkStatus; use crate::web::state::UsbApiState; use super::types::OtgConfigUpdate; -use super::usb_update::{stage_hid_config_update, update_usb_config}; +use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset}; #[typeshare] #[derive(Debug, Serialize)] @@ -30,7 +30,12 @@ pub(super) async fn update_otg_config_inner( state: &UsbApiState, request: OtgConfigUpdate, ) -> Result { - let staged_config = update_usb_config(state, move |staged| { + let reset = request + .hid + .as_ref() + .and_then(|h| h.bluetooth_reset_pairing) + .unwrap_or(false); + let staged_config = update_usb_config_with_reset(state, reset, move |staged| { let requested_ch9329_descriptor = match request.hid.as_ref() { Some(update) => stage_hid_config_update(&mut staged.hid, update)?, None => None, diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 23bdfe3b..f988ee03 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -402,6 +402,9 @@ impl Ch9329DescriptorConfigUpdate { #[typeshare] #[derive(Debug, Deserialize)] pub struct HidConfigUpdate { + /// Request-only; never persisted or replayed during startup. + pub bluetooth_reset_pairing: Option, + pub bluetooth: Option, pub backend: Option, pub ch9329_port: Option, pub ch9329_baudrate: Option, @@ -426,6 +429,9 @@ pub struct OtgConfigUpdate { impl HidConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { + if let Some(config) = &self.bluetooth { + config.validate()?; + } if let Some(baudrate) = self.ch9329_baudrate { let valid_rates = [9600, 19200, 38400, 57600, 115200]; if !valid_rates.contains(&baudrate) { @@ -444,6 +450,9 @@ impl HidConfigUpdate { } pub fn apply_to(&self, config: &mut HidConfig) { + if let Some(bluetooth) = &self.bluetooth { + config.bluetooth = bluetooth.clone(); + } if let Some(backend) = self.backend.clone() { config.backend = backend; } diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index d4f4dfce..a5d73267 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -26,6 +26,17 @@ pub(super) fn stage_hid_config_update( } pub(super) async fn update_usb_config(state: &UsbApiState, stage_update: F) -> Result +where + F: FnOnce(&mut AppConfig) -> Result>, +{ + update_usb_config_with_reset(state, false, stage_update).await +} + +pub(super) async fn update_usb_config_with_reset( + state: &UsbApiState, + reset: bool, + stage_update: F, +) -> Result where F: FnOnce(&mut AppConfig) -> Result>, { @@ -53,63 +64,174 @@ where staged_config.uac.validate()?; } - if let Err(error) = state - .coordinator - .apply_config(&old_config, &staged_config) - .await - { - return Err(rollback_after_failure(state, &staged_config, &old_config, error, false).await); + staged_config.hid.bluetooth.validate()?; + if reset && staged_config.hid.backend != HidBackend::Bluetooth { + return Err(AppError::BadRequest( + "Pairing reset requires Bluetooth HID".into(), + )); + } + #[cfg(not(target_os = "linux"))] + if reset { + return Err(AppError::BadRequest("Bluetooth HID requires Linux".into())); + } + #[cfg(target_os = "linux")] + if reset { + use one_kvm_bluetooth_hid::{ + adapters, + bonds::{self, Bond, BondStore}, + }; + let adapters = adapters().await.map_err(AppError::Config)?; + if !adapters + .iter() + .any(|a| a.name == staged_config.hid.bluetooth.adapter) + { + return Err(AppError::BadRequest( + "Selected Bluetooth adapter is missing".into(), + )); + } + let mut explicit = Vec::new(); + let mut runtime = state.hid.bluetooth_status().await.ok(); + if runtime + .as_ref() + .is_some_and(|s| s["initialized"].as_bool() == Some(true)) + { + // Publish the latest bond before shutdown, including pairing completed + // since the last 500 ms status update. No new peers can enter afterward. + state.hid.bluetooth_action("close", 120).await?; + runtime = state.hid.bluetooth_status().await.ok(); + } + if let Some(status) = runtime { + if let (Some(adapter), Some(peer)) = + (status["adapter_address"].as_str(), status["peer"].as_str()) + { + if !adapter.is_empty() { + explicit.push(Bond { + adapter: adapter.into(), + peer: peer.into(), + pending: true, + }); + } + } + } + let owned = state + .config + .hid_bonds() + .list() + .await + .map_err(AppError::Config)?; + for config in [&old_config.hid.bluetooth, &staged_config.hid.bluetooth] { + // A recorded hardware address takes precedence over a potentially + // renumbered hci index. Runtime targets above also carry real addresses. + if config.peer.as_ref().is_some_and(|peer| { + owned + .iter() + .chain(explicit.iter()) + .any(|bond| bond.peer.eq_ignore_ascii_case(peer)) + }) { + continue; + } + if let (Some(peer), Some(adapter)) = ( + &config.peer, + adapters.iter().find(|a| a.name == config.adapter), + ) { + explicit.push(Bond { + adapter: adapter.address.clone(), + peer: peer.clone(), + pending: true, + }); + } + } + // Stop observation before writing tombstones or removing bonds. + if old_config.hid.backend == HidBackend::Bluetooth { + state.hid.reload(crate::hid::HidBackendType::None).await?; + } + if let Err(error) = bonds::reset(&state.config.hid_bonds(), explicit).await { + return Err(AppError::Config(format!("Bluetooth binding cleanup failed; some old bindings may already be cleared and require pairing again: {error}"))); + } + staged_config.hid.bluetooth.peer = None; } - let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor { - if staged_config.hid.backend == HidBackend::Ch9329 { - match state.hid.apply_ch9329_descriptor(descriptor).await { - Ok(actual) => { - staged_config.hid.ch9329_descriptor = actual.descriptor; - true - } - Err(error) => { - return Err(rollback_after_failure( - state, - &staged_config, - &old_config, - error, - true, - ) - .await); + let result = async { + if let Err(error) = state + .coordinator + .apply_config(&old_config, &staged_config) + .await + { + return Err( + rollback_after_failure(state, &staged_config, &old_config, error, false).await, + ); + } + + #[cfg(target_os = "linux")] + if reset + && !matches!( + state.hid.backend_type().await, + crate::hid::HidBackendType::Bluetooth { .. } + ) + { + // Even identical device selections must rebuild with no pinned peer. + state + .hid + .reload(crate::hid::HidBackendType::Bluetooth { + config: staged_config.hid.bluetooth.clone(), + }) + .await?; + } + + let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor { + if staged_config.hid.backend == HidBackend::Ch9329 { + match state.hid.apply_ch9329_descriptor(descriptor).await { + Ok(actual) => { + staged_config.hid.ch9329_descriptor = actual.descriptor; + true + } + Err(error) => { + return Err(rollback_after_failure( + state, + &staged_config, + &old_config, + error, + true, + ) + .await); + } } + } else { + false } } else { false + }; + + if let Err(error) = state + .config + .update(|config| { + config.hid = staged_config.hid.clone(); + config.msd = staged_config.msd.clone(); + config.otg_network = staged_config.otg_network.clone(); + config.uac = staged_config.uac.clone(); + config.enforce_invariants(); + }) + .await + { + return Err(rollback_after_failure( + state, + &staged_config, + &old_config, + AppError::Config(format!( + "Failed to persist USB configuration after apply: {error}" + )), + descriptor_was_applied, + ) + .await); } - } else { - false - }; - if let Err(error) = state - .config - .update(|config| { - config.hid = staged_config.hid.clone(); - config.msd = staged_config.msd.clone(); - config.otg_network = staged_config.otg_network.clone(); - config.uac = staged_config.uac.clone(); - config.enforce_invariants(); - }) - .await - { - return Err(rollback_after_failure( - state, - &staged_config, - &old_config, - AppError::Config(format!( - "Failed to persist USB configuration after apply: {error}" - )), - descriptor_was_applied, - ) - .await); + Ok(staged_config) } - - Ok(staged_config) + .await; + result.map_err(|error| if reset { + AppError::Config(format!("Old One-KVM HID bindings were cleared; pair again. Configuration apply failed: {error}")) + } else { error }) } async fn rollback_after_failure( @@ -156,6 +278,8 @@ mod tests { fn hid_update() -> HidConfigUpdate { HidConfigUpdate { + bluetooth_reset_pairing: None, + bluetooth: None, backend: None, ch9329_port: None, ch9329_baudrate: None, @@ -170,6 +294,37 @@ mod tests { } } + #[test] + fn reset_is_request_only_and_feature_updates_preserve_device_selection() { + let mut hid = HidConfig::default(); + hid.backend = HidBackend::Ch9329; + hid.ch9329_port = "COM7".into(); + let update: HidConfigUpdate = serde_json::from_value(serde_json::json!({ + "ch9329_hybrid_mouse": true, "bluetooth_reset_pairing": true + })) + .unwrap(); + stage_hid_config_update(&mut hid, &update).unwrap(); + assert_eq!(hid.backend, HidBackend::Ch9329); + assert_eq!(hid.ch9329_port, "COM7"); + assert!(serde_json::to_value(&hid) + .unwrap() + .get("bluetooth_reset_pairing") + .is_none()); + } + + #[test] + fn invalid_target_does_not_mutate_staged_config() { + let mut hid = HidConfig::default(); + let before = serde_json::to_value(&hid).unwrap(); + let update: HidConfigUpdate = serde_json::from_value(serde_json::json!({ + "backend": "bluetooth", "bluetooth_reset_pairing": true, + "bluetooth": { "adapter": "hci0", "name": "" } + })) + .unwrap(); + assert!(stage_hid_config_update(&mut hid, &update).is_err()); + assert_eq!(serde_json::to_value(hid).unwrap(), before); + } + #[test] fn stages_regular_hid_fields_immediately() { let mut hid = HidConfig::default(); diff --git a/src/web/handlers/hid_api.rs b/src/web/handlers/hid_api.rs index 5794c216..e1f25b01 100644 --- a/src/web/handlers/hid_api.rs +++ b/src/web/handlers/hid_api.rs @@ -112,3 +112,43 @@ fn cached_ch9329_descriptor( descriptor, } } + +#[derive(Deserialize)] +pub struct BluetoothAction { + pub action: String, + pub seconds: Option, +} +pub async fn hid_bluetooth_status( + State(state): State>, +) -> Result> { + Ok(Json(state.hid.bluetooth_status().await?)) +} +pub async fn hid_bluetooth_action( + State(state): State>, + Json(req): Json, +) -> Result> { + let _guard = crate::runtime::try_apply_lock(&state.config_apply_locks.otg, "bluetooth")?; + state + .hid + .bluetooth_action(&req.action, req.seconds.unwrap_or(120)) + .await?; + Ok(Json(serde_json::json!({"success": true}))) +} + +pub async fn hid_bluetooth_adapters() -> Result> { + #[cfg(target_os = "linux")] + { + Ok(Json( + serde_json::to_value( + one_kvm_bluetooth_hid::adapters() + .await + .map_err(AppError::ServiceUnavailable)?, + ) + .map_err(|e| AppError::Internal(e.to_string()))?, + )) + } + #[cfg(not(target_os = "linux"))] + { + Err(AppError::BadRequest("Bluetooth HID requires Linux".into())) + } +} diff --git a/src/web/routes.rs b/src/web/routes.rs index 29f7eb32..1f1bd5ca 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -91,6 +91,14 @@ pub fn create_router(state: Arc) -> Router { .route("/webrtc/close", post(handlers::webrtc_close_session)) // HID endpoints .route("/hid/status", get(handlers::hid_status)) + .route( + "/hid/bluetooth/adapters", + get(handlers::hid_bluetooth_adapters), + ) + .route( + "/hid/bluetooth", + get(handlers::hid_bluetooth_status).post(handlers::hid_bluetooth_action), + ) .route( "/hid/ch9329/descriptor", get(handlers::hid_ch9329_descriptor),