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/libs/hwcodec/build.rs b/libs/hwcodec/build.rs index 53dcc822..08441f97 100644 --- a/libs/hwcodec/build.rs +++ b/libs/hwcodec/build.rs @@ -5,7 +5,8 @@ use std::{ }; fn main() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // Read the current source path when running, since build artifacts may move. + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let cpp_dir = manifest_dir.join("cpp"); println!("cargo:rerun-if-changed=src"); println!("cargo:rerun-if-changed={}", cpp_dir.display()); @@ -17,7 +18,7 @@ fn main() { } fn build_common(builder: &mut Build) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); let common_dir = manifest_dir.join("cpp").join("common"); @@ -367,7 +368,7 @@ mod ffmpeg { } fn ffmpeg_ffi() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let ffmpeg_ram_dir = manifest_dir.join("cpp").join("common"); let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h"); println!("cargo:rerun-if-changed={}", ffi_header_path.display()); @@ -381,7 +382,7 @@ mod ffmpeg { } fn build_ffmpeg_ram(builder: &mut Build) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let ffmpeg_ram_dir = manifest_dir.join("cpp").join("ffmpeg_ram"); let ffi_header = ffmpeg_ram_dir .join("ffmpeg_ram_ffi.h") @@ -420,7 +421,7 @@ mod ffmpeg { return; } - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let capture_header = manifest_dir .join("cpp") .join("ffmpeg_capture_ffi.h") @@ -443,7 +444,7 @@ mod ffmpeg { } fn build_ffmpeg_hw(builder: &mut Build) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let ffmpeg_hw_dir = manifest_dir.join("cpp").join("ffmpeg_hw"); let ffi_header = ffmpeg_hw_dir .join("ffmpeg_hw_ffi.h") @@ -487,6 +488,7 @@ mod ffmpeg { } } builder.file(ffmpeg_hw_dir.join("ffmpeg_hw_mjpeg_h26x.cpp")); + builder.file(ffmpeg_hw_dir.join("rkmpp_dmabuf.cpp")); } else { println!( "cargo:info=Skipping ffmpeg_hw_mjpeg_h26x.cpp (RKMPP) for arch {}", diff --git a/libs/hwcodec/cpp/common/platform/linux/linux.cpp b/libs/hwcodec/cpp/common/platform/linux/linux.cpp index 94ccd566..a4b160d9 100644 --- a/libs/hwcodec/cpp/common/platform/linux/linux.cpp +++ b/libs/hwcodec/cpp/common/platform/linux/linux.cpp @@ -146,6 +146,67 @@ int linux_support_v4l2m2m() { return false; }; + auto is_qcom_platform = [&]() -> bool { + const char *platform_hints[] = { + "qcom", + "qualcomm", + "venus", + "iris", + "sc7280", + "qcm6490", + "qcs6490", + }; + + const char *platform_files[] = { + "/proc/device-tree/compatible", + "/proc/device-tree/model", + "/sys/firmware/devicetree/base/compatible", + "/sys/firmware/devicetree/base/model", + }; + + for (size_t i = 0; i < sizeof(platform_files) / sizeof(platform_files[0]); i++) { + std::string value; + if (read_text_file(platform_files[i], &value) && + contains_any(to_lower(value), platform_hints, + sizeof(platform_hints) / sizeof(platform_hints[0]))) { + return true; + } + } + + const char *video_nodes[] = { + "video0", + "video1", + "video2", + "video3", + "video10", + "video11", + "video32", + }; + const char *video_hints[] = { + "qcom-iris", + "qcom,", + "venus", + "iris", + }; + + for (size_t i = 0; i < sizeof(video_nodes) / sizeof(video_nodes[0]); i++) { + std::string name; + std::string modalias; + const std::string base = std::string("/sys/class/video4linux/") + video_nodes[i]; + if (read_text_file((base + "/name").c_str(), &name) && + contains_any(to_lower(name), video_hints, sizeof(video_hints) / sizeof(video_hints[0]))) { + return true; + } + if (read_text_file((base + "/device/modalias").c_str(), &modalias) && + contains_any(to_lower(modalias), video_hints, + sizeof(video_hints) / sizeof(video_hints[0]))) { + return true; + } + } + + return false; + }; + auto is_amlogic_platform = [&]() -> bool { const char *platform_hints[] = { "amlogic", @@ -210,7 +271,8 @@ int linux_support_v4l2m2m() { return false; }; - const bool amlogic_platform = is_amlogic_platform(); + const bool qcom_platform = is_qcom_platform(); + const bool amlogic_platform = !qcom_platform && is_amlogic_platform(); if (amlogic_platform && !v4l2m2m_allowed()) { LOG_WARN(std::string( "V4L2 M2M: skipped probe on Amlogic platform; set ONE_KVM_V4L2M2M_ALLOW=1 to enable")); diff --git a/libs/hwcodec/cpp/common/util.cpp b/libs/hwcodec/cpp/common/util.cpp index 63b4e430..a2762aab 100644 --- a/libs/hwcodec/cpp/common/util.cpp +++ b/libs/hwcodec/cpp/common/util.cpp @@ -4,6 +4,9 @@ extern "C" { } #include "util.h" +#include +#include +#include #include #include #include @@ -45,17 +48,46 @@ bool is_software_hevc(const std::string &name) { return true; } +bool is_qcom_iris_driver() { + const char *driver_path = "/sys/class/video4linux/video1/name"; + std::ifstream file(driver_path); + if (!file.is_open()) return false; + + std::string value; + std::getline(file, value, '\0'); + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value.find("qcom-iris") != std::string::npos || + value.find("iris-encoder") != std::string::npos || + value.find("iris") != std::string::npos; +} + } // anonymous namespace namespace util_encode { +bool is_qcom_iris_platform() { + return is_qcom_iris_driver(); +} + +bool supports_forced_keyframe(const std::string &name) { + if (name.find("v4l2m2m") != std::string::npos && is_qcom_iris_platform()) { + return false; + } + return true; +} + void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs, int gop, int fps, int thread_count) { c->has_b_frames = 0; c->max_b_frames = 0; - if (gop > 0 && gop < std::numeric_limits::max()) { - c->gop_size = gop; - c->keyint_min = gop; // Match keyint_min to gop for consistent keyframe interval + const bool qcom_iris_v4l2 = + name.find("v4l2m2m") != std::string::npos && is_qcom_iris_platform(); + const int effective_gop = qcom_iris_v4l2 ? std::max(5, fps / 3) : gop; + if (effective_gop > 0 && effective_gop < std::numeric_limits::max()) { + c->gop_size = effective_gop; + c->keyint_min = effective_gop; // Match keyint_min to gop for consistent keyframe interval } else if (name.find("vaapi") != std::string::npos) { c->gop_size = fps > 0 ? fps : 30; // Default to 1 second keyframe interval c->keyint_min = c->gop_size; @@ -120,7 +152,8 @@ bool set_lantency_free(void *priv_data, const std::string &name) { } if (name.find("amf") != std::string::npos) { if ((ret = av_opt_set(priv_data, "query_timeout", "1000", 0)) < 0) { - LOG_WARN(std::string("amf query_timeout option is unavailable, ret = ") + av_err2str(ret)); + LOG_DEBUG(std::string("amf query_timeout option is unavailable, ret = ") + + av_err2str(ret)); } } if (name.find("qsv") != std::string::npos) { @@ -139,7 +172,8 @@ bool set_lantency_free(void *priv_data, const std::string &name) { if (name.find("rkmpp") != std::string::npos) { // Set async_depth to 1 for minimal buffering (0 = synchronous, higher = more buffering) if ((ret = av_opt_set(priv_data, "async_depth", "1", 0)) < 0) { - LOG_WARN(std::string("rkmpp set async_depth failed, ret = ") + av_err2str(ret)); + LOG_DEBUG(std::string("rkmpp async_depth option is unavailable, ret = ") + + av_err2str(ret)); // Not fatal - older FFmpeg versions may not support this option } } @@ -147,11 +181,14 @@ bool set_lantency_free(void *priv_data, const std::string &name) { if (name.find("v4l2m2m") != std::string::npos) { // Minimize number of output buffers for lower latency if ((ret = av_opt_set_int(priv_data, "num_output_buffers", 4, 0)) < 0) { - LOG_WARN(std::string("v4l2m2m set num_output_buffers failed, ret = ") + av_err2str(ret)); + LOG_DEBUG(std::string("v4l2m2m num_output_buffers option is unavailable, ret = ") + + av_err2str(ret)); // Not fatal } - if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 4, 0)) < 0) { - LOG_WARN(std::string("v4l2m2m set num_capture_buffers failed, ret = ") + av_err2str(ret)); + const int capture_buffers = is_qcom_iris_driver() ? 12 : 8; + if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", capture_buffers, 0)) < 0) { + LOG_DEBUG(std::string("v4l2m2m num_capture_buffers option is unavailable, ret = ") + + av_err2str(ret)); // Not fatal } } @@ -360,6 +397,14 @@ struct CodecOptions { bool set_rate_control(AVCodecContext *c, const std::string &name, int rc, int q) { + // Remote-desktop content is usually sparse. VBR avoids padding static + // frames up to the target bitrate while allowing short bursts for screen + // changes. Keep those bursts bounded at twice the target bitrate. + if (rc == RC_VBR && c->bit_rate > 0) { + c->rc_max_rate = c->bit_rate * 2; + c->rc_buffer_size = c->rc_max_rate; + } + if (name.find("vaapi") != std::string::npos && rc == RC_CQ) { // Used only after the normal bitrate-based VAAPI initialization fails. // Some drivers, including Intel iHD on Jasper Lake, expose CQP as their @@ -458,6 +503,10 @@ bool set_others(void *priv_data, const std::string &name) { bool change_bit_rate(AVCodecContext *c, const std::string &name, int kbs) { if (kbs > 0) { c->bit_rate = kbs * 1000; + if (c->rc_max_rate > 0 && name.find("qsv") == std::string::npos) { + c->rc_max_rate = c->bit_rate * 2; + c->rc_buffer_size = c->rc_max_rate; + } if (name.find("qsv") != std::string::npos) { c->rc_max_rate = c->bit_rate; } diff --git a/libs/hwcodec/cpp/common/util.h b/libs/hwcodec/cpp/common/util.h index 0e5f4ee4..9a924692 100644 --- a/libs/hwcodec/cpp/common/util.h +++ b/libs/hwcodec/cpp/common/util.h @@ -9,6 +9,9 @@ extern "C" { namespace util_encode { +bool is_qcom_iris_platform(); +bool supports_forced_keyframe(const std::string &name); + void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs, int gop, int fps, int thread_count); bool set_lantency_free(void *priv_data, const std::string &name); diff --git a/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h new file mode 100644 index 00000000..c7f4ad56 --- /dev/null +++ b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include + +// Validate bounded JPEG headers before giving hardware a fixed-size output +// buffer. Only baseline 8-bit JPEG is accepted; other streams use copy fallback. +// No scan-data traversal or full-packet copy is needed. +inline bool rkmpp_dma_jpeg_header(const uint8_t *data, size_t size, int width, int height) { + if (!data || size < 4 || data[0] != 0xff || data[1] != 0xd8) return false; + size_t pos = 2; + bool sof = false; + while (pos < size) { + if (data[pos++] != 0xff) return false; + while (pos < size && data[pos] == 0xff) ++pos; + if (pos == size) return false; + const unsigned marker = data[pos++]; + if (!marker || marker == 0xd8 || marker == 0xd9 || marker == 1 || + (marker >= 0xd0 && marker <= 0xd7)) return false; + if (size - pos < 2) return false; + const size_t length = (size_t(data[pos]) << 8) | data[pos + 1]; + if (length < 2 || length > size - pos) return false; + if (marker == 0xc0) { + if (sof || length < 8 || data[pos + 2] != 8) return false; + const unsigned h = (unsigned(data[pos + 3]) << 8) | data[pos + 4]; + const unsigned w = (unsigned(data[pos + 5]) << 8) | data[pos + 6]; + const unsigned components = data[pos + 7]; + if (w != unsigned(width) || h != unsigned(height) || + (components != 1 && components != 3) || length != 8 + 3 * components) return false; + sof = true; + } else if (marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 && marker != 0xcc) { + return false; // Progressive, lossless, extended or differential SOF. + } else if (marker == 0xda) { + return sof && length >= 6 && size - pos > length; + } + pos += length; + } + return false; +} diff --git a/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp new file mode 100644 index 00000000..74c13621 --- /dev/null +++ b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp @@ -0,0 +1,329 @@ +#include "rkmpp_dmabuf_ffi.h" +#include +#include +#include +#include +#include "rkmpp_dma_jpeg.h" + +// Native MPP is already linked by the ARM FFmpeg/RKMPP build. Keep this +// optional for toolchains which only supply the FFmpeg headers. +#if defined(__linux__) && __has_include() +#define HAVE_MPP_DMA 1 +#include +#include +#include +extern "C" { +#include +#include +#include +#include +#include +#include +#include +} +#endif + +static thread_local char dma_error[192] = {}; +static int fail(const char *operation, int code) { + std::snprintf(dma_error, sizeof(dma_error), "%s (ret=%d)", operation, code); + return -1; +} + +#ifdef HAVE_MPP_DMA +struct RkmppDmaEncoder { + MppCtx ctx = nullptr; + MppApi *api = nullptr; + MppEncCfg cfg = nullptr; + MppPacket packet = nullptr; + std::array buffers{}; + std::array capacities{}; + size_t count = 0; + size_t minimum = 0; + int width = 0, height = 0, stride = 0; + MppFrameFormat format = MPP_FMT_YUV420SP; + bool jpeg = false; + MppCtx decoder = nullptr; + MppApi *dec_api = nullptr; + MppBufferGroup decoded_group = nullptr; + MppBuffer decoded_buffer = nullptr; + MppFrame decoded_frame = nullptr; + MppPacket input_packet = nullptr; + bool decoded_layout_set = false; + int decoded_stride = 0, decoded_vstride = 0; + + void close() { + if (packet) mpp_packet_deinit(&packet); + if (ctx) { + // A timeout must not expose still-in-use input to V4L2 QBUF. + api->reset(ctx); + mpp_destroy(ctx); + ctx = nullptr; + } + // On any decoder failure, end hardware access before releasing the + // input packet, exported buffers, or allowing the caller's QBUF. + if (decoder) { + dec_api->reset(decoder); + mpp_destroy(decoder); + decoder = nullptr; + } + if (input_packet) mpp_packet_deinit(&input_packet); + if (decoded_frame) mpp_frame_deinit(&decoded_frame); + if (decoded_buffer) { mpp_buffer_put(decoded_buffer); decoded_buffer = nullptr; } + if (decoded_group) { mpp_buffer_group_put(decoded_group); decoded_group = nullptr; } + for (auto &buffer : buffers) { + if (buffer) { mpp_buffer_put(buffer); buffer = nullptr; } + } + if (cfg) { mpp_enc_cfg_deinit(cfg); cfg = nullptr; } + } + ~RkmppDmaEncoder() { close(); } +}; + +static bool set_cfg(RkmppDmaEncoder *e, const char *key, int value) { + int ret = mpp_enc_cfg_set_s32(e->cfg, key, value); + if (ret) fail(key, ret); + return ret == 0; +} + +extern "C" int rkmpp_dma_reconfigure(RkmppDmaEncoder *e, int kbps, int gop) { + if (!e || !e->ctx || kbps <= 0 || kbps > 1000000 || gop <= 0) + return fail("invalid DMA encoder configuration", -1); + const int bps = kbps * 1000; + if (!set_cfg(e, "rc:bps_target", bps) || + !set_cfg(e, "rc:bps_max", bps + bps / 16) || + !set_cfg(e, "rc:bps_min", bps - bps / 16) || + !set_cfg(e, "rc:gop", gop)) return -1; + int ret = e->api->control(e->ctx, MPP_ENC_SET_CFG, e->cfg); + return ret ? fail("MPP_ENC_SET_CFG", ret) : 0; +} + +extern "C" RkmppDmaEncoder *rkmpp_dma_new( + int width, int height, int stride, int format, int codec, int fps, + int kbps, int gop, const int *fds, const size_t *sizes, size_t count) { + if (width <= 0 || height <= 0 || width > 8192 || height > 8192 || + (width & 1) || (height & 1) || format < 0 || format > 4 || + codec < 0 || codec > 1 || fps <= 0 || fps > 240 || + (format != 4 && stride < width * (format == 1 || format == 3 ? 3 : format == 2 ? 2 : 1)) || + (format == 2 && stride % 16 != 0) || !fds || !sizes || !count || count > 16) { + fail("invalid DMA frame layout", -1); return nullptr; + } + auto *e = new (std::nothrow) RkmppDmaEncoder; + if (!e) { fail("allocate DMA encoder", -1); return nullptr; } + e->width = width; e->height = height; e->stride = stride; e->count = count; + e->jpeg = format == 4; + if (e->jpeg) e->stride = stride = (width + 15) & ~15; + const int vstride = e->jpeg ? (height + 15) & ~15 : height; + e->format = format == 3 ? MPP_FMT_RGB888 : format == 2 ? MPP_FMT_YUV422_YUYV : format == 1 ? MPP_FMT_BGR888 : MPP_FMT_YUV420SP; + auto abort_init = [e](const char *op, int ret) -> RkmppDmaEncoder * { + fail(op, ret); delete e; return nullptr; + }; + int ret = mpp_create(&e->ctx, &e->api); + if (ret) return abort_init("mpp_create", ret); + RK_S64 timeout = 2000; + ret = e->api->control(e->ctx, MPP_SET_OUTPUT_TIMEOUT, &timeout); + if (ret) return abort_init("MPP_SET_OUTPUT_TIMEOUT", ret); + ret = e->api->control(e->ctx, MPP_SET_INPUT_TIMEOUT, &timeout); + if (ret) return abort_init("MPP_SET_INPUT_TIMEOUT", ret); + ret = mpp_init(e->ctx, MPP_CTX_ENC, codec ? MPP_VIDEO_CodingHEVC : MPP_VIDEO_CodingAVC); + if (ret) return abort_init("mpp_init", ret); + ret = mpp_enc_cfg_init(&e->cfg); + if (ret) return abort_init("mpp_enc_cfg_init", ret); + ret = e->api->control(e->ctx, MPP_ENC_GET_CFG, e->cfg); + if (ret) return abort_init("MPP_ENC_GET_CFG", ret); + if (!set_cfg(e, "prep:width", width) || !set_cfg(e, "prep:height", height) || + !set_cfg(e, "prep:hor_stride", stride) || !set_cfg(e, "prep:ver_stride", vstride) || + !set_cfg(e, "prep:format", e->format) || !set_cfg(e, "rc:mode", MPP_ENC_RC_MODE_CBR) || + !set_cfg(e, "rc:fps_in_flex", 0) || !set_cfg(e, "rc:fps_in_num", fps) || + !set_cfg(e, "rc:fps_in_denorm", 1) || !set_cfg(e, "rc:fps_out_flex", 0) || + !set_cfg(e, "rc:fps_out_num", fps) || !set_cfg(e, "rc:fps_out_denorm", 1) || + !set_cfg(e, "codec:type", codec ? MPP_VIDEO_CodingHEVC : MPP_VIDEO_CodingAVC)) { + delete e; return nullptr; + } + // Match the browser-friendly baseline profile used by the existing RKMPP + // byte encoder, rather than inheriting MPP's High-profile default. + const int level = int64_t(width) * height * fps <= int64_t(1920) * 1080 * 60 ? 42 : 52; + if (!codec && (!set_cfg(e, "h264:profile", 66) || !set_cfg(e, "h264:level", level) || + !set_cfg(e, "h264:cabac_en", 0) || !set_cfg(e, "h264:trans8x8", 0))) { + delete e; return nullptr; + } + if (rkmpp_dma_reconfigure(e, kbps, gop)) { delete e; return nullptr; } + MppEncHeaderMode mode = MPP_ENC_HEADER_MODE_EACH_IDR; + ret = e->api->control(e->ctx, MPP_ENC_SET_HEADER_MODE, &mode); + if (ret) return abort_init("MPP_ENC_SET_HEADER_MODE", ret); + // Reject arithmetic overflow even on 32-bit ARM; stride is supplied by a driver. + if (size_t(stride) > std::numeric_limits::max() / size_t(height)) + return abort_init("DMA buffer size overflow", -1); + size_t minimum = size_t(stride) * height; + if (format == 0) { + if (minimum > std::numeric_limits::max() / 3) + return abort_init("DMA buffer size overflow", -1); + minimum = minimum * 3 / 2; + } + if (e->jpeg) minimum = 68; // SOI/payload plus bounded hardware read headroom. + e->minimum = minimum; + for (size_t i = 0; i < count; ++i) { + if (fds[i] < 0 || sizes[i] < minimum) return abort_init("short DMA buffer", -1); + MppBufferInfo info{}; + info.type = MPP_BUFFER_TYPE_EXT_DMA; info.fd = fds[i]; + info.size = sizes[i]; info.index = static_cast(i); + ret = mpp_buffer_import(&e->buffers[i], &info); + if (ret) return abort_init("mpp_buffer_import", ret); + e->capacities[i] = sizes[i]; + } + if (e->jpeg) { + ret = mpp_create(&e->decoder, &e->dec_api); + if (ret) return abort_init("mpp_create JPEG decoder", ret); + ret = mpp_init(e->decoder, MPP_CTX_DEC, MPP_VIDEO_CodingMJPEG); + if (ret) return abort_init("mpp_init JPEG decoder", ret); + MppFrameFormat output = MPP_FMT_YUV420SP; + ret = e->dec_api->control(e->decoder, MPP_DEC_SET_OUTPUT_FORMAT, &output); + if (ret) return abort_init("JPEG NV12 output", ret); + ret = mpp_buffer_group_get_internal(&e->decoded_group, MPP_BUFFER_TYPE_DRM); + if (ret) return abort_init("JPEG output buffer group", ret); + // MPP JPEG requires aligned storage; reserve the conservative size used + // by its advanced-task decoder demo. One output reused after encode. + ret = mpp_buffer_get(e->decoded_group, &e->decoded_buffer, size_t(stride) * vstride * 4); + if (ret) return abort_init("JPEG output buffer", ret); + ret = mpp_frame_init(&e->decoded_frame); + if (ret) return abort_init("JPEG output frame", ret); + mpp_frame_set_buffer(e->decoded_frame, e->decoded_buffer); + } + return e; +} + +static int dma_read_sync(MppBuffer buffer, bool start) { + dma_buf_sync sync{}; + sync.flags = DMA_BUF_SYNC_READ | (start ? DMA_BUF_SYNC_START : DMA_BUF_SYNC_END); + int ret; + do { ret = ioctl(mpp_buffer_get_fd(buffer), DMA_BUF_IOCTL_SYNC, &sync); } + while (ret < 0 && errno == EINTR); + return ret; +} + +static int decode_jpeg(RkmppDmaEncoder *e, size_t index, size_t bytes_used) { + MppBuffer input = e->buffers[index]; + // The parser reads only header bytes with explicit DMA CPU-read ownership. + if (dma_read_sync(input, true)) return fail("JPEG DMA read sync start", errno); + const auto *data = static_cast(mpp_buffer_get_ptr(input)); + const bool valid = rkmpp_dma_jpeg_header(data, bytes_used, e->width, e->height); + if (dma_read_sync(input, false)) return fail("JPEG DMA read sync end", errno); + if (!valid) return fail("unsupported/mismatched JPEG header", -1); + + int ret = mpp_packet_init_with_buffer(&e->input_packet, input); + if (ret) return fail("JPEG input packet", ret); + mpp_packet_set_length(e->input_packet, bytes_used); + MppTask task = nullptr; + ret = e->dec_api->poll(e->decoder, MPP_PORT_INPUT, static_cast(2000)); + if (ret) return fail("JPEG input poll", ret); + ret = e->dec_api->dequeue(e->decoder, MPP_PORT_INPUT, &task); + if (ret || !task) return fail("JPEG input task", ret); + ret = mpp_task_meta_set_packet(task, KEY_INPUT_PACKET, e->input_packet); + if (ret) return fail("JPEG input metadata", ret); + ret = mpp_task_meta_set_frame(task, KEY_OUTPUT_FRAME, e->decoded_frame); + if (ret) return fail("JPEG output metadata", ret); + ret = e->dec_api->enqueue(e->decoder, MPP_PORT_INPUT, task); + if (ret) return fail("JPEG submit", ret); + task = nullptr; + ret = e->dec_api->poll(e->decoder, MPP_PORT_OUTPUT, static_cast(2000)); + if (ret) return fail("JPEG output poll", ret); + ret = e->dec_api->dequeue(e->decoder, MPP_PORT_OUTPUT, &task); + if (ret || !task) return fail("JPEG output task", ret); + MppFrame result = nullptr; + ret = mpp_task_meta_get_frame(task, KEY_OUTPUT_FRAME, &result); + if (ret || result != e->decoded_frame) return fail("JPEG output frame mismatch", ret); + ret = e->dec_api->enqueue(e->decoder, MPP_PORT_OUTPUT, task); + if (ret) return fail("JPEG return output task", ret); + ret = e->dec_api->poll(e->decoder, MPP_PORT_INPUT, static_cast(2000)); + if (ret) return fail("JPEG input completion", ret); + mpp_packet_deinit(&e->input_packet); + if (mpp_frame_get_errinfo(result) || mpp_frame_get_discard(result) || + mpp_frame_get_info_change(result) || + mpp_frame_get_width(result) != unsigned(e->width) || + mpp_frame_get_height(result) != unsigned(e->height) || + mpp_frame_get_fmt(result) != MPP_FMT_YUV420SP || + mpp_frame_get_buffer(result) != e->decoded_buffer) { + std::snprintf(dma_error, sizeof(dma_error), + "invalid JPEG decoded frame: err=%u discard=%u info_change=%u size=%ux%u fmt=%x buffer_match=%d", + mpp_frame_get_errinfo(result), mpp_frame_get_discard(result), mpp_frame_get_info_change(result), + mpp_frame_get_width(result), mpp_frame_get_height(result), unsigned(mpp_frame_get_fmt(result)), + int(mpp_frame_get_buffer(result) == e->decoded_buffer)); + return -1; + } + const int hs = mpp_frame_get_hor_stride(result), vs = mpp_frame_get_ver_stride(result); + if (hs < e->width || vs < e->height || hs > 8192 || vs > 8192 || (hs & 15) || (vs & 15) || + size_t(hs) * vs * 3 / 2 > mpp_buffer_get_size(e->decoded_buffer)) + return fail("invalid JPEG decoded stride", -1); + if (!e->decoded_layout_set) { + if (!set_cfg(e, "prep:hor_stride", hs) || !set_cfg(e, "prep:ver_stride", vs)) return -1; + ret = e->api->control(e->ctx, MPP_ENC_SET_CFG, e->cfg); + if (ret) return fail("JPEG encoder layout", ret); + e->decoded_stride = hs; e->decoded_vstride = vs; e->decoded_layout_set = true; + } else if (hs != e->decoded_stride || vs != e->decoded_vstride) { + return fail("JPEG decoded layout changed", -1); + } + return 0; +} + +extern "C" int rkmpp_dma_encode(RkmppDmaEncoder *e, size_t index, size_t bytes_used, int fresh_fd, int64_t pts_us, + int force_idr, const uint8_t **data, size_t *size) { + if (!e || !e->ctx || index >= e->count || !data || !size) + return fail("invalid DMA encode call", -1); + *data = nullptr; *size = 0; + if (e->packet) mpp_packet_deinit(&e->packet); + auto abort_encode = [e](const char *op, int ret) { + fail(op, ret); e->close(); return -1; + }; + if (bytes_used > e->capacities[index] || + (e->jpeg ? (bytes_used < 4 || e->capacities[index] - bytes_used < 64) : bytes_used != e->minimum)) + return abort_encode("invalid DMA payload length", -1); + if (fresh_fd >= 0) { + MppBuffer replacement = nullptr; + MppBufferInfo info{}; + info.type = MPP_BUFFER_TYPE_EXT_DMA; info.fd = fresh_fd; + info.size = e->capacities[index]; info.index = static_cast(index); + const int ret = mpp_buffer_import(&replacement, &info); + if (ret) return abort_encode("refresh USB DMA import", ret); + if (e->buffers[index]) mpp_buffer_put(e->buffers[index]); + e->buffers[index] = replacement; + } + if (e->jpeg && decode_jpeg(e, index, bytes_used)) { + // Preserve the detailed decoder failure while ending BOTH engines. + e->close(); return -1; + } + if (force_idr) { + int ret = e->api->control(e->ctx, MPP_ENC_SET_IDR_FRAME, nullptr); + if (ret) return abort_encode("MPP_ENC_SET_IDR_FRAME", ret); + } + MppFrame frame = e->jpeg ? e->decoded_frame : nullptr; + int ret = 0; + if (!e->jpeg) { + ret = mpp_frame_init(&frame); + if (ret) return abort_encode("mpp_frame_init", ret); + mpp_frame_set_width(frame, e->width); mpp_frame_set_height(frame, e->height); + mpp_frame_set_hor_stride(frame, e->stride); mpp_frame_set_ver_stride(frame, e->height); + mpp_frame_set_fmt(frame, e->format); + mpp_frame_set_buffer(frame, e->buffers[index]); + } + mpp_frame_set_pts(frame, pts_us); + ret = e->api->encode_put_frame(e->ctx, frame); + if (!e->jpeg) mpp_frame_deinit(&frame); + if (ret) return abort_encode("encode_put_frame", ret); + ret = e->api->encode_get_packet(e->ctx, &e->packet); + if (ret || !e->packet) return abort_encode("encode_get_packet", ret); + if (mpp_packet_is_partition(e->packet) || !mpp_packet_get_length(e->packet)) + return abort_encode("incomplete DMA encoder output", -1); + // One synchronous input, no temporal scalability/reordering/split output. + // A completed packet is the input-consumption barrier for this mode. + *data = static_cast(mpp_packet_get_pos(e->packet)); + *size = mpp_packet_get_length(e->packet); + return 0; +} +extern "C" void rkmpp_dma_free(RkmppDmaEncoder *e) { delete e; } +#else +extern "C" RkmppDmaEncoder *rkmpp_dma_new(int,int,int,int,int,int,int,int,const int*,const size_t*,size_t) { + fail("RKMPP DMA support not built", -1); return nullptr; +} +extern "C" int rkmpp_dma_encode(RkmppDmaEncoder*,size_t,size_t,int,int64_t,int,const uint8_t**,size_t*) { return -1; } +extern "C" int rkmpp_dma_reconfigure(RkmppDmaEncoder*,int,int) { return -1; } +extern "C" void rkmpp_dma_free(RkmppDmaEncoder*) {} +#endif +extern "C" const char *rkmpp_dma_error(void) { return dma_error; } diff --git a/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h new file mode 100644 index 00000000..a72ef612 --- /dev/null +++ b/libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct RkmppDmaEncoder RkmppDmaEncoder; +// format: 0=NV12, 1=BGR24, 2=YUYV, 3=RGB24 (byte stride), 4=MJPEG (stride ignored). +// codec: 0=H264, 1=HEVC. MJPEG is decoded to hardware NV12, then encoded. +RkmppDmaEncoder *rkmpp_dma_new(int width, int height, int stride, int format, + int codec, int fps, int kbps, int gop, + const int *fds, const size_t *sizes, size_t count); +// Synchronous input-completion boundary. On failure the encoder is destroyed +// internally BEFORE returning, so it cannot keep reading the capture buffer. +// Output is borrowed until the next call or destruction; copy before reusing it. +// bytes_used must be the actual captured payload. MJPEG needs 64 bytes of +// readable allocation headroom; never pass sizeimage as the compressed length. +// fresh_fd=-1 reuses the original import (native HDMI). UVC supplies a new +// export of this dequeued slot. Keep it open until replacement/free; the old +// export can be closed AFTER this call, including on failure. +int rkmpp_dma_encode(RkmppDmaEncoder *encoder, size_t index, size_t bytes_used, int fresh_fd, int64_t pts_us, + int force_idr, const uint8_t **data, size_t *size); +int rkmpp_dma_reconfigure(RkmppDmaEncoder *encoder, int kbps, int gop); +void rkmpp_dma_free(RkmppDmaEncoder *encoder); +const char *rkmpp_dma_error(void); + +#ifdef __cplusplus +} +#endif diff --git a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp index d96fbd0e..19ffd84b 100644 --- a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp +++ b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp @@ -26,7 +26,6 @@ static thread_local std::string g_encoder_last_error; static void set_encoder_last_error(const std::string &message) { g_encoder_last_error = message; - LOG_ERROR(message); } static int calculate_offset_length(int pix_fmt, int height, const int *linesize, @@ -380,12 +379,12 @@ private: frame->pts = ms; // Force keyframe if requested - if (force_keyframe_) { + if (force_keyframe_ && util_encode::supports_forced_keyframe(name_)) { frame->pict_type = AV_PICTURE_TYPE_I; - force_keyframe_ = false; } else { frame->pict_type = AV_PICTURE_TYPE_NONE; } + force_keyframe_ = false; ret = avcodec_send_frame(c_, frame); if (ret == AVERROR(EAGAIN)) { @@ -646,8 +645,8 @@ ffmpeg_ram_new_encoder(const char *name, int width, // allowing CQP-only drivers to pass probing and normal encoder creation. if (name && std::string(name).find("vaapi") != std::string::npos && rc != RC_CQ) { - LOG_WARN(std::string("VAAPI bitrate-based rate control failed for ") + - name + ", retrying with CQP"); + LOG_DEBUG(std::string("VAAPI bitrate-based rate control failed for ") + + name + ", retrying with CQP"); encoder = try_create(RC_CQ, 0); if (encoder) { return encoder; diff --git a/libs/hwcodec/src/ffmpeg.rs b/libs/hwcodec/src/ffmpeg.rs index 73b67705..4bbff498 100644 --- a/libs/hwcodec/src/ffmpeg.rs +++ b/libs/hwcodec/src/ffmpeg.rs @@ -32,14 +32,14 @@ pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const std::os::r if let Ok(str_slice) = c_str.to_str() { let string = String::from(str_slice); if level == AV_LOG_ERROR as i32 { - log::error!("{}", string); if string.contains(could_not_find_ref_with_poc) { hwcodec_set_flag_could_not_find_ref_with_poc(); } + log::debug!("{}", string); } else if level == AV_LOG_PANIC as i32 || level == AV_LOG_FATAL as i32 { log::error!("{}", string); } else if level == AV_LOG_WARNING as i32 { - log::warn!("{}", string); + log::debug!("{}", string); } else if level == AV_LOG_INFO as i32 { log::info!("{}", string); } else if level == AV_LOG_VERBOSE as i32 || level == AV_LOG_DEBUG as i32 { diff --git a/libs/hwcodec/src/ffmpeg_ram/encode.rs b/libs/hwcodec/src/ffmpeg_ram/encode.rs index de99b9cd..7f01dff0 100644 --- a/libs/hwcodec/src/ffmpeg_ram/encode.rs +++ b/libs/hwcodec/src/ffmpeg_ram/encode.rs @@ -343,7 +343,7 @@ fn log_failed_probe_attempt( } fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool { - use log::{debug, warn}; + use log::debug; debug!("Testing encoder: {}", codec.name); @@ -395,7 +395,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo } Err(err) => { last_err = Some(err); - warn!( + debug!( "Encoder {} test attempt {} returned error: {}", codec.name, attempt_no, err ); @@ -412,10 +412,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo ); false } - Err(_) => { - warn!("Failed to create encoder {}", codec.name); - false - } + Err(_) => false, } } @@ -543,7 +540,7 @@ impl Encoder { if codec.is_null() { let message = encoder_last_error_message(); if !message.is_empty() { - log::error!("ffmpeg_ram_new_encoder failed: {}", message); + log::debug!("ffmpeg_ram_new_encoder failed: {}", message); } return Err(()); } @@ -595,6 +592,16 @@ impl Encoder { Some(Encoder::packet_callback), ); if result == -11 || result == 0 { + if self.ctx.name.contains("v4l2m2m") { + return Ok(frames + .into_iter() + .map(|frame| EncodeBytesFrame { + data: Bytes::copy_from_slice(frame.data.as_ref()), + pts: frame.pts, + key: frame.key, + }) + .collect()); + } return Ok(frames); } Err(result) diff --git a/libs/hwcodec/src/lib.rs b/libs/hwcodec/src/lib.rs index 9a57fa75..b1e01c37 100644 --- a/libs/hwcodec/src/lib.rs +++ b/libs/hwcodec/src/lib.rs @@ -5,6 +5,11 @@ pub mod ffmpeg; #[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))] pub mod ffmpeg_hw; pub mod ffmpeg_ram; +#[cfg(all( + target_os = "linux", + any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp") +))] +pub mod rkmpp_dmabuf; #[no_mangle] pub extern "C" fn hwcodec_log(level: i32, message: *const std::os::raw::c_char) { diff --git a/libs/hwcodec/src/rkmpp_dmabuf.rs b/libs/hwcodec/src/rkmpp_dmabuf.rs new file mode 100644 index 00000000..58a02394 --- /dev/null +++ b/libs/hwcodec/src/rkmpp_dmabuf.rs @@ -0,0 +1,193 @@ +//! Synchronous RKMPP encoder for pre-exported V4L2 DMA buffers. +//! Unlike the byte-slice encoder this never reads raw pixels on the CPU. + +use std::ffi::{c_char, c_int, c_void, CStr}; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::ptr::NonNull; + +unsafe extern "C" { + fn rkmpp_dma_new( + width: c_int, + height: c_int, + stride: c_int, + format: c_int, + codec: c_int, + fps: c_int, + kbps: c_int, + gop: c_int, + fds: *const c_int, + sizes: *const usize, + count: usize, + ) -> *mut c_void; + fn rkmpp_dma_encode( + encoder: *mut c_void, + index: usize, + bytes_used: usize, + fresh_fd: c_int, + pts_us: i64, + force_idr: c_int, + data: *mut *const u8, + size: *mut usize, + ) -> c_int; + fn rkmpp_dma_reconfigure(encoder: *mut c_void, kbps: c_int, gop: c_int) -> c_int; + fn rkmpp_dma_free(encoder: *mut c_void); + fn rkmpp_dma_error() -> *const c_char; +} + +#[derive(Debug, Clone, Copy)] +#[repr(i32)] +pub enum DmaFormat { + Nv12 = 0, + Bgr24 = 1, + Yuyv = 2, + Rgb24 = 3, + Mjpeg = 4, +} + +pub struct DmaEncoderConfig { + pub width: u32, + pub height: u32, + pub stride: u32, + pub format: DmaFormat, + pub hevc: bool, + pub fps: u32, + pub bitrate_kbps: u32, + pub gop: u32, +} + +pub struct DmaEncoder { + ctx: NonNull, + // Export FDs remain open until AFTER mpp_destroy and imported buffer release. + _buffers: Vec<(OwnedFd, usize)>, + // Keep refreshed exports alive until native replacement/release has ended + // all references to the previous import. At most one FD per capture slot. + fresh_buffers: Vec>, +} + +// Exclusive ownership: the context can move between threads but all calls are sequential. +unsafe impl Send for DmaEncoder {} + +fn last_error() -> String { + unsafe { + CStr::from_ptr(rkmpp_dma_error()) + .to_string_lossy() + .into_owned() + } +} + +impl DmaEncoder { + pub fn new(config: DmaEncoderConfig, buffers: Vec<(OwnedFd, usize)>) -> Result { + let fds: Vec<_> = buffers.iter().map(|(fd, _)| fd.as_raw_fd()).collect(); + let sizes: Vec<_> = buffers.iter().map(|(_, size)| *size).collect(); + for value in [ + config.width, + config.height, + config.stride, + config.fps, + config.bitrate_kbps, + config.gop, + ] { + if value > c_int::MAX as u32 { + return Err("DMA encoder parameter overflow".into()); + } + } + let ptr = unsafe { + rkmpp_dma_new( + config.width as _, + config.height as _, + config.stride as _, + config.format as c_int, + config.hevc as _, + config.fps as _, + config.bitrate_kbps as _, + config.gop as _, + fds.as_ptr(), + sizes.as_ptr(), + buffers.len(), + ) + }; + Ok(Self { + ctx: NonNull::new(ptr).ok_or_else(last_error)?, + fresh_buffers: (0..buffers.len()).map(|_| None).collect(), + _buffers: buffers, + }) + } + + /// # Safety + /// The indexed buffer must be dequeued and exclusively leased to this call. + /// Do not requeue/write it until this function returns. On failure native MPP + /// is synchronously destroyed before returning, ending all input access. + /// `bytes_used` is the actual DQBUF payload length, not the buffer capacity. + /// A refreshed FD, if supplied, must refer to the same leased capture slot + /// with the capacity registered at construction. Ownership is retained here. + pub unsafe fn encode( + &mut self, + index: usize, + bytes_used: usize, + fresh_fd: Option, + pts_ms: i64, + force_idr: bool, + ) -> Result, String> { + let mut data = std::ptr::null(); + let mut size = 0; + if index >= self.fresh_buffers.len() { + return Err("Invalid DMA capture index".into()); + } + let ret = unsafe { + rkmpp_dma_encode( + self.ctx.as_ptr(), + index, + bytes_used, + fresh_fd.as_ref().map_or(-1, AsRawFd::as_raw_fd), + pts_ms.saturating_mul(1000), + force_idr as _, + &mut data, + &mut size, + ) + }; + if fresh_fd.is_some() { + // Native has now released the previous import, or destroyed both + // hardware contexts on error. Only now may its old FD be closed. + self.fresh_buffers[index] = fresh_fd; + } + if ret != 0 { + return Err(last_error()); + } + if data.is_null() || size == 0 { + return Err("Empty RKMPP DMA packet".into()); + } + // Copy only the compressed output, releasing the driver's packet promptly + // regardless of how long a network subscriber retains its Bytes. + Ok(unsafe { std::slice::from_raw_parts(data, size) }.to_vec()) + } + + pub fn reconfigure(&mut self, kbps: u32, gop: u32) -> Result<(), String> { + if kbps > c_int::MAX as u32 || gop > c_int::MAX as u32 { + return Err("DMA encoder parameter overflow".into()); + } + if unsafe { rkmpp_dma_reconfigure(self.ctx.as_ptr(), kbps as _, gop as _) } != 0 { + return Err(last_error()); + } + Ok(()) + } +} + +impl Drop for DmaEncoder { + fn drop(&mut self) { + unsafe { rkmpp_dma_free(self.ctx.as_ptr()) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_discriminants_match_native_abi() { + assert_eq!(DmaFormat::Nv12 as c_int, 0); + assert_eq!(DmaFormat::Bgr24 as c_int, 1); + assert_eq!(DmaFormat::Yuyv as c_int, 2); + assert_eq!(DmaFormat::Rgb24 as c_int, 3); + assert_eq!(DmaFormat::Mjpeg as c_int, 4); + } +} diff --git a/res/vcpkg/libyuv/build.rs b/res/vcpkg/libyuv/build.rs index 33205e2c..466cb069 100644 --- a/res/vcpkg/libyuv/build.rs +++ b/res/vcpkg/libyuv/build.rs @@ -5,7 +5,8 @@ use std::{ }; fn main() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // Read the current source path when running, since build artifacts may move. + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let cpp_dir = manifest_dir.join("cpp"); println!("cargo:rerun-if-changed=cpp/yuv_ffi.h"); diff --git a/src/atx/mod.rs b/src/atx/mod.rs index 3b51f8c4..5ab879ae 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -27,7 +27,7 @@ pub use types::{ ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig, AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, LCUS_RELAY_MAX_CHANNEL, }; -pub use wol::{list_wol_history, record_wol_history, send_wol}; +pub use wol::send_wol; #[cfg(any(unix, test))] fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool { diff --git a/src/atx/wol.rs b/src/atx/wol.rs index ab66bd2e..04a4366a 100644 --- a/src/atx/wol.rs +++ b/src/atx/wol.rs @@ -7,8 +7,6 @@ use tracing::info; use crate::error::{AppError, Result}; -const WOL_HISTORY_MAX_ENTRIES: i64 = 50; - const MAGIC_PACKET_SIZE: usize = 102; fn parse_mac_address(mac: &str) -> Result<[u8; 6]> { @@ -118,55 +116,6 @@ pub fn send_wol(mac_address: &str, interface: Option<&str>) -> Result<()> { Ok(()) } -pub async fn record_wol_history(pool: &sqlx::Pool, mac_address: &str) -> Result<()> { - sqlx::query( - r#" - INSERT INTO wol_history (mac_address, updated_at) - VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER)) - ON CONFLICT(mac_address) DO UPDATE SET - updated_at = excluded.updated_at - "#, - ) - .bind(mac_address) - .execute(pool) - .await?; - - sqlx::query( - r#" - DELETE FROM wol_history - WHERE mac_address NOT IN ( - SELECT mac_address FROM wol_history - ORDER BY updated_at DESC - LIMIT ?1 - ) - "#, - ) - .bind(WOL_HISTORY_MAX_ENTRIES) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn list_wol_history( - pool: &sqlx::Pool, - limit: usize, -) -> Result> { - let rows = sqlx::query_as( - r#" - SELECT mac_address, updated_at - FROM wol_history - ORDER BY updated_at DESC - LIMIT ?1 - "#, - ) - .bind(limit as i64) - .fetch_all(pool) - .await?; - - Ok(rows) -} - #[cfg(test)] mod tests { use super::*; 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/auth/two_factor.rs b/src/auth/two_factor.rs index 73b013bb..27d29761 100644 --- a/src/auth/two_factor.rs +++ b/src/auth/two_factor.rs @@ -227,15 +227,14 @@ impl TwoFactorService { return Err(AppError::AuthError("Invalid TOTP code".to_string())); } - let mut transaction = self.pool.begin().await?; let result = sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)") .bind(user_id) .bind(secret.to_string()) - .execute(&mut *transaction) + .execute(&self.pool) .await; match result { - Ok(_) => transaction.commit().await?, + Ok(_) => {} Err(sqlx::Error::Database(error)) if error.is_unique_violation() => { return Err(AppError::Conflict("TOTP is already enabled".to_string())); } diff --git a/src/auth/user.rs b/src/auth/user.rs index e131fc6b..0333492f 100644 --- a/src/auth/user.rs +++ b/src/auth/user.rs @@ -1,7 +1,5 @@ use serde::{Deserialize, Serialize}; use sqlx::{Pool, Sqlite}; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; use uuid::Uuid; use super::password::{hash_password, verify_password}; @@ -112,15 +110,13 @@ impl UserStore { } let password_hash = hash_password(new_password)?; - let now = OffsetDateTime::now_utc(); - - let result = - sqlx::query("UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3") - .bind(&password_hash) - .bind(now.format(&Rfc3339).expect("RFC3339 format")) - .bind(user_id) - .execute(&self.pool) - .await?; + let result = sqlx::query( + "UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE id = ?2", + ) + .bind(&password_hash) + .bind(user_id) + .execute(&self.pool) + .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("User not found".to_string())); @@ -143,13 +139,13 @@ impl UserStore { return Ok(()); } - let now = OffsetDateTime::now_utc(); - let result = sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3") - .bind(new_username) - .bind(now.format(&Rfc3339).expect("RFC3339 format")) - .bind(user_id) - .execute(&self.pool) - .await?; + let result = sqlx::query( + "UPDATE users SET username = ?1, updated_at = datetime('now') WHERE id = ?2", + ) + .bind(new_username) + .bind(user_id) + .execute(&self.pool) + .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("User not found".to_string())); diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index c8607835..af83cc35 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)] @@ -191,6 +241,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(), @@ -255,3 +306,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/schema/stream.rs b/src/config/schema/stream.rs index 25908e4c..f1acb752 100644 --- a/src/config/schema/stream.rs +++ b/src/config/schema/stream.rs @@ -102,8 +102,8 @@ pub enum EncoderType { Qsv, Amf, Rkmpp, + #[serde(alias = "amlogic")] V4l2m2m, - Amlogic, } impl EncoderType { @@ -117,7 +117,6 @@ impl EncoderType { EncoderType::Amf => "AMD AMF", EncoderType::Rkmpp => "Rockchip MPP", EncoderType::V4l2m2m => "V4L2 M2M", - EncoderType::Amlogic => "AMLENC", } } } diff --git a/src/config/store.rs b/src/config/store.rs index 4a56323a..b8324a04 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -17,13 +17,13 @@ pub struct ConfigStore { } impl ConfigStore { - pub fn new(pool: Pool) -> Result { - Ok(Self { + pub fn new(pool: Pool) -> Self { + Self { pool, cache: Arc::new(ArcSwap::from_pointee(AppConfig::default())), change_tx: broadcast::channel(16).0, write_lock: Arc::new(Mutex::new(())), - }) + } } pub async fn load(&self) -> Result<()> { @@ -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() } @@ -145,7 +150,7 @@ mod tests { let db = DatabasePool::new(&db_path).await.unwrap(); db.init_schema().await.unwrap(); - let store = ConfigStore::new(db.clone_pool()).unwrap(); + let store = ConfigStore::new(db.clone_pool()); store.load().await.unwrap(); let config = store.get(); @@ -163,7 +168,7 @@ mod tests { assert!(config.initialized); assert_eq!(config.web.http_port, 9000); - let store2 = ConfigStore::new(db.clone_pool()).unwrap(); + let store2 = ConfigStore::new(db.clone_pool()); store2.load().await.unwrap(); let config = store2.get(); assert!(config.initialized); @@ -176,7 +181,7 @@ mod tests { let db_path = dir.path().join("test.db"); let db = DatabasePool::new(&db_path).await.unwrap(); db.init_schema().await.unwrap(); - let store = ConfigStore::new(db.clone_pool()).unwrap(); + let store = ConfigStore::new(db.clone_pool()); store.load().await.unwrap(); sqlx::query("DROP TABLE config") @@ -210,7 +215,7 @@ mod tests { .await .unwrap(); - let store = ConfigStore::new(db.clone_pool()).unwrap(); + let store = ConfigStore::new(db.clone_pool()); store.load().await.unwrap(); let (persisted,): (String,) = sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'") 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 b2935c58..d8cae180 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,3 +1,40 @@ +#[cfg(target_os = "linux")] +pub mod hid_bonds; mod pool; +mod wol_history; + +use std::path::Path; + +use crate::error::Result; pub use pool::DatabasePool; +pub use wol_history::WolHistoryStore; + +/// Open the application database stored in `data_dir` and ensure its schema exists. +pub async fn open_database_pool(data_dir: &Path) -> Result { + let db = DatabasePool::new(&data_dir.join("one-kvm.db")).await?; + db.init_schema().await?; + Ok(db) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn open_database_pool_creates_data_dir_and_initializes_schema() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().join("nested").join("data"); + + let db = open_database_pool(&data_dir).await.unwrap(); + + assert!(data_dir.join("one-kvm.db").is_file()); + let users_table: Option = sqlx::query_scalar( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'", + ) + .fetch_optional(db.pool()) + .await + .unwrap(); + assert_eq!(users_table.as_deref(), Some("users")); + } +} diff --git a/src/db/pool.rs b/src/db/pool.rs index b06dd4bc..3a3c5ac4 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -1,4 +1,7 @@ -use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite}; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, + Pool, Sqlite, +}; use std::path::Path; use std::time::Duration; @@ -15,114 +18,62 @@ impl DatabasePool { tokio::fs::create_dir_all(parent).await?; } - let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); + let options = SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Full) + .busy_timeout(Duration::from_secs(5)); let pool = SqlitePoolOptions::new() .max_connections(4) .acquire_timeout(Duration::from_secs(5)) .idle_timeout(Duration::from_secs(300)) - .connect(&db_url) + .connect_with(options) .await?; Ok(Self { pool }) } pub async fn init_schema(&self) -> Result<()> { - self.create_config_table().await?; - self.create_users_table().await?; - self.create_user_totp_credentials_table().await?; - self.create_api_tokens_table().await?; - self.create_wol_history_table().await?; - Ok(()) - } - - async fn create_config_table(&self) -> Result<()> { + // Keep migrations embedded in the binary so deployments do not need an + // extra migrations directory or another runtime dependency. + let mut transaction = self.pool.begin().await?; sqlx::query( r#" - CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) ) "#, ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_users_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - "#, - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_api_tokens_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS api_tokens ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - token_hash TEXT NOT NULL, - permissions TEXT NOT NULL, - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_used TEXT - ) - "#, - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_user_totp_credentials_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS user_totp_credentials ( - user_id TEXT PRIMARY KEY, - secret TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - "#, - ) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn create_wol_history_table(&self) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS wol_history ( - mac_address TEXT PRIMARY KEY, - updated_at INTEGER NOT NULL - ) - "#, - ) - .execute(&self.pool) + .execute(&mut *transaction) .await?; - sqlx::query( - r#" - CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at - ON wol_history(updated_at DESC) - "#, - ) - .execute(&self.pool) - .await?; + let current_version: i64 = + sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_migrations") + .fetch_one(&mut *transaction) + .await?; + + for (version, statements) in SCHEMA_MIGRATIONS + .iter() + .enumerate() + .map(|(index, statements)| ((index + 1) as i64, *statements)) + { + if version <= current_version { + continue; + } + for &statement in statements { + sqlx::query(statement).execute(&mut *transaction).await?; + } + sqlx::query("INSERT INTO schema_migrations (version) VALUES (?1)") + .bind(version) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; Ok(()) } @@ -133,4 +84,65 @@ impl DatabasePool { pub fn clone_pool(&self) -> Pool { self.pool.clone() } + + pub fn wol_history(&self) -> super::WolHistoryStore { + super::WolHistoryStore::new(self.pool.clone()) + } } + +// Each item is one version; statements within a version run atomically. +// New schema changes should be appended as a new item, never edited in place. +const SCHEMA_MIGRATIONS: &[&[&str]] = &[ + &[ + r#" + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS user_totp_credentials ( + user_id TEXT PRIMARY KEY, + secret TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + permissions TEXT NOT NULL, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT + ) + "#, + r#" + CREATE TABLE IF NOT EXISTS wol_history ( + mac_address TEXT PRIMARY KEY, + updated_at INTEGER NOT NULL + ) + "#, + r#" + CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at + ON wol_history(updated_at DESC) + "#, + ], + &[r#" + 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/db/wol_history.rs b/src/db/wol_history.rs new file mode 100644 index 00000000..0c1c20c0 --- /dev/null +++ b/src/db/wol_history.rs @@ -0,0 +1,39 @@ +use sqlx::{Pool, Sqlite}; + +use crate::error::Result; + +const MAX_ENTRIES: i64 = 50; + +#[derive(Clone)] +pub struct WolHistoryStore { + pool: Pool, +} + +impl WolHistoryStore { + pub(crate) fn new(pool: Pool) -> Self { + Self { pool } + } + + pub async fn record(&self, mac_address: &str) -> Result<()> { + let mut tx = self.pool.begin().await?; + sqlx::query("INSERT INTO wol_history (mac_address, updated_at) VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER)) ON CONFLICT(mac_address) DO UPDATE SET updated_at = excluded.updated_at") + .bind(mac_address) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM wol_history WHERE mac_address NOT IN (SELECT mac_address FROM wol_history ORDER BY updated_at DESC LIMIT ?1)") + .bind(MAX_ENTRIES) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list(&self, limit: usize) -> Result> { + Ok(sqlx::query_as( + "SELECT mac_address, updated_at FROM wol_history ORDER BY updated_at DESC LIMIT ?1", + ) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?) + } +} diff --git a/src/error.rs b/src/error.rs index 43d77fca..a105dc96 100644 --- a/src/error.rs +++ b/src/error.rs @@ -102,7 +102,9 @@ impl MsdErrorCode { Self::MsdDownloadIncomplete => "The remote image download was incomplete.", Self::MsdDriveNotInitialized => "The virtual drive is not initialized.", Self::MsdDriveConnected => "The virtual drive is connected to the controlled computer.", - Self::MsdDriveFilesystemUnsupported => "The virtual drive filesystem is unsupported.", + Self::MsdDriveFilesystemUnsupported => { + "Web file management does not support this virtual drive format." + } Self::MsdDriveSizeInvalid => "The virtual drive size is invalid.", Self::MsdStorageSpaceUnavailable => { "Available virtual media storage space could not be determined." @@ -183,7 +185,7 @@ impl MsdErrorCode { "Verify the remote server and network connection, then retry." } Self::MsdDriveFilesystemUnsupported => { - "Reinitialize the virtual drive with a supported filesystem, then retry." + "Mount the drive on the controlled computer, or use a supported format for web file management." } Self::MsdStorageSpaceUnavailable => { "Verify that virtual media storage is available, then retry." @@ -386,7 +388,7 @@ mod tests { ( MsdDriveFilesystemUnsupported, "MSD_DRIVE_FILESYSTEM_UNSUPPORTED", - "The virtual drive filesystem is unsupported.", + "Web file management does not support this virtual drive format.", ), ( MsdDriveSizeInvalid, diff --git a/src/hid/backend.rs b/src/hid/backend.rs index f32021cb..12c1d5bd 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")] @@ -35,6 +38,7 @@ impl HidBackendType { pub fn name_str(&self) -> &str { match self { Self::Otg => "otg", + Self::Bluetooth { .. } => "bluetooth", Self::Ch9329 { .. } => "ch9329", Self::None => "none", } @@ -83,6 +87,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/ch9329.rs b/src/hid/ch9329.rs index a19db531..df4ab5a5 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -44,7 +44,10 @@ const PARAM_CFG_VID_PID_OFFSET: usize = 11; const PARAM_CFG_STRING_FLAGS_OFFSET: usize = 36; const DESCRIPTOR_READ_RETRIES: usize = 3; const DESCRIPTOR_RETRY_DELAY_MS: u64 = 80; -const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 3000; + +// CH9329/CH9329F can take several seconds to restart after a descriptor update. +const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 5000; + const USB_STRING_MAX_LEN: usize = 23; const USB_STRING_FLAG_ENABLE: u8 = 0x80; const USB_STRING_FLAG_MANUFACTURER: u8 = 0x04; @@ -379,33 +382,41 @@ impl Ch9329Backend { Self::write_packet(port, address, cmd, data)?; - let mut pending = Vec::with_capacity(128); + // Keep enough room for a full parameter response and adjacent packets. + let mut pending = Vec::with_capacity(256); let deadline = Instant::now() + Duration::from_millis(RESPONSE_TIMEOUT_MS); let expected_ok = expected_response_cmd(cmd, false); let expected_err = expected_response_cmd(cmd, true); loop { - let mut chunk = [0u8; 128]; + let mut chunk = [0u8; 256]; match port.read(&mut chunk) { Ok(n) if n > 0 => { pending.extend_from_slice(&chunk[..n]); + // Drain every complete frame so adjacent/out-of-order responses + // cannot block the response for the current command. while let Some((response, consumed)) = try_extract_response(&pending) { + let current_response_cmd = response.cmd; pending.drain(..consumed); - if response.cmd == expected_ok || response.cmd == expected_err { + + if current_response_cmd == expected_ok + || current_response_cmd == expected_err + { return Ok(response); } trace!( - "CH9329 ignored out-of-order response: expected 0x{:02X}/0x{:02X}, got 0x{:02X}", + "CH9329 filtered an overlapping packet: expected 0x{:02X}/0x{:02X}, bypass 0x{:02X}", expected_ok, expected_err, - response.cmd + current_response_cmd ); } + // Bound memory use if a noisy or disconnected port keeps delivering bytes. if pending.len() > MAX_PACKET_SIZE * 4 { - let keep = MAX_PACKET_SIZE; + let keep = MAX_PACKET_SIZE * 2; pending.drain(..pending.len().saturating_sub(keep)); } } @@ -421,15 +432,19 @@ impl Ch9329Backend { if Instant::now() >= deadline { return Err(Self::backend_error( - format!("No matching response from CH9329 for cmd 0x{:02X}", cmd), + format!( + "No matching response from CH9329 for cmd 0x{:02X}. Remaining buffer: {}", + cmd, + Self::hex_bytes(&pending) + ), "no_response", )); } - thread::sleep(Duration::from_millis(1)); + // Give the serial driver a short opportunity to deliver the next chunk. + thread::sleep(Duration::from_micros(200)); } } - fn try_best_effort_reset(port: &mut dyn serialport::SerialPort, address: u8) { if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) { trace!("CH9329 best-effort reset failed: {}", err); @@ -709,7 +724,6 @@ impl Ch9329Backend { let mut port = Self::open_port(port_path, baud_rate)?; Self::read_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR) } - fn open_ready_port( port_path: &str, baud_rate: u32, @@ -880,7 +894,7 @@ impl Ch9329Backend { match Self::open_ready_port(port_path, baud_rate, address) { Ok((port, info)) => { info!( - "CH9329 reconnected: {}, USB: {}", + "CH9329-compatible chip reconnected: {}, USB: {}", info.version, if info.usb_connected { "connected" @@ -903,7 +917,6 @@ impl Ch9329Backend { } } } - fn recover_worker_port( mut port: Box, rx: &mpsc::Receiver, @@ -1196,7 +1209,7 @@ impl HidBackend for Ch9329Backend { match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) { Ok(Ok(info)) => { info!( - "CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}", + "CH9329-compatible chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}", info.version, if info.usb_connected { "connected" @@ -1215,13 +1228,13 @@ impl HidBackend for Ch9329Backend { Ok(Err(err)) => { self.record_error( format!( - "CH9329 not responding on {} @ {} baud: {}", + "CH9329-compatible chip not responding on {} @ {} baud: {}", self.port_path, self.baud_rate, err ), "init_failed", ); warn!( - "CH9329 not responding on {} @ {} baud, retrying in background: {}", + "CH9329-compatible chip not responding on {} @ {} baud, retrying in background: {}", self.port_path, self.baud_rate, err ); *self.worker_tx.lock() = Some(tx); @@ -1231,9 +1244,12 @@ impl HidBackend for Ch9329Backend { Err(_) => { let _ = tx.send(WorkerCommand::Shutdown); let _ = handle.join(); - self.record_error("Timed out waiting for CH9329 worker init", "init_timeout"); + self.record_error( + "Timed out waiting for CH9329-compatible worker init", + "init_timeout", + ); Err(AppError::Internal( - "Timed out waiting for CH9329 initialization".to_string(), + "Timed out waiting for CH9329-compatible initialization".to_string(), )) } } diff --git a/src/hid/ch9329_proto.rs b/src/hid/ch9329_proto.rs index c0594f30..28eb9202 100644 --- a/src/hid/ch9329_proto.rs +++ b/src/hid/ch9329_proto.rs @@ -10,6 +10,7 @@ pub const DEFAULT_ADDR: u8 = 0x00; pub const DEFAULT_BAUD_RATE: u32 = 9600; pub const MAX_DATA_LEN: usize = 64; pub const MAX_PACKET_SIZE: usize = 70; +const EXTENDED_PARAMETER_RESPONSE_SIZES: [usize; 2] = [72, 88]; pub mod cmd { pub const GET_INFO: u8 = 0x01; @@ -130,7 +131,8 @@ impl Response { let cmd = bytes[3]; let len = bytes[4] as usize; - if bytes.len() < 5 + len + 1 { + let expected_frame_len = 6 + len; + if bytes.len() < expected_frame_len { return None; } @@ -139,7 +141,7 @@ impl Response { .iter() .fold(0u8, |acc, &x| acc.wrapping_add(x)); if expected_checksum != calculated_checksum { - tracing::warn!( + tracing::debug!( "CH9329 checksum mismatch: expected {:02X}, got {:02X}", expected_checksum, calculated_checksum @@ -215,6 +217,11 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> { } let len = buffer[offset + 4] as usize; + if len > MAX_DATA_LEN { + offset += 1; + continue; + } + let frame_len = 6 + len; if offset + frame_len > buffer.len() { return None; @@ -225,8 +232,103 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> { return Some((response, offset + frame_len)); } + // Some CH9329F firmware appends reserved bytes to GET_PARA_CFG while + // retaining the protocol LEN value of 50. Locate and validate the real + // checksum, return the documented 50-byte payload, and consume the + // complete extended frame. Other commands keep strict framing. + let cmd = buffer[offset + 3]; + let data_start = offset + 5; + let parameter_payload_is_plausible = cmd == expected_response_cmd(cmd::GET_PARA_CFG, false) + && len == 50 + && matches!(buffer[data_start], 0x00..=0x03 | 0x80..=0x83) + && matches!(buffer[data_start + 1], 0x00..=0x02 | 0x80..=0x82); + if parameter_payload_is_plausible { + for extended_size in EXTENDED_PARAMETER_RESPONSE_SIZES { + let extended_end = offset + extended_size; + if buffer.len() >= extended_end { + let checksum_index = extended_end - 1; + if calculate_checksum(&buffer[offset..checksum_index]) != buffer[checksum_index] + { + continue; + } + return Some(( + Response { + cmd, + data: buffer[data_start..data_start + len].to_vec(), + is_error: false, + error_code: None, + }, + extended_end, + )); + } + } + + if buffer.len() < offset + EXTENDED_PARAMETER_RESPONSE_SIZES[1] { + return None; + } + } + offset += 1; } None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_standard_response_and_checksum() { + let frame = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0x00, 0, 0, 0, 0, 0]); + let response = Response::parse(&frame).expect("valid response"); + assert_eq!(response.cmd, 0x81); + assert_eq!(response.data, vec![0x30, 0x01, 0x00, 0, 0, 0, 0, 0]); + assert!(!response.is_error); + } + + #[test] + fn extracts_extended_parameter_response_with_valid_trailing_checksum() { + let payload = [ + 0x80, 0x80, 0x00, 0x00, 0x00, 0x25, 0x80, 0x08, 0x00, 0x00, 0x03, 0x86, 0x1A, 0x2A, + 0xE1, 0x00, 0x00, 0x00, 0x01, 0x00, 0x0D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + for reserved_len in [16, 32] { + let mut frame = vec![0x57, 0xAB, DEFAULT_ADDR, 0x88, 50]; + frame.extend_from_slice(&payload); + frame.extend_from_slice(&vec![0; reserved_len]); + frame.push(calculate_checksum(&frame)); + + assert!(Response::parse(&frame[..56]).is_none()); + let (response, consumed) = try_extract_response(&frame).expect("extended response"); + assert_eq!(response.cmd, 0x88); + assert_eq!(response.data, payload); + assert!(!response.is_error); + assert_eq!(consumed, frame.len()); + } + } + + #[test] + fn rejects_bad_checksum_for_other_commands() { + let mut frame = build_packet(DEFAULT_ADDR, 0x89, &[0x00; 50]); + *frame.last_mut().unwrap() ^= 0xFF; + assert!(Response::parse(&frame).is_none()); + assert!(try_extract_response(&frame).is_none()); + } + + #[test] + fn extracts_noise_and_adjacent_packets() { + let first = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0, 0, 0, 0, 0, 0]); + let second = build_packet(DEFAULT_ADDR, 0x82, &[0x00]); + let mut buffer = vec![0x00, 0xFF]; + buffer.extend_from_slice(&first); + buffer.extend_from_slice(&second); + + let (_, consumed) = try_extract_response(&buffer).expect("first response"); + assert_eq!(consumed, 2 + first.len()); + let (response, _) = try_extract_response(&buffer[consumed..]).expect("second response"); + assert_eq!(response.cmd, 0x82); + } +} diff --git a/src/hid/factory.rs b/src/hid/factory.rs index 61e8b53d..3e88b3ac 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))] @@ -58,6 +64,22 @@ impl HidBackendFactory { )?, ))) } + 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/lib.rs b/src/lib.rs index d7e0509f..a8b8fa93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,8 @@ pub mod redfish; #[cfg(feature = "desktop")] pub mod rtsp; #[cfg(feature = "desktop")] +pub mod runtime; +#[cfg(feature = "desktop")] pub mod rustdesk; #[cfg(feature = "desktop")] pub mod state; diff --git a/src/main.rs b/src/main.rs index fff1b3c6..897b9f3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,44 +2,21 @@ use std::collections::HashSet; use std::future::Future; use std::io::Write; use std::net::{IpAddr, SocketAddr}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::path::PathBuf; use axum_server::tls_rustls::RustlsConfig; use clap::{Args, Parser, Subcommand, ValueEnum}; use futures::{stream::FuturesUnordered, StreamExt}; use rustls::crypto::{ring, CryptoProvider}; -use tokio::sync::{broadcast, mpsc}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use one_kvm::atx::AtxController; -use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality}; use one_kvm::auth::{SessionStore, TwoFactorService, UserStore}; -use one_kvm::computer_use::ComputerUseManager; -use one_kvm::config::{self, AppConfig, ConfigStore}; -use one_kvm::db::DatabasePool; -use one_kvm::events::EventBus; -use one_kvm::extensions::ExtensionManager; -use one_kvm::hid::{HidBackendType, HidController}; -#[cfg(unix)] -use one_kvm::msd::MsdController; -#[cfg(unix)] -use one_kvm::otg::OtgService; +use one_kvm::config; +use one_kvm::db::open_database_pool; use one_kvm::platform::PlatformCapabilities; -use one_kvm::rtsp::RtspService; -use one_kvm::rustdesk::RustDeskService; -use one_kvm::state::{AppState, ShutdownAction}; -use one_kvm::update::UpdateService; +use one_kvm::runtime::{RuntimeBuilder, WebConfigOverrides}; +use one_kvm::state::ShutdownAction; use one_kvm::utils::bind_tcp_listener; -use one_kvm::video::codec_constraints::{ - enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, - StreamCodecConstraints, -}; -use one_kvm::video::format::{PixelFormat, Resolution}; -use one_kvm::video::{Streamer, VideoStreamManager}; -use one_kvm::vnc::VncService; -use one_kvm::web; -use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig}; #[derive(Debug, Clone, Copy, Default, ValueEnum)] enum LogLevel { @@ -47,7 +24,6 @@ enum LogLevel { Warn, #[default] Info, - Verbose, Debug, Trace, } @@ -93,13 +69,9 @@ struct CliArgs { #[arg(short = 'd', long, value_name = "DIR")] data_dir: Option, - /// Log level (error, warn, info, verbose, debug, trace) + /// Log level (error, warn, info, debug, trace) #[arg(short = 'l', long, value_name = "LEVEL", default_value = "info")] log_level: LogLevel, - - /// Increase verbosity (-v for verbose, -vv for debug, -vvv for trace) - #[arg(short = 'v', long, action = clap::ArgAction::Count)] - verbose: u8, } #[derive(Subcommand, Debug)] @@ -126,7 +98,7 @@ enum UserAction { async fn main() -> anyhow::Result<()> { let args = CliArgs::parse(); - init_logging(args.log_level, args.verbose); + init_logging(args.log_level); CryptoProvider::install_default(ring::default_provider()) .expect("Failed to install rustls crypto provider"); @@ -147,28 +119,20 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } - let (db, config_store, mut config) = load_runtime_config(&data_dir).await?; - - if let Some(addr) = args.address { - config.web.bind_address = addr.clone(); - config.web.bind_addresses = vec![addr]; - } - if let Some(port) = args.http_port { - config.web.http_port = port; - } - if let Some(port) = args.https_port { - config.web.https_port = port; - } - if args.enable_https { - config.web.https_enabled = true; - } - - if let Some(cert_path) = args.ssl_cert { - config.web.ssl_cert_path = Some(cert_path.to_string_lossy().to_string()); - } - if let Some(key_path) = args.ssl_key { - config.web.ssl_key_path = Some(key_path.to_string_lossy().to_string()); - } + let overrides = WebConfigOverrides { + address: args.address, + http_port: args.http_port, + https_port: args.https_port, + enable_https: args.enable_https, + ssl_cert: args.ssl_cert, + ssl_key: args.ssl_key, + }; + let mut runtime = RuntimeBuilder::new(data_dir.clone()) + .with_web_overrides(overrides) + .build() + .await?; + let config = runtime.config(); + let state = runtime.state().clone(); let bind_ips = resolve_bind_addresses(&config.web)?; let scheme = if config.web.https_enabled { @@ -187,501 +151,12 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Server will listen on: {}://{}", scheme, addr); } - let session_store = SessionStore::new(config.auth.session_timeout_secs as i64); - - let user_store = UserStore::new(db.clone_pool()); - let two_factor = TwoFactorService::new(db.clone_pool()); - - let (shutdown_tx, _) = broadcast::channel::(1); - - let events = Arc::new(EventBus::new()); - tracing::info!("Event bus initialized"); - - let (video_format, video_resolution) = parse_video_config(&config); - tracing::debug!( - "Parsed video config: {} @ {}x{}", - video_format, - video_resolution.width, - video_resolution.height - ); - - let streamer = Streamer::new(); - streamer.set_event_bus(events.clone()).await; - if let Some(ref device_path) = config.video.device { - if let Err(e) = streamer - .apply_video_config( - device_path, - video_format, - video_resolution, - config.video.fps, - ) - .await - { - tracing::warn!( - "Failed to initialize video with config: {}, will auto-detect", - e - ); - } else { - tracing::info!( - "Video configured: {} @ {}x{} {}", - device_path, - video_resolution.width, - video_resolution.height, - video_format - ); - } - } - - let webrtc_streamer = { - let webrtc_config = WebRtcStreamerConfig { - resolution: video_resolution, - input_format: video_format, - fps: config.video.fps, - bitrate_preset: config.stream.bitrate_preset, - encoder_backend: one_kvm::stream_encoder::encoder_type_to_backend( - config.stream.encoder.clone(), - ), - webrtc: { - let mut stun_servers = vec![]; - let mut turn_servers = vec![]; - - let has_custom_stun = config - .stream - .stun_server - .as_ref() - .map(|s| !s.is_empty()) - .unwrap_or(false); - let has_custom_turn = config - .stream - .turn_server - .as_ref() - .map(|s| !s.is_empty()) - .unwrap_or(false); - - if !has_custom_stun && !has_custom_turn { - use one_kvm::webrtc::config::public_ice; - let stun = public_ice::stun_server().to_string(); - tracing::info!("Using public STUN server: {}", stun); - stun_servers.push(stun); - } else { - if let Some(ref stun) = config.stream.stun_server { - if !stun.is_empty() { - stun_servers.push(stun.clone()); - tracing::info!("Using custom STUN server: {}", stun); - } - } - if let Some(ref turn) = config.stream.turn_server { - if !turn.is_empty() { - let username = config.stream.turn_username.clone().unwrap_or_default(); - let credential = - config.stream.turn_password.clone().unwrap_or_default(); - turn_servers.push(one_kvm::webrtc::config::TurnServer::new( - turn.clone(), - username.clone(), - credential, - )); - tracing::info!( - "Using custom TURN server: {} (user: {})", - turn, - username - ); - } - } - } - - one_kvm::webrtc::config::WebRtcConfig { - stun_servers, - turn_servers, - ..Default::default() - } - }, - ..Default::default() - }; - WebRtcStreamer::with_config(webrtc_config) - }; - tracing::info!("WebRTC streamer created"); - - #[cfg(unix)] - let otg_service = Arc::new(OtgService::new()); - #[cfg(unix)] - tracing::info!("OTG Service created"); - - #[cfg(unix)] - if let Err(e) = otg_service - .apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac) - .await - { - tracing::warn!("Failed to apply OTG config: {}", e); - } - - let hid_backend = match config.hid.backend { - config::HidBackend::Otg => HidBackendType::Otg, - config::HidBackend::Ch9329 => HidBackendType::Ch9329 { - port: config.hid.ch9329_port.clone(), - baud_rate: config.hid.ch9329_baudrate, - hybrid_mouse: config.hid.ch9329_hybrid_mouse, - macos_drag: config.hid.ch9329_macos_drag, - }, - config::HidBackend::None => HidBackendType::None, - }; - #[cfg(unix)] - let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone()))); - #[cfg(not(unix))] - let hid = Arc::new(HidController::new(hid_backend)); - hid.set_event_bus(events.clone()).await; - if let Err(e) = hid.init().await { - tracing::warn!("Failed to initialize HID backend: {}", e); - } - - #[cfg(unix)] - let msd = if config.msd.enabled { - let ventoy_resource_dir = data_dir.join("ventoy"); - let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path()); - if let Err(e) = controller.init(&ventoy_resource_dir).await { - tracing::warn!("Failed to initialize MSD controller: {}", e); - None - } else { - controller.set_event_bus(events.clone()).await; - Some(controller) - } - } else { - tracing::info!("MSD disabled in configuration"); - None - }; - - let atx = if config.atx.enabled { - let controller_config = config.atx.to_controller_config(); - let controller = AtxController::new(controller_config); - - if let Err(e) = controller.init().await { - tracing::warn!("Failed to initialize ATX controller: {}", e); - None - } else { - Some(controller) - } - } else { - tracing::info!("ATX disabled in configuration"); - None - }; - - let audio = { - let audio_config = AudioControllerConfig { - enabled: config.audio.enabled, - device: config.audio.device.clone(), - quality: match config.audio.quality.parse::() { - Ok(q) => q, - Err(e) => { - tracing::warn!( - "Invalid audio quality in config (value={:?}): {}, using balanced", - config.audio.quality, - e - ); - AudioQuality::Balanced - } - }, - }; - - let controller = AudioController::new(audio_config); - controller.set_event_bus(events.clone()).await; - - if config.audio.enabled { - tracing::info!( - "Audio enabled: {}, quality={}", - config.audio.device, - config.audio.quality - ); - if let Err(e) = controller.start_streaming().await { - tracing::warn!("Failed to start audio streaming: {}", e); - } - } else { - tracing::info!("Audio disabled in configuration"); - } - - Arc::new(controller) - }; - - let extensions = Arc::new(ExtensionManager::new()); - tracing::info!("Extension manager initialized"); - - webrtc_streamer.set_hid_controller(hid.clone()).await; - - webrtc_streamer.set_audio_controller(audio.clone()).await; - if config.audio.enabled { - if let Err(e) = webrtc_streamer.set_audio_enabled(true).await { - tracing::warn!("Failed to enable WebRTC audio: {}", e); - } else { - tracing::debug!("WebRTC audio enabled"); - } - } - - let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) = - streamer.current_capture_config().await; - tracing::debug!( - "Initial video config: {}x{} {:?} @ {}fps", - actual_resolution.width, - actual_resolution.height, - actual_format, - actual_fps - ); - webrtc_streamer - .update_video_config(actual_resolution, actual_format, actual_fps) - .await; - if let Some(device_path) = device_path { - let device_info = streamer.current_device().await; - webrtc_streamer - .set_capture_device(device_path, jpeg_quality, device_info) - .await; - tracing::debug!("WebRTC streamer configured for direct capture"); - } else { - tracing::warn!("No capture device configured for WebRTC"); - } - - let stream_manager = VideoStreamManager::with_webrtc_streamer( - streamer.clone(), - webrtc_streamer.clone() as std::sync::Arc, - ); - stream_manager.set_event_bus(events.clone()).await; - stream_manager.set_config_store(config_store.clone()).await; - { - let stream_manager_weak = Arc::downgrade(&stream_manager); - audio - .set_recovered_callback(Arc::new(move || { - if let Some(stream_manager) = stream_manager_weak.upgrade() { - tokio::spawn(async move { - stream_manager.reconnect_webrtc_audio_sources().await; - }); - } - })) - .await; - } - - let initial_mode = config.stream.mode.clone(); - if let Err(e) = stream_manager.init_with_mode(initial_mode.clone()).await { - tracing::warn!( - "Failed to initialize stream manager with mode {:?}: {}", - initial_mode, - e - ); - } else { - tracing::info!( - "Video stream manager initialized with mode: {:?}", - initial_mode - ); - } - - let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) { - Ok(()) => true, - Err(e) => { - tracing::warn!( - "Third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}", - e - ); - false - } - }; - - let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() { - tracing::info!( - "Initializing RustDesk service: ID={} -> {}", - config.rustdesk.device_id, - config.rustdesk.rendezvous_addr() - ); - let service = RustDeskService::new( - config.rustdesk.clone(), - stream_manager.clone(), - hid.clone(), - audio.clone(), - ); - Some(Arc::new(service)) - } else { - if config.rustdesk.enabled { - tracing::warn!( - "RustDesk enabled but configuration is incomplete (missing server or credentials)" - ); - } else { - tracing::info!("RustDesk disabled in configuration"); - } - None - }; - - let rtsp = if third_party_codec_config_valid && config.rtsp.enabled { - tracing::info!( - "Initializing RTSP service: rtsp://{}:{}/{}", - config.rtsp.bind, - config.rtsp.port, - config.rtsp.path - ); - let service = RtspService::new(config.rtsp.clone(), stream_manager.clone()); - Some(Arc::new(service)) - } else { - tracing::info!("RTSP disabled in configuration"); - None - }; - - let vnc = if third_party_codec_config_valid && config.vnc.enabled { - tracing::info!( - "Initializing VNC service: {}:{} ({:?})", - config.vnc.bind, - config.vnc.port, - config.vnc.encoding - ); - Some(Arc::new(VncService::new( - config.vnc.clone(), - stream_manager.clone(), - hid.clone(), - ))) - } else { - tracing::info!("VNC disabled in configuration"); - None - }; - - let update_service = Arc::new(UpdateService::new()); - let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone()); - - let state = AppState::new( - db.clone(), - config_store.clone(), - session_store, - user_store, - two_factor, - #[cfg(unix)] - otg_service, - stream_manager, - webrtc_streamer.clone(), - hid, - computer_use, - #[cfg(unix)] - msd, - atx, - audio, - rustdesk.clone(), - vnc.clone(), - rtsp.clone(), - extensions.clone(), - events.clone(), - update_service, - shutdown_tx.clone(), - data_dir.clone(), - ); - - #[cfg(unix)] - { - // Initialize UAC playback writer if UAC is enabled. - if config.uac.enabled { - let uac_cfg = one_kvm::audio::uac::UacPlaybackConfig { - sample_rate: config.uac.sample_rate, - channels: config.uac.channels as u16, - ..Default::default() - }; - match one_kvm::audio::uac::UacPlayback::start(uac_cfg) { - Ok(writer) => { - *state.uac_playback.write().await = Some(writer); - tracing::info!("UAC playback writer started"); - } - Err(e) => { - tracing::warn!("Failed to start UAC playback writer: {}", e); - } - } - } - } - - if config.watchdog.enabled { - if let Err(error) = state.watchdog.enable().await { - tracing::error!( - "Configured hardware watchdog failed to start; web service will continue: {}", - error - ); - } else { - tracing::info!("Hardware watchdog started"); - } - } - - extensions.set_event_bus(events.clone()).await; - - if let Some(ref service) = rustdesk { - if let Err(e) = service.start().await { - tracing::error!("Failed to start RustDesk service: {}", e); - } else { - if let Some(updated_config) = service.save_credentials() { - if let Err(e) = config_store - .update(|cfg| { - cfg.rustdesk.public_key = updated_config.public_key.clone(); - cfg.rustdesk.private_key = updated_config.private_key.clone(); - cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone(); - cfg.rustdesk.signing_private_key = - updated_config.signing_private_key.clone(); - cfg.rustdesk.uuid = updated_config.uuid.clone(); - }) - .await - { - tracing::warn!("Failed to save RustDesk credentials: {}", e); - } - } - tracing::info!("RustDesk service started"); - } - } - if let Some(ref service) = vnc { - if let Err(e) = service.start().await { - tracing::error!("Failed to start VNC service: {}", e); - } else { - tracing::info!("VNC service started"); - } - } - - if let Some(ref service) = rtsp { - if let Err(e) = service.start().await { - tracing::error!("Failed to start RTSP service: {}", e); - } else { - tracing::info!("RTSP service started"); - } - } - - { - let runtime_config = state.runtime_third_party_config().await; - let constraints = StreamCodecConstraints::from_config(&runtime_config); - state - .stream_manager - .set_runtime_codec_constraints(constraints.clone()) - .await; - match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await { - Ok(result) if result.changed => { - if let Some(message) = result.message { - tracing::info!("{}", message); - } - } - Ok(_) => {} - Err(e) => tracing::warn!("Failed to enforce startup codec constraints: {}", e), - } - } - - { - let ext_config = config_store.get(); - extensions.start_enabled(&ext_config.extensions).await; - } - - { - let extensions_clone = extensions.clone(); - let config_store_clone = config_store.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); - loop { - interval.tick().await; - let config = config_store_clone.get(); - extensions_clone.health_check(&config.extensions).await; - } - }); - tracing::info!("Extension health check task started"); - } - - state.publish_device_info().await; - - spawn_device_info_broadcaster(state.clone(), events); - - let app = web::create_router(state.clone()); + let app = runtime.router(); let listeners = bind_tcp_listeners(&bind_ips, bind_port)?; let shutdown_signal = { + let shutdown_tx = state.shutdown_tx.clone(); let mut shutdown_rx = shutdown_tx.subscribe(); async move { tokio::select! { @@ -741,7 +216,7 @@ async fn main() -> anyhow::Result<()> { servers.push(server); } - run_servers_until_shutdown(servers, shutdown_signal, &state, "HTTPS").await + run_servers_until_shutdown(servers, shutdown_signal, "HTTPS").await } else { let servers = FuturesUnordered::new(); for listener in listeners { @@ -753,9 +228,10 @@ async fn main() -> anyhow::Result<()> { servers.push(async move { server.await }); } - run_servers_until_shutdown(servers, shutdown_signal, &state, "HTTP").await + run_servers_until_shutdown(servers, shutdown_signal, "HTTP").await }; + runtime.shutdown().await; tracing::info!("Server shutdown complete"); if let ShutdownAction::Restart { exe_path } = shutdown_action { restart_current_process(exe_path)?; @@ -763,25 +239,16 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -fn init_logging(level: LogLevel, verbose_count: u8) { - let effective_level = match verbose_count { - 0 => level, - 1 => LogLevel::Verbose, - 2 => LogLevel::Debug, - _ => LogLevel::Trace, +fn init_logging(level: LogLevel) { + let app_level = match level { + LogLevel::Error => "error", + LogLevel::Warn => "warn", + LogLevel::Info => "info", + LogLevel::Debug => "debug", + LogLevel::Trace => "trace", }; - - let filter = match effective_level { - LogLevel::Error => "one_kvm=error,tower_http=error,webrtc_sctp=warn", - LogLevel::Warn => "one_kvm=warn,tower_http=warn,webrtc_sctp=warn", - LogLevel::Info => "one_kvm=info,tower_http=info,webrtc_sctp=warn", - LogLevel::Verbose => "one_kvm=debug,tower_http=info,webrtc_sctp=warn", - LogLevel::Debug => "one_kvm=debug,tower_http=debug,webrtc_sctp=warn", - LogLevel::Trace => "one_kvm=trace,tower_http=debug,webrtc_sctp=warn", - }; - let env_filter = - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()); + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| app_level.into()); if let Err(err) = tracing_subscriber::registry() .with(env_filter) @@ -831,24 +298,16 @@ async fn shutdown_signal() -> anyhow::Result<()> { Ok(()) } -async fn open_database_pool(data_dir: &Path) -> anyhow::Result { - let db_path = data_dir.join("one-kvm.db"); - let db = DatabasePool::new(&db_path).await?; - db.init_schema().await?; - Ok(db) -} - async fn run_servers_until_shutdown( mut servers: FuturesUnordered, shutdown_signal: impl Future, - state: &Arc, protocol: &'static str, ) -> ShutdownAction where F: Future> + Send, E: std::fmt::Display, { - let action = tokio::select! { + tokio::select! { action = shutdown_signal => { action } @@ -858,9 +317,7 @@ where } ShutdownAction::Exit } - }; - cleanup(state).await; - action + } } fn restart_current_process(exe_path: Option) -> anyhow::Result<()> { @@ -884,7 +341,6 @@ fn restart_current_process(exe_path: Option) -> anyhow::Result<()> { } async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> { - tokio::fs::create_dir_all(&data_dir).await?; let db = open_database_pool(&data_dir).await?; let users = UserStore::new(db.clone_pool()); let two_factor = TwoFactorService::new(db.clone_pool()); @@ -897,64 +353,6 @@ async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Resu } } -async fn load_runtime_config( - data_dir: &Path, -) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> { - tokio::fs::create_dir_all(data_dir).await?; - - let db = open_database_pool(data_dir).await?; - let config_store = ConfigStore::new(db.clone_pool())?; - config_store.load().await?; - let mut config = (*config_store.get()).clone(); - config.apply_platform_defaults(); - - prepare_linux_runtime_dirs(data_dir, &config_store, &mut config).await?; - - Ok((db, config_store, config)) -} - -#[cfg(unix)] -async fn prepare_linux_runtime_dirs( - data_dir: &Path, - config_store: &ConfigStore, - config: &mut AppConfig, -) -> anyhow::Result<()> { - let mut msd_dir_updated = false; - if config.msd.msd_dir.trim().is_empty() { - let msd_dir = data_dir.join("msd"); - config.msd.msd_dir = msd_dir.to_string_lossy().to_string(); - msd_dir_updated = true; - } else if !PathBuf::from(&config.msd.msd_dir).is_absolute() { - let msd_dir = data_dir.join(&config.msd.msd_dir); - tracing::warn!( - "MSD directory is relative, rebasing to {}", - msd_dir.display() - ); - config.msd.msd_dir = msd_dir.to_string_lossy().to_string(); - msd_dir_updated = true; - } - if msd_dir_updated { - config_store.set(config.clone()).await?; - } - let msd_dir = PathBuf::from(&config.msd.msd_dir); - if let Err(e) = tokio::fs::create_dir_all(msd_dir.join("images")).await { - tracing::warn!("Failed to create MSD images directory: {}", e); - } - if let Err(e) = tokio::fs::create_dir_all(msd_dir.join("ventoy")).await { - tracing::warn!("Failed to create MSD ventoy directory: {}", e); - } - Ok(()) -} - -#[cfg(not(unix))] -async fn prepare_linux_runtime_dirs( - _data_dir: &Path, - _config_store: &ConfigStore, - _config: &mut AppConfig, -) -> anyhow::Result<()> { - Ok(()) -} - async fn run_user_action( action: UserAction, users: &UserStore, @@ -1066,17 +464,6 @@ fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result (PixelFormat, Resolution) { - let format = config - .video - .format - .as_ref() - .and_then(|f: &String| f.parse::().ok()) - .unwrap_or(PixelFormat::Mjpeg); - let resolution = Resolution::new(config.video.width, config.video.height); - (format, resolution) -} - fn generate_self_signed_cert() -> anyhow::Result> { use rcgen::generate_simple_self_signed; @@ -1089,197 +476,3 @@ fn generate_self_signed_cert() -> anyhow::Result, events: Arc) { - use std::time::{Duration, Instant}; - - enum DeviceInfoTrigger { - Event, - Lagged { topic: &'static str, count: u64 }, - } - - const DEVICE_INFO_TOPICS: &[&str] = &[ - "stream.state_changed", - "stream.config_applied", - "stream.mode_ready", - ]; - const DEBOUNCE_MS: u64 = 100; - - let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel(); - - for topic in DEVICE_INFO_TOPICS { - let Some(mut rx) = events.subscribe_topic(topic) else { - tracing::warn!( - "DeviceInfo broadcaster missing topic subscription: {}", - topic - ); - continue; - }; - - let trigger_tx = trigger_tx.clone(); - let topic_name = *topic; - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(_) => { - if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { - if trigger_tx - .send(DeviceInfoTrigger::Lagged { - topic: topic_name, - count, - }) - .is_err() - { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - } - - { - let mut dirty_rx = events.subscribe_device_info_dirty(); - let trigger_tx = trigger_tx.clone(); - tokio::spawn(async move { - loop { - match dirty_rx.recv().await { - Ok(()) => { - if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { - if trigger_tx - .send(DeviceInfoTrigger::Lagged { - topic: "device_info_dirty", - count, - }) - .is_err() - { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - } - - tokio::spawn(async move { - let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS); - let mut pending_broadcast = false; - - loop { - let recv_result = if pending_broadcast { - let remaining = - DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64); - tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await - } else { - Ok(trigger_rx.recv().await) - }; - - match recv_result { - Ok(Some(DeviceInfoTrigger::Event)) => { - pending_broadcast = true; - } - Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => { - tracing::warn!( - "DeviceInfo broadcaster lagged by {} events on topic {}", - count, - topic - ); - pending_broadcast = true; - } - Ok(None) => { - tracing::info!("Event bus closed, stopping DeviceInfo broadcaster"); - break; - } - Err(_timeout) => {} - } - - if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) { - state.publish_device_info().await; - tracing::trace!("Broadcasted DeviceInfo (debounced)"); - last_broadcast = Instant::now(); - pending_broadcast = false; - } - } - }); - - tracing::info!( - "DeviceInfo broadcaster task started (debounce: {}ms)", - DEBOUNCE_MS - ); -} - -async fn cleanup(state: &Arc) { - state.extensions.stop_all().await; - tracing::info!("Extensions stopped"); - - if let Some(ref service) = *state.rustdesk.read().await { - if let Err(e) = service.stop().await { - tracing::warn!("Failed to stop RustDesk service: {}", e); - } else { - tracing::info!("RustDesk service stopped"); - } - } - - if let Some(ref service) = *state.vnc.read().await { - if let Err(e) = service.stop().await { - tracing::warn!("Failed to stop VNC service: {}", e); - } else { - tracing::info!("VNC service stopped"); - } - } - - if let Some(ref service) = *state.rtsp.read().await { - if let Err(e) = service.stop().await { - tracing::warn!("Failed to stop RTSP service: {}", e); - } else { - tracing::info!("RTSP service stopped"); - } - } - - if let Err(e) = state.stream_manager.stop().await { - tracing::warn!("Failed to stop streamer: {}", e); - } - - if let Err(e) = state.hid.shutdown().await { - tracing::warn!("Failed to shutdown HID: {}", e); - } - - #[cfg(unix)] - if let Some(msd) = state.msd.write().await.as_mut() { - if let Err(e) = msd.shutdown().await { - tracing::warn!("Failed to shutdown MSD: {}", e); - } - } - - #[cfg(unix)] - if let Err(e) = state.otg_service.shutdown().await { - tracing::warn!("Failed to shutdown OTG: {}", e); - } - - if let Some(atx) = state.atx.write().await.as_mut() { - if let Err(e) = atx.shutdown().await { - tracing::warn!("Failed to shutdown ATX: {}", e); - } - } - - if let Err(e) = state.audio.shutdown().await { - tracing::warn!("Failed to shutdown audio: {}", e); - } - - if let Err(error) = state.watchdog.disable().await { - tracing::error!( - "CRITICAL: failed to disable hardware watchdog during shutdown: {}", - error - ); - } -} diff --git a/src/msd/controller.rs b/src/msd/controller.rs index 6c7fff2b..c9b249f1 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -8,9 +8,10 @@ use tracing::{debug, info, warn}; use super::image::ImageManager; use super::monitor::MsdHealthMonitor; use super::types::{ - DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia, - MountedMediaKind, MsdState, + DiskMode, DownloadProgress, DownloadStatus, DriveFileAccess, DriveInfo, ImageInfo, + MountedMedia, MountedMediaKind, MsdState, }; +use super::ventoy_drive::VentoyDrive; use crate::error::{AppError, MsdErrorCode, Result}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; @@ -62,12 +63,8 @@ impl MsdController { ), } - if let Err(e) = std::fs::create_dir_all(&self.images_path) { - warn!("Failed to create images directory: {}", e); - } - if let Err(e) = std::fs::create_dir_all(&self.ventoy_dir) { - warn!("Failed to create ventoy directory: {}", e); - } + tokio::fs::create_dir_all(&self.images_path).await?; + tokio::fs::create_dir_all(&self.ventoy_dir).await?; info!("Fetching MSD function from OtgService"); let msd_func = self @@ -87,14 +84,9 @@ impl MsdController { state.available = true; if self.drive_path.exists() { - if let Ok(metadata) = std::fs::metadata(&self.drive_path) { - let drive_info = DriveInfo { - size: metadata.len(), - used: 0, - free: metadata.len(), - initialized: true, - path: self.drive_path.clone(), - }; + if let Ok(drive_info) = + VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown) + { state.drive_info = Some(drive_info.clone()); debug!( "Found existing virtual drive: {}", @@ -203,28 +195,6 @@ impl MsdController { self.assert_available(&state).await?; - if !self.drive_path.exists() { - self.monitor - .report_error("Virtual drive not initialized", "drive_not_found") - .await; - return Err(MsdErrorCode::MsdDriveNotInitialized.into()); - } - - let drive_info = state.drive_info.clone().or_else(|| { - std::fs::metadata(&self.drive_path) - .ok() - .map(|metadata| DriveInfo { - size: metadata.len(), - used: 0, - free: metadata.len(), - initialized: true, - path: self.drive_path.clone(), - }) - }); - if state.drive_info.is_none() { - state.drive_info = drive_info.clone(); - } - if state .mounted_media .iter() @@ -233,8 +203,22 @@ impl MsdController { return Err(MsdErrorCode::MsdMediaAlreadyMounted.into()); } - let drive_info = - drive_info.ok_or_else(|| AppError::from(MsdErrorCode::MsdDriveNotInitialized))?; + let drive_info = match self.drive_mount_info() { + Ok(info) => info, + Err(error) => { + if matches!( + &error, + AppError::Msd(msd) if msd.code() == MsdErrorCode::MsdDriveNotInitialized + ) { + self.monitor + .report_error("Virtual drive not initialized", "drive_not_found") + .await; + } + return Err(error); + } + }; + state.drive_info = Some(drive_info.clone()); + let lun = Self::lowest_free_lun(&state) .ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?; @@ -244,6 +228,8 @@ impl MsdController { return Err(e); } state.mounted_media.push(media); + state.drive_info = + Some(drive_info.with_file_access(DriveFileAccess::BlockedWhileConnected)); info!( "Mounted virtual drive on LUN {}: {}", @@ -258,6 +244,15 @@ impl MsdController { Ok(()) } + fn drive_mount_info(&self) -> Result { + VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown) + } + + pub async fn set_drive_info(&self, drive_info: Option) { + self.state.write().await.drive_info = drive_info; + self.mark_device_info_dirty().await; + } + async fn assert_available(&self, state: &MsdState) -> Result<()> { if !state.available { self.monitor @@ -297,6 +292,16 @@ impl MsdController { } fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) { + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) + { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } state.disk_mode = disk_mode; state.mounted_media.clear(); } @@ -401,6 +406,12 @@ impl MsdController { self.disconnect_lun(media.lun).await?; state.mounted_media.remove(index); + if media.kind == MountedMediaKind::Drive { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } info!("Unmounted media"); drop(state); @@ -494,6 +505,16 @@ impl MsdController { disconnected.push(media.clone()); } + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) + { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } state.mounted_media.clear(); info!("Disconnected all mounted media"); @@ -748,6 +769,29 @@ mod tests { assert!(state.mounted_media.is_empty()); } + #[tokio::test] + async fn drive_mount_metadata_ignores_cached_drive_info() { + let temp_dir = TempDir::new().unwrap(); + let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path()); + std::fs::create_dir_all(&controller.ventoy_dir).unwrap(); + std::fs::write(&controller.drive_path, vec![0u8; 128]).unwrap(); + controller.state.write().await.drive_info = Some(DriveInfo::from_raw( + controller.drive_path.clone(), + 64, + DriveFileAccess::Available, + )); + + std::fs::write(&controller.drive_path, vec![0u8; 256]).unwrap(); + let info = controller.drive_mount_info().unwrap(); + + assert_eq!(info.size, 256); + assert_eq!(info.used, None); + assert_eq!(info.file_access, DriveFileAccess::Unknown); + let media = MountedMedia::drive(0, &info); + let config = MsdController::media_config(&media); + assert_eq!(config.file, controller.drive_path); + } + #[test] fn single_disk_mode_only_exposes_lun_zero() { let mut state = MsdState::default(); @@ -846,13 +890,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let drive_path = temp_dir.path().join("ventoy.img"); std::fs::write(&drive_path, b"drive").unwrap(); - let drive = DriveInfo { - size: 5, - used: 0, - free: 5, - initialized: true, - path: drive_path, - }; + let drive = DriveInfo::from_raw(drive_path, 5, DriveFileAccess::Unknown); let mut state = MsdState::default(); MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi); state.mounted_media.push(MountedMedia::drive(0, &drive)); @@ -904,13 +942,11 @@ mod tests { let image_path = temp_dir.path().join("test.img"); std::fs::write(&image_path, b"img").unwrap(); let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3); - let drive = DriveInfo { - size: 5, - used: 0, - free: 5, - initialized: true, - path: temp_dir.path().join("ventoy.img"), - }; + let drive = DriveInfo::from_raw( + temp_dir.path().join("ventoy.img"), + 5, + DriveFileAccess::Unknown, + ); let mut state = MsdState::default(); state .mounted_media diff --git a/src/msd/mod.rs b/src/msd/mod.rs index 35fbf71a..be1bfa44 100644 --- a/src/msd/mod.rs +++ b/src/msd/mod.rs @@ -8,8 +8,8 @@ pub use controller::MsdController; pub use image::ImageManager; pub use monitor::MsdHealthMonitor; pub use types::{ - DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo, - DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia, + DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveFileAccess, + DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia, MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS, }; pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; diff --git a/src/msd/types.rs b/src/msd/types.rs index f8a001e8..1bc4efa3 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -156,26 +156,44 @@ impl DiskMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriveFileAccess { + Available, + Unsupported, + BlockedWhileConnected, + Unknown, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DriveInfo { pub size: u64, - pub used: u64, - pub free: u64, + pub used: Option, + pub free: Option, pub initialized: bool, + pub file_access: DriveFileAccess, #[serde(skip_serializing)] pub path: PathBuf, } impl DriveInfo { - pub fn new(path: PathBuf, size: u64) -> Self { + pub fn from_raw(path: PathBuf, size: u64, file_access: DriveFileAccess) -> Self { Self { size, - used: 0, - free: size, - initialized: false, + used: None, + free: None, + initialized: true, + file_access, path, } } + + pub fn with_file_access(mut self, file_access: DriveFileAccess) -> Self { + self.used = None; + self.free = None; + self.file_access = file_access; + self + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -267,4 +285,36 @@ mod tests { assert!(json.get("current_image").is_none()); assert!(json.get("slots").is_none()); } + + #[test] + fn drive_info_json_has_stable_nullable_space_and_file_access() { + let info = DriveInfo::from_raw( + PathBuf::from("/tmp/drive.img"), + 4096, + DriveFileAccess::Unsupported, + ); + + let value = serde_json::to_value(info).unwrap(); + assert_eq!(value["size"], 4096); + assert_eq!(value["used"], serde_json::Value::Null); + assert_eq!(value["free"], serde_json::Value::Null); + assert_eq!(value["initialized"], true); + assert_eq!(value["file_access"], "unsupported"); + assert!(value.get("path").is_none()); + } + + #[test] + fn drive_file_access_serializes_all_public_states() { + for (access, expected) in [ + (DriveFileAccess::Available, "available"), + (DriveFileAccess::Unsupported, "unsupported"), + ( + DriveFileAccess::BlockedWhileConnected, + "blocked_while_connected", + ), + (DriveFileAccess::Unknown, "unknown"), + ] { + assert_eq!(serde_json::to_value(access).unwrap(), expected); + } + } } diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index edeb4faf..d0e34468 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -5,7 +5,7 @@ use tracing::{info, warn}; use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage}; -use super::types::{DriveFile, DriveInfo}; +use super::types::{DriveFile, DriveFileAccess, DriveInfo}; use crate::error::{AppError, MsdErrorCode, Result}; const STREAM_CHUNK_SIZE: usize = 64 * 1024; @@ -35,11 +35,10 @@ impl VentoyDrive { &self.path } - /// Returns just the raw file size without attempting to parse the filesystem. - /// Used as a fallback when the image has been reformatted to an unsupported - /// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open. - pub fn raw_size(&self) -> Option { - std::fs::metadata(&self.path).ok().map(|m| m.len()) + /// Read and validate only the backing file metadata, without parsing its + /// partition table or filesystem. + pub fn raw_info(&self, file_access: DriveFileAccess) -> Result { + raw_drive_info(&self.path, file_access) } pub async fn init(&self, size_mb: u32) -> Result { @@ -60,9 +59,10 @@ impl VentoyDrive { Ok::(DriveInfo { size: metadata.len(), - used: 0, - free: metadata.len(), + used: Some(0), + free: Some(metadata.len()), initialized: true, + file_access: DriveFileAccess::Available, path, }) }) @@ -74,20 +74,23 @@ impl VentoyDrive { } pub async fn info(&self) -> Result { - if !self.exists() { - return Err(MsdErrorCode::MsdDriveNotInitialized.into()); - } - let path = self.path.clone(); let _lock = self.lock.read().await; tokio::task::spawn_blocking(move || { - let metadata = std::fs::metadata(&path) - .map_err(|error| drive_io_error("read drive metadata", error))?; + let raw = raw_drive_info(&path, DriveFileAccess::Unsupported)?; - let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?; + let image = match VentoyImage::open(&path) { + Ok(image) => image, + Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw), + Err(error) => return Err(ventoy_to_app_error(error)), + }; - let files = image.list_files_recursive().map_err(ventoy_to_app_error)?; + let files = match image.list_files_recursive() { + Ok(files) => files, + Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw), + Err(error) => return Err(ventoy_to_app_error(error)), + }; let used: u64 = files .iter() @@ -95,14 +98,15 @@ impl VentoyDrive { .map(|f| f.size) .sum(); - let size = metadata.len(); + let size = raw.size; let free = size.saturating_sub(used); Ok(DriveInfo { size, - used, - free, + used: Some(used), + free: Some(free), initialized: true, + file_access: DriveFileAccess::Available, path, }) }) @@ -332,6 +336,35 @@ impl VentoyDrive { } } +fn raw_drive_info(path: &Path, file_access: DriveFileAccess) -> Result { + let metadata = std::fs::metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + AppError::from(MsdErrorCode::MsdDriveNotInitialized) + } else { + drive_io_error("read drive metadata", error) + } + })?; + + if !metadata.is_file() || metadata.len() == 0 { + return Err(MsdErrorCode::MsdDriveSizeInvalid.into()); + } + + Ok(DriveInfo::from_raw( + path.to_path_buf(), + metadata.len(), + file_access, + )) +} + +fn is_unsupported_filesystem_error(error: &VentoyError) -> bool { + matches!( + error, + VentoyError::FilesystemError(_) + | VentoyError::ImageError(_) + | VentoyError::PartitionError(_) + ) +} + fn ventoy_to_app_error(err: VentoyError) -> AppError { warn!(%err, "Virtual drive filesystem operation failed"); match err { @@ -575,9 +608,71 @@ mod tests { let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap(); assert!(info.initialized); + assert_eq!(info.file_access, DriveFileAccess::Available); + assert_eq!(info.used, Some(0)); + assert!(info.free.is_some()); assert!(drive.exists()); } + #[tokio::test] + async fn raw_bytes_are_reported_as_unsupported_with_capacity() { + let temp_dir = TempDir::new().unwrap(); + let drive_path = temp_dir.path().join("custom.img"); + std::fs::write(&drive_path, vec![0x5a; 1024 * 1024]).unwrap(); + let drive = VentoyDrive::new(drive_path); + + let info = drive.info().await.unwrap(); + assert_eq!(info.size, 1024 * 1024); + assert_eq!(info.used, None); + assert_eq!(info.free, None); + assert_eq!(info.file_access, DriveFileAccess::Unsupported); + + assert!(matches!( + drive.list_files("/").await.unwrap_err(), + AppError::Msd(error) + if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported + )); + } + + #[test] + fn raw_metadata_rejects_missing_empty_and_non_file_paths() { + let temp_dir = TempDir::new().unwrap(); + let missing = VentoyDrive::new(temp_dir.path().join("missing.img")); + assert!(matches!( + missing.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveNotInitialized + )); + + let empty_path = temp_dir.path().join("empty.img"); + std::fs::write(&empty_path, []).unwrap(); + let empty = VentoyDrive::new(empty_path); + assert!(matches!( + empty.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid + )); + + let directory = VentoyDrive::new(temp_dir.path().to_path_buf()); + assert!(matches!( + directory.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid + )); + } + + #[tokio::test] + async fn supported_drive_info_has_space_values() { + if !ensure_resources() { + return; + } + let temp_dir = TempDir::new().unwrap(); + let drive = VentoyDrive::new(temp_dir.path().join("supported.img")); + drive.init(MIN_DRIVE_SIZE_MB).await.unwrap(); + + let info = drive.info().await.unwrap(); + assert_eq!(info.file_access, DriveFileAccess::Available); + assert!(info.used.is_some()); + assert!(info.free.is_some()); + } + #[tokio::test] async fn test_drive_mkdir() { if !ensure_resources() { diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs new file mode 100644 index 00000000..5b29affa --- /dev/null +++ b/src/runtime/builder.rs @@ -0,0 +1,590 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::sync::broadcast; + +use crate::atx::AtxController; +use crate::audio::{AudioController, AudioControllerConfig, AudioQuality}; +use crate::auth::{SessionStore, TwoFactorService, UserStore}; +use crate::computer_use::ComputerUseManager; +use crate::config::{self, AppConfig, ConfigStore}; +use crate::db::{open_database_pool, DatabasePool}; +use crate::events::EventBus; +use crate::extensions::ExtensionManager; +use crate::hid::{HidBackendType, HidController}; +#[cfg(unix)] +use crate::msd::MsdController; +#[cfg(unix)] +use crate::otg::OtgService; +use crate::state::{AppState, ShutdownAction}; +use crate::update::UpdateService; +use crate::video::format::{PixelFormat, Resolution}; +use crate::video::{Streamer, VideoStreamManager}; +use crate::webrtc::{WebRtcStreamer, WebRtcStreamerConfig}; + +use super::supervisor::RuntimeSupervisor; + +#[derive(Debug, Clone, Default)] +pub struct WebConfigOverrides { + pub address: Option, + pub http_port: Option, + pub https_port: Option, + pub enable_https: bool, + pub ssl_cert: Option, + pub ssl_key: Option, +} + +impl WebConfigOverrides { + fn apply(self, config: &mut AppConfig) { + if let Some(address) = self.address { + config.web.bind_address = address.clone(); + config.web.bind_addresses = vec![address]; + } + if let Some(port) = self.http_port { + config.web.http_port = port; + } + if let Some(port) = self.https_port { + config.web.https_port = port; + } + if self.enable_https { + config.web.https_enabled = true; + } + if let Some(path) = self.ssl_cert { + config.web.ssl_cert_path = Some(path.to_string_lossy().to_string()); + } + if let Some(path) = self.ssl_key { + config.web.ssl_key_path = Some(path.to_string_lossy().to_string()); + } + } +} + +pub struct RuntimeBuilder { + data_dir: PathBuf, + web_overrides: WebConfigOverrides, +} + +impl RuntimeBuilder { + pub fn new(data_dir: PathBuf) -> Self { + Self { + data_dir, + web_overrides: WebConfigOverrides::default(), + } + } + + pub fn with_web_overrides(mut self, overrides: WebConfigOverrides) -> Self { + self.web_overrides = overrides; + self + } + + pub async fn build(self) -> anyhow::Result { + let Self { + data_dir, + web_overrides, + } = self; + let (db, config_store, mut config) = load_runtime_config(&data_dir).await?; + web_overrides.apply(&mut config); + + let sessions = SessionStore::new(config.auth.session_timeout_secs as i64); + let users = UserStore::new(db.clone_pool()); + let two_factor = TwoFactorService::new(db.clone_pool()); + let (shutdown_tx, _) = broadcast::channel::(1); + + let events = Arc::new(EventBus::new()); + tracing::info!("Event bus initialized"); + + let (video_format, video_resolution) = parse_video_config(&config); + let streamer = build_streamer(&config, &events, video_format, video_resolution).await; + let webrtc = build_webrtc(&config, video_format, video_resolution); + + #[cfg(unix)] + let otg_service = build_otg(&config).await; + + let hid_backend = hid_backend_type(&config); + #[cfg(unix)] + 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); + } + + #[cfg(unix)] + let msd = build_msd(&config, &data_dir, &otg_service, &events).await; + let atx = build_atx(&config).await; + let audio = build_audio(&config, &events).await; + let extensions = Arc::new(ExtensionManager::new()); + tracing::info!("Extension manager initialized"); + + webrtc.set_hid_controller(hid.clone()).await; + webrtc.set_audio_controller(audio.clone()).await; + if config.audio.enabled { + if let Err(error) = webrtc.set_audio_enabled(true).await { + tracing::warn!("Failed to enable WebRTC audio: {}", error); + } else { + tracing::debug!("WebRTC audio enabled"); + } + } + + let stream_manager = VideoStreamManager::with_webrtc_streamer( + streamer.clone(), + webrtc.clone() as Arc, + ); + stream_manager.set_event_bus(events.clone()).await; + stream_manager.set_config_store(config_store.clone()).await; + connect_audio_recovery(&audio, &stream_manager).await; + + let initial_mode = config.stream.mode.clone(); + if let Err(error) = stream_manager.init_with_mode(initial_mode.clone()).await { + tracing::warn!( + "Failed to initialize stream manager with mode {:?}: {}", + initial_mode, + error + ); + } else { + tracing::info!( + "Video stream manager initialized with mode: {:?}", + initial_mode + ); + } + + let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone()); + let state = AppState::new( + db, + config_store.clone(), + sessions, + users, + two_factor, + #[cfg(unix)] + otg_service, + stream_manager, + webrtc, + hid, + computer_use, + #[cfg(unix)] + msd, + atx, + audio, + extensions.clone(), + events.clone(), + Arc::new(UpdateService::new()), + shutdown_tx, + data_dir.clone(), + ); + + start_uac_playback(&state, &config).await; + start_watchdog(&state, &config).await; + extensions.set_event_bus(events.clone()).await; + state.remote_access.start_configured(&config).await; + + let extension_config = config_store.get(); + extensions.start_enabled(&extension_config.extensions).await; + + state.publish_device_info().await; + let supervisor = RuntimeSupervisor::start(state.clone(), events, extensions, config_store); + + Ok(ApplicationRuntime { + state, + config, + data_dir, + supervisor, + }) + } +} + +pub struct ApplicationRuntime { + state: Arc, + config: AppConfig, + data_dir: PathBuf, + supervisor: RuntimeSupervisor, +} + +impl ApplicationRuntime { + pub fn state(&self) -> &Arc { + &self.state + } + + pub fn config(&self) -> &AppConfig { + &self.config + } + + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + pub fn router(&self) -> axum::Router { + crate::web::create_router(self.state.clone()) + } + + pub async fn shutdown(&mut self) { + self.supervisor.shutdown(&self.state).await; + } +} + +async fn load_runtime_config( + data_dir: &Path, +) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> { + let db = open_database_pool(data_dir).await?; + + let config_store = ConfigStore::new(db.clone_pool()); + config_store.load().await?; + let mut config = (*config_store.get()).clone(); + config.apply_platform_defaults(); + normalize_msd_config(data_dir, &config_store, &mut config).await?; + + Ok((db, config_store, config)) +} + +#[cfg(unix)] +async fn normalize_msd_config( + data_dir: &Path, + config_store: &ConfigStore, + config: &mut AppConfig, +) -> anyhow::Result<()> { + let mut msd_dir_updated = false; + if config.msd.msd_dir.trim().is_empty() { + config.msd.msd_dir = data_dir.join("msd").to_string_lossy().to_string(); + msd_dir_updated = true; + } else if !PathBuf::from(&config.msd.msd_dir).is_absolute() { + let msd_dir = data_dir.join(&config.msd.msd_dir); + tracing::warn!( + "MSD directory is relative, rebasing to {}", + msd_dir.display() + ); + config.msd.msd_dir = msd_dir.to_string_lossy().to_string(); + msd_dir_updated = true; + } + if msd_dir_updated { + config_store.set(config.clone()).await?; + } + Ok(()) +} + +#[cfg(not(unix))] +async fn normalize_msd_config( + _data_dir: &Path, + _config_store: &ConfigStore, + _config: &mut AppConfig, +) -> anyhow::Result<()> { + Ok(()) +} + +fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) { + let format = config + .video + .format + .as_ref() + .and_then(|format| format.parse::().ok()) + .unwrap_or(PixelFormat::Mjpeg); + ( + format, + Resolution::new(config.video.width, config.video.height), + ) +} + +async fn build_streamer( + config: &AppConfig, + events: &Arc, + format: PixelFormat, + resolution: Resolution, +) -> Arc { + tracing::debug!( + "Parsed video config: {} @ {}x{}", + format, + resolution.width, + resolution.height + ); + let streamer = Streamer::new(); + streamer.set_event_bus(events.clone()).await; + if let Some(device_path) = config.video.device.as_ref() { + if let Err(error) = streamer + .apply_video_config(device_path, format, resolution, config.video.fps) + .await + { + tracing::warn!( + "Failed to initialize video with config: {}, will auto-detect", + error + ); + } else { + tracing::info!( + "Video configured: {} @ {}x{} {}", + device_path, + resolution.width, + resolution.height, + format + ); + } + } + streamer +} + +fn build_webrtc( + config: &AppConfig, + input_format: PixelFormat, + resolution: Resolution, +) -> Arc { + let webrtc = WebRtcStreamer::with_config(WebRtcStreamerConfig { + resolution, + input_format, + fps: config.video.fps, + bitrate_preset: config.stream.bitrate_preset, + encoder_backend: crate::stream_encoder::encoder_type_to_backend( + config.stream.encoder.clone(), + ), + webrtc: build_ice_config(config), + ..Default::default() + }); + tracing::info!("WebRTC streamer created"); + webrtc +} + +fn build_ice_config(config: &AppConfig) -> crate::webrtc::config::WebRtcConfig { + let mut stun_servers = Vec::new(); + let mut turn_servers = Vec::new(); + let has_custom_stun = config + .stream + .stun_server + .as_ref() + .is_some_and(|server| !server.is_empty()); + let has_custom_turn = config + .stream + .turn_server + .as_ref() + .is_some_and(|server| !server.is_empty()); + + if !has_custom_stun && !has_custom_turn { + let stun = crate::webrtc::config::public_ice::stun_server().to_string(); + tracing::info!("Using public STUN server: {}", stun); + stun_servers.push(stun); + } else { + if let Some(stun) = config + .stream + .stun_server + .as_ref() + .filter(|server| !server.is_empty()) + { + tracing::info!("Using custom STUN server: {}", stun); + stun_servers.push(stun.clone()); + } + if let Some(turn) = config + .stream + .turn_server + .as_ref() + .filter(|server| !server.is_empty()) + { + let username = config.stream.turn_username.clone().unwrap_or_default(); + let credential = config.stream.turn_password.clone().unwrap_or_default(); + turn_servers.push(crate::webrtc::config::TurnServer::new( + turn.clone(), + username.clone(), + credential, + )); + tracing::info!("Using custom TURN server: {} (user: {})", turn, username); + } + } + + crate::webrtc::config::WebRtcConfig { + stun_servers, + turn_servers, + ..Default::default() + } +} + +#[cfg(unix)] +async fn build_otg(config: &AppConfig) -> Arc { + let service = Arc::new(OtgService::new()); + tracing::info!("OTG Service created"); + if let Err(error) = service + .apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac) + .await + { + tracing::warn!("Failed to apply OTG config: {}", error); + } + service +} + +fn hid_backend_type(config: &AppConfig) -> HidBackendType { + match config.hid.backend { + config::HidBackend::Otg => HidBackendType::Otg, + config::HidBackend::Ch9329 => HidBackendType::Ch9329 { + port: config.hid.ch9329_port.clone(), + baud_rate: config.hid.ch9329_baudrate, + hybrid_mouse: config.hid.ch9329_hybrid_mouse, + macos_drag: config.hid.ch9329_macos_drag, + }, + config::HidBackend::None => HidBackendType::None, + config::HidBackend::Bluetooth => HidBackendType::Bluetooth { + config: config.hid.bluetooth.clone(), + }, + } +} + +#[cfg(unix)] +async fn build_msd( + config: &AppConfig, + data_dir: &Path, + otg: &Arc, + events: &Arc, +) -> Option { + if !config.msd.enabled { + tracing::info!("MSD disabled in configuration"); + return None; + } + + let controller = MsdController::new(otg.clone(), config.msd.msd_dir_path()); + if let Err(error) = controller.init(&data_dir.join("ventoy")).await { + tracing::warn!("Failed to initialize MSD controller: {}", error); + return None; + } + controller.set_event_bus(events.clone()).await; + Some(controller) +} + +async fn build_atx(config: &AppConfig) -> Option { + if !config.atx.enabled { + tracing::info!("ATX disabled in configuration"); + return None; + } + + let controller = AtxController::new(config.atx.to_controller_config()); + if let Err(error) = controller.init().await { + tracing::warn!("Failed to initialize ATX controller: {}", error); + return None; + } + Some(controller) +} + +async fn build_audio(config: &AppConfig, events: &Arc) -> Arc { + let quality = config + .audio + .quality + .parse::() + .unwrap_or_else(|error| { + tracing::warn!( + "Invalid audio quality in config (value={:?}): {}, using balanced", + config.audio.quality, + error + ); + AudioQuality::Balanced + }); + let controller = Arc::new(AudioController::new(AudioControllerConfig { + enabled: config.audio.enabled, + device: config.audio.device.clone(), + quality, + })); + controller.set_event_bus(events.clone()).await; + + if config.audio.enabled { + tracing::info!( + "Audio enabled: {}, quality={}", + config.audio.device, + config.audio.quality + ); + if let Err(error) = controller.start_streaming().await { + tracing::warn!("Failed to start audio streaming: {}", error); + } + } else { + tracing::info!("Audio disabled in configuration"); + } + controller +} + +async fn connect_audio_recovery( + audio: &Arc, + stream_manager: &Arc, +) { + let stream_manager = Arc::downgrade(stream_manager); + audio + .set_recovered_callback(Arc::new(move || { + if let Some(stream_manager) = stream_manager.upgrade() { + tokio::spawn(async move { + stream_manager.reconnect_webrtc_audio_sources().await; + }); + } + })) + .await; +} + +#[cfg(unix)] +async fn start_uac_playback(state: &Arc, config: &AppConfig) { + if !config.uac.enabled { + return; + } + let playback_config = crate::audio::uac::UacPlaybackConfig { + sample_rate: config.uac.sample_rate, + channels: config.uac.channels as u16, + ..Default::default() + }; + match crate::audio::uac::UacPlayback::start(playback_config) { + Ok(writer) => { + *state.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started"); + } + Err(error) => tracing::warn!("Failed to start UAC playback writer: {}", error), + } +} + +#[cfg(not(unix))] +async fn start_uac_playback(_state: &Arc, _config: &AppConfig) {} + +async fn start_watchdog(state: &Arc, config: &AppConfig) { + if !config.watchdog.enabled { + return; + } + if let Err(error) = state.watchdog.enable().await { + tracing::error!( + "Configured hardware watchdog failed to start; web service will continue: {}", + error + ); + } else { + tracing::info!("Hardware watchdog started"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn web_overrides_only_replace_explicit_values() { + let mut config = AppConfig::default(); + let original_https_port = config.web.https_port; + + WebConfigOverrides { + address: Some("127.0.0.1".to_string()), + http_port: Some(9000), + enable_https: true, + ..Default::default() + } + .apply(&mut config); + + assert_eq!(config.web.bind_address, "127.0.0.1"); + assert_eq!(config.web.bind_addresses, ["127.0.0.1"]); + assert_eq!(config.web.http_port, 9000); + assert_eq!(config.web.https_port, original_https_port); + assert!(config.web.https_enabled); + } + + #[cfg(unix)] + #[tokio::test] + async fn normalizing_disabled_msd_does_not_create_module_directories() { + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().join("data"); + let msd_dir = temp_dir.path().join("disabled-msd"); + let db = open_database_pool(&data_dir).await.unwrap(); + let config_store = ConfigStore::new(db.clone_pool()); + config_store.load().await.unwrap(); + let mut config = (*config_store.get()).clone(); + config.msd.enabled = false; + config.msd.msd_dir = msd_dir.to_string_lossy().into_owned(); + + normalize_msd_config(&data_dir, &config_store, &mut config) + .await + .unwrap(); + + assert!(!msd_dir.join("images").exists()); + assert!(!msd_dir.join("ventoy").exists()); + } +} diff --git a/src/runtime/config_apply.rs b/src/runtime/config_apply.rs new file mode 100644 index 00000000..1be6a335 --- /dev/null +++ b/src/runtime/config_apply.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use tokio::sync::{Mutex, OwnedMutexGuard}; + +use crate::error::{AppError, Result}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ConfigApplyOptions { + pub force: bool, + pub preserve_service_state: bool, + pub runtime_only: bool, +} + +impl ConfigApplyOptions { + pub const fn forced() -> Self { + Self { + force: true, + preserve_service_state: false, + runtime_only: false, + } + } + + pub const fn preserving_service_state() -> Self { + Self { + force: false, + preserve_service_state: true, + runtime_only: false, + } + } + + pub const fn runtime_only() -> Self { + Self { + force: false, + preserve_service_state: false, + runtime_only: true, + } + } +} + +pub fn try_apply_lock(lock: &Arc>, domain: &str) -> Result> { + lock.clone().try_lock_owned().map_err(|_| { + AppError::ServiceUnavailable(format!("{domain} configuration is already applying")) + }) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 00000000..6f12c72b --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,10 @@ +mod builder; +mod config_apply; +mod remote_access; +mod supervisor; +mod usb; + +pub use builder::{ApplicationRuntime, RuntimeBuilder, WebConfigOverrides}; +pub use config_apply::{try_apply_lock, ConfigApplyOptions}; +pub use remote_access::{RemoteAccessCoordinator, RustDeskRuntimeStatus}; +pub use usb::UsbCoordinator; diff --git a/src/runtime/remote_access.rs b/src/runtime/remote_access.rs new file mode 100644 index 00000000..91a598c8 --- /dev/null +++ b/src/runtime/remote_access.rs @@ -0,0 +1,509 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::audio::AudioController; +use crate::config::{AppConfig, ConfigStore, RtspConfig, VncConfig}; +use crate::error::{AppError, Result}; +use crate::hid::HidController; +use crate::rtsp::{RtspService, RtspServiceStatus}; +use crate::rustdesk::config::RustDeskConfig; +use crate::rustdesk::RustDeskService; +use crate::video::codec_constraints::{ + enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, + StreamCodecConstraints, +}; +use crate::video::VideoStreamManager; +use crate::vnc::{VncService, VncServiceStatus}; + +use super::ConfigApplyOptions; + +#[derive(Debug, Clone)] +pub struct RustDeskRuntimeStatus { + pub service_status: String, + pub rendezvous_status: Option, + pub connection_count: usize, + pub listening: bool, + pub listen_port: Option, +} + +pub struct RemoteAccessCoordinator { + config: ConfigStore, + stream_manager: Arc, + hid: Arc, + audio: Arc, + rustdesk: RwLock>>, + vnc: RwLock>>, + rtsp: RwLock>>, +} + +impl RemoteAccessCoordinator { + pub fn new( + config: ConfigStore, + stream_manager: Arc, + hid: Arc, + audio: Arc, + ) -> Arc { + Arc::new(Self { + config, + stream_manager, + hid, + audio, + rustdesk: RwLock::new(None), + vnc: RwLock::new(None), + rtsp: RwLock::new(None), + }) + } + + pub async fn start_configured(&self, config: &AppConfig) { + if let Err(error) = validate_third_party_codec_compatibility(config) { + tracing::warn!( + "Third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}", + error + ); + return; + } + + if config.rustdesk.is_valid() { + if let Err(error) = self + .apply_rustdesk( + &RustDeskConfig::default(), + &config.rustdesk, + ConfigApplyOptions::default(), + ) + .await + { + tracing::error!("Failed to start RustDesk service: {}", error); + } + } else if config.rustdesk.enabled { + tracing::warn!( + "RustDesk enabled but configuration is incomplete (missing server or credentials)" + ); + } else { + tracing::info!("RustDesk disabled in configuration"); + } + + if config.vnc.enabled { + if let Err(error) = self + .apply_vnc( + &VncConfig::default(), + &config.vnc, + ConfigApplyOptions::default(), + ) + .await + { + tracing::error!("Failed to start VNC service: {}", error); + } + } else { + tracing::info!("VNC disabled in configuration"); + } + + if config.rtsp.enabled { + if let Err(error) = self + .apply_rtsp( + &RtspConfig::default(), + &config.rtsp, + ConfigApplyOptions::default(), + ) + .await + { + tracing::error!("Failed to start RTSP service: {}", error); + } + } else { + tracing::info!("RTSP disabled in configuration"); + } + + if let Err(error) = self.enforce_codec_constraints().await { + tracing::warn!("Failed to enforce startup codec constraints: {}", error); + } + } + + pub async fn runtime_config(&self) -> AppConfig { + let mut config = self.config.get().as_ref().clone(); + let rustdesk = self.rustdesk.read().await.clone(); + let vnc = self.vnc.read().await.clone(); + let rtsp = self.rtsp.read().await.clone(); + + config.rustdesk.enabled = rustdesk.is_some_and(|service| service.is_running()); + config.vnc.enabled = match vnc { + Some(service) => matches!( + service.status().await, + VncServiceStatus::Starting | VncServiceStatus::Running + ), + None => false, + }; + config.rtsp.enabled = match rtsp { + Some(service) => matches!( + service.status().await, + RtspServiceStatus::Starting | RtspServiceStatus::Running + ), + None => false, + }; + config + } + + pub async fn rustdesk_status(&self) -> RustDeskRuntimeStatus { + let service = self.rustdesk.read().await.clone(); + match service { + Some(service) => RustDeskRuntimeStatus { + service_status: service.status().to_string(), + rendezvous_status: service.rendezvous_status().map(|status| status.to_string()), + connection_count: service.connection_count(), + listening: service.is_listening(), + listen_port: service.is_listening().then(|| service.listen_port()), + }, + None => RustDeskRuntimeStatus { + service_status: "not_initialized".to_string(), + rendezvous_status: None, + connection_count: 0, + listening: false, + listen_port: None, + }, + } + } + + pub async fn vnc_status(&self) -> (VncServiceStatus, usize) { + let service = self.vnc.read().await.clone(); + match service { + Some(service) => (service.status().await, service.connection_count()), + None => (VncServiceStatus::Stopped, 0), + } + } + + pub async fn rtsp_status(&self) -> RtspServiceStatus { + let service = self.rtsp.read().await.clone(); + match service { + Some(service) => service.status().await, + None => RtspServiceStatus::Stopped, + } + } + + pub async fn enforce_codec_constraints(&self) -> Result> { + let config = self.runtime_config().await; + let constraints = StreamCodecConstraints::from_config(&config); + self.stream_manager + .set_runtime_codec_constraints(constraints.clone()) + .await; + let enforcement = + enforce_constraints_with_stream_manager(&self.stream_manager, &constraints).await?; + Ok(enforcement.message) + } + + pub async fn apply_rustdesk( + &self, + old_config: &RustDeskConfig, + new_config: &RustDeskConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + tracing::info!("Applying RustDesk config changes..."); + self.validate_rustdesk_candidate(new_config, options.runtime_only) + .await?; + + let need_restart = options.force + || old_config.mode != new_config.mode + || old_config.codec != new_config.codec + || old_config.direct_access_port != new_config.direct_access_port + || old_config.rendezvous_server != new_config.rendezvous_server + || old_config.relay_server != new_config.relay_server + || old_config.relay_key != new_config.relay_key + || old_config.device_id != new_config.device_id + || old_config.device_password != new_config.device_password; + let current = self.rustdesk.read().await.clone(); + let mut credentials_to_save = None; + + if !options.preserve_service_state && !new_config.enabled { + if let Some(service) = current.as_ref() { + service.stop().await.map_err(|error| { + AppError::Config(format!("Failed to stop RustDesk service: {error}")) + })?; + tracing::info!("RustDesk service stopped"); + } + *self.rustdesk.write().await = None; + } else if !options.preserve_service_state && new_config.enabled { + match current { + None => { + tracing::info!("Initializing RustDesk service..."); + let service = Arc::new(RustDeskService::new( + new_config.clone(), + self.stream_manager.clone(), + self.hid.clone(), + self.audio.clone(), + )); + *self.rustdesk.write().await = Some(service.clone()); + service.start().await.map_err(|error| { + AppError::Config(format!("Failed to start RustDesk service: {error}")) + })?; + tracing::info!("RustDesk service started with ID: {}", new_config.device_id); + credentials_to_save = service.save_credentials(); + } + Some(service) => { + if service.is_running() { + if need_restart { + service.restart(new_config.clone()).await.map_err(|error| { + AppError::Config(format!( + "Failed to restart RustDesk service: {error}" + )) + })?; + tracing::info!( + "RustDesk service restarted with ID: {}", + new_config.device_id + ); + } + } else { + service.update_config(new_config.clone()); + service.start().await.map_err(|error| { + AppError::Config(format!("Failed to start RustDesk service: {error}")) + })?; + } + credentials_to_save = service.save_credentials(); + } + } + } else if options.preserve_service_state && need_restart { + if let Some(service) = current { + let mut runtime_config = new_config.clone(); + runtime_config.enabled = true; + service.restart(runtime_config).await.map_err(|error| { + AppError::Config(format!("Failed to restart RustDesk service: {error}")) + })?; + credentials_to_save = service.save_credentials(); + } + } + + if let Some(updated) = credentials_to_save { + tracing::info!("Saving RustDesk credentials to config store..."); + self.config + .update(|config| { + config.rustdesk.public_key = updated.public_key.clone(); + config.rustdesk.private_key = updated.private_key.clone(); + config.rustdesk.signing_public_key = updated.signing_public_key.clone(); + config.rustdesk.signing_private_key = updated.signing_private_key.clone(); + config.rustdesk.uuid = updated.uuid.clone(); + }) + .await?; + tracing::info!("RustDesk credentials saved successfully"); + } + + self.log_enforced_constraints().await?; + Ok(()) + } + + pub async fn apply_vnc( + &self, + old_config: &VncConfig, + new_config: &VncConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + tracing::info!("Applying VNC config changes..."); + self.validate_vnc_candidate(new_config, options.runtime_only) + .await?; + + let runtime_config = self.runtime_config().await; + let will_run = if options.preserve_service_state { + runtime_config.vnc.enabled + } else { + new_config.enabled + }; + if will_run { + let mut candidate = runtime_config; + candidate.vnc = new_config.clone(); + candidate.vnc.enabled = true; + let constraints = StreamCodecConstraints::from_config(&candidate); + match enforce_constraints_with_stream_manager(&self.stream_manager, &constraints).await + { + Ok(result) if result.changed => { + if let Some(message) = result.message { + tracing::info!("{}", message); + } + } + Ok(_) => {} + Err(error) => tracing::warn!( + "Failed to enforce VNC stream constraints before start: {}", + error + ), + } + } + + let need_restart = options.force + || old_config.bind != new_config.bind + || old_config.port != new_config.port + || old_config.encoding != new_config.encoding + || old_config.password != new_config.password + || old_config.allow_one_client != new_config.allow_one_client; + let current = self.vnc.read().await.clone(); + + if !options.preserve_service_state && !new_config.enabled { + if let Some(service) = current.as_ref() { + service.stop().await?; + } + *self.vnc.write().await = None; + } else if !options.preserve_service_state && new_config.enabled { + match current { + None => { + let service = Arc::new(VncService::new( + new_config.clone(), + self.stream_manager.clone(), + self.hid.clone(), + )); + *self.vnc.write().await = Some(service.clone()); + service.start().await?; + tracing::info!("VNC service started"); + } + Some(service) => { + if matches!(service.status().await, VncServiceStatus::Running) { + if need_restart { + service.restart(new_config.clone()).await?; + tracing::info!("VNC service restarted"); + } + } else { + service.update_config(new_config.clone()).await; + service.start().await?; + } + } + } + } else if options.preserve_service_state && need_restart { + if let Some(service) = current { + let mut runtime_config = new_config.clone(); + runtime_config.enabled = true; + service.restart(runtime_config).await?; + } + } + + self.log_enforced_constraints().await?; + Ok(()) + } + + pub async fn apply_rtsp( + &self, + old_config: &RtspConfig, + new_config: &RtspConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + tracing::info!("Applying RTSP config changes..."); + self.validate_rtsp_candidate(new_config, options.runtime_only) + .await?; + + let need_restart = options.force + || old_config.bind != new_config.bind + || old_config.port != new_config.port + || old_config.path != new_config.path + || old_config.codec != new_config.codec + || old_config.username != new_config.username + || old_config.password != new_config.password + || old_config.allow_one_client != new_config.allow_one_client; + let current = self.rtsp.read().await.clone(); + + if !options.preserve_service_state && !new_config.enabled { + if let Some(service) = current.as_ref() { + service.stop().await.map_err(|error| { + AppError::Config(format!("Failed to stop RTSP service: {error}")) + })?; + } + *self.rtsp.write().await = None; + } else if !options.preserve_service_state && new_config.enabled { + match current { + None => { + let service = Arc::new(RtspService::new( + new_config.clone(), + self.stream_manager.clone(), + )); + *self.rtsp.write().await = Some(service.clone()); + service.start().await?; + tracing::info!("RTSP service started"); + } + Some(service) => { + if matches!(service.status().await, RtspServiceStatus::Running) { + if need_restart { + service.restart(new_config.clone()).await?; + tracing::info!("RTSP service restarted"); + } + } else { + service.update_config(new_config.clone()).await; + service.start().await?; + } + } + } + } else if options.preserve_service_state && need_restart { + if let Some(service) = current { + let mut runtime_config = new_config.clone(); + runtime_config.enabled = true; + service.restart(runtime_config).await?; + } + } + + self.log_enforced_constraints().await?; + Ok(()) + } + + pub async fn shutdown(&self) { + let rustdesk = self.rustdesk.write().await.take(); + let vnc = self.vnc.write().await.take(); + let rtsp = self.rtsp.write().await.take(); + + if let Some(service) = rustdesk { + if let Err(error) = service.stop().await { + tracing::warn!("Failed to stop RustDesk service: {}", error); + } else { + tracing::info!("RustDesk service stopped"); + } + } + if let Some(service) = vnc { + if let Err(error) = service.stop().await { + tracing::warn!("Failed to stop VNC service: {}", error); + } else { + tracing::info!("VNC service stopped"); + } + } + if let Some(service) = rtsp { + if let Err(error) = service.stop().await { + tracing::warn!("Failed to stop RTSP service: {}", error); + } else { + tracing::info!("RTSP service stopped"); + } + } + } + + async fn validate_rustdesk_candidate( + &self, + new_config: &RustDeskConfig, + runtime_only: bool, + ) -> Result<()> { + let mut candidate = self.candidate_config(runtime_only).await; + candidate.rustdesk = new_config.clone(); + validate_third_party_codec_compatibility(&candidate) + } + + async fn validate_vnc_candidate( + &self, + new_config: &VncConfig, + runtime_only: bool, + ) -> Result<()> { + let mut candidate = self.candidate_config(runtime_only).await; + candidate.vnc = new_config.clone(); + validate_third_party_codec_compatibility(&candidate) + } + + async fn validate_rtsp_candidate( + &self, + new_config: &RtspConfig, + runtime_only: bool, + ) -> Result<()> { + let mut candidate = self.candidate_config(runtime_only).await; + candidate.rtsp = new_config.clone(); + validate_third_party_codec_compatibility(&candidate) + } + + async fn candidate_config(&self, runtime_only: bool) -> AppConfig { + if runtime_only { + self.runtime_config().await + } else { + self.config.get().as_ref().clone() + } + } + + async fn log_enforced_constraints(&self) -> Result<()> { + if let Some(message) = self.enforce_codec_constraints().await? { + tracing::info!("{}", message); + } + Ok(()) + } +} diff --git a/src/runtime/supervisor.rs b/src/runtime/supervisor.rs new file mode 100644 index 00000000..2b00b8b4 --- /dev/null +++ b/src/runtime/supervisor.rs @@ -0,0 +1,228 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::config::ConfigStore; +use crate::events::EventBus; +use crate::extensions::ExtensionManager; +use crate::state::AppState; + +pub(super) struct RuntimeSupervisor { + tasks: Vec>, +} + +impl RuntimeSupervisor { + pub(super) fn start( + state: Arc, + events: Arc, + extensions: Arc, + config: ConfigStore, + ) -> Self { + let mut tasks = spawn_device_info_broadcaster(state, events); + tasks.push(spawn_extension_health_check(extensions, config)); + Self { tasks } + } + + pub(super) async fn shutdown(&mut self, state: &Arc) { + for task in self.tasks.drain(..) { + task.abort(); + } + cleanup(state).await; + } +} + +fn spawn_extension_health_check( + extensions: Arc, + config: ConfigStore, +) -> JoinHandle<()> { + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + let config = config.get(); + extensions.health_check(&config.extensions).await; + } + }); + tracing::info!("Extension health check task started"); + task +} + +fn spawn_device_info_broadcaster( + state: Arc, + events: Arc, +) -> Vec> { + enum DeviceInfoTrigger { + Event, + Lagged { topic: &'static str, count: u64 }, + } + + const DEVICE_INFO_TOPICS: &[&str] = &[ + "stream.state_changed", + "stream.config_applied", + "stream.mode_ready", + ]; + const DEBOUNCE_MS: u64 = 100; + + let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel(); + let mut tasks = Vec::new(); + + for topic in DEVICE_INFO_TOPICS { + let Some(mut rx) = events.subscribe_topic(topic) else { + tracing::warn!( + "DeviceInfo broadcaster missing topic subscription: {}", + topic + ); + continue; + }; + + let trigger_tx = trigger_tx.clone(); + let topic_name = *topic; + tasks.push(tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(_) => { + if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + if trigger_tx + .send(DeviceInfoTrigger::Lagged { + topic: topic_name, + count, + }) + .is_err() + { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + })); + } + + { + let mut dirty_rx = events.subscribe_device_info_dirty(); + let trigger_tx = trigger_tx.clone(); + tasks.push(tokio::spawn(async move { + loop { + match dirty_rx.recv().await { + Ok(()) => { + if trigger_tx.send(DeviceInfoTrigger::Event).is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + if trigger_tx + .send(DeviceInfoTrigger::Lagged { + topic: "device_info_dirty", + count, + }) + .is_err() + { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + })); + } + + tasks.push(tokio::spawn(async move { + let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS); + let mut pending_broadcast = false; + + loop { + let recv_result = if pending_broadcast { + let remaining = + DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64); + tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await + } else { + Ok(trigger_rx.recv().await) + }; + + match recv_result { + Ok(Some(DeviceInfoTrigger::Event)) => { + pending_broadcast = true; + } + Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => { + tracing::warn!( + "DeviceInfo broadcaster lagged by {} events on topic {}", + count, + topic + ); + pending_broadcast = true; + } + Ok(None) => { + tracing::info!("Event bus closed, stopping DeviceInfo broadcaster"); + break; + } + Err(_timeout) => {} + } + + if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) { + state.publish_device_info().await; + tracing::trace!("Broadcasted DeviceInfo (debounced)"); + last_broadcast = Instant::now(); + pending_broadcast = false; + } + } + })); + + tracing::info!( + "DeviceInfo broadcaster task started (debounce: {}ms)", + DEBOUNCE_MS + ); + tasks +} + +async fn cleanup(state: &Arc) { + state.extensions.stop_all().await; + tracing::info!("Extensions stopped"); + + state.remote_access.shutdown().await; + + if let Err(error) = state.stream_manager.stop().await { + tracing::warn!("Failed to stop streamer: {}", error); + } + + if let Err(error) = state.hid.shutdown().await { + tracing::warn!("Failed to shutdown HID: {}", error); + } + + #[cfg(unix)] + { + let msd = state.msd.write().await.take(); + if let Some(msd) = msd { + if let Err(error) = msd.shutdown().await { + tracing::warn!("Failed to shutdown MSD: {}", error); + } + } + + if let Err(error) = state.otg_service.shutdown().await { + tracing::warn!("Failed to shutdown OTG: {}", error); + } + } + + let atx = state.atx.write().await.take(); + if let Some(atx) = atx { + if let Err(error) = atx.shutdown().await { + tracing::warn!("Failed to shutdown ATX: {}", error); + } + } + + if let Err(error) = state.audio.shutdown().await { + tracing::warn!("Failed to shutdown audio: {}", error); + } + + if let Err(error) = state.watchdog.disable().await { + tracing::error!( + "CRITICAL: failed to disable hardware watchdog during shutdown: {}", + error + ); + } +} diff --git a/src/runtime/usb.rs b/src/runtime/usb.rs new file mode 100644 index 00000000..f57500eb --- /dev/null +++ b/src/runtime/usb.rs @@ -0,0 +1,333 @@ +use std::path::PathBuf; +use std::sync::Arc; + +#[cfg(unix)] +use tokio::sync::RwLock; + +use crate::config::{AppConfig, HidBackend, HidConfig, MsdConfig, OtgNetworkConfig, UacConfig}; +use crate::error::{AppError, Result}; +use crate::events::EventBus; +use crate::hid::{HidBackendType, HidController}; +#[cfg(unix)] +use crate::msd::MsdController; +#[cfg(unix)] +use crate::otg::OtgService; + +use super::ConfigApplyOptions; + +pub struct UsbCoordinator { + hid: Arc, + #[cfg(unix)] + otg: Arc, + #[cfg(unix)] + msd: Arc>>, + #[cfg(unix)] + uac_playback: Arc>>, + events: Arc, + data_dir: PathBuf, +} + +impl UsbCoordinator { + #[allow(clippy::too_many_arguments)] + pub fn new( + hid: Arc, + #[cfg(unix)] otg: Arc, + #[cfg(unix)] msd: Arc>>, + #[cfg(unix)] uac_playback: Arc>>, + events: Arc, + data_dir: PathBuf, + ) -> Arc { + Arc::new(Self { + hid, + #[cfg(unix)] + otg, + #[cfg(unix)] + msd, + #[cfg(unix)] + uac_playback, + events, + data_dir, + }) + } + + pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> { + #[cfg(unix)] + { + let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg + && new_config.hid.backend != HidBackend::Otg; + let hid_unchanged = old_config.hid == new_config.hid; + let gadget_rebuilt = old_config.msd != new_config.msd + || old_config.otg_network != new_config.otg_network + || old_config.uac != new_config.uac + || old_config.hid.otg_udc != new_config.hid.otg_udc + || old_config.hid.otg_descriptor != new_config.hid.otg_descriptor + || old_config.hid.backend != new_config.hid.backend + || old_config.hid.constrained_otg_functions() + != new_config.hid.constrained_otg_functions() + || old_config.hid.effective_otg_keyboard_leds() + != new_config.hid.effective_otg_keyboard_leds(); + let restart_uac = + old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt); + + if restart_uac { + let playback = self.uac_playback.write().await.take(); + if let Some(playback) = playback { + playback.stop(); + tracing::info!("UAC playback writer stopped before OTG reconcile"); + } + } + + if transitioning_away_from_otg { + self.apply_hid( + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await?; + } else { + self.reconcile_otg( + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ) + .await?; + self.apply_hid( + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await?; + } + + if hid_unchanged && gadget_rebuilt && new_config.hid.backend == HidBackend::Otg { + tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices"); + self.hid + .reload(hid_backend_type(&new_config.hid)) + .await + .map_err(|error| { + AppError::Config(format!("HID reload after gadget rebuild failed: {error}")) + })?; + } + + self.apply_msd( + &old_config.msd, + &new_config.msd, + &new_config.hid, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await?; + + if restart_uac && new_config.uac.enabled { + let config = crate::audio::uac::UacPlaybackConfig { + sample_rate: new_config.uac.sample_rate, + channels: new_config.uac.channels as u16, + ..Default::default() + }; + let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| { + AppError::Config(format!("Failed to start UAC playback: {error}")) + })?; + *self.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started after OTG reconcile"); + } else if restart_uac { + tracing::info!("UAC playback remains disabled"); + } + + Ok(()) + } + + #[cfg(not(unix))] + { + self.apply_hid( + &old_config.hid, + &new_config.hid, + &new_config.msd, + &new_config.otg_network, + &new_config.uac, + ConfigApplyOptions::default(), + ) + .await + } + } + + async fn apply_hid( + &self, + old_config: &HidConfig, + new_config: &HidConfig, + msd_config: &MsdConfig, + network_config: &OtgNetworkConfig, + uac_config: &UacConfig, + 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 = + old_config.constrained_otg_functions() != new_config.constrained_otg_functions(); + let keyboard_leds_changed = + old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); + let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse + || old_config.ch9329_macos_drag != new_config.ch9329_macos_drag; + + 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 + && !hid_functions_changed + && !keyboard_leds_changed + && !options.force + { + tracing::info!("HID config unchanged, skipping reload"); + return Ok(()); + } + + tracing::info!("Applying HID config changes..."); + let backend = hid_backend_type(new_config); + let transitioning_away_from_otg = + old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg; + let otg_changed = hid_otg_config_changed(old_config, new_config); + + if transitioning_away_from_otg { + self.hid + .reload(backend.clone()) + .await + .map_err(|error| AppError::Config(format!("HID reload failed: {error}")))?; + } + if otg_changed { + self.reconcile_otg(new_config, msd_config, network_config, uac_config) + .await?; + } + if !transitioning_away_from_otg { + self.hid + .reload(backend) + .await + .map_err(|error| AppError::Config(format!("HID reload failed: {error}")))?; + } + + tracing::info!( + "HID backend reloaded successfully: {:?}", + new_config.backend + ); + Ok(()) + } + + async fn reconcile_otg( + &self, + hid: &HidConfig, + msd: &MsdConfig, + network: &OtgNetworkConfig, + uac: &UacConfig, + ) -> Result<()> { + #[cfg(unix)] + { + self.otg + .apply_config(hid, msd, network, uac) + .await + .map_err(|error| AppError::Config(format!("OTG reconcile failed: {error}"))) + } + #[cfg(not(unix))] + { + let _ = (hid, msd, network, uac); + Ok(()) + } + } + + #[cfg(unix)] + async fn apply_msd( + &self, + old_config: &MsdConfig, + new_config: &MsdConfig, + hid_config: &HidConfig, + network_config: &OtgNetworkConfig, + uac_config: &UacConfig, + options: ConfigApplyOptions, + ) -> Result<()> { + let old_enabled = old_config.enabled; + let new_enabled = new_config.enabled && hid_config.backend == HidBackend::Otg; + let directory_changed = old_config.msd_dir != new_config.msd_dir; + let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string + || old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string; + + if !options.force && old_enabled == new_enabled && !directory_changed && !inquiry_changed { + tracing::info!("MSD configuration unchanged, no reload needed"); + return Ok(()); + } + + if new_enabled { + tracing::info!("(Re)initializing MSD..."); + self.reconcile_otg(hid_config, new_config, network_config, uac_config) + .await?; + + let old_msd = self.msd.write().await.take(); + if let Some(msd) = old_msd { + msd.shutdown() + .await + .map_err(|error| AppError::Config(format!("MSD shutdown failed: {error}")))?; + } + + let msd = MsdController::new(self.otg.clone(), new_config.msd_dir_path()); + msd.init(&self.data_dir.join("ventoy")) + .await + .map_err(|error| AppError::Config(format!("MSD initialization failed: {error}")))?; + msd.set_event_bus(self.events.clone()).await; + *self.msd.write().await = Some(msd); + tracing::info!("MSD initialized successfully"); + } else { + tracing::info!("MSD disabled in config, shutting down..."); + let old_msd = self.msd.write().await.take(); + if let Some(msd) = old_msd { + msd.shutdown() + .await + .map_err(|error| AppError::Config(format!("MSD shutdown failed: {error}")))?; + } + tracing::info!("MSD shutdown complete"); + self.reconcile_otg(hid_config, new_config, network_config, uac_config) + .await?; + } + + if hid_config.backend == HidBackend::Otg && (options.force || old_enabled != new_enabled) { + self.hid + .reload(HidBackendType::Otg) + .await + .map_err(|error| AppError::Config(format!("OTG HID reload failed: {error}")))?; + } + Ok(()) + } +} + +fn hid_backend_type(config: &HidConfig) -> HidBackendType { + match config.backend { + HidBackend::Otg => HidBackendType::Otg, + HidBackend::Ch9329 => HidBackendType::Ch9329 { + port: config.ch9329_port.clone(), + baud_rate: config.ch9329_baudrate, + hybrid_mouse: config.ch9329_hybrid_mouse, + macos_drag: config.ch9329_macos_drag, + }, + HidBackend::None => HidBackendType::None, + HidBackend::Bluetooth => HidBackendType::Bluetooth { + config: config.bluetooth.clone(), + }, + } +} + +fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool { + old_config.backend == HidBackend::Otg + || new_config.backend == HidBackend::Otg + || old_config.otg_udc != new_config.otg_udc + || old_config.otg_descriptor != new_config.otg_descriptor + || old_config.constrained_otg_functions() != new_config.constrained_otg_functions() + || old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() +} diff --git a/src/rustdesk/bytes_codec.rs b/src/rustdesk/bytes_codec.rs index d00896ab..a45dc2bd 100644 --- a/src/rustdesk/bytes_codec.rs +++ b/src/rustdesk/bytes_codec.rs @@ -1,7 +1,7 @@ //! Variable-length TCP framing (RustDesk wire format). use bytes::{Buf, BufMut, Bytes, BytesMut}; -use std::io; +use std::io::{self, IoSlice}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; const MAX_PACKET_LENGTH: usize = 0x3FFFFFFF; @@ -53,6 +53,18 @@ fn decode_header(first_byte: u8, header_bytes: &[u8]) -> (usize, usize) { } pub async fn read_frame(reader: &mut R) -> io::Result { + read_frame_with_limit(reader, MAX_PACKET_LENGTH).await +} + +/// Read one framed message while enforcing a caller-selected allocation limit. +/// +/// Network-facing protocol stages should use a substantially smaller limit than +/// the wire format's theoretical maximum so an untrusted peer cannot force a +/// huge allocation by sending only a length header. +pub async fn read_frame_with_limit( + reader: &mut R, + max_packet_length: usize, +) -> io::Result { let mut first_byte = [0u8; 1]; reader.read_exact(&mut first_byte).await?; @@ -65,10 +77,10 @@ pub async fn read_frame(reader: &mut R) -> io::Result MAX_PACKET_LENGTH { + if msg_len > max_packet_length { return Err(io::Error::new( io::ErrorKind::InvalidData, - "Message too large", + format!("Message too large: {msg_len} bytes exceeds {max_packet_length}-byte limit"), )); } @@ -86,6 +98,53 @@ pub async fn write_frame(writer: &mut W, data: &[u8]) -> Ok(()) } +/// Write a frame without copying its payload into a second contiguous buffer. +/// TCP writers normally send the small header and payload in one vectored write. +pub async fn write_frame_vectored( + writer: &mut W, + data: &[u8], +) -> io::Result<()> { + let len = data.len(); + let mut header = [0u8; 4]; + let header_len = if len <= 0x3F { + header[0] = (len << 2) as u8; + 1 + } else if len <= 0x3FFF { + header[..2].copy_from_slice(&(((len << 2) as u16) | 0x1).to_le_bytes()); + 2 + } else if len <= 0x3FFFFF { + let value = ((len << 2) as u32) | 0x2; + header[0] = (value & 0xFF) as u8; + header[1] = ((value >> 8) & 0xFF) as u8; + header[2] = ((value >> 16) & 0xFF) as u8; + 3 + } else if len <= MAX_PACKET_LENGTH { + header.copy_from_slice(&(((len << 2) as u32) | 0x3).to_le_bytes()); + 4 + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Message too large", + )); + }; + + let slices = [IoSlice::new(&header[..header_len]), IoSlice::new(data)]; + let written = writer.write_vectored(&slices).await?; + if written == 0 { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write RustDesk frame", + )); + } + if written < header_len { + writer.write_all(&header[written..header_len]).await?; + writer.write_all(data).await?; + } else { + writer.write_all(&data[written - header_len..]).await?; + } + Ok(()) +} + pub async fn write_frame_buffered( writer: &mut W, data: &[u8], @@ -281,4 +340,32 @@ mod tests { let decoded = codec.decode(&mut buf).unwrap().unwrap(); assert_eq!(decoded.len(), 100000); } + + #[tokio::test] + async fn read_limit_rejects_length_before_allocating_payload() { + let encoded = encode_frame(&vec![0u8; 1024]).unwrap(); + let (mut writer, mut reader) = tokio::io::duplex(encoded.len()); + tokio::spawn(async move { + writer.write_all(&encoded).await.unwrap(); + }); + + let error = read_frame_with_limit(&mut reader, 128).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[tokio::test] + async fn vectored_writer_round_trips() { + let payload = vec![0x5a; 100_000]; + let (mut writer, mut reader) = tokio::io::duplex(payload.len() + 4); + let expected = payload.clone(); + let send = tokio::spawn(async move { + write_frame_vectored(&mut writer, &payload).await.unwrap(); + }); + + let decoded = read_frame_with_limit(&mut reader, expected.len()) + .await + .unwrap(); + send.await.unwrap(); + assert_eq!(decoded.as_ref(), expected.as_slice()); + } } diff --git a/src/rustdesk/config.rs b/src/rustdesk/config.rs index 0e0868b3..12dd09d5 100644 --- a/src/rustdesk/config.rs +++ b/src/rustdesk/config.rs @@ -11,12 +11,24 @@ pub enum RustDeskCodec { H265, } +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum RustDeskMode { + #[default] + Id, + DirectIp, +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct RustDeskConfig { pub enabled: bool, + pub mode: RustDeskMode, pub codec: RustDeskCodec, + pub direct_access_port: u16, pub rendezvous_server: String, pub relay_server: Option, #[typeshare(skip)] @@ -40,7 +52,9 @@ impl Default for RustDeskConfig { fn default() -> Self { Self { enabled: false, + mode: RustDeskMode::Id, codec: RustDeskCodec::H264, + direct_access_port: 21118, rendezvous_server: String::new(), relay_server: None, relay_key: None, @@ -58,9 +72,12 @@ impl Default for RustDeskConfig { impl RustDeskConfig { pub fn is_valid(&self) -> bool { self.enabled - && !self.rendezvous_server.is_empty() && !self.device_id.is_empty() && !self.device_password.is_empty() + && match self.mode { + RustDeskMode::Id => !self.rendezvous_server.trim().is_empty(), + RustDeskMode::DirectIp => self.direct_access_port != 0, + } } pub fn effective_rendezvous_server(&self) -> &str { @@ -214,4 +231,43 @@ mod tests { config.rendezvous_server = String::new(); assert_eq!(config.effective_rendezvous_server(), ""); } + + #[test] + fn direct_ip_mode_is_valid_without_rendezvous_server() { + let config = RustDeskConfig { + enabled: true, + mode: RustDeskMode::DirectIp, + rendezvous_server: String::new(), + ..Default::default() + }; + + assert!(config.is_valid()); + } + + #[test] + fn id_mode_is_invalid_without_rendezvous_server() { + let config = RustDeskConfig { + enabled: true, + mode: RustDeskMode::Id, + rendezvous_server: String::new(), + ..Default::default() + }; + + assert!(!config.is_valid()); + } + + #[test] + fn legacy_config_defaults_to_id_mode() { + let config: RustDeskConfig = serde_json::from_value(serde_json::json!({ + "enabled": false, + "codec": "h264", + "rendezvous_server": "", + "device_id": "123456789", + "device_password": "password" + })) + .expect("legacy RustDesk config should deserialize"); + + assert_eq!(config.mode, RustDeskMode::Id); + assert_eq!(config.direct_access_port, 21118); + } } diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index a435e4ee..f954f6df 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -1,16 +1,17 @@ //! Incoming RustDesk TCP sessions (handshake, AV, input). +use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use bytes::{Bytes, BytesMut}; +use bytes::Bytes; use parking_lot::RwLock; use protobuf::Message as ProtobufMessage; use sodiumoxide::crypto::box_; use tokio::net::tcp::OwnedWriteHalf; use tokio::net::TcpStream; -use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::sync::{mpsc, watch}; use tracing::{debug, error, info, warn}; use crate::audio::AudioController; @@ -21,11 +22,13 @@ use crate::video::codec::BitratePreset; use crate::video::codec_constraints::{encoder_codec_to_id, encoder_codec_to_video_codec}; use crate::video::stream_manager::VideoStreamManager; -use super::bytes_codec::{read_frame, write_frame, write_frame_buffered}; +use super::bytes_codec::{read_frame_with_limit, write_frame_vectored}; use super::config::RustDeskConfig; use super::crypto::{self, KeyPair, SigningKeyPair}; use super::frame_adapters::{AudioFrameAdapter, VideoCodec, VideoFrameAdapter}; -use super::hid_adapter::{convert_key_events, convert_mouse_event, mouse_type}; +use super::hid_adapter::{convert_key_events_in_code_space, convert_mouse_event, mouse_type}; +use super::keyboard_mapping::KeyboardCodeSpace; +use super::protocol::hbb::message::key_event as ke_union; use super::protocol::{ decode_message, login_response, message, misc, Clipboard, ControlKey, DisplayInfo, Hash, HbbMessage, IdPk, KeyEvent, LoginRequest, LoginResponse, Misc, MouseEvent, OptionMessage, @@ -41,10 +44,54 @@ const DEFAULT_SCREEN_HEIGHT: u32 = 1080; /// Default mouse event throttle interval (16ms ≈ 60Hz) const DEFAULT_MOUSE_THROTTLE_MS: u64 = 16; -/// Advertised RustDesk version for client compatibility. -const RUSTDESK_COMPAT_VERSION: &str = "1.4.5"; -// Advertised platform for RustDesk clients. This affects which UI options are shown. -const RUSTDESK_COMPAT_PLATFORM: &str = "Windows"; +/// Limit work retained for unauthenticated peers. +const MAX_CONNECTIONS: usize = 8; +const MAX_UNAUTHENTICATED_PACKET_LENGTH: usize = 256 * 1024; +const MAX_AUTHENTICATED_PACKET_LENGTH: usize = 8 * 1024 * 1024; +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_PASSWORD_ATTEMPTS: u8 = 5; +const CONNECTION_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); + +/// RustDesk-facing identity and the physical key code space implied by it. +struct RustDeskCompatibility { + version: &'static str, + platform: RustDeskCompatibilityPlatform, +} + +#[derive(Clone, Copy)] +enum RustDeskCompatibilityPlatform { + Windows, +} + +impl RustDeskCompatibilityPlatform { + const fn name(self) -> &'static str { + match self { + Self::Windows => "Windows", + } + } + + const fn keyboard_code_space(self) -> KeyboardCodeSpace { + match self { + Self::Windows => KeyboardCodeSpace::WindowsSet1, + } + } +} + +const RUSTDESK_COMPATIBILITY: RustDeskCompatibility = RustDeskCompatibility { + version: "1.4.5", + platform: RustDeskCompatibilityPlatform::Windows, +}; + +fn key_event_union_details(event: &KeyEvent) -> (&'static str, String) { + match &event.union { + Some(ke_union::Union::ControlKey(key)) => ("ControlKey", format!("0x{:X}", key.value())), + Some(ke_union::Union::Chr(code)) => ("Chr", format!("0x{code:X}")), + Some(ke_union::Union::Unicode(code)) => ("Unicode", format!("0x{code:X}")), + Some(ke_union::Union::Seq(seq)) => ("Seq", format!("{seq:?}")), + Some(ke_union::Union::Win2winHotkey(code)) => ("Win2winHotkey", format!("0x{code:X}")), + None => ("None", "none".to_string()), + } +} /// Input event throttler /// @@ -100,6 +147,12 @@ pub enum ConnectionState { Error(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionMode { + Secure, + DirectIp, +} + /// Incoming connection from a RustDesk client pub struct Connection { /// Connection ID @@ -113,12 +166,18 @@ pub struct Connection { /// Connection state state: Arc>, /// Our signing keypair (Ed25519) for signing SignedId messages - signing_keypair: SigningKeyPair, + signing_keypair: Option, /// Temporary Curve25519 keypair for this connection (used for encryption) /// Generated fresh for each connection, public key goes in IdPk.pk temp_keypair: (box_::PublicKey, box_::SecretKey), /// Device password password: String, + /// Connection path determines whether the RustDesk signed-ID handshake is used. + mode: ConnectionMode, + /// Password hashing salt sent to the client. + password_salt: String, + /// Per-connection challenge prevents replaying a captured password hash. + password_challenge: String, /// HID controller for keyboard/mouse events hid: Option>, /// Audio controller for audio streaming @@ -128,10 +187,8 @@ pub struct Connection { /// Screen dimensions for mouse coordinate conversion screen_width: u32, screen_height: u32, - /// Message sender to connection handler - tx: mpsc::UnboundedSender, /// Shutdown signal - shutdown_tx: broadcast::Sender<()>, + shutdown_tx: watch::Sender, /// Video streaming task handle video_task: Option>, /// Audio streaming task handle @@ -140,14 +197,10 @@ pub struct Connection { session_key: Option, /// Encryption enabled flag encryption_enabled: bool, - /// Encryption sequence number (for nonce generation) - enc_seqnum: u64, /// Decryption sequence number (for nonce generation) dec_seqnum: u64, /// Negotiated video codec (after client capability exchange) negotiated_codec: Option, - /// Video frame sender for restarting video after codec switch - video_frame_tx: Option>, /// Input event throttler to prevent HID device EAGAIN errors input_throttler: InputThrottler, /// Last measured round-trip delay in milliseconds (for TestDelay responses) @@ -158,21 +211,18 @@ pub struct Connection { last_caps_lock: bool, /// Whether relative mouse mode is currently active for this connection relative_mouse_active: bool, + /// Latest throttled move; flushed on the next HID interval. + pending_mouse_move: Option, /// Server-configured RustDesk video codec. configured_codec: VideoEncoderType, + /// Failed authentication attempts on this TCP session. + password_attempts: u8, } -/// Messages sent to connection handler -#[derive(Debug)] -pub enum ConnectionMessage { - /// Send video frame - VideoFrame(Bytes), - /// Send audio frame - AudioFrame(Bytes), - /// Send cursor data - CursorData(Bytes), - /// Close connection - Close, +enum WriterCommand { + SetSessionKey(secretbox::Key), + Send { data: Bytes, encrypt: bool }, + Shutdown, } /// Messages received from client @@ -197,13 +247,13 @@ impl Connection { pub fn new( id: u32, config: &RustDeskConfig, - signing_keypair: SigningKeyPair, + mode: ConnectionMode, + signing_keypair: Option, hid: Option>, audio: Option>, video_manager: Option>, - ) -> (Self, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - let (shutdown_tx, _) = broadcast::channel(1); + ) -> Self { + let (shutdown_tx, _) = watch::channel(false); // Generate fresh Curve25519 keypair for this connection // This is used for encrypting the symmetric key exchange @@ -214,7 +264,7 @@ impl Connection { super::config::RustDeskCodec::H265 => VideoEncoderType::H265, }; - let conn = Self { + Self { id, device_id: config.device_id.clone(), peer_id: String::new(), @@ -223,30 +273,30 @@ impl Connection { signing_keypair, temp_keypair, password: config.device_password.clone(), + mode, + password_salt: config.device_id.clone(), + password_challenge: uuid::Uuid::new_v4().simple().to_string(), hid, audio, video_manager, screen_width: DEFAULT_SCREEN_WIDTH, screen_height: DEFAULT_SCREEN_HEIGHT, - tx, shutdown_tx, video_task: None, audio_task: None, session_key: None, encryption_enabled: false, - enc_seqnum: 0, dec_seqnum: 0, negotiated_codec: None, - video_frame_tx: None, input_throttler: InputThrottler::new(), last_delay: 0, last_test_delay_sent: None, last_caps_lock: false, relative_mouse_active: false, + pending_mouse_move: None, configured_codec, - }; - - (conn, rx) + password_attempts: 0, + } } /// Get connection ID @@ -264,9 +314,8 @@ impl Connection { &self.peer_id } - /// Get message sender - pub fn sender(&self) -> mpsc::UnboundedSender { - self.tx.clone() + pub fn shutdown_sender(&self) -> watch::Sender { + self.shutdown_tx.clone() } /// Handle an incoming TCP connection @@ -276,45 +325,75 @@ impl Connection { peer_addr: SocketAddr, ) -> anyhow::Result<()> { info!("New connection from {}", peer_addr); + stream.set_nodelay(true)?; *self.state.write() = ConnectionState::Handshaking; let (mut reader, writer) = stream.into_split(); - let writer = Arc::new(Mutex::new(writer)); let mut shutdown_rx = self.shutdown_tx.subscribe(); + if *shutdown_rx.borrow() { + *self.state.write() = ConnectionState::Closed; + return Ok(()); + } - // Send our SignedId first (this is what RustDesk protocol expects) - // The SignedId contains our device ID and temporary public key - let signed_id_msg = self.create_signed_id_message(&self.device_id.clone()); - let signed_id_bytes = signed_id_msg - .write_to_bytes() - .map_err(|e| anyhow::anyhow!("Failed to encode SignedId: {}", e))?; - debug!("Sending SignedId with device_id={}", self.device_id); - self.send_framed_arc(&writer, &signed_id_bytes).await?; + // Keep socket writes out of the input loop. A congested video path must + // never prevent us from reading keyboard and mouse events. + let (control_tx, control_rx) = mpsc::channel::(32); + // Absorb short encoder/socket scheduling bursts without treating a + // momentarily busy writer as a broken inter-frame sequence. + let (video_tx, video_rx) = mpsc::channel::(4); + let (audio_tx, audio_rx) = mpsc::channel::(8); + let mut writer_task = tokio::spawn(run_connection_writer( + writer, control_rx, video_rx, audio_rx, + )); + + match self.mode { + ConnectionMode::Secure => { + // ID-server and relay connections authenticate our ephemeral key through hbbs. + let signed_id_msg = self.create_signed_id_message(&self.device_id.clone()); + let signed_id_bytes = signed_id_msg + .write_to_bytes() + .map_err(|e| anyhow::anyhow!("Failed to encode SignedId: {}", e))?; + debug!("Sending SignedId with device_id={}", self.device_id); + self.send_framed(&control_tx, &signed_id_bytes).await?; + } + ConnectionMode::DirectIp => { + // Standard RustDesk direct-IP clients do not perform the signed-ID handshake. + // They expect password authentication to start immediately. + let hash_msg = self.create_hash_message(); + let hash_bytes = hash_msg + .write_to_bytes() + .map_err(|e| anyhow::anyhow!("Failed to encode Hash: {}", e))?; + debug!("Sending password challenge for direct IP connection"); + self.send_framed(&control_tx, &hash_bytes).await?; + } + } - // Channel for receiving video frames to send (bounded to provide backpressure) - let (video_tx, mut video_rx) = mpsc::channel::(4); let mut video_streaming = false; - - // Channel for receiving audio frames to send (bounded to provide backpressure) - let (audio_tx, mut audio_rx) = mpsc::channel::(8); let mut audio_streaming = false; // Timer for sending TestDelay to measure round-trip latency // RustDesk clients display this delay information let mut test_delay_interval = tokio::time::interval(Duration::from_secs(1)); test_delay_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut mouse_flush_interval = + tokio::time::interval(Duration::from_millis(DEFAULT_MOUSE_THROTTLE_MS)); + mouse_flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // Pre-allocated buffer for framing (reused across sends to reduce allocations) - // Typical H264 frame is 10-100KB, pre-allocate 128KB - let mut frame_buf = BytesMut::with_capacity(128 * 1024); + let handshake_deadline = tokio::time::sleep(HANDSHAKE_TIMEOUT); + tokio::pin!(handshake_deadline); loop { + let packet_limit = if self.state() == ConnectionState::Active { + MAX_AUTHENTICATED_PACKET_LENGTH + } else { + MAX_UNAUTHENTICATED_PACKET_LENGTH + }; tokio::select! { // Read framed message from client using RustDesk's variable-length encoding - result = read_frame(&mut reader) => { + result = read_frame_with_limit(&mut reader, packet_limit) => { match result { Ok(msg_buf) => { - if let Err(e) = self.handle_message_arc(&msg_buf, &writer, &video_tx, &mut video_streaming, &audio_tx, &mut audio_streaming).await { + if let Err(e) = self.handle_message_arc(&msg_buf, &control_tx, &video_tx, &mut video_streaming, &audio_tx, &mut audio_streaming).await { error!("Error handling message: {}", e); break; } @@ -334,61 +413,38 @@ impl Connection { } } - // Send video frames (encrypted if session key is set) - // Optimized path: inline encryption and use pre-allocated buffer - Some(frame_data) = video_rx.recv() => { - let send_result = if let Some(ref key) = self.session_key { - // Encrypt the frame - self.enc_seqnum += 1; - let nonce = Self::get_nonce(self.enc_seqnum); - let ciphertext = secretbox::seal(&frame_data, &nonce, key); - // Send using pre-allocated buffer - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &ciphertext, &mut frame_buf).await - } else { - // No encryption, send plain - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &frame_data, &mut frame_buf).await - }; - - if let Err(e) = send_result { - error!("Error sending video frame: {}", e); - break; - } - } - - // Send audio frames (encrypted if session key is set) - Some(frame_data) = audio_rx.recv() => { - let send_result = if let Some(ref key) = self.session_key { - // Encrypt the frame - self.enc_seqnum += 1; - let nonce = Self::get_nonce(self.enc_seqnum); - let ciphertext = secretbox::seal(&frame_data, &nonce, key); - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &ciphertext, &mut frame_buf).await - } else { - // No encryption, send plain - let mut w = writer.lock().await; - write_frame_buffered(&mut *w, &frame_data, &mut frame_buf).await - }; - - if let Err(e) = send_result { - error!("Error sending audio frame: {}", e); - break; - } - } - // Send TestDelay periodically to measure latency _ = test_delay_interval.tick() => { if self.state() == ConnectionState::Active && self.last_test_delay_sent.is_none() { - if let Err(e) = self.send_test_delay(&writer).await { + if let Err(e) = self.send_test_delay(&control_tx).await { warn!("Failed to send TestDelay: {}", e); } } } + _ = mouse_flush_interval.tick(), if self.pending_mouse_move.is_some() => { + if let Some(mouse_event) = self.pending_mouse_move.take() { + self.send_mouse_event_to_hid(&mouse_event).await; + self.input_throttler.mark_mouse_sent(); + } + } + + _ = &mut handshake_deadline, if self.state() != ConnectionState::Active => { + warn!("RustDesk handshake timed out for {}", peer_addr); + break; + } + + result = &mut writer_task => { + match result { + Ok(Ok(())) => debug!("RustDesk writer stopped for {}", peer_addr), + Ok(Err(error)) => warn!("RustDesk writer failed for {}: {}", peer_addr, error), + Err(error) => warn!("RustDesk writer task failed for {}: {}", peer_addr, error), + } + break; + } + // Shutdown signal - _ = shutdown_rx.recv() => { + _ = shutdown_rx.changed() => { info!("Connection shutdown requested"); break; } @@ -405,19 +461,33 @@ impl Connection { task.abort(); } + let _ = control_tx.try_send(WriterCommand::Shutdown); + if !writer_task.is_finished() { + match tokio::time::timeout(Duration::from_secs(1), &mut writer_task).await { + Ok(_) => {} + Err(_) => { + writer_task.abort(); + let _ = writer_task.await; + } + } + } + *self.state.write() = ConnectionState::Closed; Ok(()) } - /// Send framed message using Arc> with RustDesk's variable-length encoding - async fn send_framed_arc( + async fn send_framed( &self, - writer: &Arc>, + writer: &mpsc::Sender, data: &[u8], ) -> anyhow::Result<()> { - let mut w = writer.lock().await; - write_frame(&mut *w, data).await?; - Ok(()) + writer + .send(WriterCommand::Send { + data: Bytes::copy_from_slice(data), + encrypt: false, + }) + .await + .map_err(|_| anyhow::anyhow!("RustDesk writer is closed")) } /// Generate nonce from sequence number (RustDesk format) @@ -429,22 +499,18 @@ impl Connection { /// Send encrypted framed message if encryption is enabled /// RustDesk uses sequence-based nonce, NOT nonce prefix in message - async fn send_encrypted_arc( - &mut self, - writer: &Arc>, + async fn send_encrypted( + &self, + writer: &mpsc::Sender, data: &[u8], ) -> anyhow::Result<()> { - if let Some(ref key) = self.session_key { - // Increment encryption sequence number - self.enc_seqnum += 1; - let nonce = Self::get_nonce(self.enc_seqnum); - // Encrypt the message - RustDesk only sends ciphertext, no nonce prefix - let ciphertext = secretbox::seal(data, &nonce, key); - self.send_framed_arc(writer, &ciphertext).await - } else { - // No encryption, send plain - self.send_framed_arc(writer, data).await - } + writer + .send(WriterCommand::Send { + data: Bytes::copy_from_slice(data), + encrypt: self.session_key.is_some(), + }) + .await + .map_err(|_| anyhow::anyhow!("RustDesk writer is closed")) } /// Handle incoming message with Arc writer @@ -452,7 +518,7 @@ impl Connection { async fn handle_message_arc( &mut self, data: &[u8], - writer: &Arc>, + writer: &mpsc::Sender, video_tx: &mpsc::Sender, video_streaming: &mut bool, audio_tx: &mpsc::Sender, @@ -505,8 +571,6 @@ impl Connection { // Handle login and start video/audio streaming if successful if self.handle_login_request_arc(&lr, writer).await? { - // Store video_tx for potential codec switching - self.video_frame_tx = Some(video_tx.clone()); // Start video streaming if !*video_streaming { self.start_video_streaming(video_tx.clone()); @@ -576,7 +640,7 @@ impl Connection { async fn handle_login_request_arc( &mut self, lr: &LoginRequest, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result { info!( "Login request from {} ({}), password_len={}", @@ -598,19 +662,23 @@ impl Connection { let response_bytes = error_response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &response_bytes).await?; + self.send_encrypted(writer, &response_bytes).await?; // Don't close connection - wait for retry with password return Ok(false); } // Verify the password if !self.verify_password(&lr.password) { + self.password_attempts = self.password_attempts.saturating_add(1); warn!("Wrong password from {}", lr.my_id); let error_response = self.create_login_error_response("Wrong Password"); let response_bytes = error_response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &response_bytes).await?; + self.send_encrypted(writer, &response_bytes).await?; + if self.password_attempts >= MAX_PASSWORD_ATTEMPTS { + anyhow::bail!("Too many failed RustDesk password attempts"); + } // Don't close connection - wait for retry with correct password return Ok(false); } @@ -618,6 +686,7 @@ impl Connection { // Password valid or no password required info!("Login successful for {}", lr.my_id); + self.password_attempts = 0; *self.state.write() = ConnectionState::Active; // Select the best available video codec @@ -630,7 +699,7 @@ impl Connection { let response_bytes = response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &response_bytes).await?; + self.send_encrypted(writer, &response_bytes).await?; Ok(true) } @@ -673,7 +742,7 @@ impl Connection { async fn handle_misc_arc( &mut self, misc: &Misc, - _writer: &Arc>, + _writer: &mpsc::Sender, ) -> anyhow::Result<()> { match &misc.union { Some(misc::Union::SwitchDisplay(sd)) => { @@ -842,7 +911,11 @@ impl Connection { // Sign the IdPk bytes with Ed25519 // RustDesk's sign::sign() prepends the 64-byte signature to the message - let signed_id_pk = self.signing_keypair.sign(&id_pk_bytes); + let signed_id_pk = self + .signing_keypair + .as_ref() + .expect("secure RustDesk connections require a signing keypair") + .sign(&id_pk_bytes); let mut signed_id = SignedId::new(); signed_id.id = signed_id_pk.into(); @@ -857,7 +930,7 @@ impl Connection { async fn handle_peer_public_key( &mut self, pk: &PublicKey, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result<()> { // RustDesk's PublicKey message has two parts: // - asymmetric_value: The peer's temporary Curve25519 public key (32 bytes) @@ -880,6 +953,10 @@ impl Connection { ) { Ok(session_key) => { info!("Session key negotiated successfully"); + writer + .send(WriterCommand::SetSessionKey(session_key.clone())) + .await + .map_err(|_| anyhow::anyhow!("RustDesk writer is closed"))?; self.session_key = Some(session_key); self.encryption_enabled = true; } @@ -917,7 +994,7 @@ impl Connection { "Sending Hash message for password authentication (encrypted={})", self.encryption_enabled ); - self.send_encrypted_arc(writer, &hash_bytes).await?; + self.send_encrypted(writer, &hash_bytes).await?; Ok(()) } @@ -930,7 +1007,7 @@ impl Connection { async fn handle_signed_id( &mut self, si: &SignedId, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result<()> { // The SignedId contains a signed IdPk message // Try to parse the IdPk from the signed data @@ -972,7 +1049,7 @@ impl Connection { let signed_id_bytes = signed_id_msg .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_framed_arc(writer, &signed_id_bytes).await?; + self.send_framed(writer, &signed_id_bytes).await?; Ok(()) } @@ -980,7 +1057,7 @@ impl Connection { /// Verify password fn verify_password(&self, provided: &[u8]) -> bool { // RustDesk password verification: - // We send Hash { salt: device_id, challenge: "" } to client + // We send a stable salt and a fresh per-connection challenge to the client. // The client calculates: SHA256(SHA256(password + salt) + challenge) // See create_hash_message() for the salt and challenge we use // @@ -993,19 +1070,27 @@ impl Connection { return false; } - // The client calculates: SHA256(SHA256(password + salt) + challenge) - // where salt is our device_id and challenge is empty - let expected_hash = crypto::hash_password_double(&self.password, &self.device_id, ""); + let expected_hash = crypto::hash_password_double( + &self.password, + &self.password_salt, + &self.password_challenge, + ); // Try comparison with double hash - if provided == expected_hash.as_slice() { + if provided.len() == expected_hash.len() + && sodiumoxide::utils::memcmp(provided, expected_hash.as_slice()) + { debug!("Password verified with double hash"); return true; } - // Also try single hash for compatibility - let expected_hash_single = crypto::hash_password(&self.password, &self.device_id); - if provided == expected_hash_single.as_slice() { + // Keep the legacy single-hash fallback only inside the encrypted ID-service path. + // It has no per-connection challenge and must not be accepted on direct IP access. + let expected_hash_single = crypto::hash_password(&self.password, &self.password_salt); + if self.mode == ConnectionMode::Secure + && provided.len() == expected_hash_single.len() + && sodiumoxide::utils::memcmp(provided, expected_hash_single.as_slice()) + { debug!("Password verified with single hash"); return true; } @@ -1022,7 +1107,7 @@ impl Connection { } /// Create login response with dynamically detected encoder capabilities - async fn create_login_response(&self, success: bool) -> HbbMessage { + async fn create_login_response(&mut self, success: bool) -> HbbMessage { if success { // Dynamically detect available encoders let registry = EncoderRegistry::global(); @@ -1053,6 +1138,10 @@ impl Connection { display_height = height; } } + // Use the same geometry for both the advertised display and HID + // absolute-coordinate conversion. + self.screen_width = display_width.max(1); + self.screen_height = display_height.max(1); let mut display_info = DisplayInfo::new(); display_info.x = 0; @@ -1073,11 +1162,11 @@ impl Connection { let mut peer_info = PeerInfo::new(); peer_info.username = "one-kvm".to_string(); peer_info.hostname = hostname_from_etc(); - peer_info.platform = RUSTDESK_COMPAT_PLATFORM.to_string(); + peer_info.platform = RUSTDESK_COMPATIBILITY.platform.name().to_string(); peer_info.displays.push(display_info); peer_info.current_display = 0; peer_info.sas_enabled = false; - peer_info.version = RUSTDESK_COMPAT_VERSION.to_string(); + peer_info.version = RUSTDESK_COMPATIBILITY.version.to_string(); peer_info.encoding = protobuf::MessageField::some(encoding); let mut login_response = LoginResponse::new(); @@ -1116,11 +1205,9 @@ impl Connection { /// Create Hash message for password authentication /// The client will hash the password with the salt and send it back in LoginRequest fn create_hash_message(&self) -> HbbMessage { - // Use device_id as salt for simplicity (RustDesk uses Config::get_salt()) - // The challenge field is not used for our password verification let mut hash = Hash::new(); - hash.salt = self.device_id.clone(); - hash.challenge = String::new(); + hash.salt = self.password_salt.clone(); + hash.challenge = self.password_challenge.clone(); let mut msg = HbbMessage::new(); msg.union = Some(message::Union::Hash(hash)); @@ -1137,7 +1224,7 @@ impl Connection { async fn handle_test_delay( &mut self, td: &TestDelay, - writer: &Arc>, + writer: &mpsc::Sender, ) -> anyhow::Result<()> { if td.from_client { // Client initiated the delay test, respond with the same time @@ -1153,7 +1240,7 @@ impl Connection { let data = response .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &data).await?; + self.send_encrypted(writer, &data).await?; debug!( "TestDelay response sent: time={}, last_delay={}ms", @@ -1180,7 +1267,10 @@ impl Connection { /// The client will echo this back, allowing us to calculate RTT. /// The measured delay is then included in future TestDelay messages /// for the client to display. - async fn send_test_delay(&mut self, writer: &Arc>) -> anyhow::Result<()> { + async fn send_test_delay( + &mut self, + writer: &mpsc::Sender, + ) -> anyhow::Result<()> { // Get current time in milliseconds since epoch let time_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1199,7 +1289,7 @@ impl Connection { let data = msg .write_to_bytes() .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; - self.send_encrypted_arc(writer, &data).await?; + self.send_encrypted(writer, &data).await?; // Record when we sent this, so we can calculate RTT when client echoes back self.last_test_delay_sent = Some(Instant::now()); @@ -1213,9 +1303,15 @@ impl Connection { /// Handle key event async fn handle_key_event(&mut self, ke: &KeyEvent) -> anyhow::Result<()> { + let (union_type, raw_value) = key_event_union_details(ke); debug!( - "Key event: down={}, press={}, chr={:?}, modifiers={:?}", - ke.down, ke.press, ke.union, ke.modifiers + mode = ke.mode.value(), + union = union_type, + raw = %raw_value, + down = ke.down, + press = ke.press, + modifiers = ?ke.modifiers, + "RustDesk key event" ); // Check for CapsLock state change in modifiers @@ -1253,15 +1349,21 @@ impl Connection { } // Convert RustDesk key event to One-KVM key events - let kb_events = convert_key_events(ke); + let kb_events = convert_key_events_in_code_space( + ke, + RUSTDESK_COMPATIBILITY.platform.keyboard_code_space(), + ); if !kb_events.is_empty() { if let Some(ref hid) = self.hid { for kb_event in kb_events { debug!( - "Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}", - kb_event.key.to_hid_usage(), - kb_event.event_type, - kb_event.modifiers.to_hid_byte() + mode = ke.mode.value(), + union = union_type, + raw = %raw_value, + hid = format_args!("0x{:02X}", kb_event.key.to_hid_usage()), + event_type = ?kb_event.event_type, + modifiers = format_args!("0x{:02X}", kb_event.modifiers.to_hid_byte()), + "Converted RustDesk key event" ); if let Err(e) = hid.send_keyboard(kb_event).await { @@ -1272,7 +1374,12 @@ impl Connection { debug!("HID controller not available, skipping key event"); } } else { - warn!("Could not convert key event to HID: chr={:?}", ke.union); + debug!( + mode = ke.mode.value(), + union = union_type, + raw = %raw_value, + "RustDesk key event produced no HID event" + ); } Ok(()) @@ -1298,46 +1405,133 @@ impl Connection { // For pure move events, apply throttling if is_pure_move && !self.input_throttler.should_send_mouse_move() { - // Skip this move event to prevent HID EAGAIN + // Coalesce moves instead of losing the final pointer position. + self.pending_mouse_move = Some(me.clone()); return Ok(()); } + // Preserve input ordering when a button/scroll event follows a move + // that was waiting for the next HID poll interval. + if !is_pure_move { + if let Some(pending) = self.pending_mouse_move.take() { + self.send_mouse_event_to_hid(&pending).await; + } + } + debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask); - // Convert RustDesk mouse event to One-KVM mouse events - let mouse_events = convert_mouse_event(me, self.screen_width, self.screen_height); - - // Send to HID controller if available - if let Some(ref hid) = self.hid { - for event in mouse_events { - if let Err(e) = hid.send_mouse(event).await { - warn!("Failed to send mouse event: {}", e); - } - } + self.send_mouse_event_to_hid(me).await; + if self.hid.is_some() { // Mark that we sent a mouse event (for non-move events) if !is_pure_move { self.input_throttler.mark_mouse_sent(); } - } else { - debug!("HID controller not available, skipping mouse event"); } Ok(()) } + async fn send_mouse_event_to_hid(&self, event: &MouseEvent) { + let mouse_events = convert_mouse_event(event, self.screen_width, self.screen_height); + if let Some(ref hid) = self.hid { + for mouse_event in mouse_events { + if let Err(error) = hid.send_mouse(mouse_event).await { + warn!("Failed to send mouse event: {}", error); + } + } + } else { + debug!("HID controller not available, skipping mouse event"); + } + } + /// Close the connection pub fn close(&self) { - let _ = self.shutdown_tx.send(()); + self.shutdown_tx.send_replace(true); *self.state.write() = ConnectionState::Closed; } } +async fn run_connection_writer( + mut writer: OwnedWriteHalf, + mut control_rx: mpsc::Receiver, + mut video_rx: mpsc::Receiver, + mut audio_rx: mpsc::Receiver, +) -> anyhow::Result<()> { + let mut session_key: Option = None; + let mut sequence = 0u64; + + loop { + let (data, encrypt) = tokio::select! { + biased; + + command = control_rx.recv() => { + match command { + Some(WriterCommand::SetSessionKey(key)) => { + session_key = Some(key); + continue; + } + Some(WriterCommand::Send { data, encrypt }) => (data, encrypt), + Some(WriterCommand::Shutdown) | None => break, + } + } + frame = video_rx.recv() => { + match frame { + Some(data) => (data, session_key.is_some()), + None => continue, + } + } + frame = audio_rx.recv() => { + match frame { + Some(data) => (data, session_key.is_some()), + None => continue, + } + } + }; + + let encrypted; + let payload = if encrypt { + if let Some(key) = session_key.as_ref() { + sequence = sequence.wrapping_add(1); + let nonce = Connection::get_nonce(sequence); + encrypted = secretbox::seal(&data, &nonce, key); + encrypted.as_slice() + } else { + data.as_ref() + } + } else { + data.as_ref() + }; + + tokio::time::timeout( + Duration::from_secs(10), + write_frame_vectored(&mut writer, payload), + ) + .await + .map_err(|_| anyhow::anyhow!("RustDesk socket write timed out"))??; + } + + Ok(()) +} + /// Lightweight connection info for tracking active connections pub struct ConnectionInfo { /// Connection ID pub id: u32, /// Connection state (shared with Connection) pub state: Arc>, + shutdown_tx: watch::Sender, + abort_handle: Option, +} + +struct ConnectionCleanup { + id: u32, + connections: Arc>>, +} + +impl Drop for ConnectionCleanup { + fn drop(&mut self) { + self.connections.write().remove(&self.id); + } } impl ConnectionInfo { @@ -1350,7 +1544,7 @@ impl ConnectionInfo { /// Connection manager pub struct ConnectionManager { /// Active connection info - connections: Arc>>>>, + connections: Arc>>, /// Next connection ID next_id: Arc>, /// Configuration @@ -1371,7 +1565,7 @@ impl ConnectionManager { /// Create a new connection manager pub fn new(config: RustDeskConfig) -> Self { Self { - connections: Arc::new(RwLock::new(Vec::new())), + connections: Arc::new(RwLock::new(HashMap::new())), next_id: Arc::new(RwLock::new(1)), config: Arc::new(RwLock::new(config)), keypair: Arc::new(RwLock::new(None)), @@ -1397,6 +1591,10 @@ impl ConnectionManager { *self.video_manager.write() = Some(video_manager); } + pub fn update_config(&self, config: RustDeskConfig) { + *self.config.write() = config; + } + /// Set keypair pub fn set_keypair(&self, keypair: KeyPair) { *self.keypair.write() = Some(keypair); @@ -1432,6 +1630,42 @@ impl ConnectionManager { stream: TcpStream, peer_addr: SocketAddr, ) -> anyhow::Result { + self.accept_connection_with_mode(stream, peer_addr, ConnectionMode::Secure) + .await + } + + pub async fn accept_direct_connection( + &self, + stream: TcpStream, + peer_addr: SocketAddr, + ) -> anyhow::Result { + self.accept_connection_with_mode(stream, peer_addr, ConnectionMode::DirectIp) + .await + } + + pub async fn accept_listener_connection( + &self, + stream: TcpStream, + peer_addr: SocketAddr, + ) -> anyhow::Result { + let mode = match self.config.read().mode { + super::config::RustDeskMode::Id => ConnectionMode::Secure, + super::config::RustDeskMode::DirectIp => ConnectionMode::DirectIp, + }; + self.accept_connection_with_mode(stream, peer_addr, mode) + .await + } + + async fn accept_connection_with_mode( + &self, + stream: TcpStream, + peer_addr: SocketAddr, + mode: ConnectionMode, + ) -> anyhow::Result { + if self.connection_count() >= MAX_CONNECTIONS { + anyhow::bail!("RustDesk connection limit ({MAX_CONNECTIONS}) reached"); + } + let id = { let mut next = self.next_id.write(); let id = *next; @@ -1440,25 +1674,53 @@ impl ConnectionManager { }; let config = self.config.read().clone(); - let signing_keypair = self.ensure_signing_keypair(); + let signing_keypair = match mode { + ConnectionMode::Secure => Some(self.ensure_signing_keypair()), + ConnectionMode::DirectIp => None, + }; let hid = self.hid.read().clone(); let audio = self.audio.read().clone(); let video_manager = self.video_manager.read().clone(); - let (mut conn, _rx) = - Connection::new(id, &config, signing_keypair, hid, audio, video_manager); + let mut conn = Connection::new( + id, + &config, + mode, + signing_keypair, + hid, + audio, + video_manager, + ); // Track connection state for external access let state = conn.state.clone(); - self.connections - .write() - .push(Arc::new(RwLock::new(ConnectionInfo { id, state }))); + let shutdown_tx = conn.shutdown_sender(); + { + let mut connections = self.connections.write(); + if connections.len() >= MAX_CONNECTIONS { + anyhow::bail!("RustDesk connection limit ({MAX_CONNECTIONS}) reached"); + } + connections.insert( + id, + ConnectionInfo { + id, + state, + shutdown_tx, + abort_handle: None, + }, + ); + } // Spawn connection handler - Connection is moved, not locked - tokio::spawn(async move { + let connections = self.connections.clone(); + let task = tokio::spawn(async move { + let _cleanup = ConnectionCleanup { id, connections }; if let Err(e) = conn.handle_tcp(stream, peer_addr).await { error!("Connection {} error: {}", id, e); } }); + if let Some(connection) = self.connections.write().get_mut(&id) { + connection.abort_handle = Some(task.abort_handle()); + } Ok(id) } @@ -1468,11 +1730,47 @@ impl ConnectionManager { self.connections.read().len() } - /// Mark all connections as closed (actual connection tasks will detect this) - pub fn close_all(&self) { - let connections = self.connections.read(); - for conn_info in connections.iter() { - *conn_info.read().state.write() = ConnectionState::Closed; + /// Cancel all sessions and wait briefly for their TCP tasks to exit. + pub async fn close_all(&self) { + let senders = self + .connections + .read() + .values() + .map(|connection| connection.shutdown_tx.clone()) + .collect::>(); + for sender in senders { + sender.send_replace(true); + } + + let wait_for_empty = async { + while !self.connections.read().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + if tokio::time::timeout(CONNECTION_SHUTDOWN_TIMEOUT, wait_for_empty) + .await + .is_err() + { + warn!( + "Timed out waiting for {} RustDesk connection(s) to close", + self.connection_count() + ); + let abort_handles = self + .connections + .read() + .values() + .filter_map(|connection| connection.abort_handle.clone()) + .collect::>(); + for handle in abort_handles { + handle.abort(); + } + + let abort_wait = async { + while !self.connections.read().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + let _ = tokio::time::timeout(Duration::from_secs(1), abort_wait).await; } } } @@ -1490,7 +1788,7 @@ async fn run_video_streaming( video_manager: Arc, video_tx: mpsc::Sender, state: Arc>, - shutdown_tx: broadcast::Sender<()>, + shutdown_tx: watch::Sender, negotiated_codec: VideoEncoderType, ) -> anyhow::Result<()> { use crate::video::codec::VideoCodecType; @@ -1523,6 +1821,9 @@ async fn run_video_streaming( let mut video_adapter = VideoFrameAdapter::new(codec); let mut shutdown_rx = shutdown_tx.subscribe(); + if *shutdown_rx.borrow() { + return Ok(()); + } let mut encoded_count: u64 = 0; let mut last_log_time = Instant::now(); let mut waiting_for_keyframe = true; @@ -1585,7 +1886,7 @@ async fn run_video_streaming( tokio::select! { biased; - _ = shutdown_rx.recv() => { + _ = shutdown_rx.changed() => { debug!("Shutdown signal received, stopping video for connection {}", conn_id); break 'subscribe_loop; } @@ -1632,7 +1933,9 @@ async fn run_video_streaming( frame.pts_ms as u64, ); - // Send to connection (backpressure instead of dropping) + // A small bounded queue absorbs transient writer jitter. + // Backpressure here cannot block input handling because the + // TCP reader and writer run independently. if video_tx.send(msg_bytes).await.is_err() { debug!("Video channel closed for connection {}", conn_id); break 'subscribe_loop; @@ -1671,12 +1974,15 @@ async fn run_audio_streaming( audio_controller: Arc, audio_tx: mpsc::Sender, state: Arc>, - shutdown_tx: broadcast::Sender<()>, + shutdown_tx: watch::Sender, ) -> anyhow::Result<()> { // Audio format: 48kHz stereo Opus let mut audio_adapter = AudioFrameAdapter::new(48000, 2); let mut shutdown_rx = shutdown_tx.subscribe(); + if *shutdown_rx.borrow() { + return Ok(()); + } let mut frame_count: u64 = 0; let mut last_log_time = Instant::now(); @@ -1731,7 +2037,7 @@ async fn run_audio_streaming( tokio::select! { biased; - _ = shutdown_rx.recv() => { + _ = shutdown_rx.changed() => { debug!("Shutdown signal received, stopping audio for connection {}", conn_id); break 'subscribe_loop; } @@ -1753,10 +2059,14 @@ async fn run_audio_streaming( // Convert OpusFrame to RustDesk AudioFrame message let msg_bytes = audio_adapter.encode_opus_bytes(&opus_frame.data); - // Send to connection (blocks if channel is full, providing backpressure) - if audio_tx.send(msg_bytes).await.is_err() { - debug!("Audio channel closed for connection {}", conn_id); - break 'subscribe_loop; + // Audio is real-time data; dropping an old packet is preferable + // to accumulating seconds of latency. + match audio_tx.try_send(msg_bytes) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!("Audio channel closed for connection {}", conn_id); + break 'subscribe_loop; + } } frame_count += 1; @@ -1781,3 +2091,176 @@ async fn run_audio_streaming( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn connection(mode: ConnectionMode) -> Connection { + crypto::init().expect("crypto should initialize"); + let config = RustDeskConfig { + device_id: "123456789".to_string(), + device_password: "fixed-password".to_string(), + ..Default::default() + }; + let connection = Connection::new( + 1, + &config, + mode, + (mode == ConnectionMode::Secure).then(SigningKeyPair::generate), + None, + None, + None, + ); + connection + } + + async fn first_server_message(mode: ConnectionMode) -> HbbMessage { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener should bind"); + let address = listener + .local_addr() + .expect("listener should have an address"); + let client = tokio::spawn(async move { + TcpStream::connect(address) + .await + .expect("client should connect") + }); + let (server_stream, peer_addr) = listener.accept().await.expect("server should accept"); + let mut client_stream = client.await.expect("client task should finish"); + let mut server_connection = connection(mode); + let server = + tokio::spawn( + async move { server_connection.handle_tcp(server_stream, peer_addr).await }, + ); + + let bytes = read_frame_with_limit(&mut client_stream, MAX_UNAUTHENTICATED_PACKET_LENGTH) + .await + .expect("client should receive the first frame"); + drop(client_stream); + let _ = tokio::time::timeout(Duration::from_secs(1), server).await; + decode_message(&bytes).expect("first frame should contain a RustDesk message") + } + + #[test] + fn direct_ip_password_challenge_is_non_empty_and_verifiable() { + let connection = connection(ConnectionMode::DirectIp); + let message = connection.create_hash_message(); + let hash = match message.union { + Some(message::Union::Hash(hash)) => hash, + _ => panic!("expected Hash message"), + }; + + assert_eq!(hash.salt, "123456789"); + assert!(!hash.challenge.is_empty()); + + let response = crypto::hash_password_double("fixed-password", &hash.salt, &hash.challenge); + assert!(connection.verify_password(&response)); + } + + #[test] + fn direct_ip_rejects_legacy_replayable_single_hash() { + let direct = connection(ConnectionMode::DirectIp); + let single_hash = crypto::hash_password("fixed-password", "123456789"); + assert!(!direct.verify_password(&single_hash)); + + let secure = connection(ConnectionMode::Secure); + assert!(secure.verify_password(&single_hash)); + } + + #[test] + fn password_challenge_changes_for_each_connection() { + let first = connection(ConnectionMode::DirectIp); + let second = connection(ConnectionMode::DirectIp); + assert_ne!(first.password_challenge, second.password_challenge); + } + + #[tokio::test] + async fn direct_ip_connection_starts_with_password_hash() { + let message = first_server_message(ConnectionMode::DirectIp).await; + assert!(matches!(message.union, Some(message::Union::Hash(_)))); + } + + #[tokio::test] + async fn secure_connection_starts_with_signed_id() { + let message = first_server_message(ConnectionMode::Secure).await; + assert!(matches!(message.union, Some(message::Union::SignedId(_)))); + } + + #[tokio::test] + async fn connection_manager_close_all_cancels_and_removes_sessions() { + let config = RustDeskConfig { + enabled: true, + mode: super::super::config::RustDeskMode::DirectIp, + ..Default::default() + }; + let manager = ConnectionManager::new(config); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (server, peer) = listener.accept().await.unwrap(); + let _client = client.await.unwrap(); + + manager + .accept_direct_connection(server, peer) + .await + .unwrap(); + assert_eq!(manager.connection_count(), 1); + manager.close_all().await; + assert_eq!(manager.connection_count(), 0); + } + + #[tokio::test] + async fn connection_manager_enforces_session_limit() { + let config = RustDeskConfig { + enabled: true, + mode: super::super::config::RustDeskMode::DirectIp, + ..Default::default() + }; + let manager = ConnectionManager::new(config); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let mut clients = Vec::new(); + + for _ in 0..MAX_CONNECTIONS { + let client = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (server, peer) = listener.accept().await.unwrap(); + clients.push(client.await.unwrap()); + manager + .accept_direct_connection(server, peer) + .await + .unwrap(); + } + let extra_client = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (extra_server, extra_peer) = listener.accept().await.unwrap(); + clients.push(extra_client.await.unwrap()); + assert!(manager + .accept_direct_connection(extra_server, extra_peer) + .await + .is_err()); + + manager.close_all().await; + assert_eq!(manager.connection_count(), 0); + } + + #[tokio::test] + async fn password_attempts_are_bounded_per_session() { + let mut connection = connection(ConnectionMode::DirectIp); + let (writer, _reader) = mpsc::channel(32); + let mut login = LoginRequest::new(); + login.my_id = "attacker".to_string(); + login.password = vec![0u8; 32].into(); + + for _ in 1..MAX_PASSWORD_ATTEMPTS { + assert!(!connection + .handle_login_request_arc(&login, &writer) + .await + .unwrap()); + } + assert!(connection + .handle_login_request_arc(&login, &writer) + .await + .is_err()); + } +} diff --git a/src/rustdesk/frame_adapters.rs b/src/rustdesk/frame_adapters.rs index 93dc6f67..7a3ed733 100644 --- a/src/rustdesk/frame_adapters.rs +++ b/src/rustdesk/frame_adapters.rs @@ -31,8 +31,6 @@ pub struct VideoFrameAdapter { codec: VideoCodec, seq: u32, timestamp_base: u64, - h264_sps: Option, - h264_pps: Option, } impl VideoFrameAdapter { @@ -41,8 +39,6 @@ impl VideoFrameAdapter { codec, seq: 0, timestamp_base: 0, - h264_sps: None, - h264_pps: None, } } @@ -56,7 +52,6 @@ impl VideoFrameAdapter { is_keyframe: bool, timestamp_ms: u64, ) -> Message { - let data = self.prepare_h264_frame(data, is_keyframe); if self.seq == 0 { self.timestamp_base = timestamp_ms; } @@ -86,39 +81,6 @@ impl VideoFrameAdapter { msg } - fn prepare_h264_frame(&mut self, data: Bytes, is_keyframe: bool) -> Bytes { - if self.codec != VideoCodec::H264 { - return data; - } - - let (sps, pps) = crate::video::codec::h264_bitstream::extract_sps_pps(&data); - let mut has_sps = false; - let mut has_pps = false; - - if let Some(sps) = sps { - self.h264_sps = Some(Bytes::from(sps)); - has_sps = true; - } - if let Some(pps) = pps { - self.h264_pps = Some(Bytes::from(pps)); - has_pps = true; - } - - if is_keyframe && (!has_sps || !has_pps) { - if let (Some(sps), Some(pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) { - let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len()); - out.extend_from_slice(&[0, 0, 0, 1]); - out.extend_from_slice(sps); - out.extend_from_slice(&[0, 0, 0, 1]); - out.extend_from_slice(pps); - out.extend_from_slice(&data); - return Bytes::from(out); - } - } - - data - } - pub fn encode_frame(&mut self, data: &[u8], is_keyframe: bool, timestamp_ms: u64) -> Message { self.encode_frame_from_bytes(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms) } diff --git a/src/rustdesk/hid_adapter.rs b/src/rustdesk/hid_adapter.rs index cfc106ac..d819aef9 100644 --- a/src/rustdesk/hid_adapter.rs +++ b/src/rustdesk/hid_adapter.rs @@ -1,10 +1,11 @@ +use super::keyboard_mapping::{self, CharacterMapping, KeyboardCodeSpace}; use super::protocol::hbb::message::key_event as ke_union; use super::protocol::{ControlKey, KeyEvent, KeyboardMode, MouseEvent}; use crate::hid::{ CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent, MouseEventType, }; -use protobuf::Enum; +use tracing::debug; pub mod mouse_type { pub const MOVE: i32 = 0; @@ -105,14 +106,23 @@ fn button_id_to_button(button_id: i32) -> Option { } } +/// Convert using the code space associated with One-KVM's compatibility platform. pub fn convert_key_events(event: &KeyEvent) -> Vec { + convert_key_events_in_code_space(event, KeyboardCodeSpace::WindowsSet1) +} + +pub(super) fn convert_key_events_in_code_space( + event: &KeyEvent, + code_space: KeyboardCodeSpace, +) -> Vec { let base_modifiers = if is_modifier_control_key(event) { KeyboardModifiers::default() } else { parse_modifiers(event) }; - let Some(mapping) = key_event_to_hid(event, base_modifiers) else { + let Some(mapping) = key_event_to_mapping(event, code_space, base_modifiers) else { + log_rejected_key_event(event, code_space); return Vec::new(); }; @@ -135,13 +145,12 @@ pub fn convert_key_events(event: &KeyEvent) -> Vec { }, ] } else { - let event_type = if event.down { - KeyEventType::Down - } else { - KeyEventType::Up - }; vec![KeyboardEvent { - event_type, + event_type: if event.down { + KeyEventType::Down + } else { + KeyEventType::Up + }, key: mapping.key, modifiers: mapping.modifiers, }] @@ -159,534 +168,337 @@ struct KeyMapping { added_shift: bool, } -fn key_event_to_hid(event: &KeyEvent, modifiers: KeyboardModifiers) -> Option { +fn key_event_to_mapping( + event: &KeyEvent, + code_space: KeyboardCodeSpace, + modifiers: KeyboardModifiers, +) -> Option { + let mode = event.mode.enum_value().ok()?; match &event.union { - Some(ke_union::Union::ControlKey(ck)) => { - let key = CanonicalKey::from_hid_usage(control_key_to_hid(ck.value())?)?; - Some(KeyMapping { - key, + Some(ke_union::Union::ControlKey(key)) => { + plain_mapping(keyboard_mapping::control_key(key.value())?, modifiers) + } + Some(ke_union::Union::Unicode(ch)) => character_mapping(*ch, modifiers), + Some(ke_union::Union::Chr(code)) => match mode { + KeyboardMode::Map | KeyboardMode::Translate => plain_mapping( + keyboard_mapping::physical_key(code_space, *code)?, modifiers, - added_shift: false, - }) - } - Some(ke_union::Union::Chr(chr)) => { - if event.mode.value() != KeyboardMode::Map.value() { - if let Some(mapping) = shifted_printable_char_to_hid(*chr, modifiers) { - return Some(mapping); - } - } - let key = CanonicalKey::from_hid_usage(keycode_to_hid(*chr)?)?; - Some(KeyMapping { - key, - modifiers, - added_shift: false, - }) - } - Some(ke_union::Union::Unicode(unicode)) => { - let mapping = printable_char_to_hid(*unicode, modifiers)?; - Some(mapping) - } - _ => None, + ), + KeyboardMode::Legacy | KeyboardMode::Auto => legacy_character_mapping(*code, modifiers), + }, + Some(ke_union::Union::Seq(_)) | Some(ke_union::Union::Win2winHotkey(_)) | None => None, + } +} + +fn plain_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> Option { + Some(KeyMapping { + key, + modifiers, + added_shift: false, + }) +} + +fn character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option { + let CharacterMapping { key, needs_shift } = keyboard_mapping::character(ch)?; + if !needs_shift { + return plain_mapping(key, modifiers); + } + + let added_shift = !modifiers.left_shift && !modifiers.right_shift; + let mut shifted_modifiers = modifiers; + shifted_modifiers.left_shift = true; + Some(KeyMapping { + key, + modifiers: shifted_modifiers, + added_shift, + }) +} + +fn legacy_character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option { + let CharacterMapping { key, needs_shift } = keyboard_mapping::character(ch)?; + // Legacy Chr historically relied on the event's modifier list for uppercase + // letters, while synthesizing Shift for printable US-layout symbols. + if needs_shift && !(0x41..=0x5A).contains(&ch) { + character_mapping(ch, modifiers) + } else { + plain_mapping(key, modifiers) } } fn is_modifier_control_key(event: &KeyEvent) -> bool { - if let Some(ke_union::Union::ControlKey(ck)) = &event.union { - let val = ck.value(); - return val == ControlKey::Control.value() - || val == ControlKey::Shift.value() - || val == ControlKey::Alt.value() - || val == ControlKey::Meta.value() - || val == ControlKey::RControl.value() - || val == ControlKey::RShift.value() - || val == ControlKey::RAlt.value(); - } - false + let Some(ke_union::Union::ControlKey(key)) = &event.union else { + return false; + }; + matches!( + key.enum_value(), + Ok(ControlKey::Control) + | Ok(ControlKey::Shift) + | Ok(ControlKey::Alt) + | Ok(ControlKey::Meta) + | Ok(ControlKey::RControl) + | Ok(ControlKey::RShift) + | Ok(ControlKey::RAlt) + | Ok(ControlKey::RWin) + ) } fn parse_modifiers(event: &KeyEvent) -> KeyboardModifiers { let mut modifiers = KeyboardModifiers::default(); - for modifier in &event.modifiers { - let val = modifier.value(); - match val { - x if x == ControlKey::Control.value() => modifiers.left_ctrl = true, - x if x == ControlKey::Shift.value() => modifiers.left_shift = true, - x if x == ControlKey::Alt.value() => modifiers.left_alt = true, - x if x == ControlKey::Meta.value() => modifiers.left_meta = true, - x if x == ControlKey::RControl.value() => modifiers.right_ctrl = true, - x if x == ControlKey::RShift.value() => modifiers.right_shift = true, - x if x == ControlKey::RAlt.value() => modifiers.right_alt = true, + match modifier.enum_value() { + Ok(ControlKey::Control) => modifiers.left_ctrl = true, + Ok(ControlKey::Shift) => modifiers.left_shift = true, + Ok(ControlKey::Alt) => modifiers.left_alt = true, + Ok(ControlKey::Meta) => modifiers.left_meta = true, + Ok(ControlKey::RControl) => modifiers.right_ctrl = true, + Ok(ControlKey::RShift) => modifiers.right_shift = true, + Ok(ControlKey::RAlt) => modifiers.right_alt = true, + Ok(ControlKey::RWin) => modifiers.right_meta = true, _ => {} } } - modifiers } -fn with_shift(mut modifiers: KeyboardModifiers) -> KeyboardModifiers { - modifiers.left_shift = true; - modifiers -} - -fn shifted_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping { - let added_shift = !modifiers.left_shift && !modifiers.right_shift; - KeyMapping { - key, - modifiers: with_shift(modifiers), - added_shift, - } -} - -fn plain_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping { - KeyMapping { - key, - modifiers, - added_shift: false, - } -} - -fn shifted_printable_char_to_hid(ch: u32, modifiers: KeyboardModifiers) -> Option { - match ch { - 33 => Some(shifted_mapping(CanonicalKey::Digit1, modifiers)), - 64 => Some(shifted_mapping(CanonicalKey::Digit2, modifiers)), - 35 => Some(shifted_mapping(CanonicalKey::Digit3, modifiers)), - 36 => Some(shifted_mapping(CanonicalKey::Digit4, modifiers)), - 37 => Some(shifted_mapping(CanonicalKey::Digit5, modifiers)), - 94 => Some(shifted_mapping(CanonicalKey::Digit6, modifiers)), - 38 => Some(shifted_mapping(CanonicalKey::Digit7, modifiers)), - 42 => Some(shifted_mapping(CanonicalKey::Digit8, modifiers)), - 40 => Some(shifted_mapping(CanonicalKey::Digit9, modifiers)), - 41 => Some(shifted_mapping(CanonicalKey::Digit0, modifiers)), - 95 => Some(shifted_mapping(CanonicalKey::Minus, modifiers)), - 43 => Some(shifted_mapping(CanonicalKey::Equal, modifiers)), - 123 => Some(shifted_mapping(CanonicalKey::BracketLeft, modifiers)), - 125 => Some(shifted_mapping(CanonicalKey::BracketRight, modifiers)), - 124 => Some(shifted_mapping(CanonicalKey::Backslash, modifiers)), - 58 => Some(shifted_mapping(CanonicalKey::Semicolon, modifiers)), - 34 => Some(shifted_mapping(CanonicalKey::Quote, modifiers)), - 126 => Some(shifted_mapping(CanonicalKey::Backquote, modifiers)), - 60 => Some(shifted_mapping(CanonicalKey::Comma, modifiers)), - 62 => Some(shifted_mapping(CanonicalKey::Period, modifiers)), - 63 => Some(shifted_mapping(CanonicalKey::Slash, modifiers)), - _ => None, - } -} - -fn printable_char_to_hid(ch: u32, modifiers: KeyboardModifiers) -> Option { - match ch { - 65..=90 => Some(shifted_mapping( - CanonicalKey::from_hid_usage((ch - 65 + 0x04) as u8)?, - modifiers, - )), - 97..=122 => Some(plain_mapping( - CanonicalKey::from_hid_usage((ch - 97 + 0x04) as u8)?, - modifiers, - )), - 48 => Some(plain_mapping(CanonicalKey::Digit0, modifiers)), - 49 => Some(plain_mapping(CanonicalKey::Digit1, modifiers)), - 50 => Some(plain_mapping(CanonicalKey::Digit2, modifiers)), - 51 => Some(plain_mapping(CanonicalKey::Digit3, modifiers)), - 52 => Some(plain_mapping(CanonicalKey::Digit4, modifiers)), - 53 => Some(plain_mapping(CanonicalKey::Digit5, modifiers)), - 54 => Some(plain_mapping(CanonicalKey::Digit6, modifiers)), - 55 => Some(plain_mapping(CanonicalKey::Digit7, modifiers)), - 56 => Some(plain_mapping(CanonicalKey::Digit8, modifiers)), - 57 => Some(plain_mapping(CanonicalKey::Digit9, modifiers)), - 32 => Some(plain_mapping(CanonicalKey::Space, modifiers)), - 13 | 10 => Some(plain_mapping(CanonicalKey::Enter, modifiers)), - 9 => Some(plain_mapping(CanonicalKey::Tab, modifiers)), - 27 => Some(plain_mapping(CanonicalKey::Escape, modifiers)), - 8 => Some(plain_mapping(CanonicalKey::Backspace, modifiers)), - 127 => Some(plain_mapping(CanonicalKey::Delete, modifiers)), - 45 => Some(plain_mapping(CanonicalKey::Minus, modifiers)), - 61 => Some(plain_mapping(CanonicalKey::Equal, modifiers)), - 91 => Some(plain_mapping(CanonicalKey::BracketLeft, modifiers)), - 93 => Some(plain_mapping(CanonicalKey::BracketRight, modifiers)), - 92 => Some(plain_mapping(CanonicalKey::Backslash, modifiers)), - 59 => Some(plain_mapping(CanonicalKey::Semicolon, modifiers)), - 39 => Some(plain_mapping(CanonicalKey::Quote, modifiers)), - 96 => Some(plain_mapping(CanonicalKey::Backquote, modifiers)), - 44 => Some(plain_mapping(CanonicalKey::Comma, modifiers)), - 46 => Some(plain_mapping(CanonicalKey::Period, modifiers)), - 47 => Some(plain_mapping(CanonicalKey::Slash, modifiers)), - _ => shifted_printable_char_to_hid(ch, modifiers), - } -} - -fn control_key_to_hid(key: i32) -> Option { - match key { - x if x == ControlKey::Alt as i32 => Some(0xE2), // Left Alt - x if x == ControlKey::Backspace as i32 => Some(0x2A), - x if x == ControlKey::CapsLock as i32 => Some(0x39), - x if x == ControlKey::Control as i32 => Some(0xE0), // Left Ctrl - x if x == ControlKey::Delete as i32 => Some(0x4C), - x if x == ControlKey::DownArrow as i32 => Some(0x51), - x if x == ControlKey::End as i32 => Some(0x4D), - x if x == ControlKey::Escape as i32 => Some(0x29), - x if x == ControlKey::F1 as i32 => Some(0x3A), - x if x == ControlKey::F2 as i32 => Some(0x3B), - x if x == ControlKey::F3 as i32 => Some(0x3C), - x if x == ControlKey::F4 as i32 => Some(0x3D), - x if x == ControlKey::F5 as i32 => Some(0x3E), - x if x == ControlKey::F6 as i32 => Some(0x3F), - x if x == ControlKey::F7 as i32 => Some(0x40), - x if x == ControlKey::F8 as i32 => Some(0x41), - x if x == ControlKey::F9 as i32 => Some(0x42), - x if x == ControlKey::F10 as i32 => Some(0x43), - x if x == ControlKey::F11 as i32 => Some(0x44), - x if x == ControlKey::F12 as i32 => Some(0x45), - x if x == ControlKey::Home as i32 => Some(0x4A), - x if x == ControlKey::LeftArrow as i32 => Some(0x50), - x if x == ControlKey::Meta as i32 => Some(0xE3), // Left GUI/Windows - x if x == ControlKey::PageDown as i32 => Some(0x4E), - x if x == ControlKey::PageUp as i32 => Some(0x4B), - x if x == ControlKey::Return as i32 => Some(0x28), - x if x == ControlKey::RightArrow as i32 => Some(0x4F), - x if x == ControlKey::Shift as i32 => Some(0xE1), // Left Shift - x if x == ControlKey::Space as i32 => Some(0x2C), - x if x == ControlKey::Tab as i32 => Some(0x2B), - x if x == ControlKey::UpArrow as i32 => Some(0x52), - x if x == ControlKey::Numpad0 as i32 => Some(0x62), - x if x == ControlKey::Numpad1 as i32 => Some(0x59), - x if x == ControlKey::Numpad2 as i32 => Some(0x5A), - x if x == ControlKey::Numpad3 as i32 => Some(0x5B), - x if x == ControlKey::Numpad4 as i32 => Some(0x5C), - x if x == ControlKey::Numpad5 as i32 => Some(0x5D), - x if x == ControlKey::Numpad6 as i32 => Some(0x5E), - x if x == ControlKey::Numpad7 as i32 => Some(0x5F), - x if x == ControlKey::Numpad8 as i32 => Some(0x60), - x if x == ControlKey::Numpad9 as i32 => Some(0x61), - x if x == ControlKey::Insert as i32 => Some(0x49), - x if x == ControlKey::Pause as i32 => Some(0x48), - x if x == ControlKey::Scroll as i32 => Some(0x47), - x if x == ControlKey::NumLock as i32 => Some(0x53), - x if x == ControlKey::RShift as i32 => Some(0xE5), - x if x == ControlKey::RControl as i32 => Some(0xE4), - x if x == ControlKey::RAlt as i32 => Some(0xE6), - x if x == ControlKey::Multiply as i32 => Some(0x55), - x if x == ControlKey::Add as i32 => Some(0x57), - x if x == ControlKey::Subtract as i32 => Some(0x56), - x if x == ControlKey::Decimal as i32 => Some(0x63), - x if x == ControlKey::Divide as i32 => Some(0x54), - x if x == ControlKey::NumpadEnter as i32 => Some(0x58), - _ => None, - } -} - -fn keycode_to_hid(keycode: u32) -> Option { - if let Some(hid) = ascii_to_hid(keycode) { - return Some(hid); - } - if let Some(hid) = windows_vk_to_hid(keycode) { - return Some(hid); - } - x11_keycode_to_hid(keycode) -} - -fn ascii_to_hid(ascii: u32) -> Option { - match ascii { - 97..=122 => Some((ascii - 97 + 0x04) as u8), - 65..=90 => Some((ascii - 65 + 0x04) as u8), - 48 => Some(0x27), // 0 - 49..=57 => Some((ascii - 49 + 0x1E) as u8), // 1-9 - 32 => Some(0x2C), // Space - 13 => Some(0x28), // Enter (CR) - 10 => Some(0x28), // Enter (LF) - 9 => Some(0x2B), // Tab - 27 => Some(0x29), // Escape - 8 => Some(0x2A), // Backspace - 127 => Some(0x4C), // Delete - 45 => Some(0x2D), // - - 61 => Some(0x2E), // = - 91 => Some(0x2F), // [ - 93 => Some(0x30), // ] - 92 => Some(0x31), // \ - 59 => Some(0x33), // ; - 39 => Some(0x34), // ' - 96 => Some(0x35), // ` - 44 => Some(0x36), // , - 46 => Some(0x37), // . - 47 => Some(0x38), // / - _ => None, - } -} - -fn windows_vk_to_hid(vk: u32) -> Option { - match vk { - 0x41..=0x5A => { - let letter = (vk - 0x41) as u8; - Some(match letter { - 0 => 0x04, // A - 1 => 0x05, // B - 2 => 0x06, // C - 3 => 0x07, // D - 4 => 0x08, // E - 5 => 0x09, // F - 6 => 0x0A, // G - 7 => 0x0B, // H - 8 => 0x0C, // I - 9 => 0x0D, // J - 10 => 0x0E, // K - 11 => 0x0F, // L - 12 => 0x10, // M - 13 => 0x11, // N - 14 => 0x12, // O - 15 => 0x13, // P - 16 => 0x14, // Q - 17 => 0x15, // R - 18 => 0x16, // S - 19 => 0x17, // T - 20 => 0x18, // U - 21 => 0x19, // V - 22 => 0x1A, // W - 23 => 0x1B, // X - 24 => 0x1C, // Y - 25 => 0x1D, // Z - _ => return None, - }) +fn log_rejected_key_event(event: &KeyEvent, code_space: KeyboardCodeSpace) { + let mode = event.mode.value(); + match &event.union { + Some(ke_union::Union::ControlKey(key)) => debug!( + mode, + union = "ControlKey", + raw = format_args!("0x{:X}", key.value()), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + Some(ke_union::Union::Chr(code)) => debug!( + mode, + union = "Chr", + raw = format_args!("0x{code:X}"), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + Some(ke_union::Union::Unicode(code)) => debug!( + mode, + union = "Unicode", + raw = format_args!("0x{code:X}"), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + Some(ke_union::Union::Seq(seq)) => { + debug!(mode, union = "Seq", raw = ?seq, ?code_space, "Dropping unsupported RustDesk keyboard event") } - 0x30 => Some(0x27), // 0 - 0x31..=0x39 => Some((vk - 0x31 + 0x1E) as u8), // 1-9 - 0x60 => Some(0x62), // Numpad 0 - 0x61..=0x69 => Some((vk - 0x61 + 0x59) as u8), // Numpad 1-9 - 0x6A => Some(0x55), // Numpad * - 0x6B => Some(0x57), // Numpad + - 0x6D => Some(0x56), // Numpad - - 0x6E => Some(0x63), // Numpad . - 0x6F => Some(0x54), // Numpad / - 0x70..=0x7B => Some((vk - 0x70 + 0x3A) as u8), - 0x08 => Some(0x2A), // Backspace - 0x09 => Some(0x2B), // Tab - 0x0D => Some(0x28), // Enter - 0x1B => Some(0x29), // Escape - 0x20 => Some(0x2C), // Space - 0x21 => Some(0x4B), // Page Up - 0x22 => Some(0x4E), // Page Down - 0x23 => Some(0x4D), // End - 0x24 => Some(0x4A), // Home - 0x25 => Some(0x50), // Left Arrow - 0x26 => Some(0x52), // Up Arrow - 0x27 => Some(0x4F), // Right Arrow - 0x28 => Some(0x51), // Down Arrow - 0x2D => Some(0x49), // Insert - 0x2E => Some(0x4C), // Delete - 0xBA => Some(0x33), // ; : - 0xBB => Some(0x2E), // = + - 0xBC => Some(0x36), // , < - 0xBD => Some(0x2D), // - _ - 0xBE => Some(0x37), // . > - 0xBF => Some(0x38), // / ? - 0xC0 => Some(0x35), // ` ~ - 0xDB => Some(0x2F), // [ { - 0xDC => Some(0x31), // \ | - 0xDD => Some(0x30), // ] } - 0xDE => Some(0x34), // ' " - 0x14 => Some(0x39), // Caps Lock - 0x90 => Some(0x53), // Num Lock - 0x91 => Some(0x47), // Scroll Lock - 0x2C => Some(0x46), // Print Screen - 0x13 => Some(0x48), // Pause - _ => None, - } -} - -fn x11_keycode_to_hid(keycode: u32) -> Option { - match keycode { - 10..=18 => Some((keycode - 10 + 0x1E) as u8), // 1-9 - 19 => Some(0x27), // 0 - 20 => Some(0x2D), // - - 21 => Some(0x2E), // = - 34 => Some(0x2F), // [ - 35 => Some(0x30), // ] - 24 => Some(0x14), // q - 25 => Some(0x1A), // w - 26 => Some(0x08), // e - 27 => Some(0x15), // r - 28 => Some(0x17), // t - 29 => Some(0x1C), // y - 30 => Some(0x18), // u - 31 => Some(0x0C), // i - 32 => Some(0x12), // o - 33 => Some(0x13), // p - 38 => Some(0x04), // a - 39 => Some(0x16), // s - 40 => Some(0x07), // d - 41 => Some(0x09), // f - 42 => Some(0x0A), // g - 43 => Some(0x0B), // h - 44 => Some(0x0D), // j - 45 => Some(0x0E), // k - 46 => Some(0x0F), // l - 47 => Some(0x33), // ; - 48 => Some(0x34), // ' - 49 => Some(0x35), // ` - 51 => Some(0x31), // \ - 52 => Some(0x1D), // z - 53 => Some(0x1B), // x - 54 => Some(0x06), // c - 55 => Some(0x19), // v - 56 => Some(0x05), // b - 57 => Some(0x11), // n - 58 => Some(0x10), // m - 59 => Some(0x36), // , - 60 => Some(0x37), // . - 61 => Some(0x38), // / - 65 => Some(0x2C), - _ => None, + Some(ke_union::Union::Win2winHotkey(code)) => debug!( + mode, + union = "Win2winHotkey", + raw = format_args!("0x{code:X}"), + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), + None => debug!( + mode, + union = "None", + ?code_space, + "Dropping unsupported RustDesk keyboard event" + ), } } #[cfg(test)] mod tests { use super::*; + use protobuf::EnumOrUnknown; - #[test] - fn test_control_key_mapping() { - assert_eq!(control_key_to_hid(ControlKey::Escape.value()), Some(0x29)); - assert_eq!(control_key_to_hid(ControlKey::Return.value()), Some(0x28)); - assert_eq!(control_key_to_hid(ControlKey::Space.value()), Some(0x2C)); + fn key_event(mode: KeyboardMode, union: ke_union::Union, down: bool) -> KeyEvent { + let mut event = KeyEvent::new(); + event.mode = EnumOrUnknown::new(mode); + event.union = Some(union); + event.down = down; + event + } + + fn map_chr(code: u32, down: bool) -> KeyEvent { + key_event(KeyboardMode::Map, ke_union::Union::Chr(code), down) } #[test] - fn test_convert_mouse_move() { - let mut event = MouseEvent::new(); - event.x = 500; - event.y = 300; - event.mask = mouse_type::MOVE; // Pure move event - - let events = convert_mouse_event(&event, 1920, 1080); - assert!(!events.is_empty()); - assert_eq!(events[0].event_type, MouseEventType::MoveAbs); - } - - #[test] - fn test_convert_mouse_button_down() { - let mut event = MouseEvent::new(); - event.x = 500; - event.y = 300; - event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN; - - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, MouseEventType::Down); - assert_eq!(events[0].button, Some(MouseButton::Left)); - } - - #[test] - fn test_convert_mouse_button_down_does_not_move() { - let mut event = MouseEvent::new(); - event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN; - - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, MouseEventType::Down); - assert_eq!(events[0].button, Some(MouseButton::Left)); - } - - #[test] - fn test_convert_mouse_wheel_does_not_move() { - let mut event = MouseEvent::new(); - event.x = 500; - event.y = 1; - event.mask = mouse_type::WHEEL; - - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, MouseEventType::Scroll); - assert_eq!(events[0].scroll, 1); - } - - #[test] - fn test_convert_mouse_move_relative() { + fn mouse_events_keep_existing_semantics() { let mut event = MouseEvent::new(); event.x = -12; event.y = 8; event.mask = mouse_type::MOVE_RELATIVE; - let events = convert_mouse_event(&event, 1920, 1080); - assert_eq!(events.len(), 1); assert_eq!(events[0].event_type, MouseEventType::Move); - assert_eq!(events[0].x, -12); - assert_eq!(events[0].y, 8); + assert_eq!((events[0].x, events[0].y), (-12, 8)); + + event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN; + let events = convert_mouse_event(&event, 1920, 1080); + assert_eq!(events[0].event_type, MouseEventType::Down); + assert_eq!(events[0].button, Some(MouseButton::Left)); + assert_eq!((events[0].x, events[0].y), (0, 0)); } #[test] - fn test_convert_key_event() { - use protobuf::EnumOrUnknown; - let mut key_event = KeyEvent::new(); - key_event.down = true; - key_event.press = false; - key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new( - ControlKey::Return, - ))); - - let result = convert_key_event(&key_event); - assert!(result.is_some()); - - let kb_event = result.unwrap(); - assert_eq!(kb_event.event_type, KeyEventType::Down); - assert_eq!(kb_event.key, CanonicalKey::Enter); + fn map_delete_generates_only_delete_down_and_up() { + let down = convert_key_events(&map_chr(0xE053, true)); + let up = convert_key_events(&map_chr(0xE053, false)); + assert_eq!((down.len(), up.len()), (1, 1)); + assert_eq!( + (down[0].event_type, up[0].event_type), + (KeyEventType::Down, KeyEventType::Up) + ); + assert_eq!( + (down[0].key, up[0].key), + (CanonicalKey::Delete, CanonicalKey::Delete) + ); + assert_eq!( + (down[0].key.to_hid_usage(), up[0].key.to_hid_usage()), + (0x4C, 0x4C) + ); } #[test] - fn test_convert_at_press_to_shift_digit2() { - let mut key_event = KeyEvent::new(); - key_event.press = true; - key_event.union = Some(ke_union::Union::Unicode('@' as u32)); + fn map_scan_codes_do_not_become_ascii_digits() { + let alt = convert_key_events(&map_chr(0x38, true)); + let shift = convert_key_events(&map_chr(0x36, true)); + assert_eq!(alt[0].key, CanonicalKey::AltLeft); + assert_ne!(alt[0].key, CanonicalKey::Digit8); + assert_eq!(shift[0].key, CanonicalKey::ShiftRight); + assert_ne!(shift[0].key, CanonicalKey::Digit6); + } - let events = convert_key_events(&key_event); + #[test] + fn legacy_uses_character_semantics_for_same_values() { + let digit8 = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x38), true); + let digit6 = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x36), true); + assert_eq!(convert_key_events(&digit8)[0].key, CanonicalKey::Digit8); + assert_eq!(convert_key_events(&digit6)[0].key, CanonicalKey::Digit6); + + let uppercase = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x41), true); + let uppercase = convert_key_events(&uppercase); + assert_eq!(uppercase[0].key, CanonicalKey::KeyA); + assert!(!uppercase[0].modifiers.left_shift); + } + + #[test] + fn translate_chr_uses_physical_fallback_semantics() { + let event = key_event(KeyboardMode::Translate, ke_union::Union::Chr(0xE053), true); + assert_eq!(convert_key_events(&event)[0].key, CanonicalKey::Delete); + } + + #[test] + fn unknown_map_codes_never_fall_back() { + for code in [0, 0x59, 0x61, 0x7F, 0xE054, 0x0101] { + assert!( + convert_key_events(&map_chr(code, true)).is_empty(), + "0x{code:X}" + ); + } + + let ascii_a_value = convert_key_events(&map_chr(0x41, true)); + assert_eq!(ascii_a_value[0].key, CanonicalKey::F7); + assert_ne!(ascii_a_value[0].key, CanonicalKey::KeyA); + } + + #[test] + fn unicode_and_control_keys_stay_independent() { + let unicode = key_event( + KeyboardMode::Map, + ke_union::Union::Unicode('@' as u32), + true, + ); + let control = key_event( + KeyboardMode::Translate, + ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::Delete)), + true, + ); + let unicode = convert_key_events(&unicode); + assert_eq!(unicode[0].key, CanonicalKey::Digit2); + assert!(unicode[0].modifiers.left_shift); + assert_eq!(convert_key_events(&control)[0].key, CanonicalKey::Delete); + } + + #[test] + fn press_and_repeated_events_preserve_state_model() { + let mut press = map_chr(0x1E, false); + press.press = true; + press + .modifiers + .push(EnumOrUnknown::new(ControlKey::Control)); + let events = convert_key_events(&press); assert_eq!(events.len(), 2); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[0].key, CanonicalKey::Digit2); + assert_eq!( + (events[0].event_type, events[1].event_type), + (KeyEventType::Down, KeyEventType::Up) + ); + assert_eq!( + (events[0].key, events[1].key), + (CanonicalKey::KeyA, CanonicalKey::KeyA) + ); + assert!(events.iter().all(|event| event.modifiers.left_ctrl)); + + let down = map_chr(0x1E, true); + assert_eq!(convert_key_events(&down)[0].event_type, KeyEventType::Down); + assert_eq!(convert_key_events(&down)[0].event_type, KeyEventType::Down); + } + + #[test] + fn shifted_press_releases_only_synthetic_shift() { + let mut event = key_event( + KeyboardMode::Legacy, + ke_union::Union::Chr('@' as u32), + false, + ); + event.press = true; + let events = convert_key_events(&event); assert!(events[0].modifiers.left_shift); - assert_eq!(events[1].event_type, KeyEventType::Up); - assert_eq!(events[1].key, CanonicalKey::Digit2); assert!(!events[1].modifiers.left_shift); } #[test] - fn test_convert_shifted_chr_to_shift_digit2() { - let mut key_event = KeyEvent::new(); - key_event.down = true; - key_event.union = Some(ke_union::Union::Chr('@' as u32)); - - let events = convert_key_events(&key_event); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[0].key, CanonicalKey::Digit2); - assert!(events[0].modifiers.left_shift); + fn modifier_control_key_does_not_duplicate_state() { + let mut event = key_event( + KeyboardMode::Legacy, + ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::RWin)), + true, + ); + event.modifiers.push(EnumOrUnknown::new(ControlKey::RWin)); + let events = convert_key_events(&event); + assert_eq!(events[0].key, CanonicalKey::MetaRight); + assert_eq!(events[0].modifiers, KeyboardModifiers::default()); } #[test] - fn test_convert_map_mode_chr_as_physical_key() { - use protobuf::EnumOrUnknown; - let mut key_event = KeyEvent::new(); - key_event.down = true; - key_event.mode = EnumOrUnknown::new(KeyboardMode::Map); - key_event.union = Some(ke_union::Union::Chr(0x41)); - - let events = convert_key_events(&key_event); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[0].key, CanonicalKey::KeyA); - assert!(!events[0].modifiers.left_shift); + fn legacy_delete_and_audited_controls_are_mapped() { + for (control, expected) in [ + (ControlKey::Delete, CanonicalKey::Delete), + (ControlKey::Snapshot, CanonicalKey::PrintScreen), + (ControlKey::RWin, CanonicalKey::MetaRight), + (ControlKey::Apps, CanonicalKey::ContextMenu), + ] { + let event = key_event( + KeyboardMode::Legacy, + ke_union::Union::ControlKey(EnumOrUnknown::new(control)), + true, + ); + assert_eq!(convert_key_events(&event)[0].key, expected); + } } #[test] - fn test_convert_press_generates_down_and_up() { - use protobuf::EnumOrUnknown; - let mut key_event = KeyEvent::new(); - key_event.press = true; - key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new( - ControlKey::Return, - ))); + fn rejects_unknown_modes_and_unsupported_unions() { + let mut unknown = map_chr(0x1E, true); + unknown.mode = EnumOrUnknown::from_i32(99); + assert!(convert_key_events(&unknown).is_empty()); - let events = convert_key_events(&key_event); - assert_eq!(events.len(), 2); - assert_eq!(events[0].event_type, KeyEventType::Down); - assert_eq!(events[1].event_type, KeyEventType::Up); - assert_eq!(events[0].key, CanonicalKey::Enter); - assert_eq!(events[1].key, CanonicalKey::Enter); + let seq = key_event( + KeyboardMode::Translate, + ke_union::Union::Seq("a".to_string()), + true, + ); + assert!(convert_key_events(&seq).is_empty()); + + let mut empty = KeyEvent::new(); + empty.mode = EnumOrUnknown::new(KeyboardMode::Legacy); + assert!(convert_key_events(&empty).is_empty()); } } diff --git a/src/rustdesk/keyboard_mapping.rs b/src/rustdesk/keyboard_mapping.rs new file mode 100644 index 00000000..301e5e85 --- /dev/null +++ b/src/rustdesk/keyboard_mapping.rs @@ -0,0 +1,424 @@ +//! RustDesk keyboard code-space mappings. +//! +//! Keep physical position codes separate from character and control-key values. In +//! particular, a Windows Set-1 scan code must never fall through to ASCII, VK, or +//! X11 interpretation. + +use super::protocol::ControlKey; +use crate::hid::CanonicalKey; + +/// Physical keyboard code space selected by the platform advertised to RustDesk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum KeyboardCodeSpace { + WindowsSet1, +} + +pub(super) fn physical_key(code_space: KeyboardCodeSpace, code: u32) -> Option { + match code_space { + KeyboardCodeSpace::WindowsSet1 => windows_set1_key(code), + } +} + +/// Convert a Windows Set-1 make code as encoded by RustDesk. +/// +/// Ordinary codes occupy the low byte. Extended codes are encoded as `0xE0xx`. +/// Other multi-byte values (including the special Pause sequence) are rejected. +pub(super) fn windows_set1_key(scan_code: u32) -> Option { + use CanonicalKey as Key; + + match scan_code { + 0x01 => Some(Key::Escape), + 0x02 => Some(Key::Digit1), + 0x03 => Some(Key::Digit2), + 0x04 => Some(Key::Digit3), + 0x05 => Some(Key::Digit4), + 0x06 => Some(Key::Digit5), + 0x07 => Some(Key::Digit6), + 0x08 => Some(Key::Digit7), + 0x09 => Some(Key::Digit8), + 0x0A => Some(Key::Digit9), + 0x0B => Some(Key::Digit0), + 0x0C => Some(Key::Minus), + 0x0D => Some(Key::Equal), + 0x0E => Some(Key::Backspace), + 0x0F => Some(Key::Tab), + 0x10 => Some(Key::KeyQ), + 0x11 => Some(Key::KeyW), + 0x12 => Some(Key::KeyE), + 0x13 => Some(Key::KeyR), + 0x14 => Some(Key::KeyT), + 0x15 => Some(Key::KeyY), + 0x16 => Some(Key::KeyU), + 0x17 => Some(Key::KeyI), + 0x18 => Some(Key::KeyO), + 0x19 => Some(Key::KeyP), + 0x1A => Some(Key::BracketLeft), + 0x1B => Some(Key::BracketRight), + 0x1C => Some(Key::Enter), + 0x1D => Some(Key::ControlLeft), + 0x1E => Some(Key::KeyA), + 0x1F => Some(Key::KeyS), + 0x20 => Some(Key::KeyD), + 0x21 => Some(Key::KeyF), + 0x22 => Some(Key::KeyG), + 0x23 => Some(Key::KeyH), + 0x24 => Some(Key::KeyJ), + 0x25 => Some(Key::KeyK), + 0x26 => Some(Key::KeyL), + 0x27 => Some(Key::Semicolon), + 0x28 => Some(Key::Quote), + 0x29 => Some(Key::Backquote), + 0x2A => Some(Key::ShiftLeft), + 0x2B => Some(Key::Backslash), + 0x2C => Some(Key::KeyZ), + 0x2D => Some(Key::KeyX), + 0x2E => Some(Key::KeyC), + 0x2F => Some(Key::KeyV), + 0x30 => Some(Key::KeyB), + 0x31 => Some(Key::KeyN), + 0x32 => Some(Key::KeyM), + 0x33 => Some(Key::Comma), + 0x34 => Some(Key::Period), + 0x35 => Some(Key::Slash), + 0x36 => Some(Key::ShiftRight), + 0x37 => Some(Key::NumpadMultiply), + 0x38 => Some(Key::AltLeft), + 0x39 => Some(Key::Space), + 0x3A => Some(Key::CapsLock), + 0x3B => Some(Key::F1), + 0x3C => Some(Key::F2), + 0x3D => Some(Key::F3), + 0x3E => Some(Key::F4), + 0x3F => Some(Key::F5), + 0x40 => Some(Key::F6), + 0x41 => Some(Key::F7), + 0x42 => Some(Key::F8), + 0x43 => Some(Key::F9), + 0x44 => Some(Key::F10), + 0x45 => Some(Key::NumLock), + 0x46 => Some(Key::ScrollLock), + 0x47 => Some(Key::Numpad7), + 0x48 => Some(Key::Numpad8), + 0x49 => Some(Key::Numpad9), + 0x4A => Some(Key::NumpadSubtract), + 0x4B => Some(Key::Numpad4), + 0x4C => Some(Key::Numpad5), + 0x4D => Some(Key::Numpad6), + 0x4E => Some(Key::NumpadAdd), + 0x4F => Some(Key::Numpad1), + 0x50 => Some(Key::Numpad2), + 0x51 => Some(Key::Numpad3), + 0x52 => Some(Key::Numpad0), + 0x53 => Some(Key::NumpadDecimal), + 0x56 => Some(Key::IntlBackslash), + 0x57 => Some(Key::F11), + 0x58 => Some(Key::F12), + + 0xE01C => Some(Key::NumpadEnter), + 0xE01D => Some(Key::ControlRight), + 0xE035 => Some(Key::NumpadDivide), + 0xE037 => Some(Key::PrintScreen), + 0xE038 => Some(Key::AltRight), + 0xE047 => Some(Key::Home), + 0xE048 => Some(Key::ArrowUp), + 0xE049 => Some(Key::PageUp), + 0xE04B => Some(Key::ArrowLeft), + 0xE04D => Some(Key::ArrowRight), + 0xE04F => Some(Key::End), + 0xE050 => Some(Key::ArrowDown), + 0xE051 => Some(Key::PageDown), + 0xE052 => Some(Key::Insert), + 0xE053 => Some(Key::Delete), + 0xE05B => Some(Key::MetaLeft), + 0xE05C => Some(Key::MetaRight), + 0xE05D => Some(Key::ContextMenu), + _ => None, + } +} + +pub(super) fn control_key(key: i32) -> Option { + use CanonicalKey as Key; + + match key { + x if x == ControlKey::Alt as i32 => Some(Key::AltLeft), + x if x == ControlKey::Backspace as i32 => Some(Key::Backspace), + x if x == ControlKey::CapsLock as i32 => Some(Key::CapsLock), + x if x == ControlKey::Control as i32 => Some(Key::ControlLeft), + x if x == ControlKey::Delete as i32 => Some(Key::Delete), + x if x == ControlKey::DownArrow as i32 => Some(Key::ArrowDown), + x if x == ControlKey::End as i32 => Some(Key::End), + x if x == ControlKey::Escape as i32 => Some(Key::Escape), + x if x == ControlKey::F1 as i32 => Some(Key::F1), + x if x == ControlKey::F2 as i32 => Some(Key::F2), + x if x == ControlKey::F3 as i32 => Some(Key::F3), + x if x == ControlKey::F4 as i32 => Some(Key::F4), + x if x == ControlKey::F5 as i32 => Some(Key::F5), + x if x == ControlKey::F6 as i32 => Some(Key::F6), + x if x == ControlKey::F7 as i32 => Some(Key::F7), + x if x == ControlKey::F8 as i32 => Some(Key::F8), + x if x == ControlKey::F9 as i32 => Some(Key::F9), + x if x == ControlKey::F10 as i32 => Some(Key::F10), + x if x == ControlKey::F11 as i32 => Some(Key::F11), + x if x == ControlKey::F12 as i32 => Some(Key::F12), + x if x == ControlKey::Home as i32 => Some(Key::Home), + x if x == ControlKey::LeftArrow as i32 => Some(Key::ArrowLeft), + x if x == ControlKey::Meta as i32 => Some(Key::MetaLeft), + x if x == ControlKey::PageDown as i32 => Some(Key::PageDown), + x if x == ControlKey::PageUp as i32 => Some(Key::PageUp), + x if x == ControlKey::Return as i32 => Some(Key::Enter), + x if x == ControlKey::RightArrow as i32 => Some(Key::ArrowRight), + x if x == ControlKey::Shift as i32 => Some(Key::ShiftLeft), + x if x == ControlKey::Space as i32 => Some(Key::Space), + x if x == ControlKey::Tab as i32 => Some(Key::Tab), + x if x == ControlKey::UpArrow as i32 => Some(Key::ArrowUp), + x if x == ControlKey::Numpad0 as i32 => Some(Key::Numpad0), + x if x == ControlKey::Numpad1 as i32 => Some(Key::Numpad1), + x if x == ControlKey::Numpad2 as i32 => Some(Key::Numpad2), + x if x == ControlKey::Numpad3 as i32 => Some(Key::Numpad3), + x if x == ControlKey::Numpad4 as i32 => Some(Key::Numpad4), + x if x == ControlKey::Numpad5 as i32 => Some(Key::Numpad5), + x if x == ControlKey::Numpad6 as i32 => Some(Key::Numpad6), + x if x == ControlKey::Numpad7 as i32 => Some(Key::Numpad7), + x if x == ControlKey::Numpad8 as i32 => Some(Key::Numpad8), + x if x == ControlKey::Numpad9 as i32 => Some(Key::Numpad9), + x if x == ControlKey::Pause as i32 => Some(Key::Pause), + x if x == ControlKey::Snapshot as i32 => Some(Key::PrintScreen), + x if x == ControlKey::Insert as i32 => Some(Key::Insert), + x if x == ControlKey::Scroll as i32 => Some(Key::ScrollLock), + x if x == ControlKey::NumLock as i32 => Some(Key::NumLock), + x if x == ControlKey::RWin as i32 => Some(Key::MetaRight), + x if x == ControlKey::Apps as i32 => Some(Key::ContextMenu), + x if x == ControlKey::Multiply as i32 => Some(Key::NumpadMultiply), + x if x == ControlKey::Add as i32 => Some(Key::NumpadAdd), + x if x == ControlKey::Subtract as i32 => Some(Key::NumpadSubtract), + x if x == ControlKey::Decimal as i32 => Some(Key::NumpadDecimal), + x if x == ControlKey::Divide as i32 => Some(Key::NumpadDivide), + x if x == ControlKey::NumpadEnter as i32 => Some(Key::NumpadEnter), + x if x == ControlKey::RShift as i32 => Some(Key::ShiftRight), + x if x == ControlKey::RControl as i32 => Some(Key::ControlRight), + x if x == ControlKey::RAlt as i32 => Some(Key::AltRight), + _ => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct CharacterMapping { + pub key: CanonicalKey, + pub needs_shift: bool, +} + +pub(super) fn character(ch: u32) -> Option { + use CanonicalKey as Key; + + let plain = |key| CharacterMapping { + key, + needs_shift: false, + }; + let shifted = |key| CharacterMapping { + key, + needs_shift: true, + }; + + Some(match ch { + 0x61..=0x7A => plain(CanonicalKey::from_hid_usage((ch - 0x61 + 0x04) as u8)?), + 0x41..=0x5A => shifted(CanonicalKey::from_hid_usage((ch - 0x41 + 0x04) as u8)?), + 0x30 => plain(Key::Digit0), + 0x31 => plain(Key::Digit1), + 0x32 => plain(Key::Digit2), + 0x33 => plain(Key::Digit3), + 0x34 => plain(Key::Digit4), + 0x35 => plain(Key::Digit5), + 0x36 => plain(Key::Digit6), + 0x37 => plain(Key::Digit7), + 0x38 => plain(Key::Digit8), + 0x39 => plain(Key::Digit9), + 0x20 => plain(Key::Space), + 0x0D | 0x0A => plain(Key::Enter), + 0x09 => plain(Key::Tab), + 0x1B => plain(Key::Escape), + 0x08 => plain(Key::Backspace), + 0x7F => plain(Key::Delete), + 0x2D => plain(Key::Minus), + 0x3D => plain(Key::Equal), + 0x5B => plain(Key::BracketLeft), + 0x5D => plain(Key::BracketRight), + 0x5C => plain(Key::Backslash), + 0x3B => plain(Key::Semicolon), + 0x27 => plain(Key::Quote), + 0x60 => plain(Key::Backquote), + 0x2C => plain(Key::Comma), + 0x2E => plain(Key::Period), + 0x2F => plain(Key::Slash), + 0x21 => shifted(Key::Digit1), + 0x40 => shifted(Key::Digit2), + 0x23 => shifted(Key::Digit3), + 0x24 => shifted(Key::Digit4), + 0x25 => shifted(Key::Digit5), + 0x5E => shifted(Key::Digit6), + 0x26 => shifted(Key::Digit7), + 0x2A => shifted(Key::Digit8), + 0x28 => shifted(Key::Digit9), + 0x29 => shifted(Key::Digit0), + 0x5F => shifted(Key::Minus), + 0x2B => shifted(Key::Equal), + 0x7B => shifted(Key::BracketLeft), + 0x7D => shifted(Key::BracketRight), + 0x7C => shifted(Key::Backslash), + 0x3A => shifted(Key::Semicolon), + 0x22 => shifted(Key::Quote), + 0x7E => shifted(Key::Backquote), + 0x3C => shifted(Key::Comma), + 0x3E => shifted(Key::Period), + 0x3F => shifted(Key::Slash), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_set1_matrix_is_complete() { + use CanonicalKey as Key; + + let cases = [ + (0x01, Key::Escape), + (0x02, Key::Digit1), + (0x03, Key::Digit2), + (0x04, Key::Digit3), + (0x05, Key::Digit4), + (0x06, Key::Digit5), + (0x07, Key::Digit6), + (0x08, Key::Digit7), + (0x09, Key::Digit8), + (0x0A, Key::Digit9), + (0x0B, Key::Digit0), + (0x0C, Key::Minus), + (0x0D, Key::Equal), + (0x0E, Key::Backspace), + (0x0F, Key::Tab), + (0x10, Key::KeyQ), + (0x11, Key::KeyW), + (0x12, Key::KeyE), + (0x13, Key::KeyR), + (0x14, Key::KeyT), + (0x15, Key::KeyY), + (0x16, Key::KeyU), + (0x17, Key::KeyI), + (0x18, Key::KeyO), + (0x19, Key::KeyP), + (0x1A, Key::BracketLeft), + (0x1B, Key::BracketRight), + (0x1C, Key::Enter), + (0x1D, Key::ControlLeft), + (0x1E, Key::KeyA), + (0x1F, Key::KeyS), + (0x20, Key::KeyD), + (0x21, Key::KeyF), + (0x22, Key::KeyG), + (0x23, Key::KeyH), + (0x24, Key::KeyJ), + (0x25, Key::KeyK), + (0x26, Key::KeyL), + (0x27, Key::Semicolon), + (0x28, Key::Quote), + (0x29, Key::Backquote), + (0x2A, Key::ShiftLeft), + (0x2B, Key::Backslash), + (0x2C, Key::KeyZ), + (0x2D, Key::KeyX), + (0x2E, Key::KeyC), + (0x2F, Key::KeyV), + (0x30, Key::KeyB), + (0x31, Key::KeyN), + (0x32, Key::KeyM), + (0x33, Key::Comma), + (0x34, Key::Period), + (0x35, Key::Slash), + (0x36, Key::ShiftRight), + (0x37, Key::NumpadMultiply), + (0x38, Key::AltLeft), + (0x39, Key::Space), + (0x3A, Key::CapsLock), + (0x3B, Key::F1), + (0x3C, Key::F2), + (0x3D, Key::F3), + (0x3E, Key::F4), + (0x3F, Key::F5), + (0x40, Key::F6), + (0x41, Key::F7), + (0x42, Key::F8), + (0x43, Key::F9), + (0x44, Key::F10), + (0x45, Key::NumLock), + (0x46, Key::ScrollLock), + (0x47, Key::Numpad7), + (0x48, Key::Numpad8), + (0x49, Key::Numpad9), + (0x4A, Key::NumpadSubtract), + (0x4B, Key::Numpad4), + (0x4C, Key::Numpad5), + (0x4D, Key::Numpad6), + (0x4E, Key::NumpadAdd), + (0x4F, Key::Numpad1), + (0x50, Key::Numpad2), + (0x51, Key::Numpad3), + (0x52, Key::Numpad0), + (0x53, Key::NumpadDecimal), + (0x56, Key::IntlBackslash), + (0x57, Key::F11), + (0x58, Key::F12), + (0xE01C, Key::NumpadEnter), + (0xE01D, Key::ControlRight), + (0xE035, Key::NumpadDivide), + (0xE037, Key::PrintScreen), + (0xE038, Key::AltRight), + (0xE047, Key::Home), + (0xE048, Key::ArrowUp), + (0xE049, Key::PageUp), + (0xE04B, Key::ArrowLeft), + (0xE04D, Key::ArrowRight), + (0xE04F, Key::End), + (0xE050, Key::ArrowDown), + (0xE051, Key::PageDown), + (0xE052, Key::Insert), + (0xE053, Key::Delete), + (0xE05B, Key::MetaLeft), + (0xE05C, Key::MetaRight), + (0xE05D, Key::ContextMenu), + ]; + + for (scan_code, expected) in cases { + assert_eq!( + windows_set1_key(scan_code), + Some(expected), + "0x{scan_code:04X}" + ); + } + } + + #[test] + fn windows_set1_rejects_unknown_and_invalid_multibyte_codes() { + for scan_code in [0, 0x54, 0x59, 0xD3, 0xE000, 0xE054, 0xE11D45, 0x0101] { + assert_eq!(windows_set1_key(scan_code), None, "0x{scan_code:X}"); + } + } + + #[test] + fn audited_control_keys_map_without_hid_round_trip() { + assert_eq!( + control_key(ControlKey::RWin as i32), + Some(CanonicalKey::MetaRight) + ); + assert_eq!( + control_key(ControlKey::Apps as i32), + Some(CanonicalKey::ContextMenu) + ); + assert_eq!( + control_key(ControlKey::Snapshot as i32), + Some(CanonicalKey::PrintScreen) + ); + assert_eq!(control_key(ControlKey::Power as i32), None); + } +} diff --git a/src/rustdesk/mod.rs b/src/rustdesk/mod.rs index b8be700b..035df233 100644 --- a/src/rustdesk/mod.rs +++ b/src/rustdesk/mod.rs @@ -6,6 +6,7 @@ pub mod connection; pub mod crypto; pub mod frame_adapters; pub mod hid_adapter; +mod keyboard_mapping; pub mod protocol; pub mod punch; pub mod rendezvous; @@ -17,7 +18,7 @@ use std::time::Duration; use parking_lot::RwLock; use protobuf::Message; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, Semaphore}; use tokio::task::JoinHandle; use tracing::{debug, error, info, warn}; @@ -26,13 +27,14 @@ use crate::hid::HidController; use crate::utils::bind_tcp_listener; use crate::video::stream_manager::VideoStreamManager; -use self::config::RustDeskConfig; +use self::config::{RustDeskConfig, RustDeskMode}; use self::connection::ConnectionManager; use self::protocol::{make_local_addr, make_relay_response, make_request_relay}; use self::rendezvous::{AddrMangle, RendezvousMediator, RendezvousStatus}; const RELAY_CONNECT_TIMEOUT_MS: u64 = 10_000; const SERVICE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_PENDING_CONNECTION_ATTEMPTS: usize = 8; #[derive(Debug, Clone, PartialEq)] pub enum ServiceStatus { @@ -53,14 +55,13 @@ impl std::fmt::Display for ServiceStatus { } } -const DIRECT_LISTEN_PORT: u16 = 21118; - pub struct RustDeskService { config: Arc>, status: Arc>, rendezvous: Arc>>>, rendezvous_handle: Arc>>>, tcp_listener_handle: Arc>>>>, + listener_start_lock: Arc>, listen_port: Arc>, connection_manager: Arc, video_manager: Arc, @@ -78,6 +79,7 @@ impl RustDeskService { ) -> Self { let (shutdown_tx, _) = broadcast::channel(1); let connection_manager = Arc::new(ConnectionManager::new(config.clone())); + let direct_access_port = config.direct_access_port; Self { config: Arc::new(RwLock::new(config)), @@ -85,7 +87,8 @@ impl RustDeskService { rendezvous: Arc::new(RwLock::new(None)), rendezvous_handle: Arc::new(RwLock::new(None)), tcp_listener_handle: Arc::new(RwLock::new(None)), - listen_port: Arc::new(RwLock::new(DIRECT_LISTEN_PORT)), + listener_start_lock: Arc::new(tokio::sync::Mutex::new(())), + listen_port: Arc::new(RwLock::new(direct_access_port)), connection_manager, video_manager, hid, @@ -107,6 +110,7 @@ impl RustDeskService { } pub fn update_config(&self, config: RustDeskConfig) { + self.connection_manager.update_config(config.clone()); *self.config.write() = config; } @@ -126,7 +130,11 @@ impl RustDeskService { self.tcp_listener_handle.read().is_some() } - pub async fn start(&self) -> anyhow::Result<()> { + pub fn is_running(&self) -> bool { + self.status() == ServiceStatus::Running + } + + pub async fn start(self: &Arc) -> anyhow::Result<()> { let config = self.config.read().clone(); if !config.enabled { @@ -146,9 +154,8 @@ impl RustDeskService { *self.status.write() = ServiceStatus::Starting; info!( - "Starting RustDesk service with ID: {} -> {}", - config.device_id, - config.rendezvous_addr() + "Starting RustDesk service in {:?} mode with ID: {}", + config.mode, config.device_id, ); if let Err(e) = crypto::init() { @@ -157,6 +164,29 @@ impl RustDeskService { return Err(e.into()); } + self.connection_manager.set_hid(self.hid.clone()); + + self.connection_manager.set_audio(self.audio.clone()); + + self.connection_manager + .set_video_manager(self.video_manager.clone()); + + if config.mode == RustDeskMode::DirectIp { + let listen_port = match self + .ensure_tcp_listener(config.direct_access_port, false) + .await + { + Ok(result) => result, + Err(err) => { + *self.status.write() = ServiceStatus::Error(err.to_string()); + return Err(err); + } + }; + *self.listen_port.write() = listen_port; + *self.status.write() = ServiceStatus::Running; + return Ok(()); + } + let mediator = Arc::new(RendezvousMediator::new(config.clone())); let keypair = mediator.ensure_keypair(); @@ -165,36 +195,27 @@ impl RustDeskService { let signing_keypair = mediator.ensure_signing_keypair(); self.connection_manager.set_signing_keypair(signing_keypair); - self.connection_manager.set_hid(self.hid.clone()); - - self.connection_manager.set_audio(self.audio.clone()); - - self.connection_manager - .set_video_manager(self.video_manager.clone()); - *self.rendezvous.write() = Some(mediator.clone()); - let (tcp_handles, listen_port) = match self.start_tcp_listener_with_port().await { - Ok(result) => result, - Err(err) => { - *self.status.write() = ServiceStatus::Error(err.to_string()); - return Err(err); - } - }; - *self.tcp_listener_handle.write() = Some(tcp_handles); - - mediator.set_listen_port(listen_port); - let connection_manager = self.connection_manager.clone(); let service_config = self.config.clone(); + let connection_attempts = Arc::new(Semaphore::new(MAX_PENDING_CONNECTION_ATTEMPTS)); mediator.set_punch_callback(Arc::new({ let connection_manager = connection_manager.clone(); let service_config = service_config.clone(); + let connection_attempts = connection_attempts.clone(); move |peer_addr, rendezvous_addr, relay_server, uuid, socket_addr, device_id| { let conn_mgr = connection_manager.clone(); let config = service_config.clone(); + let attempts = connection_attempts.clone(); tokio::spawn(async move { + let Ok(_permit) = attempts.try_acquire_owned() else { + warn!( + "Dropping RustDesk punch request: too many pending connection attempts" + ); + return; + }; if let Some(addr) = peer_addr { info!("Attempting P2P direct connection to {}", addr); match punch::try_direct_connection(addr).await { @@ -232,10 +253,18 @@ impl RustDeskService { mediator.set_relay_callback(Arc::new({ let connection_manager = connection_manager.clone(); let service_config = service_config.clone(); + let connection_attempts = connection_attempts.clone(); move |rendezvous_addr, relay_server, uuid, socket_addr, device_id| { let conn_mgr = connection_manager.clone(); let config = service_config.clone(); + let attempts = connection_attempts.clone(); tokio::spawn(async move { + let Ok(_permit) = attempts.try_acquire_owned() else { + warn!( + "Dropping RustDesk relay request: too many pending connection attempts" + ); + return; + }; let relay_key = rustdesk_relay_key(&config); if let Err(e) = handle_relay_request( &rendezvous_addr, @@ -254,19 +283,39 @@ impl RustDeskService { } })); - let connection_manager2 = self.connection_manager.clone(); + let weak_service = Arc::downgrade(self); + let intranet_attempts = connection_attempts.clone(); mediator.set_intranet_callback(Arc::new( - move |rendezvous_addr, peer_socket_addr, local_addr, relay_server, device_id| { - let conn_mgr = connection_manager2.clone(); - + move |rendezvous_addr, peer_socket_addr, local_ip, relay_server, device_id| { + let weak_service = weak_service.clone(); + let attempts = intranet_attempts.clone(); tokio::spawn(async move { + let Ok(_permit) = attempts.try_acquire_owned() else { + warn!("Dropping RustDesk intranet request: too many pending connection attempts"); + return; + }; + let Some(service) = weak_service.upgrade() else { + return; + }; + let preferred_port = service.config.read().direct_access_port; + let listen_port = match service + .ensure_tcp_listener(preferred_port, true) + .await + { + Ok(port) => port, + Err(error) => { + error!("Failed to start on-demand RustDesk listener: {}", error); + return; + } + }; + let local_addr = SocketAddr::new(local_ip, listen_port); if let Err(e) = handle_intranet_request( &rendezvous_addr, &peer_socket_addr, local_addr, &relay_server, &device_id, - conn_mgr, + service.connection_manager.clone(), ) .await { @@ -298,16 +347,29 @@ impl RustDeskService { Ok(()) } - async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec>, u16)> { - let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) { + async fn ensure_tcp_listener( + self: &Arc, + preferred_port: u16, + allow_ephemeral_fallback: bool, + ) -> anyhow::Result { + let _guard = self.listener_start_lock.lock().await; + if self.tcp_listener_handle.read().is_some() { + return Ok(*self.listen_port.read()); + } + if self.status() == ServiceStatus::Stopped { + anyhow::bail!("RustDesk service stopped before listener could start"); + } + + let (listeners, listen_port) = match self.bind_direct_listeners(preferred_port) { Ok(result) => result, - Err(err) => { + Err(error) if allow_ephemeral_fallback => { warn!( - "Failed to bind RustDesk TCP on port {}: {}, falling back to random port", - DIRECT_LISTEN_PORT, err + "RustDesk port {} unavailable for on-demand listening: {}; using an ephemeral port", + preferred_port, error ); self.bind_direct_listeners(0)? } + Err(error) => return Err(error), }; *self.listen_port.write() = listen_port; @@ -328,15 +390,13 @@ impl RustDeskService { match result { Ok((stream, peer_addr)) => { info!("Accepted direct connection from {}", peer_addr); - let conn_mgr = conn_mgr.clone(); - tokio::spawn(async move { - if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await { - error!("Failed to handle direct connection from {}: {}", peer_addr, e); - } - }); + if let Err(e) = conn_mgr.accept_listener_connection(stream, peer_addr).await { + warn!("Rejected direct connection from {}: {}", peer_addr, e); + } } Err(e) => { error!("TCP accept error: {}", e); + tokio::time::sleep(Duration::from_millis(100)).await; } } } @@ -350,7 +410,8 @@ impl RustDeskService { handles.push(handle); } - Ok((handles, listen_port)) + *self.tcp_listener_handle.write() = Some(handles); + Ok(listen_port) } fn bind_direct_listeners(&self, port: u16) -> anyhow::Result<(Vec, u16)> { @@ -384,8 +445,8 @@ impl RustDeskService { info!("Stopping RustDesk service"); let _ = self.shutdown_tx.send(()); - - self.connection_manager.close_all(); + let _listener_guard = self.listener_start_lock.lock().await; + *self.status.write() = ServiceStatus::Stopped; if let Some(mediator) = self.rendezvous.read().as_ref() { mediator.stop(); @@ -403,13 +464,15 @@ impl RustDeskService { } } + // No listener can admit a new session after this point. + self.connection_manager.close_all().await; + *self.rendezvous.write() = None; - *self.status.write() = ServiceStatus::Stopped; Ok(()) } - pub async fn restart(&self, config: RustDeskConfig) -> anyhow::Result<()> { + pub async fn restart(self: &Arc, config: RustDeskConfig) -> anyhow::Result<()> { self.stop().await?; self.update_config(config); self.start().await diff --git a/src/rustdesk/rendezvous.rs b/src/rustdesk/rendezvous.rs index 6147c535..ab5f919b 100644 --- a/src/rustdesk/rendezvous.rs +++ b/src/rustdesk/rendezvous.rs @@ -128,7 +128,7 @@ pub type RelayCallback = Arc, String) + S pub type PunchCallback = Arc, String, String, String, Vec, String) + Send + Sync>; -pub type IntranetCallback = Arc, SocketAddr, String, String) + Send + Sync>; +pub type IntranetCallback = Arc, IpAddr, String, String) + Send + Sync>; pub struct RendezvousMediator { config: Arc>, @@ -143,7 +143,6 @@ pub struct RendezvousMediator { relay_callback: Arc>>, punch_callback: Arc>>, intranet_callback: Arc>>, - listen_port: Arc>, shutdown_tx: broadcast::Sender<()>, } @@ -166,23 +165,10 @@ impl RendezvousMediator { relay_callback: Arc::new(RwLock::new(None)), punch_callback: Arc::new(RwLock::new(None)), intranet_callback: Arc::new(RwLock::new(None)), - listen_port: Arc::new(RwLock::new(21118)), shutdown_tx, } } - pub fn set_listen_port(&self, port: u16) { - let old_port = *self.listen_port.read(); - if old_port != port { - *self.listen_port.write() = port; - self.increment_serial(); - } - } - - pub fn listen_port(&self) -> u16 { - *self.listen_port.read() - } - pub fn increment_serial(&self) { let mut serial = self.serial.write(); *serial = serial.wrapping_add(1); @@ -430,7 +416,9 @@ impl RendezvousMediator { ) -> anyhow::Result<()> { let id = self.device_id(); - let local_addrs = get_local_addresses(); + let local_addrs = tokio::task::spawn_blocking(get_local_addresses) + .await + .map_err(|error| anyhow::anyhow!("Failed to inspect local addresses: {error}"))?; if local_addrs.is_empty() { debug!("No local addresses available for LocalAddr response"); return Ok(()); @@ -439,21 +427,18 @@ impl RendezvousMediator { let config = self.config.read().clone(); let rendezvous_addr = config.rendezvous_addr(); - let listen_port = self.listen_port(); - let local_ip = local_addrs[0]; - let local_sock_addr = SocketAddr::new(local_ip, listen_port); info!( - "FetchLocalAddr: calling intranet callback with local_addr={}, rendezvous={}", - local_sock_addr, rendezvous_addr + "FetchLocalAddr: requesting an on-demand listener for {}, rendezvous={}", + local_ip, rendezvous_addr ); if let Some(callback) = self.intranet_callback.read().as_ref() { callback( rendezvous_addr, peer_socket_addr.to_vec(), - local_sock_addr, + local_ip, relay_server.to_string(), id, ); diff --git a/src/state.rs b/src/state.rs index 9afc162c..1d1e1389 100644 --- a/src/state.rs +++ b/src/state.rs @@ -19,11 +19,9 @@ use crate::hid::HidController; use crate::msd::MsdController; #[cfg(unix)] use crate::otg::OtgService; -use crate::rtsp::RtspService; -use crate::rustdesk::RustDeskService; +use crate::runtime::{RemoteAccessCoordinator, UsbCoordinator}; use crate::update::UpdateService; use crate::video::VideoStreamManager; -use crate::vnc::VncService; use crate::watchdog::WatchdogController; use crate::webrtc::WebRtcStreamer; @@ -81,9 +79,8 @@ pub struct AppState { pub audio: Arc, #[cfg(unix)] pub uac_playback: Arc>>, - pub rustdesk: Arc>>>, - pub vnc: Arc>>>, - pub rtsp: Arc>>>, + pub usb: Arc, + pub remote_access: Arc, pub extensions: Arc, pub events: Arc, device_info_tx: watch::Sender>, @@ -111,9 +108,6 @@ impl AppState { #[cfg(unix)] msd: Option, atx: Option, audio: Arc, - rustdesk: Option>, - vnc: Option>, - rtsp: Option>, extensions: Arc, events: Arc, update: Arc, @@ -122,6 +116,28 @@ impl AppState { ) -> Arc { let (device_info_tx, _device_info_rx) = watch::channel(None); + let remote_access = RemoteAccessCoordinator::new( + config.clone(), + stream_manager.clone(), + hid.clone(), + audio.clone(), + ); + #[cfg(unix)] + let msd = Arc::new(RwLock::new(msd)); + #[cfg(unix)] + let uac_playback = Arc::new(RwLock::new(None)); + let usb = UsbCoordinator::new( + hid.clone(), + #[cfg(unix)] + otg_service.clone(), + #[cfg(unix)] + msd.clone(), + #[cfg(unix)] + uac_playback.clone(), + events.clone(), + data_dir.clone(), + ); + Arc::new(Self { db, config, @@ -135,12 +151,11 @@ impl AppState { hid, computer_use, #[cfg(unix)] - msd: Arc::new(RwLock::new(msd)), + msd, atx: Arc::new(RwLock::new(atx)), audio, - rustdesk: Arc::new(RwLock::new(rustdesk)), - vnc: Arc::new(RwLock::new(vnc)), - rtsp: Arc::new(RwLock::new(rtsp)), + usb, + remote_access, extensions, events, device_info_tx, @@ -151,7 +166,7 @@ impl AppState { config_apply_locks: ConfigApplyLocks::new(), data_dir, #[cfg(unix)] - uac_playback: Arc::new(RwLock::new(None)), + uac_playback, }) } @@ -159,33 +174,6 @@ impl AppState { &self.data_dir } - pub async fn runtime_third_party_config(&self) -> crate::config::AppConfig { - let mut config = self.config.get().as_ref().clone(); - - config.rustdesk.enabled = self - .rustdesk - .read() - .await - .as_ref() - .is_some_and(|service| service.is_listening()); - config.vnc.enabled = match self.vnc.read().await.as_ref() { - Some(service) => matches!( - service.status().await, - crate::vnc::VncServiceStatus::Starting | crate::vnc::VncServiceStatus::Running - ), - None => false, - }; - config.rtsp.enabled = match self.rtsp.read().await.as_ref() { - Some(service) => matches!( - service.status().await, - crate::rtsp::RtspServiceStatus::Starting | crate::rtsp::RtspServiceStatus::Running - ), - None => false, - }; - - config - } - pub fn subscribe_device_info(&self) -> watch::Receiver> { self.device_info_tx.subscribe() } diff --git a/src/stream_encoder.rs b/src/stream_encoder.rs index 4bae805a..ce0360f5 100644 --- a/src/stream_encoder.rs +++ b/src/stream_encoder.rs @@ -14,29 +14,5 @@ pub fn encoder_type_to_backend(encoder: EncoderType) -> Option { EncoderType::Amf => Some(EncoderBackend::Amf), EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp), EncoderType::V4l2m2m => Some(EncoderBackend::V4l2m2m), - EncoderType::Amlogic => Some(EncoderBackend::Amlogic), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn maps_amlogic_config_to_backend() { - assert_eq!( - encoder_type_to_backend(EncoderType::Amlogic), - Some(EncoderBackend::Amlogic) - ); - } - - #[test] - fn amlogic_config_json_round_trip() { - let json = serde_json::to_string(&EncoderType::Amlogic).unwrap(); - assert_eq!(json, "\"amlogic\""); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - EncoderType::Amlogic - ); } } diff --git a/src/video/capture/dmabuf_layout.rs b/src/video/capture/dmabuf_layout.rs new file mode 100644 index 00000000..2a2caceb --- /dev/null +++ b/src/video/capture/dmabuf_layout.rs @@ -0,0 +1,201 @@ +//! Conservative, dependency-free eligibility checks for linear RKMPP input. + +pub struct DmaCaptureLayout<'a> { + pub native_hdmi: bool, + pub driver: &'a str, + pub bus_info: &'a str, + pub configurable_usb: bool, + pub single_planar: bool, + pub fourcc: [u8; 4], + pub width: u32, + pub height: u32, + pub stride: u32, +} + +impl DmaCaptureLayout<'_> { + /// Minimum readable bytes, not the driver's page-aligned allocation size. + pub fn minimum_bytes(&self) -> Option { + if self.width == 0 + || self.height == 0 + || self.width > 8192 + || self.height > 8192 + || self.width % 2 != 0 + || self.height % 2 != 0 + { + return None; + } + let bytes_per_row = if self.native_hdmi { + match &self.fourcc { + b"NV12" => self.width, + b"BGR3" => self.width.checked_mul(3)?, + _ => return None, + } + } else if self.configurable_usb + && self.single_planar + && self.driver == "uvcvideo" + && self.bus_info.starts_with("usb-") + { + match &self.fourcc { + // Compressed frames have variable bytesused and no byte stride. + b"MJPG" => return Some(4), + b"YUYV" if self.stride % 16 == 0 => self.width.checked_mul(2)?, + b"NV12" if self.stride % 16 == 0 => self.width, + b"RGB3" if self.stride % 16 == 0 => self.width.checked_mul(3)?, + _ => return None, + } + } else { + return None; + }; + if self.stride < bytes_per_row { + return None; + } + let size = (self.stride as usize).checked_mul(self.height as usize)?; + if self.fourcc == *b"NV12" { + size.checked_mul(3)?.checked_div(2) + } else { + Some(size) + } + } +} + +/// An MJPEG DMA packet has a bounded payload, not stride * height bytes. +/// Reserve readable headroom for the MPP bitstream reader without modifying +/// capture memory. The decoder receives only `used`, never the allocation size. +pub fn valid_payload( + compressed: bool, + used: usize, + capacity: usize, + expected: Option, +) -> bool { + if compressed { + used >= 4 && used.checked_add(64).is_some_and(|end| end <= capacity) + } else { + used > 0 && Some(used) == expected && used <= capacity + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn usb() -> DmaCaptureLayout<'static> { + DmaCaptureLayout { + native_hdmi: false, + driver: "uvcvideo", + bus_info: "usb-fc880000.usb-1.1", + configurable_usb: true, + single_planar: true, + fourcc: *b"YUYV", + width: 1920, + height: 1080, + stride: 3840, + } + } + + #[test] + fn usb_yuyv_uses_byte_stride_and_supports_padding() { + let mut layout = usb(); + assert_eq!(layout.minimum_bytes(), Some(4_147_200)); + layout.width = 640; + layout.height = 480; + layout.stride = 1280; + assert_eq!(layout.minimum_bytes(), Some(614_400)); + layout.stride = 1296; + assert_eq!(layout.minimum_bytes(), Some(622_080)); + } + + #[test] + fn usb_requires_correct_driver_bus_queue_and_control_mode() { + let mut layout = usb(); + layout.driver = "rkcif"; + assert_eq!(layout.minimum_bytes(), None); + layout = usb(); + layout.bus_info = "platform:hdmi"; + assert_eq!(layout.minimum_bytes(), None); + layout = usb(); + layout.single_planar = false; + assert_eq!(layout.minimum_bytes(), None); + layout = usb(); + layout.configurable_usb = false; + assert_eq!(layout.minimum_bytes(), None); + } + + #[test] + fn unverified_usb_formats_stay_on_copy_path() { + for fourcc in [ + *b"H264", *b"NV21", *b"NV16", *b"NV24", *b"BGR3", *b"YU12", *b"UYVY", *b"YVYU", + *b"BAD!", + ] { + let mut layout = usb(); + layout.fourcc = fourcc; + assert_eq!(layout.minimum_bytes(), None, "{fourcc:?}"); + } + } + + #[test] + fn usb_nv12_rgb_and_mjpeg_layouts() { + let mut layout = usb(); + layout.fourcc = *b"NV12"; + layout.stride = 1920; + assert_eq!(layout.minimum_bytes(), Some(3_110_400)); + layout.fourcc = *b"RGB3"; + layout.stride = 5760; + assert_eq!(layout.minimum_bytes(), Some(6_220_800)); + layout.stride = 1920; + assert_eq!(layout.minimum_bytes(), None); + layout.fourcc = *b"MJPG"; + layout.stride = 0; + assert_eq!(layout.minimum_bytes(), Some(4)); + } + + #[test] + fn compressed_payload_is_bounded_and_not_allocation_size() { + assert!(valid_payload(true, 63163, 4147200, None)); + for used in [0, 3, 4147200, usize::MAX] { + assert!(!valid_payload(true, used, 4147200, None)); + } + assert!(valid_payload(true, 4, 68, None)); + assert!(!valid_payload(true, 4, 67, None)); + assert!(valid_payload(false, 614400, 614400, Some(614400))); + assert!(!valid_payload(false, 614399, 614400, Some(614400))); + assert!(!valid_payload(false, 614400, 614399, Some(614400))); + } + + #[test] + fn malformed_geometry_or_stride_is_rejected() { + for (w, h, stride) in [ + (0, 1080, 3840), + (1920, 0, 3840), + (1919, 1080, 3840), + (1920, 1079, 3840), + (8194, 1080, 16384), + (1920, 8194, 3840), + (1920, 1080, 0), + (1920, 1080, 1920), + (1920, 1080, 3841), + (u32::MAX, u32::MAX, u32::MAX), + ] { + let mut layout = usb(); + layout.width = w; + layout.height = h; + layout.stride = stride; + assert_eq!(layout.minimum_bytes(), None); + } + } + + #[test] + fn native_hdmi_formats_are_preserved_but_not_expanded() { + let mut layout = usb(); + layout.native_hdmi = true; + layout.single_planar = false; + layout.fourcc = *b"BGR3"; + layout.stride = 5760; + assert_eq!(layout.minimum_bytes(), Some(6_220_800)); + layout.fourcc = *b"NV12"; + layout.stride = 1920; + assert_eq!(layout.minimum_bytes(), Some(3_110_400)); + layout.fourcc = *b"YUYV"; + layout.stride = 3840; + assert_eq!(layout.minimum_bytes(), None); + } +} diff --git a/src/video/capture/linux.rs b/src/video/capture/linux.rs index 6ae0cc00..ad6e7d5b 100644 --- a/src/video/capture/linux.rs +++ b/src/video/capture/linux.rs @@ -3,6 +3,8 @@ use std::fs::File; use std::io; use std::os::fd::AsFd; +#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] +use std::os::fd::OwnedFd; use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -29,6 +31,10 @@ use crate::video::device::VideoControlMode; use crate::video::format::{PixelFormat, Resolution}; use crate::video::signal::SignalStatus; +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +#[path = "dmabuf_layout.rs"] +mod dmabuf_layout; + /// Metadata for a captured frame. #[derive(Debug, Clone, Copy)] pub struct CaptureMeta { @@ -67,6 +73,8 @@ pub struct CaptureStream { bridge_kind: Option, native_hdmirx_state: Option, native_hdmirx_next_state_check: Option, + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + dma_layout_bytes: Option, } fn open_capture_device(path: &Path) -> io::Result { @@ -319,6 +327,24 @@ impl CaptureStream { mappings.push(plane_maps); } + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + let dma_layout_bytes = PixelFormat::from_v4l2r(actual_fmt.pixelformat).and_then(|format| { + dmabuf_layout::DmaCaptureLayout { + native_hdmi: is_native_hdmirx, + driver: &caps.driver, + bus_info: &caps.bus_info, + configurable_usb: !is_source_following + && bridge.kind.is_none() + && !bridge.has_subdev(), + single_planar: queue == QueueType::VideoCapture, + fourcc: format.to_fourcc(), + width: actual_resolution.width, + height: actual_resolution.height, + stride, + } + .minimum_bytes() + }); + let mut stream = Self { fd, queue, @@ -332,6 +358,8 @@ impl CaptureStream { bridge_kind: bridge.kind, native_hdmirx_state, native_hdmirx_next_state_check, + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + dma_layout_bytes, }; stream.queue_all_buffers()?; @@ -421,10 +449,7 @@ impl CaptureStream { } } - pub fn next_into( - &mut self, - dst: &mut Vec, - ) -> std::result::Result { + fn dequeue_buffer(&mut self) -> std::result::Result { self.wait_ready()?; // Several vendor BSPs update G_FMT/DV timings without making the @@ -455,6 +480,143 @@ impl CaptureStream { }; CaptureReadError::Io(error) })?; + Ok(dqbuf) + } + + /// Native HDMI NV12/BGR24 and single-planar USB UVC YUYV/NV12/RGB24/MJPEG. + /// Actual EXPBUF/import support is probed separately; failure retains copy. + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + pub(crate) fn supports_rkmpp_dmabuf(&self) -> bool { + self.dma_layout_bytes.is_some_and(|minimum| { + (2..=16).contains(&self.mappings.len()) + && self + .mappings + .iter() + .all(|planes| planes.len() == 1 && planes[0].len() >= minimum) + }) + } + + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + pub(crate) fn export_dmabufs(&self) -> io::Result> { + if !self.supports_rkmpp_dmabuf() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Unsupported RKMPP DMA capture layout", + )); + } + self.mappings + .iter() + .enumerate() + .map(|(index, planes)| { + let fd = ioctl::expbuf(&self.fd, self.queue, index, 0, ioctl::ExpbufFlags::CLOEXEC) + .map_err(|error| io::Error::other(error.to_string()))?; + Ok((fd, planes[0].len())) + }) + .collect() + } + + /// Run a synchronous consumer while a buffer is dequeued. QBUF occurs only + /// after the callback returns, including its error path. Consumers must end + /// hardware access before returning; see hwcodec::rkmpp_dmabuf::encode. + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + pub(crate) fn with_next_dmabuf( + &mut self, + consume: impl FnOnce(usize, usize, Option) -> T, + ) -> std::result::Result<(CaptureMeta, T), CaptureReadError> { + let buffer = self.dequeue_buffer()?; + let index = buffer.as_v4l2_buffer().index as usize; + let sequence = buffer.as_v4l2_buffer().sequence as u64; + if index >= self.mappings.len() { + return Err( + io::Error::new(io::ErrorKind::InvalidData, "Invalid capture buffer index").into(), + ); + } + let expected = self.expected_capture_bytes(); + let mapped_size = self.mappings[index][0].len(); + let native_hdmi = self.native_hdmirx_state.is_some(); + let compressed = self.format == PixelFormat::Mjpeg; + let lease = BufferReturn(Some(|| { + self.queue_buffer(index as u32) + .map_err(|e| io::Error::other(e.to_string())) + })); + if buffer.as_v4l2_buffer().flags & v4l2r::bindings::V4L2_BUF_FLAG_ERROR != 0 { + // A corrupt UVC frame is not a source change or a DMA failure. + // Return it without ever letting the encoder read its payload. + lease.finish()?; + return Err(io::Error::from(io::ErrorKind::WouldBlock).into()); + } + if !native_hdmi + && buffer.as_v4l2_buffer().field != v4l2r::bindings::v4l2_field_V4L2_FIELD_NONE + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Interlaced USB DMA frames are not supported", + ) + .into()); + } + let mut planes = buffer.planes_iter(); + let plane = planes + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Missing DMA plane"))?; + if planes.next().is_some() || plane.data_offset.copied().unwrap_or(0) != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Unsupported DMA plane offset/layout", + ) + .into()); + } + let bytes_used = *plane.bytesused as usize; + if !dmabuf_layout::valid_payload(compressed, bytes_used, mapped_size, expected) { + if !native_hdmi { + // An unexpected UVC payload is not evidence of a source mode + // change. Disable DMA instead of reopening it indefinitely. + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Unexpected USB DMA payload length", + ) + .into()); + } + return Err(CaptureReadError::SourceChanged); + } + // UVC commonly fills vmalloc memory on the CPU. Older BSP exporters + // cache DMA attachments without usable per-frame CPU-access sync hooks. + // A fresh export object forces a fresh device mapping of this completed + // frame. Reuse the actual capture allocation, not a stale attachment. + let fresh_fd = if !native_hdmi { + Some( + ioctl::expbuf( + &self.fd, + self.queue, + index, + 0, + ioctl::ExpbufFlags::CLOEXEC | ioctl::ExpbufFlags::RDWR, + ) + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("USB DMA re-export failed: {error}"), + ) + })?, + ) + } else { + None + }; + let output = consume(index, bytes_used, fresh_fd); + lease.finish()?; + Ok(( + CaptureMeta { + bytes_used, + sequence, + }, + output, + )) + } + + pub fn next_into( + &mut self, + dst: &mut Vec, + ) -> std::result::Result { + let dqbuf = self.dequeue_buffer()?; let index = dqbuf.as_v4l2_buffer().index as usize; let sequence = dqbuf.as_v4l2_buffer().sequence as u64; @@ -664,7 +826,7 @@ impl CaptureStream { Ok(()) } - fn queue_buffer(&mut self, index: u32) -> Result<()> { + fn queue_buffer(&self, index: u32) -> Result<()> { let handle = MmapHandle; let planes = self.mappings[index as usize] .iter() @@ -682,6 +844,64 @@ impl CaptureStream { } } +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +struct BufferReturn io::Result<()>>(Option); + +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +impl io::Result<()>> BufferReturn { + fn finish(mut self) -> io::Result<()> { + self.0.take().expect("capture lease already returned")() + } +} + +#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))] +impl io::Result<()>> Drop for BufferReturn { + fn drop(&mut self) { + if let Some(return_buffer) = self.0.take() { + if let Err(error) = return_buffer() { + warn!("Failed to return leased capture buffer: {}", error); + } + } + } +} + +#[cfg(test)] +mod dma_lease_tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn returns_buffer_once_after_consumer_and_does_not_retry_failed_qbuf() { + let operations = RefCell::new(Vec::new()); + let lease = BufferReturn(Some(|| { + operations.borrow_mut().push("qbuf"); + Err(io::Error::other("device lost")) + })); + operations.borrow_mut().push("encode completed"); + assert!(lease.finish().is_err()); + assert_eq!(*operations.borrow(), ["encode completed", "qbuf"]); + } + + #[test] + fn returns_buffer_on_validation_error_or_unwind() { + let returns = std::cell::Cell::new(0); + { + let _lease = BufferReturn(Some(|| { + returns.set(returns.get() + 1); + Ok(()) + })); + } + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _lease = BufferReturn(Some(|| { + returns.set(returns.get() + 1); + Ok(()) + })); + panic!("consumer panic"); + })); + assert_eq!(returns.get(), 2); + } +} + impl Drop for CaptureStream { fn drop(&mut self) { // Release ordering matters on rkcif: a subsequent open()/S_FMT from a diff --git a/src/video/capture/status.rs b/src/video/capture/status.rs index c6b91939..0238a8b2 100644 --- a/src/video/capture/status.rs +++ b/src/video/capture/status.rs @@ -2,8 +2,32 @@ use std::io; +#[cfg(any( + test, + all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")) +))] +use crate::video::device::VideoControlMode; use crate::video::signal::SignalStatus; +#[cfg(any( + test, + all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")) +))] +pub(crate) fn capture_recovery_status( + control_mode: VideoControlMode, + error: &io::Error, +) -> SignalStatus { + if control_mode == VideoControlMode::Configurable && error.kind() == io::ErrorKind::TimedOut { + return SignalStatus::UvcCaptureStall; + } + match classify_capture_io_error(error) { + CaptureIoErrorKind::TransientSignal { + status: Some(status), + } => status, + _ => SignalStatus::NoSignal, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CaptureIoErrorKind { DeviceLost, @@ -52,6 +76,33 @@ pub fn capture_error_log_key(err: &io::Error) -> String { mod tests { use super::*; + #[test] + fn recovery_distinguishes_uvc_stalls_from_hdmi_signal_loss() { + let timeout = io::Error::from(io::ErrorKind::TimedOut); + assert_eq!( + capture_recovery_status(VideoControlMode::Configurable, &timeout), + SignalStatus::UvcCaptureStall + ); + assert_eq!( + capture_recovery_status(VideoControlMode::SourceFollowing, &timeout), + SignalStatus::NoSignal + ); + assert_eq!( + capture_recovery_status( + VideoControlMode::Configurable, + &io::Error::from_raw_os_error(71) + ), + SignalStatus::UvcUsbError + ); + assert_eq!( + capture_recovery_status( + VideoControlMode::SourceFollowing, + &io::Error::from_raw_os_error(5) + ), + SignalStatus::NoSignal + ); + } + #[test] fn maps_known_signal_status_strings() { assert_eq!( diff --git a/src/video/codec/amlenc.rs b/src/video/codec/amlenc.rs deleted file mode 100644 index 99fa06b1..00000000 --- a/src/video/codec/amlenc.rs +++ /dev/null @@ -1,985 +0,0 @@ -//! Native Amlogic AMLENC bindings for the S912/GXM vendor Linux 4.9 stack. -//! -//! The vendor libraries are deliberately loaded at runtime. They must be built -//! with the One-KVM ABI v1 patch from the standalone `amlenc` repository; -//! unpatched 0.4 libraries -//! are rejected before any device access is attempted. - -use std::env; -use std::ffi::{c_int, c_long, c_uchar, c_uint, OsStr}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; - -use bytes::Bytes; -use libloading::Library; -use tracing::{debug, warn}; - -use crate::error::{AppError, Result}; -use crate::video::format::Resolution; - -pub const AMLENC_ABI_VERSION: c_int = 1; -pub const AMLENC_H264_CODEC_NAME: &str = "h264_amlenc"; -pub const AMLENC_H265_CODEC_NAME: &str = "hevc_amlenc"; -pub const AMLENC_H264_DEFAULT_LIBRARY: &str = "libvpcodec.so"; -pub const AMLENC_H265_DEFAULT_LIBRARY: &str = "libvphevcodec.so"; - -const AMLENC_MAX_WIDTH: u32 = 1920; -const AMLENC_MAX_HEIGHT: u32 = 1080; -const AMLENC_MAX_FPS: u32 = 60; -const MIN_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; -const OUTPUT_STALL_TIMEOUT: Duration = Duration::from_secs(1); -const CODEC_ID_H264: c_int = 4; -const CODEC_ID_H265: c_int = 5; -const IMG_FMT_NV12: c_int = 1; -const FRAME_TYPE_AUTO: c_int = 1; -const FRAME_TYPE_IDR: c_int = 2; -const H264_NV12_FORMAT: c_int = 0; -const H265_NV12_FORMAT: c_int = 1; - -type AbiVersionFn = unsafe extern "C" fn() -> c_int; -type H264InitFn = unsafe extern "C" fn(c_int, c_int, c_int, c_int, c_int, c_int, c_int) -> c_long; -type H265InitFn = unsafe extern "C" fn(c_int, c_int, c_int, c_int, c_int, c_int) -> c_long; -type H264EncodeFn = - unsafe extern "C" fn(c_long, c_int, *mut c_uchar, c_int, *mut c_uchar, c_int) -> c_int; -type H265EncodeFn = - unsafe extern "C" fn(c_long, c_int, *mut c_uchar, c_uint, *mut c_uchar, c_int) -> c_int; -type DestroyFn = unsafe extern "C" fn(c_long) -> c_int; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AmlencCodec { - H264, - H265, -} - -impl AmlencCodec { - pub fn codec_name(self) -> &'static str { - match self { - Self::H264 => AMLENC_H264_CODEC_NAME, - Self::H265 => AMLENC_H265_CODEC_NAME, - } - } - - pub fn default_library(self) -> &'static str { - match self { - Self::H264 => AMLENC_H264_DEFAULT_LIBRARY, - Self::H265 => AMLENC_H265_DEFAULT_LIBRARY, - } - } - - pub fn library_env(self) -> &'static str { - match self { - Self::H264 => "ONE_KVM_AMLENC_H264_LIB", - Self::H265 => "ONE_KVM_AMLENC_H265_LIB", - } - } - - pub fn device_node(self) -> &'static str { - match self { - Self::H264 => "/dev/amvenc_avc", - Self::H265 => "/dev/HevcEnc", - } - } -} - -#[derive(Debug, Clone, Copy)] -pub struct AmlencConfig { - pub codec: AmlencCodec, - pub resolution: Resolution, - pub fps: u32, - pub bitrate_kbps: u32, - pub gop: u32, -} - -impl AmlencConfig { - pub fn validate(self) -> Result<()> { - let width = self.resolution.width; - let height = self.resolution.height; - if width == 0 - || height == 0 - || width > AMLENC_MAX_WIDTH - || height > AMLENC_MAX_HEIGHT - || width % 16 != 0 - || height % 2 != 0 - { - return Err(AppError::VideoError(format!( - "AMLENC requires NV12 with 16-aligned width, even height, and at most 1920x1080 (got {}x{})", - width, height - ))); - } - if !(1..=AMLENC_MAX_FPS).contains(&self.fps) { - return Err(AppError::VideoError(format!( - "AMLENC supports 1-60 fps (got {})", - self.fps - ))); - } - if self.bitrate_kbps == 0 || self.bitrate_kbps > (c_int::MAX as u32 / 1000) { - return Err(AppError::VideoError(format!( - "Invalid AMLENC bitrate: {} kbps", - self.bitrate_kbps - ))); - } - if self.gop > c_int::MAX as u32 { - return Err(AppError::VideoError("AMLENC GOP is too large".to_string())); - } - nv12_frame_size(self.resolution)?; - Ok(()) - } - - fn bitrate_bps(self) -> c_int { - (self.bitrate_kbps * 1000) as c_int - } - - fn vendor_gop(self) -> c_int { - match self.codec { - // GXM's H.264 microcode can time out on a later natural IDR for - // complex 1080p pictures. The pinned vendor library defines zero - // as an infinite GOP (one IDR when the instance is created). - AmlencCodec::H264 => 0, - AmlencCodec::H265 => self.gop as c_int, - } - } -} - -pub fn nv12_frame_size(resolution: Resolution) -> Result { - let pixels = (resolution.width as usize) - .checked_mul(resolution.height as usize) - .ok_or_else(|| AppError::VideoError("AMLENC NV12 frame size overflow".to_string()))?; - pixels - .checked_mul(3) - .map(|value| value / 2) - .ok_or_else(|| AppError::VideoError("AMLENC NV12 frame size overflow".to_string())) -} - -fn validate_abi_version(version: c_int, path: &Path) -> Result<()> { - if version != AMLENC_ABI_VERSION { - return Err(AppError::VideoError(format!( - "AMLENC library {} has ABI {}, expected ABI v{}; apply the one-kvm-amlenc-abi-v1.patch from the standalone amlenc repository", - path.display(), - version, - AMLENC_ABI_VERSION - ))); - } - Ok(()) -} - -struct H264Api { - _library: Library, - init: H264InitFn, - encode: H264EncodeFn, - destroy: DestroyFn, -} - -struct H265Api { - _library: Library, - init: H265InitFn, - encode: H265EncodeFn, - destroy: DestroyFn, -} - -enum AmlencApi { - H264(H264Api), - H265(H265Api), -} - -unsafe fn required_symbol(library: &Library, name: &[u8], path: &Path) -> Result { - // SAFETY: the caller supplies the signature from the fixed upstream headers. - unsafe { library.get::(name) } - .map(|symbol| *symbol) - .map_err(|error| { - AppError::VideoError(format!( - "AMLENC library {} is missing {}: {}", - path.display(), - String::from_utf8_lossy(name).trim_end_matches('\0'), - error - )) - }) -} - -impl AmlencApi { - fn load(codec: AmlencCodec, path: &Path) -> Result { - // SAFETY: all calls are made through signatures checked against the pinned headers, - // and the Library remains owned by the API object for the lifetime of the pointers. - let library = unsafe { Library::new(path) }.map_err(|error| { - AppError::VideoError(format!( - "Failed to load AMLENC {} library {}: {}", - codec.codec_name(), - path.display(), - error - )) - })?; - let abi_version: AbiVersionFn = - unsafe { required_symbol(&library, b"one_kvm_amlenc_abi_version\0", path)? }; - // SAFETY: the ABI marker has no arguments or side effects. - validate_abi_version(unsafe { abi_version() }, path)?; - - Ok(match codec { - AmlencCodec::H264 => { - let init: H264InitFn = - unsafe { required_symbol(&library, b"vl_video_encoder_init\0", path)? }; - let encode: H264EncodeFn = - unsafe { required_symbol(&library, b"vl_video_encoder_encode\0", path)? }; - let destroy: DestroyFn = - unsafe { required_symbol(&library, b"vl_video_encoder_destory\0", path)? }; - Self::H264(H264Api { - _library: library, - init, - encode, - destroy, - }) - } - AmlencCodec::H265 => { - let init: H265InitFn = - unsafe { required_symbol(&library, b"vl_video_encoder_init\0", path)? }; - let encode: H265EncodeFn = - unsafe { required_symbol(&library, b"vl_video_encoder_encode\0", path)? }; - let destroy: DestroyFn = - unsafe { required_symbol(&library, b"vl_video_encoder_destory\0", path)? }; - Self::H265(H265Api { - _library: library, - init, - encode, - destroy, - }) - } - }) - } - - unsafe fn init(&self, config: AmlencConfig) -> c_long { - let width = config.resolution.width as c_int; - let height = config.resolution.height as c_int; - match self { - Self::H264(api) => unsafe { - (api.init)( - CODEC_ID_H264, - width, - height, - config.fps as c_int, - config.bitrate_bps(), - config.vendor_gop(), - IMG_FMT_NV12, - ) - }, - Self::H265(api) => unsafe { - (api.init)( - CODEC_ID_H265, - width, - height, - config.fps as c_int, - config.bitrate_bps(), - config.gop as c_int, - ) - }, - } - } - - unsafe fn encode( - &self, - handle: c_long, - frame_type: c_int, - input: *mut c_uchar, - output: *mut c_uchar, - output_len: usize, - ) -> c_int { - match self { - // H.264's fourth argument is documented as input length, but the pinned - // implementation uses it exclusively as output capacity. - Self::H264(api) => unsafe { - (api.encode)( - handle, - frame_type, - input, - output_len as c_int, - output, - H264_NV12_FORMAT, - ) - }, - Self::H265(api) => unsafe { - (api.encode)( - handle, - frame_type, - input, - output_len as c_uint, - output, - H265_NV12_FORMAT, - ) - }, - } - } - - unsafe fn destroy(&self, handle: c_long) { - match self { - Self::H264(api) => { - unsafe { (api.destroy)(handle) }; - } - Self::H265(api) => { - unsafe { (api.destroy)(handle) }; - } - } - } -} - -static AMLENC_INSTANCE_ACTIVE: AtomicBool = AtomicBool::new(false); - -struct ExclusiveInstance; - -impl ExclusiveInstance { - fn acquire() -> Result { - AMLENC_INSTANCE_ACTIVE - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .map_err(|_| { - AppError::VideoError( - "AMLENC hardware is already in use by another encoder or self-check" - .to_string(), - ) - })?; - Ok(Self) - } -} - -impl Drop for ExclusiveInstance { - fn drop(&mut self) { - AMLENC_INSTANCE_ACTIVE.store(false, Ordering::Release); - } -} - -pub struct AmlencEncoder { - api: AmlencApi, - handle: c_long, - config: AmlencConfig, - output: Vec, - force_keyframe: bool, - rebuild_before_next_frame: bool, - expect_parameterized_keyframe: bool, - last_output: Instant, - _exclusive: ExclusiveInstance, -} - -impl AmlencEncoder { - pub fn new(config: AmlencConfig) -> Result { - let path = env::var_os(config.codec.library_env()) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(config.codec.default_library())); - Self::with_library(config, path) - } - - pub fn with_library(config: AmlencConfig, path: impl AsRef) -> Result { - config.validate()?; - let exclusive = ExclusiveInstance::acquire()?; - let path = PathBuf::from(path.as_ref()); - let api = AmlencApi::load(config.codec, &path)?; - let frame_size = nv12_frame_size(config.resolution)?; - let output = vec![0; frame_size.max(MIN_OUTPUT_BUFFER_SIZE)]; - let mut encoder = Self { - api, - handle: 0, - config, - output, - force_keyframe: false, - rebuild_before_next_frame: false, - expect_parameterized_keyframe: true, - last_output: Instant::now(), - _exclusive: exclusive, - }; - encoder.create_handle()?; - Ok(encoder) - } - - pub fn codec_name(&self) -> &'static str { - self.config.codec.codec_name() - } - - pub fn config(&self) -> AmlencConfig { - self.config - } - - fn create_handle(&mut self) -> Result<()> { - debug!( - "Creating {} at {}x{} {} fps {} kbps", - self.codec_name(), - self.config.resolution.width, - self.config.resolution.height, - self.config.fps, - self.config.bitrate_kbps - ); - // SAFETY: config validation guarantees values accepted by ABI v1. - self.handle = unsafe { self.api.init(self.config) }; - if self.handle <= 0 { - return Err(AppError::VideoError(format!( - "AMLENC {} initialization failed; check {}, firmware, CMA, and device permissions", - self.codec_name(), - self.config.codec.device_node() - ))); - } - // The first H.264 picture is naturally an IDR. Never pass the - // in-place FORCE_IDR command to the GXM H.264 microcode: later IDRs can - // wedge it. H.265 does not share that observed defect and retains its - // ABI-v1 forced-IRAP behavior. - self.force_keyframe = self.config.codec == AmlencCodec::H265; - self.rebuild_before_next_frame = false; - self.expect_parameterized_keyframe = true; - self.last_output = Instant::now(); - Ok(()) - } - - fn destroy_handle(&mut self) { - if self.handle > 0 { - // SAFETY: the handle was returned by this API instance and is destroyed once. - unsafe { self.api.destroy(self.handle) }; - self.handle = 0; - } - } - - fn rebuild(&mut self, reason: &str) -> Result<()> { - warn!("Rebuilding {} encoder: {}", self.codec_name(), reason); - self.destroy_handle(); - self.create_handle() - } - - pub fn request_keyframe(&mut self) { - if self.config.codec == AmlencCodec::H264 { - // A fresh encoder reliably emits SPS/PPS + IDR on its first AUTO - // frame. Coalesce repeated client requests while a rebuild or - // fresh first frame is already pending. - if !self.expect_parameterized_keyframe { - self.rebuild_before_next_frame = true; - } - } else { - self.force_keyframe = true; - } - } - - pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> { - let mut updated = self.config; - updated.bitrate_kbps = bitrate_kbps; - updated.validate()?; - self.config = updated; - self.rebuild("bitrate changed") - } - - pub fn encode_raw(&mut self, data: &[u8]) -> Result> { - let expected = nv12_frame_size(self.config.resolution)?; - if data.len() != expected { - return Err(AppError::VideoError(format!( - "AMLENC requires contiguous NV12 data of exactly {} bytes (got {})", - expected, - data.len() - ))); - } - - if self.rebuild_before_next_frame { - self.rebuild("H.264 keyframe requested")?; - } - - match self.encode_once(data) { - Ok(frame) => Ok(frame), - Err(first_error) => { - self.rebuild(&format!("vendor encode call failed: {first_error}"))?; - self.encode_once(data).map_err(|retry_error| { - AppError::VideoError(format!( - "AMLENC encode failed after one rebuild: {}; retry: {}", - first_error, retry_error - )) - }) - } - } - } - - fn encode_once(&mut self, data: &[u8]) -> Result> { - if self.handle <= 0 { - return Err(AppError::VideoError( - "AMLENC handle is not initialized".to_string(), - )); - } - let forced = self.force_keyframe; - let require_parameterized_keyframe = self.expect_parameterized_keyframe || forced; - let frame_type = if forced { - FRAME_TYPE_IDR - } else { - FRAME_TYPE_AUTO - }; - // The vendor API takes a mutable pointer but does not modify VMALLOC input. - // SAFETY: input/output live for the call, capacities are ABI-sized and the - // output length is validated before any slice is formed. - let length = unsafe { - self.api.encode( - self.handle, - frame_type, - data.as_ptr() as *mut c_uchar, - self.output.as_mut_ptr(), - self.output.len(), - ) - }; - if length < 0 { - return Err(AppError::VideoError(format!( - "{} vendor library returned {}", - self.codec_name(), - length - ))); - } - // A keyframe request applies to one submitted frame. Repeating IDR on - // every zero-output call can trap the S912 driver in its light-reset - // loop; WebRTC will issue another request if this attempt was skipped. - if forced { - self.force_keyframe = false; - } - let length = length as usize; - if length > self.output.len() { - return Err(AppError::VideoError(format!( - "{} returned oversized output {} > {}", - self.codec_name(), - length, - self.output.len() - ))); - } - if length == 0 { - if forced { - return Err(AppError::VideoError(format!( - "{} produced no output for a forced keyframe", - self.codec_name() - ))); - } - // The vendor ABI uses zero for rate-control skips and recoverable - // hardware timeouts. Do not rebuild for a few skipped frames, but - // recover if the vendor stops producing output altogether. - if self.last_output.elapsed() >= OUTPUT_STALL_TIMEOUT { - self.rebuild("no encoded output for one second")?; - } - return Ok(None); - } - - let encoded = &self.output[..length]; - let nal_summary = inspect_annex_b(self.config.codec, encoded); - let keyframe = nal_summary.keyframe; - if require_parameterized_keyframe - && (!keyframe || !nal_summary.has_parameter_sets(self.config.codec)) - { - return Err(AppError::VideoError(format!( - "{} fresh/forced keyframe did not contain an IRAP/IDR and complete parameter sets", - self.codec_name() - ))); - } - self.force_keyframe = false; - self.expect_parameterized_keyframe = false; - self.last_output = Instant::now(); - Ok(Some((Bytes::copy_from_slice(encoded), keyframe))) - } -} - -impl Drop for AmlencEncoder { - fn drop(&mut self) { - self.destroy_handle(); - } -} - -#[derive(Default)] -struct AnnexBNalSummary { - keyframe: bool, - vps: bool, - sps: bool, - pps: bool, -} - -impl AnnexBNalSummary { - fn has_parameter_sets(&self, codec: AmlencCodec) -> bool { - match codec { - AmlencCodec::H264 => self.sps && self.pps, - AmlencCodec::H265 => self.vps && self.sps && self.pps, - } - } -} - -fn inspect_annex_b(codec: AmlencCodec, data: &[u8]) -> AnnexBNalSummary { - let mut summary = AnnexBNalSummary::default(); - let mut index = 0; - while index + 3 <= data.len() { - let start_len = if index + 4 <= data.len() && data[index..index + 4] == [0, 0, 0, 1] { - 4 - } else if data[index..index + 3] == [0, 0, 1] { - 3 - } else { - index += 1; - continue; - }; - let nal = index + start_len; - if nal >= data.len() { - break; - } - let nal_type = match codec { - AmlencCodec::H264 => data[nal] & 0x1f, - AmlencCodec::H265 => (data[nal] >> 1) & 0x3f, - }; - match codec { - AmlencCodec::H264 => match nal_type { - 5 => summary.keyframe = true, - 7 => summary.sps = true, - 8 => summary.pps = true, - _ => {} - }, - AmlencCodec::H265 => match nal_type { - 16..=23 => summary.keyframe = true, - 32 => summary.vps = true, - 33 => summary.sps = true, - 34 => summary.pps = true, - _ => {} - }, - } - index = nal + 1; - } - summary -} - -pub fn is_keyframe(codec: AmlencCodec, data: &[u8]) -> bool { - inspect_annex_b(codec, data).keyframe -} - -pub fn has_parameter_sets(codec: AmlencCodec, data: &[u8]) -> bool { - inspect_annex_b(codec, data).has_parameter_sets(codec) -} - -#[cfg_attr( - not(any(test, all(target_os = "linux", target_arch = "aarch64"))), - allow(dead_code) -)] -fn is_s912_gxm_compatible(compatible: &[u8]) -> bool { - let compatible = String::from_utf8_lossy(compatible).to_ascii_lowercase(); - compatible.contains("amlogic,gxm") - || compatible.contains("amlogic, gxm") - || compatible.contains("amlogic,meson-gxm") - || compatible.contains("amlogic,s912") -} - -pub fn system_is_s912_gxm() -> Result { - #[cfg(all(target_os = "linux", target_arch = "aarch64"))] - { - let compatible = std::fs::read("/proc/device-tree/compatible").map_err(|error| { - AppError::VideoError(format!( - "Cannot read /proc/device-tree/compatible for AMLENC detection: {}", - error - )) - })?; - return Ok(is_s912_gxm_compatible(&compatible)); - } - #[cfg(not(all(target_os = "linux", target_arch = "aarch64")))] - Ok(false) -} - -/// Perform the destructive part of backend detection: initialize and encode one -/// 640x480 NV12 frame. The caller must first check SoC compatibility and node. -pub fn smoke_test(codec: AmlencCodec) -> Result<()> { - let resolution = Resolution::new(640, 480); - let config = AmlencConfig { - codec, - resolution, - fps: 30, - bitrate_kbps: 1_000, - gop: 30, - }; - let mut encoder = AmlencEncoder::new(config)?; - let mut frame = vec![0x80; nv12_frame_size(resolution)?]; - frame[..(resolution.width * resolution.height) as usize].fill(0x10); - for _ in 0..3 { - if encoder.encode_raw(&frame)?.is_some() { - return Ok(()); - } - } - Err(AppError::VideoError(format!( - "{} produced no output during the 640x480 probe", - codec.codec_name() - ))) -} - -#[cfg(test)] -mod tests { - use super::*; - #[cfg(unix)] - use std::process::Command; - #[cfg(unix)] - use std::sync::Mutex; - - #[cfg(unix)] - static TEST_INSTANCE_MUTEX: Mutex<()> = Mutex::new(()); - - #[cfg(unix)] - const H264_FIXTURE: &str = r#" - static int values[16]; - static int mode; - static int fail_pending; - int one_kvm_amlenc_abi_version(void) { return 1; } - long vl_video_encoder_init(int codec, int width, int height, int fps, - int bitrate, int gop, int image_format) { - values[0]++; values[1] = codec; values[2] = width; values[3] = height; - values[4] = fps; values[5] = bitrate; values[6] = gop; - values[7] = image_format; return 1; - } - int vl_video_encoder_encode(long handle, int frame_type, unsigned char *in, - int in_size, unsigned char *out, int format) { - (void)handle; (void)in; values[8]++; values[9] = frame_type; - values[10] = in_size; values[11] = format; - if (fail_pending) { fail_pending = 0; return -9; } - if (mode == 2) return 0; - if (mode == 3) return 2000000; - { unsigned char data[] = {0,0,1,0x67,0,0,1,0x68,0,0,1,0x65}; - for (unsigned long i = 0; i < sizeof(data); i++) out[i] = data[i]; - return sizeof(data); } - } - int vl_video_encoder_destory(long handle) { (void)handle; values[12]++; return 1; } - int test_get(int index) { return values[index]; } - void test_set_mode(int value) { mode = value; } - void test_fail_once(void) { fail_pending = 1; } - "#; - - #[cfg(unix)] - const H265_FIXTURE: &str = r#" - static int values[16]; - static int mode; - int one_kvm_amlenc_abi_version(void) { return 1; } - long vl_video_encoder_init(int codec, int width, int height, int fps, - int bitrate, int gop) { - values[0]++; values[1] = codec; values[2] = width; values[3] = height; - values[4] = fps; values[5] = bitrate; values[6] = gop; return 1; - } - int vl_video_encoder_encode(long handle, int frame_type, unsigned char *in, - unsigned int output_len, unsigned char *out, int format) { - (void)handle; (void)in; values[8]++; values[9] = frame_type; - values[10] = output_len; values[11] = format; - if (mode == 3) return output_len + 1; - { unsigned char data[] = {0,0,1,0x40,1,0,0,1,0x42,1,0,0,1,0x44,1, - 0,0,1,0x26,1}; - for (unsigned long i = 0; i < sizeof(data); i++) out[i] = data[i]; - return sizeof(data); } - } - int vl_video_encoder_destory(long handle) { (void)handle; values[12]++; return 1; } - int test_get(int index) { return values[index]; } - void test_set_mode(int value) { mode = value; } - "#; - - #[cfg(unix)] - fn build_fixture(directory: &Path, name: &str, source: &str) -> PathBuf { - let source_path = directory.join(format!("{name}.c")); - let library_path = directory.join(format!("lib{name}.so")); - std::fs::write(&source_path, source).unwrap(); - let status = Command::new("cc") - .args(["-shared", "-fPIC"]) - .arg(&source_path) - .arg("-o") - .arg(&library_path) - .status() - .unwrap(); - assert!(status.success()); - library_path - } - - #[test] - fn validates_geometry_fps_and_nv12_size() { - let valid = AmlencConfig { - codec: AmlencCodec::H264, - resolution: Resolution::new(1920, 1080), - fps: 60, - bitrate_kbps: 8_000, - gop: 60, - }; - assert!(valid.validate().is_ok()); - assert_eq!(nv12_frame_size(valid.resolution).unwrap(), 3_110_400); - - for invalid in [ - AmlencConfig { - resolution: Resolution::new(1919, 1080), - ..valid - }, - AmlencConfig { - resolution: Resolution::new(1920, 1079), - ..valid - }, - AmlencConfig { - resolution: Resolution::new(2560, 1440), - ..valid - }, - AmlencConfig { fps: 61, ..valid }, - ] { - assert!(invalid.validate().is_err()); - } - } - - #[test] - fn recognizes_vendor_and_mainline_gxm_compatibles() { - assert!(is_s912_gxm_compatible(b"amlogic, Gxm\0khadas,kvim2")); - assert!(is_s912_gxm_compatible( - b"amlogic,q200\0amlogic,s912\0amlogic,meson-gxm" - )); - assert!(!is_s912_gxm_compatible(b"rockchip,rk3588")); - } - - #[test] - fn validates_abi_marker() { - let path = Path::new("libvpcodec.so"); - assert!(validate_abi_version(AMLENC_ABI_VERSION, path).is_ok()); - assert!(validate_abi_version(0, path).is_err()); - } - - #[test] - fn parses_h264_idr_and_parameter_sets() { - let data = [0, 0, 0, 1, 0x67, 1, 0, 0, 1, 0x68, 2, 0, 0, 0, 1, 0x65, 3]; - assert!(is_keyframe(AmlencCodec::H264, &data)); - assert!(has_parameter_sets(AmlencCodec::H264, &data)); - assert!(!is_keyframe(AmlencCodec::H264, &[0, 0, 1, 0x41])); - } - - #[test] - fn parses_h265_irap_and_parameter_sets() { - let data = [ - 0, - 0, - 1, - 32 << 1, - 1, - 0, - 0, - 1, - 33 << 1, - 1, - 0, - 0, - 1, - 34 << 1, - 1, - 0, - 0, - 1, - 19 << 1, - 1, - ]; - assert!(is_keyframe(AmlencCodec::H265, &data)); - assert!(has_parameter_sets(AmlencCodec::H265, &data)); - assert!(!is_keyframe(AmlencCodec::H265, &[0, 0, 1, 1 << 1, 1])); - } - - #[test] - #[cfg(unix)] - fn loads_symbols_maps_both_abis_and_recovers() { - let _test_instance = TEST_INSTANCE_MUTEX.lock().unwrap(); - let directory = tempfile::tempdir().unwrap(); - let h264_path = build_fixture(directory.path(), "amlenc_h264", H264_FIXTURE); - let h265_path = build_fixture(directory.path(), "amlenc_h265", H265_FIXTURE); - - type GetFn = unsafe extern "C" fn(c_int) -> c_int; - type SetModeFn = unsafe extern "C" fn(c_int); - type FailOnceFn = unsafe extern "C" fn(); - - // Keep this second dlopen alive so the fixture's counters remain available. - let h264_control = unsafe { Library::new(&h264_path) }.unwrap(); - let h264_get: GetFn = unsafe { *h264_control.get(b"test_get\0").unwrap() }; - let h264_set_mode: SetModeFn = unsafe { *h264_control.get(b"test_set_mode\0").unwrap() }; - let h264_fail_once: FailOnceFn = unsafe { *h264_control.get(b"test_fail_once\0").unwrap() }; - - let resolution = Resolution::new(640, 480); - let frame = vec![0x80; nv12_frame_size(resolution).unwrap()]; - { - let mut encoder = AmlencEncoder::with_library( - AmlencConfig { - codec: AmlencCodec::H264, - resolution, - fps: 60, - bitrate_kbps: 2_000, - gop: 60, - }, - &h264_path, - ) - .unwrap(); - assert!(encoder.encode_raw(&frame).unwrap().unwrap().1); - // SAFETY: indices and fixture signatures are fixed above. - unsafe { - assert_eq!(h264_get(1), CODEC_ID_H264); - assert_eq!(h264_get(4), 60); - assert_eq!(h264_get(5), 2_000_000); - assert_eq!(h264_get(6), 0); - assert_eq!(h264_get(7), IMG_FMT_NV12); - assert_eq!(h264_get(9), FRAME_TYPE_AUTO); - assert_eq!(h264_get(10), MIN_OUTPUT_BUFFER_SIZE as c_int); - assert_eq!(h264_get(11), H264_NV12_FORMAT); - - h264_fail_once(); - } - assert!(encoder.encode_raw(&frame).unwrap().is_some()); - unsafe { assert_eq!(h264_get(0), 2) }; - - unsafe { h264_set_mode(2) }; - encoder.request_keyframe(); - assert!(encoder.encode_raw(&frame).unwrap().is_none()); - unsafe { assert_eq!(h264_get(9), FRAME_TYPE_AUTO) }; - unsafe { assert_eq!(h264_get(0), 3) }; - assert!(encoder.encode_raw(&frame).unwrap().is_none()); - unsafe { assert_eq!(h264_get(9), FRAME_TYPE_AUTO) }; - assert!(encoder.encode_raw(&frame).unwrap().is_none()); - unsafe { assert_eq!(h264_get(0), 3) }; - - encoder.last_output = Instant::now() - OUTPUT_STALL_TIMEOUT; - assert!(encoder.encode_raw(&frame).unwrap().is_none()); - unsafe { assert_eq!(h264_get(0), 4) }; - - unsafe { h264_set_mode(0) }; - encoder.set_bitrate(3_000).unwrap(); - assert!(encoder.encode_raw(&frame).unwrap().unwrap().1); - unsafe { - assert_eq!(h264_get(5), 3_000_000); - assert_eq!(h264_get(9), FRAME_TYPE_AUTO); - assert_eq!(h264_get(0), 5); - } - } - - let h265_control = unsafe { Library::new(&h265_path) }.unwrap(); - let h265_get: GetFn = unsafe { *h265_control.get(b"test_get\0").unwrap() }; - let h265_set_mode: SetModeFn = unsafe { *h265_control.get(b"test_set_mode\0").unwrap() }; - { - let mut encoder = AmlencEncoder::with_library( - AmlencConfig { - codec: AmlencCodec::H265, - resolution, - fps: 30, - bitrate_kbps: 1_500, - gop: 30, - }, - &h265_path, - ) - .unwrap(); - assert!(encoder.encode_raw(&frame).unwrap().unwrap().1); - unsafe { - assert_eq!(h265_get(1), CODEC_ID_H265); - assert_eq!(h265_get(4), 30); - assert_eq!(h265_get(5), 1_500_000); - assert_eq!(h265_get(9), FRAME_TYPE_IDR); - assert_eq!(h265_get(10), MIN_OUTPUT_BUFFER_SIZE as c_int); - assert_eq!(h265_get(11), H265_NV12_FORMAT); - h265_set_mode(3); - } - let error = encoder.encode_raw(&frame).unwrap_err().to_string(); - assert!(error.contains("oversized output")); - } - } - - #[test] - #[cfg(unix)] - fn rejects_unpatched_library_without_abi_symbol() { - let _test_instance = TEST_INSTANCE_MUTEX.lock().unwrap(); - let directory = tempfile::tempdir().unwrap(); - let path = build_fixture( - directory.path(), - "unpatched_amlenc", - "long vl_video_encoder_init(void) { return 1; }", - ); - let error = AmlencEncoder::with_library( - AmlencConfig { - codec: AmlencCodec::H264, - resolution: Resolution::new(640, 480), - fps: 30, - bitrate_kbps: 1_000, - gop: 30, - }, - path, - ) - .err() - .expect("unpatched library must be rejected") - .to_string(); - assert!(error.contains("one_kvm_amlenc_abi_version")); - } -} diff --git a/src/video/codec/h264.rs b/src/video/codec/h264.rs index 5ebc31e6..8b0ac59a 100644 --- a/src/video/codec/h264.rs +++ b/src/video/codec/h264.rs @@ -48,8 +48,6 @@ pub enum H264EncoderType { Rkmpp, /// V4L2 M2M (ARM generic) - requires hwcodec extension V4l2M2m, - /// Amlogic S912/GXM AMLENC - Amlogic, /// Software encoding (libx264/openh264) Software, /// No encoder available @@ -66,7 +64,6 @@ impl std::fmt::Display for H264EncoderType { H264EncoderType::Vaapi => write!(f, "VAAPI"), H264EncoderType::Rkmpp => write!(f, "RKMPP"), H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), - H264EncoderType::Amlogic => write!(f, "AMLENC"), H264EncoderType::Software => write!(f, "Software"), H264EncoderType::None => write!(f, "None"), } @@ -83,7 +80,6 @@ impl From for H264EncoderType { EncoderBackend::Vaapi => H264EncoderType::Vaapi, EncoderBackend::Rkmpp => H264EncoderType::Rkmpp, EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m, - EncoderBackend::Amlogic => H264EncoderType::Amlogic, EncoderBackend::Software => H264EncoderType::Software, } } @@ -202,7 +198,7 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec { align: 1, fps: 30, gop: 30, - rc: RateControl::RC_CBR, + rc: RateControl::RC_VBR, quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (ultrafast) kbs: 2000, q: 23, @@ -301,7 +297,7 @@ impl H264Encoder { align: 1, fps: config.fps as i32, gop: config.gop_size as i32, - rc: RateControl::RC_CBR, + rc: RateControl::RC_VBR, quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (lowest latency) kbs: config.bitrate_kbps as i32, q: 23, diff --git a/src/video/codec/h264_bitstream.rs b/src/video/codec/h264_bitstream.rs index eac14041..d47357e5 100644 --- a/src/video/codec/h264_bitstream.rs +++ b/src/video/codec/h264_bitstream.rs @@ -252,13 +252,37 @@ pub fn avcc_to_annex_b(data: &[u8]) -> Option> { } } -pub fn normalize_for_webrtc(data: &[u8]) -> Vec { - if is_annex_b(data) { - return strip_aud_nal_units(data); +/// Normalize a length-prefixed H.264 access unit to Annex-B when necessary. +/// +/// FFmpeg normally exposes elementary H.264 from hardware encoders as +/// Annex-B, but some V4L2 M2M drivers return AVCC-style packets. Consumers +/// such as RustDesk do not receive codec extradata from our protocol adapter, +/// so passing those packets through unchanged leaves the decoder unable to +/// find NAL unit boundaries. +pub fn normalize_annex_b(data: bytes::Bytes) -> bytes::Bytes { + // A four-byte start code is unambiguous for real encoder output. A + // three-byte prefix is not: an AVCC NAL of 256..511 bytes also begins + // with 00 00 01. Validate AVCC before accepting that shorter prefix. + if data.starts_with(&[0, 0, 0, 1]) { + return data; } - if let Some(annex_b) = avcc_to_annex_b(data) { - return strip_aud_nal_units(&annex_b); + if let Some(annex_b) = avcc_to_annex_b(data.as_ref()) { + return bytes::Bytes::from(annex_b); + } + + data +} + +pub fn normalize_for_webrtc(data: &[u8]) -> Vec { + if !data.starts_with(&[0, 0, 0, 1]) { + if let Some(annex_b) = avcc_to_annex_b(data) { + return strip_aud_nal_units(&annex_b); + } + } + + if is_annex_b(data) { + return strip_aud_nal_units(data); } data.to_vec() @@ -296,4 +320,36 @@ mod tests { Some("42402a".to_string()) ); } + + #[test] + fn converts_avcc_access_unit_to_annex_b() { + let avcc = [ + 0, 0, 0, 4, 0x67, 0x42, 0x40, 0x1f, // SPS + 0, 0, 0, 2, 0x68, 0xce, // PPS + 0, 0, 0, 3, 0x65, 0x88, 0x84, // IDR + ]; + + let annex_b = normalize_annex_b(bytes::Bytes::copy_from_slice(&avcc)); + assert!(is_annex_b(&annex_b)); + assert!(has_sps_pps(&annex_b)); + assert!(is_keyframe(&annex_b)); + } + + #[test] + fn leaves_annex_b_packet_unchanged() { + let annex_b = bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x84]); + let normalized = normalize_annex_b(annex_b.clone()); + assert_eq!(normalized, annex_b); + } + + #[test] + fn recognizes_avcc_length_that_looks_like_three_byte_start_code() { + let mut avcc = vec![0, 0, 1, 0]; + avcc.push(0x65); + avcc.resize(4 + 256, 0x88); + + let annex_b = normalize_annex_b(bytes::Bytes::from(avcc)); + assert_eq!(&annex_b[..5], &[0, 0, 0, 1, 0x65]); + assert!(is_keyframe(&annex_b)); + } } diff --git a/src/video/codec/h265.rs b/src/video/codec/h265.rs index dd5df5e7..08f3dbed 100644 --- a/src/video/codec/h265.rs +++ b/src/video/codec/h265.rs @@ -45,8 +45,6 @@ pub enum H265EncoderType { Rkmpp, /// V4L2 M2M (ARM generic) V4l2M2m, - /// Amlogic S912/GXM AMLENC - Amlogic, /// Software encoder (libx265) Software, /// No encoder available @@ -63,7 +61,6 @@ impl std::fmt::Display for H265EncoderType { H265EncoderType::Vaapi => write!(f, "VAAPI"), H265EncoderType::Rkmpp => write!(f, "RKMPP"), H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), - H265EncoderType::Amlogic => write!(f, "AMLENC"), H265EncoderType::Software => write!(f, "Software"), H265EncoderType::None => write!(f, "None"), } @@ -79,7 +76,6 @@ impl From for H265EncoderType { EncoderBackend::Vaapi => H265EncoderType::Vaapi, EncoderBackend::Rkmpp => H265EncoderType::Rkmpp, EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m, - EncoderBackend::Amlogic => H265EncoderType::Amlogic, EncoderBackend::Software => H265EncoderType::Software, } } diff --git a/src/video/codec/h265_bitstream.rs b/src/video/codec/h265_bitstream.rs new file mode 100644 index 00000000..0c0b58f7 --- /dev/null +++ b/src/video/codec/h265_bitstream.rs @@ -0,0 +1,112 @@ +const VPS_NAL_TYPE: u8 = 32; +const SPS_NAL_TYPE: u8 = 33; +const PPS_NAL_TYPE: u8 = 34; + +fn find_start_code(data: &[u8], from: usize) -> Option<(usize, usize)> { + let mut offset = from; + while offset + 3 <= data.len() { + if offset + 4 <= data.len() && data[offset..offset + 4] == [0, 0, 0, 1] { + return Some((offset, 4)); + } + if data[offset..offset + 3] == [0, 0, 1] { + return Some((offset, 3)); + } + offset += 1; + } + None +} + +fn for_each_nal(data: &[u8], mut visit: impl FnMut(u8, &[u8])) { + let mut cursor = 0; + while let Some((start, start_code_len)) = find_start_code(data, cursor) { + let nal_start = start + start_code_len; + if nal_start + 2 > data.len() { + break; + } + let next_start = find_start_code(data, nal_start) + .map(|(offset, _)| offset) + .unwrap_or(data.len()); + let mut nal_end = next_start; + while nal_end > nal_start && data[nal_end - 1] == 0 { + nal_end -= 1; + } + if nal_end >= nal_start + 2 { + visit((data[nal_start] >> 1) & 0x3f, &data[nal_start..nal_end]); + } + if next_start == data.len() { + break; + } + cursor = next_start; + } +} + +pub fn is_keyframe(data: &[u8]) -> bool { + let mut keyframe = false; + for_each_nal(data, |nal_type, _| { + if (16..=23).contains(&nal_type) { + keyframe = true; + } + }); + keyframe +} + +pub fn extract_vps_sps_pps(data: &[u8]) -> (Option>, Option>, Option>) { + let mut vps = None; + let mut sps = None; + let mut pps = None; + for_each_nal(data, |nal_type, nal| match nal_type { + VPS_NAL_TYPE => vps = Some(nal.to_vec()), + SPS_NAL_TYPE => sps = Some(nal.to_vec()), + PPS_NAL_TYPE => pps = Some(nal.to_vec()), + _ => {} + }); + (vps, sps, pps) +} + +pub fn has_vps_sps_pps(data: &[u8]) -> bool { + let (vps, sps, pps) = extract_vps_sps_pps(data); + vps.is_some() && sps.is_some() && pps.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_irap_but_not_trail_frame() { + assert!(is_keyframe(&[0, 0, 0, 1, 19 << 1, 1, 0xaa])); + assert!(is_keyframe(&[0, 0, 1, 21 << 1, 1, 0xbb])); + assert!(!is_keyframe(&[0, 0, 0, 1, 1 << 1, 1, 0xcc])); + } + + #[test] + fn extracts_parameter_sets() { + let data = [ + 0, + 0, + 0, + 1, + 32 << 1, + 1, + 0xaa, + 0, + 0, + 1, + 33 << 1, + 1, + 0xbb, + 0, + 0, + 0, + 1, + 34 << 1, + 1, + 0xcc, + ]; + let (vps, sps, pps) = extract_vps_sps_pps(&data); + assert_eq!(vps.unwrap(), [32 << 1, 1, 0xaa]); + assert_eq!(sps.unwrap(), [33 << 1, 1, 0xbb]); + assert_eq!(pps.unwrap(), [34 << 1, 1, 0xcc]); + assert!(has_vps_sps_pps(&data)); + } +} diff --git a/src/video/codec/mod.rs b/src/video/codec/mod.rs index 04d8823f..cfa663a3 100644 --- a/src/video/codec/mod.rs +++ b/src/video/codec/mod.rs @@ -3,12 +3,12 @@ use hwcodec::common::DataFormat; use hwcodec::ffmpeg_ram::CodecInfo; -pub mod amlenc; pub mod convert; pub mod h264; pub mod h264_bitstream; pub mod h265; +pub mod h265_bitstream; pub mod jpeg; pub mod registry; pub mod self_check; @@ -20,7 +20,6 @@ pub mod vp9; #[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))] pub mod mjpeg_rkmpp; -pub use amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder}; pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer}; pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat}; pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat}; diff --git a/src/video/codec/registry.rs b/src/video/codec/registry.rs index 3baa50c8..d12bb663 100644 --- a/src/video/codec/registry.rs +++ b/src/video/codec/registry.rs @@ -10,17 +10,11 @@ use std::sync::OnceLock; use std::time::Duration; use tracing::{debug, info, warn}; -use super::amlenc::{self, AmlencCodec, AMLENC_H264_CODEC_NAME, AMLENC_H265_CODEC_NAME}; - use hwcodec::common::{DataFormat, Quality, RateControl}; use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat}; use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder}; use hwcodec::ffmpeg_ram::CodecInfo; -// Keep native AMLENC behind the highest-priority desktop GPU backends while -// ensuring it is selected before hwcodec's software priority (3). -const AMLENC_PRIORITY: i32 = 2; - /// Video encoder format type #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum VideoEncoderType { @@ -102,8 +96,6 @@ pub enum EncoderBackend { Rkmpp, /// V4L2 Memory-to-Memory (ARM) V4l2m2m, - /// Amlogic S912/GXM vendor AMLENC - Amlogic, /// Software encoding (libx264, libx265, libvpx) Software, } @@ -123,8 +115,6 @@ impl EncoderBackend { EncoderBackend::Rkmpp } else if name.contains("v4l2m2m") { EncoderBackend::V4l2m2m - } else if name.contains("amlenc") { - EncoderBackend::Amlogic } else { EncoderBackend::Software } @@ -144,7 +134,6 @@ impl EncoderBackend { EncoderBackend::Amf => "AMF", EncoderBackend::Rkmpp => "RKMPP", EncoderBackend::V4l2m2m => "V4L2 M2M", - EncoderBackend::Amlogic => "AMLENC", EncoderBackend::Software => "Software", } } @@ -159,7 +148,6 @@ impl EncoderBackend { "amf" => Some(EncoderBackend::Amf), "rkmpp" => Some(EncoderBackend::Rkmpp), "v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m), - "amlogic" | "amlenc" => Some(EncoderBackend::Amlogic), "software" | "cpu" => Some(EncoderBackend::Software), _ => None, } @@ -286,79 +274,6 @@ impl EncoderRegistry { } } - fn detect_amlenc(&mut self) { - match amlenc::system_is_s912_gxm() { - Ok(true) => {} - Ok(false) => { - debug!("AMLENC skipped: host is not Linux/aarch64 S912/GXM"); - return; - } - Err(error) => { - warn!("AMLENC skipped: {}", error); - return; - } - } - - self.detect_amlenc_candidates( - true, - |codec| std::path::Path::new(codec.device_node()).exists(), - amlenc::smoke_test, - ); - } - - fn detect_amlenc_candidates( - &mut self, - compatible: bool, - mut node_exists: NodeExists, - mut smoke_test: SmokeTest, - ) where - NodeExists: FnMut(AmlencCodec) -> bool, - SmokeTest: FnMut(AmlencCodec) -> crate::error::Result<()>, - { - if !compatible { - return; - } - - for (codec, format, codec_name) in [ - ( - AmlencCodec::H264, - VideoEncoderType::H264, - AMLENC_H264_CODEC_NAME, - ), - ( - AmlencCodec::H265, - VideoEncoderType::H265, - AMLENC_H265_CODEC_NAME, - ), - ] { - let node = codec.device_node(); - if !node_exists(codec) { - warn!( - "AMLENC {} unavailable: device node {} is missing", - format, node - ); - continue; - } - - match smoke_test(codec) { - Ok(()) => { - self.encoders - .entry(format) - .or_default() - .push(AvailableEncoder { - format, - codec_name: codec_name.to_string(), - backend: EncoderBackend::Amlogic, - priority: AMLENC_PRIORITY, - is_hardware: true, - }); - info!("Registered native AMLENC encoder: {}", codec_name); - } - Err(error) => warn!("AMLENC {} unavailable ({}): {}", format, node, error), - } - } - } - /// Get the global registry instance /// /// The registry is initialized lazily on first access with 1280x720 detection. @@ -426,8 +341,6 @@ impl EncoderRegistry { } } - self.detect_amlenc(); - // Sort encoders by priority (lower is better) for encoders in self.encoders.values_mut() { encoders.sort_by_key(|e| e.priority); @@ -624,14 +537,6 @@ mod tests { EncoderBackend::from_codec_name("libx264"), EncoderBackend::Software ); - assert_eq!( - EncoderBackend::from_codec_name("h264_amlenc"), - EncoderBackend::Amlogic - ); - assert_eq!( - EncoderBackend::from_str("amlogic"), - Some(EncoderBackend::Amlogic) - ); } #[test] @@ -656,65 +561,4 @@ mod tests { println!("Available formats: {:?}", registry.available_formats(false)); println!("Selectable formats: {:?}", registry.selectable_formats()); } - - #[test] - fn test_amlenc_registration_prerequisite_matrix() { - let ok = |_codec| Ok(()); - - let mut incompatible = EncoderRegistry::new(); - incompatible.detect_amlenc_candidates(false, |_| true, ok); - assert!(incompatible.encoders.is_empty()); - - let mut no_nodes = EncoderRegistry::new(); - no_nodes.detect_amlenc_candidates(true, |_| false, ok); - assert!(no_nodes.encoders.is_empty()); - - for reason in ["library missing", "ABI marker missing"] { - let mut rejected = EncoderRegistry::new(); - rejected.detect_amlenc_candidates( - true, - |_| true, - |_| Err(crate::error::AppError::VideoError(reason.to_string())), - ); - assert!(rejected.encoders.is_empty()); - } - - let mut h264_only = EncoderRegistry::new(); - h264_only.detect_amlenc_candidates(true, |codec| codec == AmlencCodec::H264, ok); - assert!(h264_only - .encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic) - .is_some()); - assert!(h264_only - .encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic) - .is_none()); - - let mut both = EncoderRegistry::new(); - both.detect_amlenc_candidates(true, |_| true, ok); - assert!(both - .encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic) - .is_some()); - assert!(both - .encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic) - .is_some()); - - both.encoders - .entry(VideoEncoderType::H264) - .or_default() - .push(AvailableEncoder { - format: VideoEncoderType::H264, - codec_name: "libx264".to_string(), - backend: EncoderBackend::Software, - priority: 3, - is_hardware: false, - }); - both.encoders - .get_mut(&VideoEncoderType::H264) - .unwrap() - .sort_by_key(|encoder| encoder.priority); - assert_eq!( - both.best_available_encoder(VideoEncoderType::H264) - .map(|encoder| encoder.backend), - Some(EncoderBackend::Amlogic) - ); - } } diff --git a/src/video/codec/self_check.rs b/src/video/codec/self_check.rs index 8325e95a..be6eed32 100644 --- a/src/video/codec/self_check.rs +++ b/src/video/codec/self_check.rs @@ -3,8 +3,8 @@ use std::sync::mpsc; use std::time::{Duration, Instant}; use super::{ - AmlencCodec, AmlencConfig, AmlencEncoder, EncoderRegistry, H264Config, H264Encoder, H265Config, - H265Encoder, VP8Config, VP8Encoder, VP9Config, VP9Encoder, VideoEncoderType, + EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder, + VP9Config, VP9Encoder, VideoEncoderType, }; use crate::error::{AppError, Result}; use crate::video::format::{PixelFormat, Resolution}; @@ -226,9 +226,6 @@ fn run_smoke_test( resolution: Resolution, codec_name_ffmpeg: &str, ) -> Result<()> { - if codec_name_ffmpeg.contains("amlenc") { - return run_amlenc_smoke_test(codec, resolution); - } match codec { VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg), VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg), @@ -237,37 +234,6 @@ fn run_smoke_test( } } -fn run_amlenc_smoke_test(codec: VideoEncoderType, resolution: Resolution) -> Result<()> { - let amlenc_codec = match codec { - VideoEncoderType::H264 => AmlencCodec::H264, - VideoEncoderType::H265 => AmlencCodec::H265, - _ => { - return Err(AppError::VideoError( - "AMLENC only supports H.264 and H.265".to_string(), - )) - } - }; - let mut encoder = AmlencEncoder::new(AmlencConfig { - codec: amlenc_codec, - resolution, - fps: 30, - bitrate_kbps: bitrate_kbps_for_resolution(resolution), - gop: 30, - })?; - let frame_len = PixelFormat::Nv12.frame_size(resolution).ok_or_else(|| { - AppError::VideoError("Cannot calculate AMLENC NV12 self-check size".to_string()) - })?; - let frame = build_nv12_test_frame(resolution, frame_len); - for _ in 0..SELF_CHECK_FRAME_ATTEMPTS { - if encoder.encode_raw(&frame)?.is_some() { - return Ok(()); - } - } - Err(AppError::VideoError( - "AMLENC produced no output after multiple frames".to_string(), - )) -} - fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> { let mut encoder = H264Encoder::with_codec( H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)), diff --git a/src/video/pipeline/dmabuf.rs b/src/video/pipeline/dmabuf.rs new file mode 100644 index 00000000..0fc9fba7 --- /dev/null +++ b/src/video/pipeline/dmabuf.rs @@ -0,0 +1,392 @@ +//! RKMPP-only capture/encode worker. Raw buffer ownership never crosses into a +//! latest-frame slot or a network subscriber. Other encoders use shared.rs. +use super::*; +use crate::video::capture::status::capture_recovery_status; +use crate::video::codec::registry::EncoderRegistry; +use hwcodec::rkmpp_dmabuf::{DmaEncoder, DmaEncoderConfig, DmaFormat}; + +pub(super) fn eligible(config: &SharedVideoPipelineConfig) -> bool { + if std::env::var("ONE_KVM_RKMPP_DMABUF").as_deref() == Ok("0") { + return false; + } + // The UVC per-frame mapping needed by older BSPs costs more than copying + // compressed packets in our current tests. Keep JPEG DMA opt-in; raw DMA + // remains automatic. The existing JPEG hardware transcode is the default. + if config.input_format == PixelFormat::Mjpeg + && std::env::var("ONE_KVM_RKMPP_MJPEG_DMABUF").as_deref() != Ok("1") + { + return false; + } + let registry = EncoderRegistry::global(); + let selected = match config.encoder_backend { + Some(backend) => registry.encoder_with_backend(config.output_codec, backend), + None => registry.best_available_encoder(config.output_codec), + }; + rkmpp_dma_eligible( + selected.map(|e| e.backend), + config.output_codec, + config.input_format, + ) +} + +pub(super) fn prepare( + stream: &CaptureStream, + config: &SharedVideoPipelineConfig, +) -> Result { + let buffers = stream + .export_dmabufs() + .map_err(|e| AppError::VideoError(e.to_string()))?; + DmaEncoder::new( + DmaEncoderConfig { + width: config.resolution.width, + height: config.resolution.height, + stride: stream.stride(), + format: match stream.format() { + PixelFormat::Nv12 => DmaFormat::Nv12, + PixelFormat::Bgr24 => DmaFormat::Bgr24, + PixelFormat::Yuyv => DmaFormat::Yuyv, + PixelFormat::Rgb24 => DmaFormat::Rgb24, + PixelFormat::Mjpeg => DmaFormat::Mjpeg, + _ => return Err(AppError::VideoError("Unsupported DMA format".into())), + }, + hevc: config.output_codec == VideoEncoderType::H265, + fps: config.fps, + bitrate_kbps: config.bitrate_kbps(), + gop: config.gop_size().max(1), + }, + buffers, + ) + .map_err(AppError::VideoError) +} + +enum CaptureEncoder { + Dma(DmaEncoder), + Copy(Box), +} + +// Field order is intentional, including during unwinding: destroy the encoder +// and its imported FDs before STREAMOFF/unmap/REQBUFS(0). +struct ActiveCapture { + encoder: Option, + stream: CaptureStream, +} + +impl ActiveCapture { + fn fallback(&mut self, config: &SharedVideoPipelineConfig) -> Result<()> { + drop(self.encoder.take()); + self.encoder = Some(CaptureEncoder::Copy(Box::new(build_encoder_state(config)?))); + Ok(()) + } +} + +struct Completion(Arc); +impl Drop for Completion { + fn drop(&mut self) { + self.0.running_flag.store(false, Ordering::Release); + self.0.clear_cmd_tx(); + let _ = self.0.encoder_done.send(true); + let _ = self.0.running.send(false); + info!("RKMPP capture/encode worker stopped and device resources released"); + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn start( + pipeline: Arc, + stream: CaptureStream, + encoder: DmaEncoder, + config: SharedVideoPipelineConfig, + device: std::path::PathBuf, + buffer_count: u32, + bridge: BridgeContext, +) -> Result<()> { + let (tx, rx) = mpsc::unbounded_channel(); + *pipeline.cmd_tx.write() = Some(tx); + pipeline.running_flag.store(true, Ordering::Release); + let _ = pipeline.encoder_done.send(false); + let _ = pipeline.running.send(true); + let worker = pipeline.clone(); + info!( + "RKMPP DMA candidate: device={} format={:?} resolution={:?} stride={}", + device.display(), + stream.format(), + stream.resolution(), + stream.stride() + ); + let active = ActiveCapture { + encoder: Some(CaptureEncoder::Dma(encoder)), + stream, + }; + let result = std::thread::Builder::new() + .name("rkmpp-dmabuf".into()) + .spawn(move || { + let _completion = Completion(worker.clone()); + if let Err(error) = run(&worker, active, config, device, buffer_count, bridge, rx) { + error!("RKMPP DMA worker failed: {}", error); + } + }); + if let Err(error) = result { + drop(Completion(pipeline)); + return Err(AppError::VideoError(format!( + "Failed to start RKMPP DMA worker: {error}" + ))); + } + info!("RKMPP DMA capture path active: no CPU raw-frame copies (ONE_KVM_RKMPP_DMABUF=0 disables it)"); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn run( + pipeline: &Arc, + initial: ActiveCapture, + mut config: SharedVideoPipelineConfig, + device: std::path::PathBuf, + buffer_count: u32, + bridge: BridgeContext, + mut commands: mpsc::UnboundedReceiver, +) -> Result<()> { + let policy = CaptureRecoveryPolicy::new(config.control_mode); + let mut active = Some(initial); + let mut allow_dma = true; + let mut failures = 0u32; + let mut idle_since: Option = None; + let buffer_pool = Arc::new(FrameBufferPool::new(2)); // allocated only on fallback + let mut fps_frames = 0u32; + let mut fps_start = Instant::now(); + let errors = LogThrottler::with_secs(5); + + while pipeline.running_flag.load(Ordering::Acquire) { + if pipeline.subscriber_count() == 0 { + if idle_since.get_or_insert_with(Instant::now).elapsed() + >= Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS) + { + break; + } + std::thread::sleep(Duration::from_millis(50)); + continue; + } + idle_since = None; + + while let Ok(command) = commands.try_recv() { + let PipelineCmd::SetBitrate { preset } = command; + // Preserve Custom values and preset-specific GOPs across fallback/reopen. + config.bitrate_preset = preset; + if let Some(capture) = active.as_mut() { + match capture.encoder.as_mut().expect("active encoder") { + CaptureEncoder::Dma(encoder) => { + if let Err(error) = + encoder.reconfigure(config.bitrate_kbps(), config.gop_size().max(1)) + { + warn!( + "RKMPP DMA reconfigure failed, using copy encoder: {}", + error + ); + capture.fallback(&config)?; + allow_dma = false; + pipeline.keyframe_requested.store(true, Ordering::Release); + } + } + CaptureEncoder::Copy(encoder) => { + pipeline.apply_cmd(encoder, PipelineCmd::SetBitrate { preset })? + } + } + } + } + + if active.is_none() { + match open_capture_stream_for_retry( + &device, + config.resolution, + config.input_format, + config.fps, + buffer_count.max(2), + Duration::from_secs(2), + bridge.clone(), + config.control_mode, + is_device_lost_message, + ) { + CaptureOpenResult::Opened(stream) => { + if stream.resolution() != config.resolution + || stream.format() != config.input_format + { + *pipeline.pending_sync_geometry.lock() = + Some((stream.resolution(), stream.format())); + break; + } + config.align_source_fps(stream.source_fps()); + // Update only timing: a concurrently queued bitrate command + // must retain the user's latest preset in the shared config. + pipeline.config.blocking_write().fps = config.fps; + let encoder = if allow_dma && stream.supports_rkmpp_dmabuf() { + match prepare(&stream, &config) { + Ok(encoder) => CaptureEncoder::Dma(encoder), + Err(error) => { + warn!("RKMPP DMA reopen failed, using copy encoder: {}", error); + allow_dma = false; + CaptureEncoder::Copy(Box::new(build_encoder_state(&config)?)) + } + } + } else { + CaptureEncoder::Copy(Box::new(build_encoder_state(&config)?)) + }; + active = Some(ActiveCapture { + encoder: Some(encoder), + stream, + }); + pipeline.keyframe_requested.store(true, Ordering::Release); + } + CaptureOpenResult::NoSignal(status) => { + failures = failures.saturating_add(1); + let delay = policy.retry_delay(failures); + pipeline.notify_state(PipelineStateNotification::no_signal( + status, + Some(delay.as_millis() as u64), + )); + wait_for_source_change(&bridge, delay, || { + pipeline.running_flag.load(Ordering::Acquire) + }); + continue; + } + CaptureOpenResult::DeviceLost(reason) => { + pipeline.mark_device_lost(reason); + break; + } + CaptureOpenResult::Fatal => break, + } + } + + let capture = active.as_mut().expect("opened capture"); + let result = match capture.encoder.as_mut().expect("active encoder") { + CaptureEncoder::Dma(encoder) => { + let pts = pipeline.pts_ms(); + capture + .stream + .with_next_dmabuf(|index, bytes_used, fresh_fd| { + let keyframe = pipeline.keyframe_requested.swap(false, Ordering::AcqRel); + // The callback holds the dequeue lease until native encode + // completes (or destroys MPP on error), before QBUF. + unsafe { encoder.encode(index, bytes_used, fresh_fd, pts, keyframe) } + }) + .map(|(_, packet)| { + packet + .map(|packet| { + let data = Bytes::from(packet); + let is_keyframe = match config.output_codec { + VideoEncoderType::H264 => h264_bitstream::is_keyframe(&data), + VideoEncoderType::H265 => h265_bitstream::is_keyframe(&data), + _ => false, + }; + let (data, is_keyframe) = pipeline.inspect_and_parameterize_packet( + config.output_codec, + data, + is_keyframe, + ); + if config.output_codec == VideoEncoderType::H264 { + pipeline.update_h264_profile_level_id(&data); + } + vec![EncodedVideoFrame { + data, + pts_ms: pts, + is_keyframe, + sequence: pipeline.sequence.fetch_add(1, Ordering::Relaxed) + 1, + duration: Duration::from_micros( + 1_000_000 / config.fps.max(1) as u64, + ), + codec: config.output_codec, + }] + }) + .map_err(AppError::VideoError) + }) + } + CaptureEncoder::Copy(encoder) => { + let mut raw = buffer_pool.take(0); + capture.stream.next_into(&mut raw).map(|meta| { + let frame = VideoFrame::from_pooled( + Arc::new(FrameBuffer::new(raw, Some(buffer_pool.clone()))), + config.resolution, + config.input_format, + capture.stream.stride(), + meta.sequence, + ); + pipeline.encode_frame_sync(encoder, &frame) + }) + } + }; + + match result { + Ok(Ok(frames)) => { + failures = 0; + pipeline.notify_state(PipelineStateNotification::streaming( + config.resolution, + config.input_format, + config.fps, + )); + for frame in frames { + pipeline.broadcast_encoded(Arc::new(frame)); + fps_frames += 1; + } + } + Ok(Err(error)) => { + if matches!(capture.encoder, Some(CaptureEncoder::Dma(_))) { + warn!("RKMPP DMA encode failed; disabling DMA for this pipeline and using copy encoder: {}", error); + capture.fallback(&config)?; + allow_dma = false; + pipeline.keyframe_requested.store(true, Ordering::Release); + } else if errors.should_log("copy_encode") { + error!("RKMPP copy encode failed: {}", error); + } + } + Err(CaptureReadError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => { + continue + } + Err(CaptureReadError::Io(error)) + if error.kind() == std::io::ErrorKind::InvalidData && allow_dma => + { + warn!( + "Unsupported RKMPP DMA frame layout, using copy encoder: {}", + error + ); + capture.fallback(&config)?; + allow_dma = false; + pipeline.keyframe_requested.store(true, Ordering::Release); + } + Err(error) => { + let mut status = SignalStatus::NoSignal; + if let CaptureReadError::Io(ref io) = error { + if classify_capture_io_error(io) == CaptureIoErrorKind::DeviceLost + || is_device_lost_message(&io.to_string()) + { + pipeline.mark_device_lost(io.to_string()); + break; + } + if errors.should_log("capture") { + warn!("RKMPP DMA capture recovery: {}", io); + } + status = capture_recovery_status(config.control_mode, io); + } + // ActiveCapture drops encoder/imports before the V4L2 stream. + drop(active.take()); + failures = failures.saturating_add(1); + let delay = policy.retry_delay(failures); + pipeline.notify_state(PipelineStateNotification::no_signal( + status, + Some(delay.as_millis() as u64), + )); + if !matches!(error, CaptureReadError::SourceChanged) { + wait_for_source_change(&bridge, delay, || { + pipeline.running_flag.load(Ordering::Acquire) + }); + } + } + } + if fps_start.elapsed() >= Duration::from_secs(1) { + pipeline.stats.blocking_lock().current_fps = + fps_frames as f32 / fps_start.elapsed().as_secs_f32(); + fps_frames = 0; + fps_start = Instant::now(); + } + } + // Explicitly release in the worker before Completion publishes stopped. + drop(active); + Ok(()) +} diff --git a/src/video/pipeline/encoder_state.rs b/src/video/pipeline/encoder_state.rs index dafbdc53..08b1cc3c 100644 --- a/src/video/pipeline/encoder_state.rs +++ b/src/video/pipeline/encoder_state.rs @@ -1,5 +1,4 @@ use crate::error::{AppError, Result}; -use crate::video::codec::amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder}; use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter}; use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat}; use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat}; @@ -117,47 +116,6 @@ impl VideoEncoderTrait for H265EncoderWrapper { } } -struct AmlencEncoderWrapper(AmlencEncoder); - -impl VideoEncoderTrait for AmlencEncoderWrapper { - fn encode_raw(&mut self, data: &[u8], _pts_ms: i64) -> Result> { - Ok(match self.0.encode_raw(data)? { - Some((data, keyframe)) => vec![EncodedFrame { - data, - key: i32::from(keyframe), - }], - None => Vec::new(), - }) - } - - fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> { - self.0.set_bitrate(bitrate_kbps) - } - - fn codec_name(&self) -> &str { - self.0.codec_name() - } - - fn request_keyframe(&mut self) { - self.0.request_keyframe() - } -} - -fn create_amlenc_encoder( - config: &SharedVideoPipelineConfig, - codec: AmlencCodec, -) -> Result> { - let encoder = AmlencEncoder::new(AmlencConfig { - codec, - resolution: config.resolution, - fps: config.fps, - bitrate_kbps: config.bitrate_kbps(), - gop: config.gop_size(), - })?; - info!("Created native AMLENC encoder: {}", encoder.codec_name()); - Ok(Box::new(AmlencEncoderWrapper(encoder))) -} - struct VP8EncoderWrapper(VP8Encoder); impl VideoEncoderTrait for VP8EncoderWrapper { @@ -231,9 +189,9 @@ fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, Pix Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12)) } -/// AMLENC and libjpeg-turbo use independent CPU/hardware resources. Decode -/// MJPEG in the capture worker so encoding the previous NV12 frame can overlap -/// with decoding the next frame. +/// V4L2 M2M hardware encoding and libjpeg-turbo use independent CPU/hardware +/// resources. Decode MJPEG outside the encoder worker so encoding the previous +/// NV12 frame can overlap with decoding subsequent frames. pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -> bool { if !config.input_format.is_compressed() || !matches!( @@ -248,7 +206,7 @@ pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) - Some(backend) => registry.encoder_with_backend(config.output_codec, backend), None => registry.best_available_encoder(config.output_codec), }; - selected.is_some_and(|encoder| encoder.backend == EncoderBackend::Amlogic) + selected.is_some_and(|encoder| encoder.backend == EncoderBackend::V4l2m2m) } pub(super) fn build_encoder_state( @@ -415,80 +373,70 @@ pub(super) fn build_encoder_state( let encoder: Box = match config.output_codec { VideoEncoderType::H264 => { let codec_name = selected_codec_name.clone(); - if codec_name == crate::video::codec::amlenc::AMLENC_H264_CODEC_NAME { - create_amlenc_encoder(config, AmlencCodec::H264)? - } else { - let direct_input_format = - h264_direct_input_format(&codec_name, pipeline_input_format); - let input_format = direct_input_format.unwrap_or_else(|| { - if codec_name.contains("libx264") { - H264InputFormat::Yuv420p - } else { - H264InputFormat::Nv12 - } - }); - - if use_rkmpp_direct { - info!( - "Creating H264 encoder with RKMPP backend for {} direct input (codec: {})", - config.input_format, codec_name - ); - } else if let Some(ref backend) = config.encoder_backend { - info!( - "Creating H264 encoder with backend {:?} (codec: {})", - backend, codec_name - ); + let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format); + let input_format = direct_input_format.unwrap_or_else(|| { + if codec_name.contains("libx264") { + H264InputFormat::Yuv420p + } else { + H264InputFormat::Nv12 } + }); - create_h264_encoder(config, input_format, &codec_name)? + if use_rkmpp_direct { + info!( + "Creating H264 encoder with RKMPP backend for {} direct input (codec: {})", + config.input_format, codec_name + ); + } else if let Some(ref backend) = config.encoder_backend { + info!( + "Creating H264 encoder with backend {:?} (codec: {})", + backend, codec_name + ); } + + create_h264_encoder(config, input_format, &codec_name)? } VideoEncoderType::H265 => { let codec_name = selected_codec_name.clone(); - if codec_name == crate::video::codec::amlenc::AMLENC_H265_CODEC_NAME { - create_amlenc_encoder(config, AmlencCodec::H265)? - } else { - let direct_input_format = - h265_direct_input_format(&codec_name, pipeline_input_format); - let input_format = direct_input_format.unwrap_or_else(|| { - if codec_name.contains("libx265") { - H265InputFormat::Yuv420p - } else { - H265InputFormat::Nv12 - } - }); - - if use_rkmpp_direct { - info!( - "Creating H265 encoder with RKMPP backend for {} direct input (codec: {})", - config.input_format, codec_name - ); - } else if let Some(ref backend) = config.encoder_backend { - info!( - "Creating H265 encoder with backend {:?} (codec: {})", - backend, codec_name - ); + let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format); + let input_format = direct_input_format.unwrap_or_else(|| { + if codec_name.contains("libx265") { + H265InputFormat::Yuv420p + } else { + H265InputFormat::Nv12 } + }); - let encoder = H265Encoder::with_codec( - H265Config { - base: EncoderConfig { - resolution: config.resolution, - input_format: config.input_format, - quality: config.bitrate_kbps(), - fps: config.fps, - gop_size: config.gop_size(), - }, - bitrate_kbps: config.bitrate_kbps(), - gop_size: config.gop_size(), - fps: config.fps, - input_format, - }, - &codec_name, - )?; - info!("Created H265 encoder: {}", encoder.codec_name()); - Box::new(H265EncoderWrapper(encoder)) + if use_rkmpp_direct { + info!( + "Creating H265 encoder with RKMPP backend for {} direct input (codec: {})", + config.input_format, codec_name + ); + } else if let Some(ref backend) = config.encoder_backend { + info!( + "Creating H265 encoder with backend {:?} (codec: {})", + backend, codec_name + ); } + + let encoder = H265Encoder::with_codec( + H265Config { + base: EncoderConfig { + resolution: config.resolution, + input_format: config.input_format, + quality: config.bitrate_kbps(), + fps: config.fps, + gop_size: config.gop_size(), + }, + bitrate_kbps: config.bitrate_kbps(), + gop_size: config.gop_size(), + fps: config.fps, + input_format, + }, + &codec_name, + )?; + info!("Created H265 encoder: {}", encoder.codec_name()); + Box::new(H265EncoderWrapper(encoder)) } VideoEncoderType::VP8 => { let codec_name = selected_codec_name.clone(); @@ -523,9 +471,7 @@ pub(super) fn build_encoder_state( }; let codec_name = encoder.codec_name(); - let use_direct_input = if codec_name.contains("amlenc") { - pipeline_input_format == PixelFormat::Nv12 - } else if codec_name.contains("rkmpp") { + let use_direct_input = if codec_name.contains("rkmpp") { matches!( pipeline_input_format, PixelFormat::Yuyv diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 81012293..1269b43d 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -21,6 +21,7 @@ use parking_lot::Mutex as ParkingMutex; use parking_lot::RwLock as ParkingRwLock; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender, TrySendError}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, watch, Mutex, RwLock}; @@ -28,9 +29,98 @@ use tracing::{debug, error, info, trace, warn}; use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, EncoderThreadState}; +#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))] +#[path = "dmabuf.rs"] +mod dmabuf; + +#[cfg(any( + test, + all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")) +))] +fn rkmpp_dma_eligible( + backend: Option, + codec: VideoEncoderType, + format: PixelFormat, +) -> bool { + backend == Some(EncoderBackend::Rkmpp) + && matches!(codec, VideoEncoderType::H264 | VideoEncoderType::H265) + && matches!( + format, + PixelFormat::Bgr24 + | PixelFormat::Nv12 + | PixelFormat::Yuyv + | PixelFormat::Rgb24 + | PixelFormat::Mjpeg + ) +} + +#[cfg(test)] +mod dma_selection_tests { + use super::*; + #[test] + fn only_selected_rkmpp_uses_dma() { + for backend in [ + EncoderBackend::Software, + EncoderBackend::Vaapi, + EncoderBackend::Nvenc, + EncoderBackend::Qsv, + EncoderBackend::Amf, + EncoderBackend::V4l2m2m, + ] { + for codec in [VideoEncoderType::H264, VideoEncoderType::H265] { + for format in [ + PixelFormat::Bgr24, + PixelFormat::Nv12, + PixelFormat::Yuyv, + PixelFormat::Rgb24, + PixelFormat::Mjpeg, + ] { + assert!(!rkmpp_dma_eligible(Some(backend), codec, format)); + } + } + } + assert!(!rkmpp_dma_eligible( + None, + VideoEncoderType::H264, + PixelFormat::Nv12 + )); + for codec in [VideoEncoderType::H264, VideoEncoderType::H265] { + for format in [ + PixelFormat::Bgr24, + PixelFormat::Nv12, + PixelFormat::Yuyv, + PixelFormat::Rgb24, + PixelFormat::Mjpeg, + ] { + assert!(rkmpp_dma_eligible( + Some(EncoderBackend::Rkmpp), + codec, + format + )); + } + } + for format in [ + PixelFormat::Nv16, + PixelFormat::Nv21, + PixelFormat::Nv24, + PixelFormat::Yuv420, + ] { + assert!(!rkmpp_dma_eligible( + Some(EncoderBackend::Rkmpp), + VideoEncoderType::H264, + format + )); + } + assert!(!rkmpp_dma_eligible( + Some(EncoderBackend::Rkmpp), + VideoEncoderType::VP9, + PixelFormat::Nv12 + )); + } +} + /// Grace period before auto-stopping pipeline when no subscribers (in seconds) const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3; -const AMLENC_MAX_FPS: u32 = 60; /// After this many consecutive timeouts, log a prominent warning. const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5; const CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD: u32 = 3; @@ -49,21 +139,104 @@ use crate::video::capture::status::{ signal_status_from_capture_kind, CaptureIoErrorKind, }; use crate::video::capture::{BridgeContext, CaptureReadError, CaptureStream}; -use crate::video::codec::h264_bitstream; use crate::video::codec::registry::{EncoderBackend, VideoEncoderType}; use crate::video::codec::MjpegToNv12Decoder; +use crate::video::codec::{h264_bitstream, h265_bitstream}; use crate::video::device::parse_bridge_kind; use crate::video::device::VideoControlMode; use crate::video::format::{PixelFormat, Resolution}; -fn amlenc_supported_fps(requested_fps: u32) -> u32 { - requested_fps.min(AMLENC_MAX_FPS) -} use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy}; use crate::video::signal::SignalStatus; const MIN_CAPTURE_FRAME_SIZE: usize = 128; +struct MjpegDecodeJob { + data: Vec, + sequence: u64, +} + +fn mjpeg_decode_worker_count(available_parallelism: usize) -> usize { + available_parallelism.max(1) +} + +fn spawn_mjpeg_decode_workers( + pipeline: &Arc, + latest_frame: &Arc>>>, + frame_seq_tx: &watch::Sender, + buffer_pool: &Arc, + resolution: Resolution, +) -> Vec> { + let available = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1); + let worker_count = mjpeg_decode_worker_count(available); + let mut senders = Vec::with_capacity(worker_count); + + for worker_id in 0..worker_count { + // A rendezvous channel deliberately has no queue. If every decoder is + // busy, capture drops the new compressed frame instead of building up + // latency behind stale frames. + let (tx, rx) = sync_channel::(0); + let worker_pipeline = pipeline.clone(); + let worker_latest_frame = latest_frame.clone(); + let worker_frame_seq_tx = frame_seq_tx.clone(); + let worker_buffer_pool = buffer_pool.clone(); + let thread_name = format!("mjpeg-decoder-{worker_id}"); + let spawn_result = std::thread::Builder::new() + .name(thread_name) + .spawn(move || { + let mut decoder = MjpegToNv12Decoder::new(resolution); + while let Ok(job) = rx.recv() { + let nv12_size = resolution.width as usize * resolution.height as usize * 3 / 2; + let mut nv12 = worker_buffer_pool.take(nv12_size); + let decode_result = decoder.decode_into(&job.data, &mut nv12); + worker_buffer_pool.put(job.data); + + if let Err(error) = decode_result { + worker_buffer_pool.put(nv12); + warn!("Dropping undecodable MJPEG frame: {}", error); + continue; + } + if !worker_pipeline.running_flag.load(Ordering::Acquire) { + worker_buffer_pool.put(nv12); + break; + } + + let frame = Arc::new(VideoFrame::from_pooled( + Arc::new(FrameBuffer::new(nv12, Some(worker_buffer_pool.clone()))), + resolution, + PixelFormat::Nv12, + resolution.width, + job.sequence, + )); + let published = { + let mut latest = worker_latest_frame.write(); + if latest + .as_ref() + .is_some_and(|current| current.sequence >= job.sequence) + { + false + } else { + *latest = Some(frame); + true + } + }; + if published { + let _ = worker_frame_seq_tx.send(job.sequence.wrapping_add(1)); + } + } + }); + + match spawn_result { + Ok(_) => senders.push(tx), + Err(error) => error!("Failed to start MJPEG decoder worker: {}", error), + } + } + + info!("Started {} parallel MJPEG decoder worker(s)", senders.len()); + senders +} #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error; @@ -75,7 +248,10 @@ pub struct EncodedVideoFrame { pub data: Bytes, /// Presentation timestamp in milliseconds pub pts_ms: i64, - /// Whether this is a keyframe + /// Whether this frame can initialize a decoder without earlier frames. + /// + /// For H.264/H.265 this is stricter than the encoder packet flag: the + /// payload must be IDR/IRAP and include all required parameter sets. pub is_keyframe: bool, /// Frame sequence number pub sequence: u64, @@ -86,7 +262,9 @@ pub struct EncodedVideoFrame { } enum PipelineCmd { - SetBitrate { bitrate_kbps: u32, gop: u32 }, + SetBitrate { + preset: crate::video::codec::BitratePreset, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -169,6 +347,15 @@ impl Default for SharedVideoPipelineConfig { } impl SharedVideoPipelineConfig { + /// Keep encoder timing aligned with the negotiated HDMI source on every open. + fn align_source_fps(&mut self, source_fps: Option) { + if self.control_mode == VideoControlMode::SourceFollowing { + if let Some(fps) = source_fps { + self.fps = fps.round().clamp(1.0, 120.0) as u32; + } + } + } + /// Get effective bitrate in kbps pub fn bitrate_kbps(&self) -> u32 { self.bitrate_preset.bitrate_kbps() @@ -280,6 +467,15 @@ pub struct SharedVideoPipelineStats { pub current_fps: f32, } +#[derive(Default)] +struct CachedH26xParameterSets { + h264_sps: Option>, + h264_pps: Option>, + h265_vps: Option>, + h265_sps: Option>, + h265_pps: Option>, +} + /// Universal shared video pipeline pub struct SharedVideoPipeline { config: RwLock, @@ -287,9 +483,8 @@ pub struct SharedVideoPipeline { stats: Mutex, running: watch::Sender, running_rx: watch::Receiver, - /// Becomes true only after the synchronous encoder worker has dropped its - /// vendor handles. Capture teardown alone is not sufficient for AMLENC: - /// a blocked dequeue/encode can otherwise overlap the next pipeline. + /// Becomes true only after the synchronous encoder worker has exited and + /// dropped its encoder handles. encoder_done: watch::Sender, encoder_done_rx: watch::Receiver, h264_profile_level_id: watch::Sender>, @@ -301,6 +496,9 @@ pub struct SharedVideoPipeline { sequence: AtomicU64, /// Atomic flag for keyframe request (avoids lock contention) keyframe_requested: AtomicBool, + parameter_sets: ParkingMutex, + /// Most recent random-access frame with all decoder parameter sets. + bootstrap_frame: ParkingRwLock>>, /// Pipeline start time for monotonic PTS calculation (microseconds from process start). /// Uses AtomicI64 instead of Mutex for lock-free access. pipeline_start_time_us: AtomicI64, @@ -340,6 +538,8 @@ impl SharedVideoPipeline { running_flag: AtomicBool::new(false), sequence: AtomicU64::new(0), keyframe_requested: AtomicBool::new(false), + parameter_sets: ParkingMutex::new(CachedH26xParameterSets::default()), + bootstrap_frame: ParkingRwLock::new(None), pipeline_start_time_us: AtomicI64::new(0), pending_sync_geometry: ParkingMutex::new(None), device_lost_reason: ParkingMutex::new(None), @@ -398,6 +598,9 @@ impl SharedVideoPipeline { // Keep at most one pending frame so a slow WebRTC writer cannot make // the encoder wait or accumulate seconds of latency. let (tx, rx) = mpsc::channel(1); + if let Some(frame) = self.bootstrap_frame.read().clone() { + let _ = tx.try_send(frame); + } self.subscribers.write().push(tx); rx } @@ -436,14 +639,13 @@ impl SharedVideoPipeline { fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> { match cmd { - PipelineCmd::SetBitrate { bitrate_kbps, gop } => { - #[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))] - let _ = gop; + PipelineCmd::SetBitrate { preset } => { + let bitrate_kbps = preset.bitrate_kbps(); #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] if state.ffmpeg_hw_enabled { if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline { pipeline - .reconfigure(bitrate_kbps as i32, gop as i32) + .reconfigure(bitrate_kbps as i32, preset.gop_size(state.fps) as i32) .map_err(|e| { let detail = if e.is_empty() { ffmpeg_hw_last_error() @@ -512,7 +714,105 @@ impl SharedVideoPipeline { let _ = self.h264_profile_level_id.send(Some(profile_level_id)); } + fn inspect_and_parameterize_packet( + &self, + codec: VideoEncoderType, + data: Bytes, + ffmpeg_keyframe: bool, + ) -> (Bytes, bool) { + match codec { + VideoEncoderType::H264 => { + let was_annex_b = h264_bitstream::is_annex_b(data.as_ref()); + let data = h264_bitstream::normalize_annex_b(data); + if !was_annex_b && h264_bitstream::is_annex_b(data.as_ref()) { + debug!("[Pipeline] Converted length-prefixed H264 packet to Annex-B"); + } + let (sps, pps) = h264_bitstream::extract_sps_pps(data.as_ref()); + // Require metadata and payload to agree before advertising a + // decoder bootstrap frame. + let is_idr = ffmpeg_keyframe && h264_bitstream::is_keyframe(data.as_ref()); + let mut cache = self.parameter_sets.lock(); + if let Some(sps) = sps.as_ref() { + cache.h264_sps = Some(sps.clone()); + } + if let Some(pps) = pps.as_ref() { + cache.h264_pps = Some(pps.clone()); + } + + if !is_idr { + return (data, false); + } + if sps.is_some() && pps.is_some() { + return (data, true); + } + + match (&cache.h264_sps, &cache.h264_pps) { + (Some(cached_sps), Some(cached_pps)) => { + let mut output = Vec::with_capacity( + data.len() + cached_sps.len() + cached_pps.len() + 8, + ); + output.extend_from_slice(&[0, 0, 0, 1]); + output.extend_from_slice(cached_sps); + output.extend_from_slice(&[0, 0, 0, 1]); + output.extend_from_slice(cached_pps); + output.extend_from_slice(data.as_ref()); + debug!("[Pipeline] Prepended cached SPS/PPS to H264 IDR"); + (Bytes::from(output), true) + } + // An IDR without SPS/PPS is not a decoder bootstrap frame. + _ => (data, false), + } + } + VideoEncoderType::H265 => { + let (vps, sps, pps) = h265_bitstream::extract_vps_sps_pps(data.as_ref()); + let is_irap = ffmpeg_keyframe && h265_bitstream::is_keyframe(data.as_ref()); + let mut cache = self.parameter_sets.lock(); + if let Some(vps) = vps.as_ref() { + cache.h265_vps = Some(vps.clone()); + } + if let Some(sps) = sps.as_ref() { + cache.h265_sps = Some(sps.clone()); + } + if let Some(pps) = pps.as_ref() { + cache.h265_pps = Some(pps.clone()); + } + + if !is_irap { + return (data, false); + } + if vps.is_some() && sps.is_some() && pps.is_some() { + return (data, true); + } + + match (&cache.h265_vps, &cache.h265_sps, &cache.h265_pps) { + (Some(cached_vps), Some(cached_sps), Some(cached_pps)) => { + let mut output = Vec::with_capacity( + data.len() + + cached_vps.len() + + cached_sps.len() + + cached_pps.len() + + 12, + ); + for parameter_set in [cached_vps, cached_sps, cached_pps] { + output.extend_from_slice(&[0, 0, 0, 1]); + output.extend_from_slice(parameter_set); + } + output.extend_from_slice(data.as_ref()); + debug!("[Pipeline] Prepended cached VPS/SPS/PPS to H265 IRAP"); + (Bytes::from(output), true) + } + _ => (data, false), + } + } + _ => (data, ffmpeg_keyframe), + } + } + fn broadcast_encoded(&self, frame: Arc) { + if frame.is_keyframe { + *self.bootstrap_frame.write() = Some(frame.clone()); + } + let subscribers = { let guard = self.subscribers.read(); if guard.is_empty() { @@ -552,19 +852,11 @@ impl SharedVideoPipeline { return Ok(()); } + *self.parameter_sets.lock() = CachedH26xParameterSets::default(); + *self.bootstrap_frame.write() = None; + let mut config = self.config.read().await.clone(); let parallel_mjpeg_decode = should_parallel_decode_mjpeg(&config); - if parallel_mjpeg_decode { - let stable_fps = amlenc_supported_fps(config.fps); - if stable_fps != config.fps { - warn!( - "Limiting S912 AMLENC capture at {}x{} from {} to {} fps (hardware limit)", - config.resolution.width, config.resolution.height, config.fps, stable_fps - ); - config.fps = stable_fps; - *self.config.write().await = config.clone(); - } - } { let mut last = self.last_state_notification.lock(); *last = None; @@ -575,7 +867,8 @@ impl SharedVideoPipeline { subdev_path.clone(), parse_bridge_kind(bridge_kind.as_deref()), ); - let preopened: Option = match open_capture_stream( + #[allow(unused_mut)] + let mut preopened: Option = match open_capture_stream( &device_path, config.resolution, config.input_format, @@ -589,16 +882,9 @@ impl SharedVideoPipeline { let negotiated_res = s.resolution(); let negotiated_fmt = s.format(); let previous = (config.resolution, config.input_format, config.fps); - if config.control_mode == VideoControlMode::SourceFollowing { - if let Some(source_fps) = s.source_fps() { - config.fps = source_fps.round().clamp(1.0, 120.0) as u32; - } - } + config.align_source_fps(s.source_fps()); config.resolution = negotiated_res; config.input_format = negotiated_fmt; - if parallel_mjpeg_decode { - config.fps = amlenc_supported_fps(config.fps); - } if previous != (config.resolution, config.input_format, config.fps) { info!( "Negotiated capture {}x{} {:?} @ {} fps (configured {}x{} {:?} @ {} fps) — aligning encoder to source", @@ -633,10 +919,36 @@ impl SharedVideoPipeline { Err(e) => return Err(e), }; + #[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))] + if dmabuf::eligible(&config) { + if let Some(stream) = preopened.as_ref().filter(|s| s.supports_rkmpp_dmabuf()) { + match dmabuf::prepare(stream, &config) { + Ok(encoder) => { + return dmabuf::start( + self.clone(), + preopened.take().expect("preopened DMA capture"), + encoder, + config, + device_path, + buffer_count, + BridgeContext::from_parts( + subdev_path, + parse_bridge_kind(bridge_kind.as_deref()), + ), + ); + } + Err(error) => warn!( + "RKMPP DMA unavailable; using existing copy pipeline: {}", + error + ), + } + } + } + let mut encoder_config = config.clone(); if parallel_mjpeg_decode { encoder_config.input_format = PixelFormat::Nv12; - info!("Using capture-thread libyuv MJPEG decode with parallel AMLENC encoding"); + info!("Using parallel libyuv MJPEG decode with hardware encoding"); } let mut encoder_state = build_encoder_state(&encoder_config)?; let _ = self.running.send(true); @@ -741,8 +1053,7 @@ impl SharedVideoPipeline { } pipeline.clear_cmd_tx(); - // Dropping encoder_state here releases AMLENC before a caller - // is allowed to construct a replacement pipeline. + // Release encoder resources before allowing a replacement pipeline. drop(encoder_state); let _ = pipeline.encoder_done.send(true); }); @@ -763,8 +1074,25 @@ impl SharedVideoPipeline { let mut pixel_format = config.input_format; let mut active_fps = config.fps; let mut stride: u32 = 0; - let mut mjpeg_decoder = - parallel_mjpeg_decode.then(|| MjpegToNv12Decoder::new(config.resolution)); + let mut mjpeg_decode_senders = parallel_mjpeg_decode + .then(|| { + spawn_mjpeg_decode_workers( + &pipeline, + &latest_frame, + &frame_seq_tx, + &buffer_pool, + config.resolution, + ) + }) + .filter(|senders| !senders.is_empty()); + let mut next_mjpeg_decoder = 0usize; + let mut mjpeg_decoder = parallel_mjpeg_decode + .then(|| MjpegToNv12Decoder::new(config.resolution)) + .filter(|_| { + mjpeg_decode_senders + .as_ref() + .is_none_or(|senders| senders.is_empty()) + }); if let Some(s) = preopened { resolution = s.resolution(); @@ -1109,6 +1437,32 @@ impl SharedVideoPipeline { pixel_format, active_fps, )); + + if let Some(senders) = mjpeg_decode_senders.as_mut() { + let mut pending = Some(MjpegDecodeJob { + data: owned, + sequence: meta.sequence, + }); + for offset in 0..senders.len() { + let index = (next_mjpeg_decoder + offset) % senders.len(); + let job = pending.take().expect("pending MJPEG decode job"); + match senders[index].try_send(job) { + Ok(()) => { + next_mjpeg_decoder = (index + 1) % senders.len(); + break; + } + Err(TrySendError::Full(job)) + | Err(TrySendError::Disconnected(job)) => { + pending = Some(job); + } + } + } + if let Some(job) = pending { + buffer_pool.put(job.data); + } + continue; + } + let (frame_data, frame_format, frame_stride) = if let Some(decoder) = mjpeg_decoder.as_mut() { let nv12_size = @@ -1169,23 +1523,7 @@ impl SharedVideoPipeline { let input_format = state.input_format; let raw_frame = frame.data(); - let process_start = PROCESS_START.get_or_init(Instant::now); - let current_ts_us = process_start.elapsed().as_micros() as i64; - let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire); - let pts_ms = if start_ts_us == 0 { - let start_ts_us = match self.pipeline_start_time_us.compare_exchange( - 0, - current_ts_us, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => current_ts_us, - Err(existing) => existing, - }; - current_ts_us.saturating_sub(start_ts_us) / 1000 - } else { - current_ts_us.saturating_sub(start_ts_us) / 1000 - }; + let pts_ms = self.pts_ms(); #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] if state.ffmpeg_hw_enabled { @@ -1213,9 +1551,11 @@ impl SharedVideoPipeline { })?; if let Some((data, is_keyframe)) = packet { + let (data, is_keyframe) = + self.inspect_and_parameterize_packet(codec, Bytes::from(data), is_keyframe); let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; return Ok(vec![EncodedVideoFrame { - data: Bytes::from(data), + data, pts_ms, is_keyframe, sequence, @@ -1295,14 +1635,15 @@ impl SharedVideoPipeline { let mut encoded_frames = Vec::with_capacity(frames.len()); for encoded in frames { - let is_keyframe = encoded.key == 1; + let (data, is_keyframe) = + self.inspect_and_parameterize_packet(codec, encoded.data, encoded.key == 1); let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; if codec == VideoEncoderType::H264 { - self.update_h264_profile_level_id(&encoded.data); + self.update_h264_profile_level_id(&data); } encoded_frames.push(EncodedVideoFrame { - data: encoded.data, + data, pts_ms, is_keyframe, sequence, @@ -1317,6 +1658,28 @@ impl SharedVideoPipeline { } } + fn pts_ms(&self) -> i64 { + let current_ts_us = PROCESS_START + .get_or_init(Instant::now) + .elapsed() + .as_micros() as i64; + let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire); + let start_ts_us = if start_ts_us == 0 { + match self.pipeline_start_time_us.compare_exchange( + 0, + current_ts_us, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => current_ts_us, + Err(existing) => existing, + } + } else { + start_ts_us + }; + current_ts_us.saturating_sub(start_ts_us) / 1000 + } + /// Stop the pipeline (non-blocking, does not wait for capture thread to exit) pub fn stop(&self) { if self.running_flag.swap(false, Ordering::AcqRel) { @@ -1396,13 +1759,11 @@ impl SharedVideoPipeline { &self, preset: crate::video::codec::BitratePreset, ) -> Result<()> { - let bitrate_kbps = preset.bitrate_kbps(); - let gop = { + { let mut config = self.config.write().await; config.bitrate_preset = preset; - config.gop_size() - }; - self.send_cmd(PipelineCmd::SetBitrate { bitrate_kbps, gop }); + } + self.send_cmd(PipelineCmd::SetBitrate { preset }); Ok(()) } @@ -1597,6 +1958,60 @@ mod tests { use super::*; use crate::video::codec::BitratePreset; + #[tokio::test] + async fn bitrate_commands_preserve_custom_values_and_gop_policy() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::default()).unwrap(); + let (tx, mut rx) = mpsc::unbounded_channel(); + *pipeline.cmd_tx.write() = Some(tx); + for preset in [ + BitratePreset::Custom(2500), + BitratePreset::Custom(1000), + BitratePreset::Speed, + BitratePreset::Quality, + ] { + pipeline.set_bitrate_preset(preset).await.unwrap(); + let PipelineCmd::SetBitrate { preset: received } = rx.try_recv().unwrap(); + assert_eq!(received, preset); + assert_eq!(pipeline.config().await.bitrate_preset, preset); + // Rebuilt encoders must retain the preset's policy at the new FPS. + let restored = SharedVideoPipelineConfig { + bitrate_preset: received, + fps: 60, + ..Default::default() + }; + assert_eq!(restored.bitrate_kbps(), preset.bitrate_kbps()); + assert_eq!(restored.gop_size(), preset.gop_size(60)); + } + } + + #[test] + fn source_reopen_updates_fps_and_gop_without_changing_geometry_or_bitrate() { + let mut config = SharedVideoPipelineConfig { + control_mode: VideoControlMode::SourceFollowing, + resolution: Resolution::HD1080, + fps: 60, + bitrate_preset: BitratePreset::Quality, + ..Default::default() + }; + config.align_source_fps(Some(29.97)); + assert_eq!(config.fps, 30); + assert_eq!(config.gop_size(), 60); + assert_eq!(config.resolution, Resolution::HD1080); + assert_eq!(config.bitrate_kbps(), 8000); + config.align_source_fps(None); + assert_eq!(config.fps, 30); + config.align_source_fps(Some(59.94)); + assert_eq!(config.fps, 60); + assert_eq!(config.gop_size(), 120); + } + + #[test] + fn configurable_capture_keeps_requested_fps() { + let mut config = SharedVideoPipelineConfig::default(); + config.align_source_fps(Some(60.0)); + assert_eq!(config.fps, 30); + } + #[test] fn test_pipeline_config() { let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, BitratePreset::Balanced); @@ -1604,11 +2019,136 @@ mod tests { let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, BitratePreset::Speed); assert_eq!(h265.output_codec, VideoEncoderType::H265); + } - assert_eq!(amlenc_supported_fps(30), 30); - assert_eq!(amlenc_supported_fps(50), 50); - assert_eq!(amlenc_supported_fps(60), 60); - assert_eq!(amlenc_supported_fps(120), 60); + #[test] + fn h264_keyframe_requires_idr_and_parameter_sets() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + + let predicted = Bytes::from_static(&[0, 0, 0, 1, 0x41, 0xc0]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, predicted, true); + assert!( + !key, + "a driver flag must not turn a P-frame into a keyframe" + ); + + let parameter_sets = + Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, parameter_sets, false); + assert!(!key, "parameter sets alone are not a keyframe"); + + let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]); + let (_, key) = pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, false); + assert!(!key, "an IDR without a driver key flag is not trusted"); + + let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]); + let (bootstrap, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, true); + assert!( + key, + "matching driver metadata and IDR payload should bootstrap" + ); + assert!(h264_bitstream::has_sps_pps(bootstrap.as_ref())); + assert!(h264_bitstream::is_keyframe(bootstrap.as_ref())); + } + + #[test] + fn h265_keyframe_requires_irap_and_parameter_sets() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h265( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + + let trail = Bytes::from_static(&[0, 0, 0, 1, 1 << 1, 1, 0xaa]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, trail, true); + assert!( + !key, + "a driver flag must not turn a trailing frame into a keyframe" + ); + + let parameter_sets = Bytes::from_static(&[ + 0, + 0, + 0, + 1, + 32 << 1, + 1, + 0xaa, + 0, + 0, + 0, + 1, + 33 << 1, + 1, + 0xbb, + 0, + 0, + 0, + 1, + 34 << 1, + 1, + 0xcc, + ]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, parameter_sets, false); + assert!(!key, "parameter sets alone are not a keyframe"); + + let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]); + let (_, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, false); + assert!(!key, "an IRAP without a driver key flag is not trusted"); + + let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]); + let (bootstrap, key) = + pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, true); + assert!( + key, + "matching driver metadata and IRAP payload should bootstrap" + ); + assert!(h265_bitstream::has_vps_sps_pps(bootstrap.as_ref())); + assert!(h265_bitstream::is_keyframe(bootstrap.as_ref())); + } + + #[test] + fn new_subscriber_receives_cached_bootstrap_frame() { + let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264( + Resolution::HD720, + BitratePreset::Balanced, + )) + .unwrap(); + let bootstrap = Arc::new(EncodedVideoFrame { + data: Bytes::from_static(&[ + 0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce, 0, 0, 0, 1, 0x65, 0x88, + ]), + pts_ms: 0, + is_keyframe: true, + sequence: 1, + duration: Duration::from_millis(33), + codec: VideoEncoderType::H264, + }); + + pipeline.broadcast_encoded(bootstrap.clone()); + let mut subscriber = pipeline.subscribe(); + let received = subscriber + .try_recv() + .expect("cached bootstrap frame should seed the subscriber queue"); + assert!(Arc::ptr_eq(&received, &bootstrap)); + } + + #[test] + fn mjpeg_workers_match_available_cpu_count() { + assert_eq!(mjpeg_decode_worker_count(1), 1); + assert_eq!(mjpeg_decode_worker_count(2), 2); + assert_eq!(mjpeg_decode_worker_count(4), 4); + assert_eq!(mjpeg_decode_worker_count(64), 64); } #[test] diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index 57343c94..761a90c6 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -195,25 +195,15 @@ impl VideoStreamManager { info!("Initializing video stream manager with mode: {:?}", mode); *self.mode.write().await = mode.clone(); - // Check if streamer is already initialized (capturer exists) - let needs_init = self.streamer.state().await == StreamerState::Uninitialized; + // A failed fixed-device configuration can leave the streamer in a transient + // state without a capture device. Treat that the same as an uninitialized + // streamer so the advertised auto-detection fallback actually runs. + let state = self.streamer.state().await; + let (device_path, _, _, _, _) = self.streamer.current_capture_config().await; + let needs_init = state == StreamerState::Uninitialized || device_path.is_none(); if needs_init { - match mode { - StreamMode::Mjpeg => { - // Initialize MJPEG streamer - if let Err(e) = self.streamer.init_auto().await { - warn!("Failed to auto-initialize MJPEG streamer: {}", e); - } - } - StreamMode::WebRTC => { - // WebRTC is initialized on-demand when clients connect - // But we still need to initialize the video capture - if let Err(e) = self.streamer.init_auto().await { - warn!("Failed to auto-initialize video capture for WebRTC: {}", e); - } - } - } + self.streamer.init_auto().await?; } self.sync_webrtc_capture_source("after init").await; diff --git a/src/web/handlers/atx_api.rs b/src/web/handlers/atx_api.rs index d439d0c3..5a1a6d21 100644 --- a/src/web/handlers/atx_api.rs +++ b/src/web/handlers/atx_api.rs @@ -171,7 +171,7 @@ pub async fn atx_wol( // Send WOL packet crate::atx::send_wol(&mac_address, interface)?; - if let Err(error) = crate::atx::record_wol_history(state.db.pool(), &mac_address).await { + if let Err(error) = state.db.wol_history().record(&mac_address).await { warn!("Failed to persist WOL history: {}", error); } @@ -191,7 +191,7 @@ pub async fn atx_wol_history( .unwrap_or(WOL_HISTORY_DEFAULT_LIMIT) .clamp(1, WOL_HISTORY_MAX_LIMIT); - let rows = crate::atx::list_wol_history(state.db.pool(), limit).await?; + let rows = state.db.wol_history().list(limit).await?; let history = rows .into_iter() diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index 387f5305..bed1afb1 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -2,97 +2,9 @@ use std::sync::Arc; use crate::config::*; use crate::error::{AppError, Result}; -use crate::rtsp::RtspService; +pub use crate::runtime::{try_apply_lock, ConfigApplyOptions}; use crate::state::AppState; use crate::stream_encoder::encoder_type_to_backend; -use crate::video::codec_constraints::{ - enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility, - StreamCodecConstraints, -}; -use tokio::sync::{Mutex, OwnedMutexGuard}; - -#[derive(Debug, Clone, Copy, Default)] -pub struct ConfigApplyOptions { - pub force: bool, - pub preserve_service_state: bool, - pub runtime_only: bool, -} - -impl ConfigApplyOptions { - pub const fn forced() -> Self { - Self { - force: true, - preserve_service_state: false, - runtime_only: false, - } - } - - pub const fn preserving_service_state() -> Self { - Self { - force: false, - preserve_service_state: true, - runtime_only: false, - } - } - - pub const fn runtime_only() -> Self { - Self { - force: false, - preserve_service_state: false, - runtime_only: true, - } - } -} - -pub fn try_apply_lock(lock: &Arc>, domain: &str) -> Result> { - lock.clone().try_lock_owned().map_err(|_| { - AppError::ServiceUnavailable(format!("{domain} configuration is already applying")) - }) -} - -fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType { - match config.backend { - HidBackend::Otg => crate::hid::HidBackendType::Otg, - HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { - port: config.ch9329_port.clone(), - baud_rate: config.ch9329_baudrate, - hybrid_mouse: config.ch9329_hybrid_mouse, - macos_drag: config.ch9329_macos_drag, - }, - HidBackend::None => crate::hid::HidBackendType::None, - } -} - -fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool { - old_config.backend == HidBackend::Otg - || new_config.backend == HidBackend::Otg - || old_config.otg_udc != new_config.otg_udc - || old_config.otg_descriptor != new_config.otg_descriptor - || old_config.constrained_otg_functions() != new_config.constrained_otg_functions() - || old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() -} - -async fn reconcile_otg_config( - state: &Arc, - hid: &HidConfig, - msd: &MsdConfig, - network: &OtgNetworkConfig, - uac: &UacConfig, -) -> Result<()> { - #[cfg(not(unix))] - { - let _ = (state, hid, msd, network, uac); - Ok(()) - } - #[cfg(unix)] - { - state - .otg_service - .apply_config(hid, msd, network, uac) - .await - .map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e))) - } -} pub async fn apply_video_config( state: &Arc, @@ -190,306 +102,6 @@ pub async fn apply_stream_config( Ok(()) } -pub async fn apply_hid_config( - state: &Arc, - old_config: &HidConfig, - new_config: &HidConfig, - msd_config: &MsdConfig, - network_config: &OtgNetworkConfig, - uac_config: &UacConfig, - options: ConfigApplyOptions, -) -> Result<()> { - new_config.validate_otg_functions()?; - - let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; - let old_hid_functions = old_config.constrained_otg_functions(); - let new_hid_functions = new_config.constrained_otg_functions(); - let hid_functions_changed = old_hid_functions != new_hid_functions; - let keyboard_leds_changed = - old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds(); - let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse - || old_config.ch9329_macos_drag != new_config.ch9329_macos_drag; - - if old_config.backend == new_config.backend - && old_config.ch9329_port == new_config.ch9329_port - && old_config.ch9329_baudrate == new_config.ch9329_baudrate - && !ch9329_runtime_changed - && old_config.otg_udc == new_config.otg_udc - && !descriptor_changed - && !hid_functions_changed - && !keyboard_leds_changed - && !options.force - { - tracing::info!("HID config unchanged, skipping reload"); - return Ok(()); - } - - tracing::info!("Applying HID config changes..."); - - let new_hid_backend = hid_backend_type(new_config); - let transitioning_away_from_otg = - old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg; - let otg_config_changed = hid_otg_config_changed(old_config, new_config); - - if transitioning_away_from_otg { - state - .hid - .reload(new_hid_backend.clone()) - .await - .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; - } - - if otg_config_changed { - reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?; - } - - if !transitioning_away_from_otg { - state - .hid - .reload(new_hid_backend) - .await - .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; - } - - tracing::info!( - "HID backend reloaded successfully: {:?}", - new_config.backend - ); - - Ok(()) -} - -#[cfg(unix)] -pub async fn apply_msd_config( - state: &Arc, - old_config: &MsdConfig, - new_config: &MsdConfig, - hid_config: &HidConfig, - network_config: &OtgNetworkConfig, - uac_config: &UacConfig, - options: ConfigApplyOptions, -) -> Result<()> { - let hid_backend_is_otg = hid_config.backend == HidBackend::Otg; - let effective_new_msd_enabled = new_config.enabled && hid_backend_is_otg; - - tracing::info!("MSD config sent, checking if reload needed..."); - tracing::debug!("Old MSD config: {:?}", old_config); - tracing::debug!("New MSD config: {:?}", new_config); - - let old_msd_enabled = old_config.enabled; - let new_msd_enabled = effective_new_msd_enabled; - let msd_dir_changed = old_config.msd_dir != new_config.msd_dir; - let inquiry_strings_changed = old_config.flash_inquiry_string - != new_config.flash_inquiry_string - || old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string; - - tracing::info!( - "MSD enabled: old={}, new={}", - old_msd_enabled, - new_msd_enabled - ); - if msd_dir_changed { - tracing::info!("MSD directory changed: {}", new_config.msd_dir); - } - if inquiry_strings_changed { - tracing::info!("MSD inquiry strings changed"); - } - - let msd_dir = new_config.msd_dir_path(); - if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) { - tracing::warn!("Failed to create MSD images directory: {}", e); - } - if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) { - tracing::warn!("Failed to create MSD ventoy directory: {}", e); - } - - let needs_reload = options.force - || old_msd_enabled != new_msd_enabled - || msd_dir_changed - || inquiry_strings_changed; - if !needs_reload { - tracing::info!("MSD configuration unchanged, no reload needed"); - return Ok(()); - } - - if new_msd_enabled { - tracing::info!("(Re)initializing MSD..."); - - reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?; - - let mut msd_guard = state.msd.write().await; - if let Some(msd) = msd_guard.as_mut() { - msd.shutdown() - .await - .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?; - } - *msd_guard = None; - drop(msd_guard); - - let msd = - crate::msd::MsdController::new(state.otg_service.clone(), new_config.msd_dir_path()); - let ventoy_resource_dir = state.data_dir().join("ventoy"); - msd.init(&ventoy_resource_dir) - .await - .map_err(|e| AppError::Config(format!("MSD initialization failed: {}", e)))?; - - let events = state.events.clone(); - msd.set_event_bus(events).await; - - *state.msd.write().await = Some(msd); - tracing::info!("MSD initialized successfully"); - } else { - tracing::info!("MSD disabled in config, shutting down..."); - - let mut msd_guard = state.msd.write().await; - if let Some(msd) = msd_guard.as_mut() { - msd.shutdown() - .await - .map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?; - } - *msd_guard = None; - tracing::info!("MSD shutdown complete"); - - reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?; - } - - if hid_config.backend == HidBackend::Otg - && (options.force || old_msd_enabled != new_msd_enabled) - { - state - .hid - .reload(crate::hid::HidBackendType::Otg) - .await - .map_err(|e| AppError::Config(format!("OTG HID reload failed: {}", e)))?; - } - - Ok(()) -} - -pub async fn apply_usb_config( - state: &Arc, - old_config: &AppConfig, - new_config: &AppConfig, -) -> Result<()> { - #[cfg(unix)] - { - let transitioning_away_from_otg = - old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg; - - let hid_unchanged = old_config.hid == new_config.hid; - let otg_gadget_rebuilt = old_config.msd != new_config.msd - || old_config.otg_network != new_config.otg_network - || old_config.uac != new_config.uac - || old_config.hid.otg_udc != new_config.hid.otg_udc - || old_config.hid.otg_descriptor != new_config.hid.otg_descriptor - || old_config.hid.backend != new_config.hid.backend - || old_config.hid.constrained_otg_functions() - != new_config.hid.constrained_otg_functions() - || old_config.hid.effective_otg_keyboard_leds() - != new_config.hid.effective_otg_keyboard_leds(); - let restart_uac_playback = - old_config.uac != new_config.uac || (new_config.uac.enabled && otg_gadget_rebuilt); - - // A bound ALSA handle refers to the old configfs function. Stop it - // before any gadget teardown so the worker cannot write through a - // disappearing PCM node. It is restarted only after every reconcile. - if restart_uac_playback { - let playback = state.uac_playback.write().await.take(); - if let Some(playback) = playback { - playback.stop(); - tracing::info!("UAC playback writer stopped before OTG reconcile"); - } - } - - if transitioning_away_from_otg { - apply_hid_config( - state, - &old_config.hid, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .await?; - } else { - reconcile_otg_config( - state, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ) - .await?; - apply_hid_config( - state, - &old_config.hid, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .await?; - } - - // When the OTG gadget was rebuilt due to MSD or network config changes - // while HID config stayed the same, the /dev/hidg* devices are new and - // the HID backend must be reloaded to reopen them. - if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg { - tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices"); - let hid_backend = hid_backend_type(&new_config.hid); - state.hid.reload(hid_backend).await.map_err(|e| { - AppError::Config(format!("HID reload after gadget rebuild failed: {}", e)) - })?; - } - - apply_msd_config( - state, - &old_config.msd, - &new_config.msd, - &new_config.hid, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .await?; - - // apply_msd_config may perform a second gadget reconcile. Resolve the - // new ALSA card only after that final rebuild, then publish the worker. - if restart_uac_playback && new_config.uac.enabled { - let config = crate::audio::uac::UacPlaybackConfig { - sample_rate: new_config.uac.sample_rate, - channels: new_config.uac.channels as u16, - ..Default::default() - }; - let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| { - AppError::Config(format!("Failed to start UAC playback: {error}")) - })?; - *state.uac_playback.write().await = Some(writer); - tracing::info!("UAC playback writer started after OTG reconcile"); - } else if restart_uac_playback { - tracing::info!("UAC playback remains disabled"); - } - - Ok(()) - } - - #[cfg(not(unix))] - { - apply_hid_config( - state, - &old_config.hid, - &new_config.hid, - &new_config.msd, - &new_config.otg_network, - &new_config.uac, - ConfigApplyOptions::default(), - ) - .await - } -} - pub async fn apply_atx_config( state: &Arc, _old_config: &AtxConfig, @@ -556,336 +168,3 @@ pub async fn apply_audio_config( Ok(()) } - -pub async fn enforce_stream_codec_constraints(state: &Arc) -> Result> { - let config = state.runtime_third_party_config().await; - let constraints = StreamCodecConstraints::from_config(&config); - state - .stream_manager - .set_runtime_codec_constraints(constraints.clone()) - .await; - let enforcement = - enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await?; - Ok(enforcement.message) -} - -async fn validate_runtime_candidate( - state: &Arc, - apply: impl FnOnce(&mut crate::config::AppConfig, T), - config: T, -) -> Result<()> { - let mut candidate = state.runtime_third_party_config().await; - apply(&mut candidate, config); - validate_third_party_codec_compatibility(&candidate) -} - -fn validate_rustdesk_candidate( - state: &Arc, - new_config: &crate::rustdesk::config::RustDeskConfig, -) -> Result<()> { - let mut candidate = state.config.get().as_ref().clone(); - candidate.rustdesk = new_config.clone(); - validate_third_party_codec_compatibility(&candidate) -} - -fn validate_vnc_candidate(state: &Arc, new_config: &VncConfig) -> Result<()> { - let mut candidate = state.config.get().as_ref().clone(); - candidate.vnc = new_config.clone(); - validate_third_party_codec_compatibility(&candidate) -} - -fn validate_rtsp_candidate(state: &Arc, new_config: &RtspConfig) -> Result<()> { - let mut candidate = state.config.get().as_ref().clone(); - candidate.rtsp = new_config.clone(); - validate_third_party_codec_compatibility(&candidate) -} - -pub async fn apply_rustdesk_config( - state: &Arc, - old_config: &crate::rustdesk::config::RustDeskConfig, - new_config: &crate::rustdesk::config::RustDeskConfig, - options: ConfigApplyOptions, -) -> Result<()> { - tracing::info!("Applying RustDesk config changes..."); - - if options.runtime_only { - validate_runtime_candidate( - state, - |candidate, config| candidate.rustdesk = config, - new_config.clone(), - ) - .await?; - } else { - validate_rustdesk_candidate(state, new_config)?; - } - - let mut rustdesk_guard = state.rustdesk.write().await; - let mut credentials_to_save = None; - let need_restart = options.force - || old_config.codec != new_config.codec - || old_config.rendezvous_server != new_config.rendezvous_server - || old_config.device_id != new_config.device_id - || old_config.device_password != new_config.device_password; - - if !options.preserve_service_state && !new_config.enabled { - if let Some(ref service) = *rustdesk_guard { - service - .stop() - .await - .map_err(|e| AppError::Config(format!("Failed to stop RustDesk service: {}", e)))?; - tracing::info!("RustDesk service stopped"); - } - *rustdesk_guard = None; - } - - if !options.preserve_service_state && new_config.enabled { - if rustdesk_guard.is_none() { - tracing::info!("Initializing RustDesk service..."); - let service = std::sync::Arc::new(crate::rustdesk::RustDeskService::new( - new_config.clone(), - state.stream_manager.clone(), - state.hid.clone(), - state.audio.clone(), - )); - *rustdesk_guard = Some(service.clone()); - service.start().await.map_err(|e| { - AppError::Config(format!("Failed to start RustDesk service: {}", e)) - })?; - tracing::info!("RustDesk service started with ID: {}", new_config.device_id); - credentials_to_save = service.save_credentials(); - } else { - if let Some(ref service) = *rustdesk_guard { - if service.is_listening() { - if need_restart { - service.restart(new_config.clone()).await.map_err(|e| { - AppError::Config(format!("Failed to restart RustDesk service: {}", e)) - })?; - tracing::info!( - "RustDesk service restarted with ID: {}", - new_config.device_id - ); - } - } else { - service.update_config(new_config.clone()); - service.start().await.map_err(|e| { - AppError::Config(format!("Failed to start RustDesk service: {}", e)) - })?; - } - credentials_to_save = service.save_credentials(); - } - } - } else if options.preserve_service_state && need_restart { - if let Some(ref service) = *rustdesk_guard { - let mut runtime_config = new_config.clone(); - runtime_config.enabled = true; - service.restart(runtime_config).await.map_err(|e| { - AppError::Config(format!("Failed to restart RustDesk service: {}", e)) - })?; - credentials_to_save = service.save_credentials(); - } - } - - drop(rustdesk_guard); - if let Some(updated_config) = credentials_to_save { - tracing::info!("Saving RustDesk credentials to config store..."); - state - .config - .update(|cfg| { - cfg.rustdesk.public_key = updated_config.public_key.clone(); - cfg.rustdesk.private_key = updated_config.private_key.clone(); - cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone(); - cfg.rustdesk.signing_private_key = updated_config.signing_private_key.clone(); - cfg.rustdesk.uuid = updated_config.uuid.clone(); - }) - .await?; - tracing::info!("RustDesk credentials saved successfully"); - } - - if let Some(message) = enforce_stream_codec_constraints(state).await? { - tracing::info!("{}", message); - } - - Ok(()) -} - -pub async fn apply_vnc_config( - state: &Arc, - old_config: &VncConfig, - new_config: &VncConfig, - options: ConfigApplyOptions, -) -> Result<()> { - tracing::info!("Applying VNC config changes..."); - - if options.runtime_only { - validate_runtime_candidate( - state, - |candidate, config| candidate.vnc = config, - new_config.clone(), - ) - .await?; - } else { - validate_vnc_candidate(state, new_config)?; - } - - let runtime_config = state.runtime_third_party_config().await; - let will_run = if options.preserve_service_state { - runtime_config.vnc.enabled - } else { - new_config.enabled - }; - if will_run { - let mut candidate = runtime_config; - candidate.vnc = new_config.clone(); - candidate.vnc.enabled = true; - let constraints = StreamCodecConstraints::from_config(&candidate); - match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await { - Ok(result) if result.changed => { - if let Some(message) = result.message { - tracing::info!("{}", message); - } - } - Ok(_) => {} - Err(e) => tracing::warn!( - "Failed to enforce VNC stream constraints before start: {}", - e - ), - } - } - - let mut vnc_guard = state.vnc.write().await; - let need_restart = options.force - || old_config.bind != new_config.bind - || old_config.port != new_config.port - || old_config.encoding != new_config.encoding - || old_config.password != new_config.password - || old_config.allow_one_client != new_config.allow_one_client; - - if !options.preserve_service_state && !new_config.enabled { - if let Some(ref service) = *vnc_guard { - service.stop().await?; - } - *vnc_guard = None; - } - - if !options.preserve_service_state && new_config.enabled { - if vnc_guard.is_none() { - let service = Arc::new(crate::vnc::VncService::new( - new_config.clone(), - state.stream_manager.clone(), - state.hid.clone(), - )); - *vnc_guard = Some(service.clone()); - service.start().await?; - tracing::info!("VNC service started"); - } else { - if let Some(ref service) = *vnc_guard { - if matches!( - service.status().await, - crate::vnc::VncServiceStatus::Running - ) { - if need_restart { - service.restart(new_config.clone()).await?; - tracing::info!("VNC service restarted"); - } - } else { - service.update_config(new_config.clone()).await; - service.start().await?; - } - } - } - } else if options.preserve_service_state && need_restart { - if let Some(ref service) = *vnc_guard { - let mut runtime_config = new_config.clone(); - runtime_config.enabled = true; - service.restart(runtime_config).await?; - } - } - - drop(vnc_guard); - if let Some(message) = enforce_stream_codec_constraints(state).await? { - tracing::info!("{}", message); - } - - Ok(()) -} - -pub async fn apply_rtsp_config( - state: &Arc, - old_config: &RtspConfig, - new_config: &RtspConfig, - options: ConfigApplyOptions, -) -> Result<()> { - tracing::info!("Applying RTSP config changes..."); - - if options.runtime_only { - validate_runtime_candidate( - state, - |candidate, config| candidate.rtsp = config, - new_config.clone(), - ) - .await?; - } else { - validate_rtsp_candidate(state, new_config)?; - } - - let mut rtsp_guard = state.rtsp.write().await; - let need_restart = options.force - || old_config.bind != new_config.bind - || old_config.port != new_config.port - || old_config.path != new_config.path - || old_config.codec != new_config.codec - || old_config.username != new_config.username - || old_config.password != new_config.password - || old_config.allow_one_client != new_config.allow_one_client; - - if !options.preserve_service_state && !new_config.enabled { - if let Some(ref service) = *rtsp_guard { - service - .stop() - .await - .map_err(|e| AppError::Config(format!("Failed to stop RTSP service: {}", e)))?; - } - *rtsp_guard = None; - } - - if !options.preserve_service_state && new_config.enabled { - if rtsp_guard.is_none() { - let service = Arc::new(RtspService::new( - new_config.clone(), - state.stream_manager.clone(), - )); - *rtsp_guard = Some(service.clone()); - service.start().await?; - tracing::info!("RTSP service started"); - } else { - if let Some(ref service) = *rtsp_guard { - if matches!( - service.status().await, - crate::rtsp::RtspServiceStatus::Running - ) { - if need_restart { - service.restart(new_config.clone()).await?; - tracing::info!("RTSP service restarted"); - } - } else { - service.update_config(new_config.clone()).await; - service.start().await?; - } - } - } - } else if options.preserve_service_state && need_restart { - if let Some(ref service) = *rtsp_guard { - let mut runtime_config = new_config.clone(); - runtime_config.enabled = true; - service.restart(runtime_config).await?; - } - } - - drop(rtsp_guard); - - if let Some(message) = enforce_stream_codec_constraints(state).await? { - tracing::info!("{}", message); - } - - Ok(()) -} diff --git a/src/web/handlers/config/hid.rs b/src/web/handlers/config/hid.rs index 3dcf45f6..9add4191 100644 --- a/src/web/handlers/config/hid.rs +++ b/src/web/handlers/config/hid.rs @@ -1,22 +1,22 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::config::HidConfig; use crate::error::Result; -use crate::state::AppState; +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 { +pub async fn get_hid_config(State(state): State) -> Json { Json(state.config.get().hid.clone()) } pub async fn update_hid_config( - State(state): State>, + 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/msd.rs b/src/web/handlers/config/msd.rs index 47a37431..a975a004 100644 --- a/src/web/handlers/config/msd.rs +++ b/src/web/handlers/config/msd.rs @@ -1,19 +1,18 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::config::MsdConfig; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::otg::update_otg_config_inner; use super::types::{MsdConfigUpdate, OtgConfigUpdate}; -pub async fn get_msd_config(State(state): State>) -> Json { +pub async fn get_msd_config(State(state): State) -> Json { Json(state.config.get().msd.clone()) } pub async fn update_msd_config( - State(state): State>, + State(state): State, Json(req): Json, ) -> Result> { let response = update_otg_config_inner( diff --git a/src/web/handlers/config/otg.rs b/src/web/handlers/config/otg.rs index b06bee9b..4ba7004f 100644 --- a/src/web/handlers/config/otg.rs +++ b/src/web/handlers/config/otg.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use axum::{extract::State, Json}; use serde::Serialize; use typeshare::typeshare; @@ -7,10 +5,10 @@ use typeshare::typeshare; use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig}; use crate::error::Result; use crate::otg::OtgNetworkStatus; -use crate::state::AppState; +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)] @@ -22,17 +20,22 @@ pub struct OtgConfigResponse { } pub async fn update_otg_config( - State(state): State>, + State(state): State, Json(request): Json, ) -> Result> { update_otg_config_inner(&state, request).await.map(Json) } pub(super) async fn update_otg_config_inner( - state: &Arc, + 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, @@ -54,6 +57,6 @@ pub(super) async fn update_otg_config_inner( hid: staged_config.hid, msd: staged_config.msd, network: staged_config.otg_network, - status: state.otg_service.network_status().await, + status: state.otg.network_status().await, }) } diff --git a/src/web/handlers/config/otg_network.rs b/src/web/handlers/config/otg_network.rs index d9da27dc..4f0abae9 100644 --- a/src/web/handlers/config/otg_network.rs +++ b/src/web/handlers/config/otg_network.rs @@ -1,21 +1,19 @@ -use std::sync::Arc; - use axum::{extract::State, Json}; use crate::config::OtgNetworkConfig; use crate::error::Result; use crate::otg::OtgNetworkStatus; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::otg::update_otg_config_inner; use super::types::{OtgConfigUpdate, OtgNetworkConfigUpdate}; -pub async fn get_otg_network_config(State(state): State>) -> Json { +pub async fn get_otg_network_config(State(state): State) -> Json { Json(state.config.get().otg_network.clone()) } pub async fn update_otg_network_config( - State(state): State>, + State(state): State, Json(request): Json, ) -> Result> { let response = update_otg_config_inner( @@ -29,6 +27,6 @@ pub async fn update_otg_network_config( Ok(Json(response.network)) } -pub async fn get_otg_network_status(State(state): State>) -> Json { - Json(state.otg_service.network_status().await) +pub async fn get_otg_network_status(State(state): State) -> Json { + Json(state.otg.network_status().await) } diff --git a/src/web/handlers/config/rtsp.rs b/src/web/handlers/config/rtsp.rs index 7a5a5fbf..f94f03c4 100644 --- a/src/web/handlers/config/rtsp.rs +++ b/src/web/handlers/config/rtsp.rs @@ -1,20 +1,22 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::RemoteAccessApiState; -use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions}; use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse}; +use crate::runtime::{try_apply_lock, ConfigApplyOptions}; -fn validate_candidate(state: &Arc, config: &crate::config::RtspConfig) -> Result<()> { +fn validate_candidate( + state: &RemoteAccessApiState, + config: &crate::config::RtspConfig, +) -> Result<()> { let mut candidate = state.config.get().as_ref().clone(); candidate.rtsp = config.clone(); crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate) } async fn persist_and_apply( - state: &Arc, + state: &RemoteAccessApiState, old_config: crate::config::RtspConfig, new_config: crate::config::RtspConfig, ) -> Result { @@ -26,31 +28,31 @@ async fn persist_and_apply( }) .await?; let stored_config = state.config.get().rtsp.clone(); - apply_rtsp_config( - state, - &old_config, - &stored_config, - ConfigApplyOptions::preserving_service_state(), - ) - .await?; + state + .coordinator + .apply_rtsp( + &old_config, + &stored_config, + ConfigApplyOptions::preserving_service_state(), + ) + .await?; Ok(stored_config) } -async fn current_status(state: &Arc) -> crate::rtsp::RtspServiceStatus { - let guard = state.rtsp.read().await; - if let Some(ref service) = *guard { - service.status().await - } else { - crate::rtsp::RtspServiceStatus::Stopped - } +async fn current_status(state: &RemoteAccessApiState) -> crate::rtsp::RtspServiceStatus { + state.coordinator.rtsp_status().await } -pub async fn get_rtsp_config(State(state): State>) -> Json { +pub async fn get_rtsp_config( + State(state): State, +) -> Json { let config = state.config.get(); Json(RtspConfigResponse::from(&config.rtsp)) } -pub async fn get_rtsp_status(State(state): State>) -> Json { +pub async fn get_rtsp_status( + State(state): State, +) -> Json { let config = state.config.get().rtsp.clone(); let status = current_status(&state).await; @@ -58,12 +60,12 @@ pub async fn get_rtsp_status(State(state): State>) -> Json>, + State(state): State, Json(req): Json, ) -> Result> { req.validate()?; - let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?; + let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?; let old_config = state.config.get().rtsp.clone(); let mut merged_config = old_config.clone(); req.apply_to(&mut merged_config); @@ -73,40 +75,42 @@ pub async fn update_rtsp_config( } pub async fn start_rtsp_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?; + let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?; let stored_config = state.config.get().rtsp.clone(); - let runtime_config = state.runtime_third_party_config().await.rtsp; + let runtime_config = state.coordinator.runtime_config().await.rtsp; let mut start_config = stored_config.clone(); start_config.enabled = true; - apply_rtsp_config( - &state, - &runtime_config, - &start_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rtsp( + &runtime_config, + &start_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let status = current_status(&state).await; Ok(Json(RtspStatusResponse::new(&stored_config, status))) } pub async fn stop_rtsp_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?; + let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?; let stored_config = state.config.get().rtsp.clone(); - let runtime_config = state.runtime_third_party_config().await.rtsp; + let runtime_config = state.coordinator.runtime_config().await.rtsp; let mut stop_config = stored_config.clone(); stop_config.enabled = false; - apply_rtsp_config( - &state, - &runtime_config, - &stop_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rtsp( + &runtime_config, + &stop_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let status = current_status(&state).await; Ok(Json(RtspStatusResponse::new(&stored_config, status))) diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index ce08d920..659f0d0b 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -1,21 +1,20 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::error::Result; use crate::rustdesk::config::RustDeskConfig; -use crate::state::AppState; +use crate::web::state::RemoteAccessApiState; -use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions}; use super::types::RustDeskConfigUpdate; +use crate::runtime::{try_apply_lock, ConfigApplyOptions}; -fn validate_candidate(state: &Arc, config: &RustDeskConfig) -> Result<()> { +fn validate_candidate(state: &RemoteAccessApiState, config: &RustDeskConfig) -> Result<()> { let mut candidate = state.config.get().as_ref().clone(); candidate.rustdesk = config.clone(); crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate) } async fn persist_and_apply( - state: &Arc, + state: &RemoteAccessApiState, old_config: RustDeskConfig, new_config: RustDeskConfig, ) -> Result { @@ -27,44 +26,43 @@ async fn persist_and_apply( }) .await?; let stored_config = state.config.get().rustdesk.clone(); - apply_rustdesk_config( - state, - &old_config, - &stored_config, - ConfigApplyOptions::preserving_service_state(), - ) - .await?; + state + .coordinator + .apply_rustdesk( + &old_config, + &stored_config, + ConfigApplyOptions::preserving_service_state(), + ) + .await?; Ok(stored_config) } -async fn current_status(state: &Arc, config: RustDeskConfig) -> RustDeskStatusResponse { - let (service_status, rendezvous_status) = { - let guard = state.rustdesk.read().await; - if let Some(ref service) = *guard { - let status = format!("{}", service.status()); - let rv_status = service.rendezvous_status().map(|s| format!("{}", s)); - (status, rv_status) - } else { - ("not_initialized".to_string(), None) - } - }; +async fn current_status( + state: &RemoteAccessApiState, + config: RustDeskConfig, +) -> RustDeskStatusResponse { + let runtime = state.coordinator.rustdesk_status().await; RustDeskStatusResponse { config: RustDeskConfigResponse::from(&config), - service_status, - rendezvous_status, + service_status: runtime.service_status, + rendezvous_status: runtime.rendezvous_status, + connection_count: runtime.connection_count, + listening: runtime.listening, + listen_port: runtime.listen_port, } } #[derive(Debug, serde::Serialize)] pub struct RustDeskConfigResponse { pub enabled: bool, + pub mode: crate::rustdesk::config::RustDeskMode, pub codec: crate::rustdesk::config::RustDeskCodec, + pub direct_access_port: u16, pub rendezvous_server: String, pub relay_server: Option, pub device_id: String, pub has_password: bool, - pub has_keypair: bool, pub relay_key: Option, } @@ -72,12 +70,13 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse { fn from(config: &RustDeskConfig) -> Self { Self { enabled: config.enabled, + mode: config.mode, codec: config.codec, + direct_access_port: config.direct_access_port, rendezvous_server: config.rendezvous_server.clone(), relay_server: config.relay_server.clone(), device_id: config.device_id.clone(), has_password: !config.device_password.is_empty(), - has_keypair: config.public_key.is_some() && config.private_key.is_some(), relay_key: config.relay_key.clone(), } } @@ -88,28 +87,31 @@ pub struct RustDeskStatusResponse { pub config: RustDeskConfigResponse, pub service_status: String, pub rendezvous_status: Option, + pub connection_count: usize, + pub listening: bool, + pub listen_port: Option, } pub async fn get_rustdesk_config( - State(state): State>, + State(state): State, ) -> Json { Json(RustDeskConfigResponse::from(&state.config.get().rustdesk)) } pub async fn get_rustdesk_status( - State(state): State>, + State(state): State, ) -> Json { let config = state.config.get().rustdesk.clone(); Json(current_status(&state, config).await) } pub async fn update_rustdesk_config( - State(state): State>, + State(state): State, Json(req): Json, ) -> Result> { req.validate()?; - let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?; + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; let old_config = state.config.get().rustdesk.clone(); let mut merged_config = old_config.clone(); req.apply_to(&mut merged_config); @@ -117,46 +119,34 @@ pub async fn update_rustdesk_config( let new_config = persist_and_apply(&state, old_config, merged_config).await?; - let constraints = state.stream_manager.codec_constraints().await; - if constraints.rustdesk_enabled || constraints.rtsp_enabled { - tracing::info!( - "Stream codec constraints active after RustDesk update: {}", - constraints.reason - ); - } - Ok(Json(RustDeskConfigResponse::from(&new_config))) } pub async fn regenerate_device_id( - State(state): State>, + State(state): State, ) -> Result> { - state - .config - .update(|config| { - config.rustdesk.device_id = RustDeskConfig::generate_device_id(); - }) - .await?; - - let new_config = state.config.get().rustdesk.clone(); + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; + let old_config = state.config.get().rustdesk.clone(); + let mut regenerated = old_config.clone(); + regenerated.device_id = RustDeskConfig::generate_device_id(); + let new_config = persist_and_apply(&state, old_config, regenerated).await?; Ok(Json(RustDeskConfigResponse::from(&new_config))) } pub async fn regenerate_device_password( - State(state): State>, + State(state): State, ) -> Result> { - state - .config - .update(|config| { - config.rustdesk.device_password = RustDeskConfig::generate_password(); - }) - .await?; - - let new_config = state.config.get().rustdesk.clone(); + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; + let old_config = state.config.get().rustdesk.clone(); + let mut regenerated = old_config.clone(); + regenerated.device_password = RustDeskConfig::generate_password(); + let new_config = persist_and_apply(&state, old_config, regenerated).await?; Ok(Json(RustDeskConfigResponse::from(&new_config))) } -pub async fn get_device_password(State(state): State>) -> Json { +pub async fn get_device_password( + State(state): State, +) -> Json { let config = state.config.get().rustdesk.clone(); Json(serde_json::json!({ "device_id": config.device_id, @@ -165,38 +155,40 @@ pub async fn get_device_password(State(state): State>) -> Json>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?; + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; let stored_config = state.config.get().rustdesk.clone(); - let runtime_config = state.runtime_third_party_config().await.rustdesk; + let runtime_config = state.coordinator.runtime_config().await.rustdesk; let mut start_config = stored_config.clone(); start_config.enabled = true; - apply_rustdesk_config( - &state, - &runtime_config, - &start_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rustdesk( + &runtime_config, + &start_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let stored_config = state.config.get().rustdesk.clone(); Ok(Json(current_status(&state, stored_config).await)) } pub async fn stop_rustdesk_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?; + let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?; let stored_config = state.config.get().rustdesk.clone(); - let runtime_config = state.runtime_third_party_config().await.rustdesk; + let runtime_config = state.coordinator.runtime_config().await.rustdesk; let mut stop_config = stored_config.clone(); stop_config.enabled = false; - apply_rustdesk_config( - &state, - &runtime_config, - &stop_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_rustdesk( + &runtime_config, + &stop_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; Ok(Json(current_status(&state, stored_config).await)) } diff --git a/src/web/handlers/config/stream.rs b/src/web/handlers/config/stream.rs index c4aac191..d6448b04 100644 --- a/src/web/handlers/config/stream.rs +++ b/src/web/handlers/config/stream.rs @@ -38,7 +38,7 @@ pub async fn update_stream_config( ) .await?; - super::apply::enforce_stream_codec_constraints(&state).await?; + state.remote_access.enforce_codec_constraints().await?; Ok(Json(StreamConfigResponse::from(&new_stream_config))) } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 72397899..ef389ec9 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, @@ -427,6 +430,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) { @@ -445,6 +451,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; } @@ -903,7 +912,9 @@ fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> { #[derive(Debug, Deserialize)] pub struct RustDeskConfigUpdate { pub enabled: Option, + pub mode: Option, pub codec: Option, + pub direct_access_port: Option, pub rendezvous_server: Option, pub relay_server: Option, pub relay_key: Option, @@ -912,6 +923,11 @@ pub struct RustDeskConfigUpdate { impl RustDeskConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { + if self.direct_access_port == Some(0) { + return Err(AppError::BadRequest( + "RustDesk direct access port must be greater than 0".into(), + )); + } // Validate rendezvous server format (should be host:port) if let Some(ref server) = self.rendezvous_server { if !server.is_empty() && !server.contains(':') { @@ -948,10 +964,24 @@ impl RustDeskConfigUpdate { } pub fn validate_merged(&self, config: &RustDeskConfig) -> crate::error::Result<()> { - if config.enabled && config.rendezvous_server.trim().is_empty() { - return Err(AppError::BadRequest( - "RustDesk ID server is required".into(), - )); + if config.enabled { + match config.mode { + crate::rustdesk::config::RustDeskMode::Id + if config.rendezvous_server.trim().is_empty() => + { + return Err(AppError::BadRequest( + "RustDesk ID server is required in ID service mode".into(), + )); + } + crate::rustdesk::config::RustDeskMode::DirectIp + if config.direct_access_port == 0 => + { + return Err(AppError::BadRequest( + "RustDesk direct access port must be greater than 0".into(), + )); + } + _ => {} + } } Ok(()) } @@ -960,9 +990,15 @@ impl RustDeskConfigUpdate { if let Some(enabled) = self.enabled { config.enabled = enabled; } + if let Some(mode) = self.mode { + config.mode = mode; + } if let Some(codec) = self.codec { config.codec = codec; } + if let Some(port) = self.direct_access_port { + config.direct_access_port = port; + } if let Some(ref server) = self.rendezvous_server { config.rendezvous_server = server.clone(); } @@ -1470,7 +1506,9 @@ mod tests { fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() { let update = RustDeskConfigUpdate { enabled: None, + mode: None, codec: None, + direct_access_port: None, rendezvous_server: None, relay_server: None, relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()), @@ -1485,7 +1523,9 @@ mod tests { let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string(); let update = RustDeskConfigUpdate { enabled: None, + mode: None, codec: None, + direct_access_port: None, rendezvous_server: None, relay_server: None, relay_key: Some(not_32), @@ -1494,6 +1534,49 @@ mod tests { assert!(update.validate().is_err()); } + #[test] + fn rustdesk_direct_ip_mode_does_not_require_id_server() { + let mut config = RustDeskConfig::default(); + config.enabled = true; + config.mode = crate::rustdesk::config::RustDeskMode::DirectIp; + config.rendezvous_server.clear(); + + let update = RustDeskConfigUpdate { + enabled: Some(true), + mode: Some(crate::rustdesk::config::RustDeskMode::DirectIp), + codec: None, + direct_access_port: Some(21118), + rendezvous_server: Some(String::new()), + relay_server: None, + relay_key: None, + device_password: None, + }; + + assert!(update.validate().is_ok()); + assert!(update.validate_merged(&config).is_ok()); + } + + #[test] + fn rustdesk_id_mode_requires_id_server_when_enabled() { + let mut config = RustDeskConfig::default(); + config.enabled = true; + config.mode = crate::rustdesk::config::RustDeskMode::Id; + config.rendezvous_server.clear(); + + let update = RustDeskConfigUpdate { + enabled: Some(true), + mode: Some(crate::rustdesk::config::RustDeskMode::Id), + codec: None, + direct_access_port: None, + rendezvous_server: Some(String::new()), + relay_server: None, + relay_key: None, + device_password: None, + }; + + assert!(update.validate_merged(&config).is_err()); + } + #[test] fn ipv6_bind_vnc_config_accepts_ipv6_literals() { for bind in ["::", "::1", "2001:db8::1"] { diff --git a/src/web/handlers/config/uac.rs b/src/web/handlers/config/uac.rs index 90b0ec43..4e1828a8 100644 --- a/src/web/handlers/config/uac.rs +++ b/src/web/handlers/config/uac.rs @@ -1,19 +1,17 @@ -use std::sync::Arc; - use axum::{extract::State, Json}; use crate::config::UacConfig; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::UsbApiState; use super::usb_update::update_usb_config; -pub async fn get_uac_config(State(state): State>) -> Json { +pub async fn get_uac_config(State(state): State) -> Json { Json(state.config.get().uac.clone()) } pub async fn update_uac_config( - State(state): State>, + State(state): State, Json(request): Json, ) -> Result> { request.validate()?; diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index d749f9e8..d2f4d2c0 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -1,11 +1,9 @@ -use std::sync::Arc; - use crate::config::{AppConfig, Ch9329DescriptorConfig, HidBackend, HidConfig}; use crate::error::{AppError, Result}; -use crate::state::AppState; +use crate::web::state::UsbApiState; -use super::apply::{apply_usb_config, try_apply_lock}; use super::types::HidConfigUpdate; +use crate::runtime::try_apply_lock; pub(super) fn stage_hid_config_update( staged_hid: &mut HidConfig, @@ -27,14 +25,22 @@ pub(super) fn stage_hid_config_update( Ok(requested_descriptor) } -pub(super) async fn update_usb_config( - state: &Arc, +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>, { - let _guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?; + let _guard = try_apply_lock(&state.apply_lock, "otg")?; let old_config = state.config.get(); let mut staged_config = old_config.as_ref().clone(); @@ -58,63 +64,178 @@ where staged_config.uac.validate()?; } - if let Err(error) = apply_usb_config(state, &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( - state: &Arc, + state: &UsbApiState, failed_config: &AppConfig, old_config: &AppConfig, primary_error: AppError, @@ -122,7 +243,11 @@ async fn rollback_after_failure( ) -> AppError { let mut rollback_errors = Vec::new(); - if let Err(error) = apply_usb_config(state, failed_config, old_config).await { + if let Err(error) = state + .coordinator + .apply_config(failed_config, old_config) + .await + { rollback_errors.push(format!("runtime rollback failed: {error}")); } if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 { @@ -141,7 +266,7 @@ async fn rollback_after_failure( let message = format!("{primary_error}; {}", rollback_errors.join("; ")); #[cfg(unix)] - state.otg_service.mark_degraded(message.clone()).await; + state.otg.mark_degraded(message.clone()).await; AppError::Config(message) } @@ -153,6 +278,8 @@ mod tests { fn hid_update() -> HidConfigUpdate { HidConfigUpdate { + bluetooth_reset_pairing: None, + bluetooth: None, backend: None, ch9329_port: None, ch9329_baudrate: None, @@ -168,6 +295,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/config/vnc.rs b/src/web/handlers/config/vnc.rs index f68a8b42..8bac9e0f 100644 --- a/src/web/handlers/config/vnc.rs +++ b/src/web/handlers/config/vnc.rs @@ -1,20 +1,22 @@ use axum::{extract::State, Json}; -use std::sync::Arc; use crate::error::Result; -use crate::state::AppState; +use crate::web::state::RemoteAccessApiState; -use super::apply::{apply_vnc_config, try_apply_lock, ConfigApplyOptions}; use super::types::{VncConfigResponse, VncConfigUpdate, VncStatusResponse}; +use crate::runtime::{try_apply_lock, ConfigApplyOptions}; -fn validate_candidate(state: &Arc, config: &crate::config::VncConfig) -> Result<()> { +fn validate_candidate( + state: &RemoteAccessApiState, + config: &crate::config::VncConfig, +) -> Result<()> { let mut candidate = state.config.get().as_ref().clone(); candidate.vnc = config.clone(); crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate) } async fn persist_and_apply( - state: &Arc, + state: &RemoteAccessApiState, old_config: crate::config::VncConfig, new_config: crate::config::VncConfig, ) -> Result { @@ -26,30 +28,26 @@ async fn persist_and_apply( }) .await?; let stored_config = state.config.get().vnc.clone(); - apply_vnc_config( - state, - &old_config, - &stored_config, - ConfigApplyOptions::preserving_service_state(), - ) - .await?; + state + .coordinator + .apply_vnc( + &old_config, + &stored_config, + ConfigApplyOptions::preserving_service_state(), + ) + .await?; Ok(stored_config) } -async fn current_status(state: &Arc) -> (crate::vnc::VncServiceStatus, usize) { - let guard = state.vnc.read().await; - if let Some(ref service) = *guard { - (service.status().await, service.connection_count()) - } else { - (crate::vnc::VncServiceStatus::Stopped, 0) - } +async fn current_status(state: &RemoteAccessApiState) -> (crate::vnc::VncServiceStatus, usize) { + state.coordinator.vnc_status().await } -pub async fn get_vnc_config(State(state): State>) -> Json { +pub async fn get_vnc_config(State(state): State) -> Json { Json(VncConfigResponse::from(&state.config.get().vnc)) } -pub async fn get_vnc_status(State(state): State>) -> Json { +pub async fn get_vnc_status(State(state): State) -> Json { let config = state.config.get().vnc.clone(); let (status, connection_count) = current_status(&state).await; @@ -57,12 +55,12 @@ pub async fn get_vnc_status(State(state): State>) -> Json>, + State(state): State, Json(req): Json, ) -> Result> { req.validate()?; - let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let _apply_guard = try_apply_lock(&state.vnc_apply_lock, "vnc")?; let old_config = state.config.get().vnc.clone(); let mut merged_config = old_config.clone(); req.apply_to(&mut merged_config); @@ -73,23 +71,24 @@ pub async fn update_vnc_config( } pub async fn start_vnc_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let _apply_guard = try_apply_lock(&state.vnc_apply_lock, "vnc")?; let stored_config = state.config.get().vnc.clone(); - let runtime_config = state.runtime_third_party_config().await.vnc; + let runtime_config = state.coordinator.runtime_config().await.vnc; let mut start_config = stored_config.clone(); start_config.enabled = true; if start_config.password.as_deref().unwrap_or("").is_empty() { start_config.password = stored_config.password.clone(); } - apply_vnc_config( - &state, - &runtime_config, - &start_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_vnc( + &runtime_config, + &start_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; let (status, connection_count) = current_status(&state).await; Ok(Json(VncStatusResponse::new( @@ -100,20 +99,21 @@ pub async fn start_vnc_service( } pub async fn stop_vnc_service( - State(state): State>, + State(state): State, ) -> Result> { - let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?; + let _apply_guard = try_apply_lock(&state.vnc_apply_lock, "vnc")?; let stored_config = state.config.get().vnc.clone(); - let runtime_config = state.runtime_third_party_config().await.vnc; + let runtime_config = state.coordinator.runtime_config().await.vnc; let mut stop_config = stored_config.clone(); stop_config.enabled = false; - apply_vnc_config( - &state, - &runtime_config, - &stop_config, - ConfigApplyOptions::runtime_only(), - ) - .await?; + state + .coordinator + .apply_vnc( + &runtime_config, + &stop_config, + ConfigApplyOptions::runtime_only(), + ) + .await?; Ok(Json(VncStatusResponse::new( &stored_config, 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/handlers/mod.rs b/src/web/handlers/mod.rs index b34927c7..4fd088f6 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -42,12 +42,12 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{debug, info, warn}; -use self::config::apply::ConfigApplyOptions; use crate::auth::{Session, SESSION_COOKIE}; use crate::config::StreamMode; use crate::diagnostics::{get_device_info, get_disk_space, DeviceInfo, DiskSpaceInfo}; use crate::error::{AppError, Result}; use crate::platform::PlatformCapabilities; +use crate::runtime::ConfigApplyOptions; use crate::state::AppState; use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest}; use crate::utils::list_serial_ports; diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index 05ca0c60..a29b8c56 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -2,7 +2,7 @@ use super::config::apply::try_apply_lock; use super::*; use crate::msd::{ - DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, + DiskModeRequest, DownloadProgress, DriveFile, DriveFileAccess, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdErrorCode, MsdState, MsdStateResponse, VentoyDrive, MIN_DRIVE_SIZE_MB, }; @@ -420,16 +420,26 @@ pub async fn msd_drive_info(State(state): State>) -> Result controller.is_drive_connected().await, + None => false, + }; + + // Never parse the filesystem while the USB host owns it. Metadata is safe + // to read and still lets the UI show the backing image capacity. + let info = if connected { + drive.raw_info(DriveFileAccess::BlockedWhileConnected) + } else { + drive.info().await + } + .map_err(|error| operation_failed("read virtual drive info", error))?; + + if let Some(controller) = msd_guard.as_ref() { + controller.set_drive_info(Some(info.clone())).await; } - drive - .info() - .await - .map(Json) - .map_err(|error| operation_failed("read virtual drive info", error)) + Ok(Json(info)) } /// Initialize Ventoy drive @@ -439,7 +449,6 @@ pub async fn msd_drive_init( payload: std::result::Result, JsonRejection>, ) -> Result> { let req = parse_msd_json(payload)?; - assert_drive_not_connected(&state).await?; let config = state.config.get(); let msd_dir = config.msd.msd_dir_path(); @@ -449,6 +458,16 @@ pub async fn msd_drive_init( })?; validate_drive_init_size(req.size_mb, disk_space.available)?; + // Mount/unmount handlers also take this outer write lock. Holding it + // across image creation prevents a mount from racing the destructive + // reinitialization after the connected-state check. + let msd_guard = state.msd.write().await; + if let Some(controller) = msd_guard.as_ref() { + if controller.is_drive_connected().await { + return Err(MsdErrorCode::MsdDriveConnected.into()); + } + } + let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -456,6 +475,9 @@ pub async fn msd_drive_init( .init(req.size_mb) .await .map_err(|error| operation_failed("initialize virtual drive", error))?; + if let Some(controller) = msd_guard.as_ref() { + controller.set_drive_info(Some(info.clone())).await; + } Ok(Json(info)) } @@ -471,14 +493,15 @@ pub async fn msd_drive_delete(State(state): State>) -> Result) -> 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), diff --git a/src/web/state.rs b/src/web/state.rs new file mode 100644 index 00000000..85fcf8d3 --- /dev/null +++ b/src/web/state.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use axum::extract::FromRef; +use tokio::sync::Mutex; + +use crate::config::ConfigStore; +use crate::hid::HidController; +#[cfg(unix)] +use crate::otg::OtgService; +use crate::runtime::{RemoteAccessCoordinator, UsbCoordinator}; +use crate::state::AppState; + +#[derive(Clone)] +pub(crate) struct RemoteAccessApiState { + pub config: ConfigStore, + pub coordinator: Arc, + pub rustdesk_apply_lock: Arc>, + pub vnc_apply_lock: Arc>, + pub rtsp_apply_lock: Arc>, +} + +impl FromRef> for RemoteAccessApiState { + fn from_ref(state: &Arc) -> Self { + Self { + config: state.config.clone(), + coordinator: state.remote_access.clone(), + rustdesk_apply_lock: state.config_apply_locks.rustdesk.clone(), + vnc_apply_lock: state.config_apply_locks.vnc.clone(), + rtsp_apply_lock: state.config_apply_locks.rtsp.clone(), + } + } +} + +#[derive(Clone)] +pub(crate) struct UsbApiState { + pub config: ConfigStore, + pub coordinator: Arc, + pub hid: Arc, + pub apply_lock: Arc>, + #[cfg(unix)] + pub otg: Arc, +} + +impl FromRef> for UsbApiState { + fn from_ref(state: &Arc) -> Self { + Self { + config: state.config.clone(), + coordinator: state.usb.clone(), + hid: state.hid.clone(), + apply_lock: state.config_apply_locks.otg.clone(), + #[cfg(unix)] + otg: state.otg_service.clone(), + } + } +} diff --git a/src/webrtc/universal_session.rs b/src/webrtc/universal_session.rs index 83b50a3b..6b4a8059 100644 --- a/src/webrtc/universal_session.rs +++ b/src/webrtc/universal_session.rs @@ -36,7 +36,7 @@ use crate::audio::OpusFrame; use crate::error::{AppError, Result}; use crate::hid::datachannel::{parse_hid_message, HidChannelEvent}; use crate::hid::HidController; -use crate::video::codec::h264_bitstream; +use crate::video::codec::{h264_bitstream, h265_bitstream}; use crate::video::types::{ BitratePreset, EncodedVideoFrame, PixelFormat, Resolution, VideoEncoderType, }; @@ -638,11 +638,16 @@ impl UniversalSession { next_keyframe_retry = Instant::now(); } - // Some H264 encoders output SPS/PPS in a separate non-keyframe AU - // before IDR. Keep this frame so browser can decode the next IDR. - let forward_h264_parameter_frame = waiting_for_keyframe - && expected_codec == VideoEncoderType::H264 - && h264_bitstream::has_sps_pps(encoded_frame.data.as_ref()); + let forward_parameter_frame = waiting_for_keyframe + && match expected_codec { + VideoEncoderType::H264 => h264_bitstream::has_sps_pps( + encoded_frame.data.as_ref(), + ), + VideoEncoderType::H265 => h265_bitstream::has_vps_sps_pps( + encoded_frame.data.as_ref(), + ), + _ => false, + }; let now = Instant::now(); if keyframe_requests < KEYFRAME_RETRY_LIMIT @@ -659,7 +664,7 @@ impl UniversalSession { ); } } - if !forward_h264_parameter_frame { + if !forward_parameter_frame { continue; } } diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index 27884dc4..852c403d 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -14,7 +14,6 @@ use crate::events::{EventBus, StreamKind, SystemEvent}; use crate::hid::HidController; use crate::video::capture::DEFAULT_CAPTURE_BUFFER_COUNT; use crate::video::codec::h264_bitstream; -use crate::video::codec::EncoderRegistry; use crate::video::device::{ enumerate_devices, select_recovery_device, VideoControlMode, VideoDevice, VideoDeviceInfo, VideoDeviceRecoveryHint, @@ -1314,26 +1313,6 @@ impl WebRtcStreamer { }; if pipeline_running { - let pipeline = self.video_pipeline.read().await.clone(); - if let Some(pipeline) = pipeline { - let pipeline_config = pipeline.config().await; - let selected_backend = pipeline_config.encoder_backend.or_else(|| { - EncoderRegistry::global() - .best_available_encoder(pipeline_config.output_codec) - .map(|encoder| encoder.backend) - }); - if pipeline_config.input_format == PixelFormat::Mjpeg - && selected_backend == Some(EncoderBackend::Amlogic) - { - info!( - "Applying AMLENC bitrate {} in the encoder worker without restarting MJPEG decode", - preset - ); - pipeline.set_bitrate_preset(preset).await?; - return Ok(()); - } - } - info!("Restarting video pipeline to apply new bitrate: {}", preset); self.stop_video_pipeline_and_release().await?; diff --git a/web/src/App.vue b/web/src/App.vue index a4da8b42..81dbeaa3 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,7 +1,9 @@