mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 10:34:24 +08:00
Compare commits
37 Commits
fix/uac-na
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef136241a | ||
|
|
78af9f67de | ||
|
|
1967d22dff | ||
|
|
0cba8ab127 | ||
|
|
d002226acb | ||
|
|
1db572e020 | ||
|
|
5de5ee42c2 | ||
|
|
2c19208094 | ||
|
|
3014edffbb | ||
|
|
ece4f303ce | ||
|
|
db9d79554a | ||
|
|
620fe0be54 | ||
|
|
79a4dcf2b0 | ||
|
|
a8ee9552a0 | ||
|
|
3749bafae0 | ||
|
|
b573f478fc | ||
|
|
d67a3ab7f8 | ||
|
|
dbe5672df4 | ||
|
|
48fd64319e | ||
|
|
ccf821ab22 | ||
|
|
b074aa5779 | ||
|
|
95a1fdf42d | ||
|
|
e678ec394d | ||
|
|
628909a192 | ||
|
|
5cab585115 | ||
|
|
9c6d8a614c | ||
|
|
db7a845d3b | ||
|
|
1d48d45592 | ||
|
|
d613739b06 | ||
|
|
6e1194fe4c | ||
|
|
f101ddb918 | ||
|
|
0054100414 | ||
|
|
486f3887c2 | ||
|
|
37af369b43 | ||
|
|
827d24fde4 | ||
|
|
b8ea38b5f3 | ||
|
|
4530d8663d |
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
ARG DEBIAN_IMAGE=debian:11
|
||||
FROM ${DEBIAN_IMAGE}
|
||||
|
||||
ARG DEBIAN_SNAPSHOT=20260713T000000Z
|
||||
ARG CHINAMIRRO=0
|
||||
ARG GH_PROXY=https://gh-proxy.com/
|
||||
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
||||
@@ -19,12 +20,11 @@ ARG RKMPP_BRANCH=jellyfin-mpp
|
||||
ARG RKRGA_BRANCH=jellyfin-rga
|
||||
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
||||
|
||||
# Optionally use China mirrors for builds in China.
|
||||
RUN if [ "$CHINAMIRRO" = "1" ]; then \
|
||||
sed -i -E \
|
||||
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
|
||||
/etc/apt/sources.list; \
|
||||
fi
|
||||
RUN printf '%s\n' \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
|
||||
> /etc/apt/sources.list
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
ARG DEBIAN_IMAGE=debian:11
|
||||
FROM ${DEBIAN_IMAGE}
|
||||
|
||||
ARG DEBIAN_SNAPSHOT=20260713T000000Z
|
||||
ARG CHINAMIRRO=0
|
||||
ARG GH_PROXY=https://gh-proxy.com/
|
||||
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
||||
@@ -19,12 +20,11 @@ ARG RKMPP_BRANCH=jellyfin-mpp
|
||||
ARG RKRGA_BRANCH=jellyfin-rga
|
||||
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
||||
|
||||
# Optionally use China mirrors for builds in China.
|
||||
RUN if [ "$CHINAMIRRO" = "1" ]; then \
|
||||
sed -i -E \
|
||||
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
|
||||
/etc/apt/sources.list; \
|
||||
fi
|
||||
RUN printf '%s\n' \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
|
||||
> /etc/apt/sources.list
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
ARG DEBIAN_IMAGE=debian:11
|
||||
FROM ${DEBIAN_IMAGE}
|
||||
|
||||
ARG DEBIAN_SNAPSHOT=20260713T000000Z
|
||||
ARG CHINAMIRRO=0
|
||||
ARG GH_PROXY=https://gh-proxy.com/
|
||||
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
||||
@@ -17,12 +18,11 @@ ARG X265_VERSION=3.4
|
||||
ARG OPUS_VERSION=1.5.2
|
||||
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
||||
|
||||
# Optionally use China mirrors for builds in China.
|
||||
RUN if [ "$CHINAMIRRO" = "1" ]; then \
|
||||
sed -i -E \
|
||||
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
|
||||
/etc/apt/sources.list; \
|
||||
fi
|
||||
RUN printf '%s\n' \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
|
||||
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
|
||||
> /etc/apt/sources.list
|
||||
|
||||
# Install Rust toolchain
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -6,6 +6,7 @@ Maintainer: SilentWind <admin@mofeng.run>
|
||||
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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -139,6 +139,7 @@ Section: admin
|
||||
Priority: optional
|
||||
Architecture: $DEB_ARCH
|
||||
Depends: $DEPS
|
||||
Recommends: bluez
|
||||
Maintainer: SilentWind <admin@mofeng.run>
|
||||
Description: A open and lightweight IP-KVM solution
|
||||
Enables BIOS-level remote management of servers and workstations.
|
||||
|
||||
24
docs/macos-drag-compatibility.md
Normal file
24
docs/macos-drag-compatibility.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# macOS 拖拽兼容模式(实验性)
|
||||
|
||||
适用于 OTG 和 CH9329 后端的绝对鼠标输入。遇到目标 Mac 上拖拽只能移动一小段便停止时,可在设置页的 HID 配置中开启「macOS 拖拽兼容模式」。默认关闭。
|
||||
|
||||
配置字段为 `hid.mouse_macos_drag`,旧 PR 的 `ch9329_macos_drag` 字段仍可作为读取别名。OTG 必须同时启用相对鼠标和绝对鼠标接口,否则配置保存会被拒绝。CH9329 同时启用 Linux 兼容开关时,此模式优先。
|
||||
|
||||
## 报告行为
|
||||
|
||||
- 未按键时仍使用绝对坐标定位。
|
||||
- 从绝对输入开始的拖拽:按下使用绝对报告,移动和滚轮使用相对报告,释放时先更新相对按钮状态,再发送绝对按钮释放。
|
||||
- 按键报告的通道保持到所有按钮释放,不随拖拽期间的输入模式切换而改变。
|
||||
- 从相对输入开始的点击、拖拽继续使用相对按钮报告;此选项不修复原生相对模式的点击兼容问题。
|
||||
- 位移从原始 15 位输入坐标换算,累计小数余量,大位移按单包范围分包并保留总量及方向。
|
||||
- 运行时使用当前视频采集尺寸,并在 HID 重载后恢复尺寸;视频尚未提供尺寸时使用配置尺寸。
|
||||
|
||||
## 验证范围与限制
|
||||
|
||||
本次 OTG 扩展及位移换算修改没有 macOS 实机验证。原 PR 作者对旧 CH9329 实现的实机反馈不能视为本版本已经验证。
|
||||
|
||||
本地自动化覆盖位移累计/分包、1080p/4K 换算、往返移动、滚轮、输入模式切换、复位、CH9329 命令队列、OTG 临时文件模拟端点及配置校验。临时文件测试仅验证写出的字节,不验证 USB 枚举或目标系统的事件解释。
|
||||
|
||||
相对位移受 macOS 鼠标速度、加速、显示缩放及多屏布局影响,无法保证与绝对定位逐像素一致。松手报告携带最后的客户端绝对坐标,仍可能出现落点偏移或光标跳位;大位移分包也可能增加低波特率串口延迟。
|
||||
|
||||
后续实机验证应分别覆盖两个后端:窗口/文件/文本拖拽,快速及慢速移动,滚轮和多按钮组合,1080p/4K 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。
|
||||
16
libs/bluetooth-hid/Cargo.toml
Normal file
16
libs/bluetooth-hid/Cargo.toml
Normal file
@@ -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"
|
||||
49
libs/bluetooth-hid/examples/pairing_probe.rs
Normal file
49
libs/bluetooth-hid/examples/pairing_probe.rs
Normal file
@@ -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)
|
||||
}
|
||||
24
libs/bluetooth-hid/examples/probe.rs
Normal file
24
libs/bluetooth-hid/examples/probe.rs
Normal file
@@ -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(())
|
||||
}
|
||||
236
libs/bluetooth-hid/src/agent.rs
Normal file
236
libs/bluetooth-hid/src/agent.rs
Normal file
@@ -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<dbus::nonblock::SyncConnection>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Agent {
|
||||
pub async fn register(shared: Arc<Shared>, adapter: String) -> Result<Self, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
145
libs/bluetooth-hid/src/bonds.rs
Normal file
145
libs/bluetooth-hid/src/bonds.rs
Normal file
@@ -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<Box<dyn Future<Output = Result<T, String>> + Send + 'a>>;
|
||||
pub trait BondStore: Send + Sync {
|
||||
fn list(&self) -> Operation<'_, Vec<Bond>>;
|
||||
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<F, Fut>(
|
||||
store: &dyn BondStore,
|
||||
address: &str,
|
||||
mut remove: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: Future<Output = Result<(), String>>,
|
||||
{
|
||||
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<Bond>) -> 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<Vec<Bond>>);
|
||||
impl BondStore for Memory {
|
||||
fn list(&self) -> Operation<'_, Vec<Bond>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
135
libs/bluetooth-hid/src/controller.rs
Normal file
135
libs/bluetooth-hid/src/controller.rs
Normal file
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
// 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<Self, String> {
|
||||
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("; "))
|
||||
}
|
||||
}
|
||||
}
|
||||
964
libs/bluetooth-hid/src/lib.rs
Normal file
964
libs/bluetooth-hid/src/lib.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
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::<Address>()
|
||||
.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<String>,
|
||||
pub pairing_seconds: u32,
|
||||
pub control_connected: bool,
|
||||
pub interrupt_connected: bool,
|
||||
pub leds: u8,
|
||||
pub generation: u64,
|
||||
pub error: Option<String>,
|
||||
pub devices: Vec<Device>,
|
||||
}
|
||||
#[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<String>,
|
||||
pairing_until: Option<Instant>,
|
||||
bonded_before_pairing: HashSet<String>,
|
||||
}
|
||||
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<String>) {
|
||||
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<State>,
|
||||
}
|
||||
impl Shared {
|
||||
fn new(peer: Option<String>) -> 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<u8>),
|
||||
Action(Action),
|
||||
Stop,
|
||||
}
|
||||
struct Request {
|
||||
command: Command,
|
||||
result: oneshot::Sender<Result<(), String>>,
|
||||
expires: Instant,
|
||||
}
|
||||
pub struct Peripheral {
|
||||
tx: mpsc::Sender<Request>,
|
||||
status: watch::Receiver<Status>,
|
||||
task: AsyncMutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
impl Peripheral {
|
||||
pub fn start(config: Config) -> Result<Self, String> {
|
||||
Self::start_with_store(config, None)
|
||||
}
|
||||
pub fn start_with_store(
|
||||
config: Config,
|
||||
bonds: Option<Arc<dyn bonds::BondStore>>,
|
||||
) -> Result<Self, String> {
|
||||
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<Status> {
|
||||
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<u8>) -> 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<SeqPacketListener, String> {
|
||||
let socket = Socket::<SeqPacket>::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<Shared>,
|
||||
agent: Option<agent::Agent>,
|
||||
controller: controller::Controller,
|
||||
listeners: [SeqPacketListener; 2],
|
||||
channels: [Option<Arc<SeqPacket>>; 2],
|
||||
channel_peer: Option<Address>,
|
||||
partial_since: Option<Instant>,
|
||||
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<Arc<dyn bonds::BondStore>>,
|
||||
recorded_peer: Option<String>,
|
||||
}
|
||||
impl Runtime {
|
||||
async fn open(
|
||||
config: &Config,
|
||||
bonds: Option<Arc<dyn bonds::BondStore>>,
|
||||
) -> Result<Self, String> {
|
||||
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<u32>) -> 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<Status, String> {
|
||||
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<Arc<SeqPacket>>, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
match socket {
|
||||
Some(socket) => socket.recv(buffer).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
async fn supervise(
|
||||
config: Config,
|
||||
bonds: Option<Arc<dyn bonds::BondStore>>,
|
||||
mut rx: mpsc::Receiver<Request>,
|
||||
tx: watch::Sender<Status>,
|
||||
) {
|
||||
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<Vec<AdapterInfo>, 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)
|
||||
}
|
||||
267
libs/bluetooth-hid/src/protocol.rs
Normal file
267
libs/bluetooth-hid/src/protocol.rs
Normal file
@@ -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#"<?xml version="1.0"?>
|
||||
<record>
|
||||
<attribute id="0x0001"><sequence><uuid value="0x1124"/></sequence></attribute>
|
||||
<attribute id="0x0004"><sequence><sequence><uuid value="0x0100"/><uint16 value="0x0011"/></sequence><sequence><uuid value="0x0011"/></sequence></sequence></attribute>
|
||||
<attribute id="0x0005"><sequence><uuid value="0x1002"/></sequence></attribute>
|
||||
<attribute id="0x0006"><sequence><uint16 value="0x656e"/><uint16 value="0x006a"/><uint16 value="0x0100"/></sequence></attribute>
|
||||
<attribute id="0x0009"><sequence><sequence><uuid value="0x1124"/><uint16 value="0x0100"/></sequence></sequence></attribute>
|
||||
<attribute id="0x000d"><sequence><sequence><sequence><uuid value="0x0100"/><uint16 value="0x0013"/></sequence><sequence><uuid value="0x0011"/></sequence></sequence></sequence></attribute>
|
||||
<attribute id="0x0100"><text value="One-KVM Keyboard and Mouse"/></attribute>
|
||||
<attribute id="0x0101"><text value="Classic Bluetooth HID"/></attribute>
|
||||
<attribute id="0x0102"><text value="One-KVM"/></attribute>
|
||||
<attribute id="0x0200"><uint16 value="0x0100"/></attribute>
|
||||
<attribute id="0x0201"><uint16 value="0x0111"/></attribute>
|
||||
<attribute id="0x0202"><uint8 value="0xc0"/></attribute>
|
||||
<attribute id="0x0203"><uint8 value="0x00"/></attribute>
|
||||
<attribute id="0x0204"><boolean value="false"/></attribute>
|
||||
<attribute id="0x0205"><boolean value="false"/></attribute>
|
||||
<attribute id="0x0206"><sequence><sequence><uint8 value="0x22"/><text encoding="hex" value="{hex}"/></sequence></sequence></attribute>
|
||||
<attribute id="0x0207"><sequence><sequence><uint16 value="0x0409"/><uint16 value="0x0100"/></sequence></sequence></attribute>
|
||||
<attribute id="0x0209"><boolean value="false"/></attribute>
|
||||
<attribute id="0x020a"><boolean value="false"/></attribute>
|
||||
<attribute id="0x020b"><uint16 value="0x0100"/></attribute>
|
||||
<attribute id="0x020c"><uint16 value="0x0c80"/></attribute>
|
||||
<attribute id="0x020d"><boolean value="true"/></attribute>
|
||||
<attribute id="0x020e"><boolean value="true"/></attribute>
|
||||
</record>"#
|
||||
)
|
||||
}
|
||||
#[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<Vec<u8>>,
|
||||
pub unplug: bool,
|
||||
pub reset: bool,
|
||||
}
|
||||
impl HidProtocol {
|
||||
pub fn input(&mut self, kind: Report, value: &[u8]) -> Result<Vec<u8>, 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<Vec<u8>> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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 {}",
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -4,6 +4,9 @@ extern "C" {
|
||||
}
|
||||
|
||||
#include "util.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <string.h>
|
||||
@@ -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<char>(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<int16_t>::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<int16_t>::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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h
Normal file
38
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// 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;
|
||||
}
|
||||
329
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp
Normal file
329
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp
Normal file
@@ -0,0 +1,329 @@
|
||||
#include "rkmpp_dmabuf_ffi.h"
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <limits>
|
||||
#include <new>
|
||||
#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(<rockchip/rk_mpi.h>)
|
||||
#define HAVE_MPP_DMA 1
|
||||
#include <cerrno>
|
||||
#include <linux/dma-buf.h>
|
||||
#include <sys/ioctl.h>
|
||||
extern "C" {
|
||||
#include <rockchip/rk_mpi.h>
|
||||
#include <rockchip/mpp_buffer.h>
|
||||
#include <rockchip/mpp_frame.h>
|
||||
#include <rockchip/mpp_packet.h>
|
||||
#include <rockchip/mpp_task.h>
|
||||
#include <rockchip/rk_venc_cfg.h>
|
||||
#include <rockchip/rk_venc_rc.h>
|
||||
}
|
||||
#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<MppBuffer, 16> buffers{};
|
||||
std::array<size_t, 16> 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<size_t>::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<size_t>::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<int>(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<const uint8_t *>(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<MppPollType>(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<MppPollType>(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<MppPollType>(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<int>(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<const uint8_t *>(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; }
|
||||
31
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h
Normal file
31
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
193
libs/hwcodec/src/rkmpp_dmabuf.rs
Normal file
193
libs/hwcodec/src/rkmpp_dmabuf.rs
Normal file
@@ -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<c_void>,
|
||||
// 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<Option<OwnedFd>>,
|
||||
}
|
||||
|
||||
// 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<Self, String> {
|
||||
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<OwnedFd>,
|
||||
pts_ms: i64,
|
||||
force_idr: bool,
|
||||
) -> Result<Vec<u8>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<sqlx::Sqlite>, 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<sqlx::Sqlite>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, i64)>> {
|
||||
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::*;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
@@ -181,6 +231,9 @@ pub struct HidConfig {
|
||||
#[serde(default)]
|
||||
pub ch9329_hybrid_mouse: bool,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "ch9329_macos_drag")]
|
||||
pub mouse_macos_drag: bool,
|
||||
#[serde(default)]
|
||||
pub ch9329_descriptor: Ch9329DescriptorConfig,
|
||||
pub mouse_absolute: bool,
|
||||
}
|
||||
@@ -189,6 +242,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(),
|
||||
@@ -197,6 +251,7 @@ impl Default for HidConfig {
|
||||
ch9329_port: "/dev/ttyUSB0".to_string(),
|
||||
ch9329_baudrate: 9600,
|
||||
ch9329_hybrid_mouse: false,
|
||||
mouse_macos_drag: false,
|
||||
ch9329_descriptor: Ch9329DescriptorConfig::default(),
|
||||
mouse_absolute: true,
|
||||
}
|
||||
@@ -222,6 +277,11 @@ impl HidConfig {
|
||||
}
|
||||
|
||||
let functions = self.effective_otg_functions();
|
||||
if self.mouse_macos_drag && (!functions.mouse_relative || !functions.mouse_absolute) {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"macOS drag compatibility requires both OTG mouse interfaces".to_string(),
|
||||
));
|
||||
}
|
||||
if functions.is_empty() {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"OTG HID functions cannot be empty".to_string(),
|
||||
@@ -252,3 +312,65 @@ impl HidConfig {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bluetooth_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn mouse_compatibility_defaults_alias_and_otg_validation() {
|
||||
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
||||
assert!(!defaults.mouse_macos_drag);
|
||||
let mut config: HidConfig =
|
||||
serde_json::from_str(r#"{"backend":"otg","ch9329_macos_drag":true}"#).unwrap();
|
||||
assert!(config.mouse_macos_drag);
|
||||
assert!(config.validate_otg_functions().is_ok());
|
||||
config.otg_profile = OtgHidProfile::LegacyMouseRelative;
|
||||
assert!(config.validate_otg_functions().is_err());
|
||||
config.backend = HidBackend::Ch9329;
|
||||
assert!(config.validate_otg_functions().is_ok());
|
||||
let saved = serde_json::to_value(&config).unwrap();
|
||||
assert_eq!(saved["mouse_macos_drag"], true);
|
||||
assert!(saved.get("ch9329_macos_drag").is_none());
|
||||
}
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ pub struct ConfigStore {
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn new(pool: Pool<Sqlite>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
pub fn new(pool: Pool<Sqlite>) -> 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<AppConfig> {
|
||||
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'")
|
||||
|
||||
80
src/db/hid_bonds.rs
Normal file
80
src/db/hid_bonds.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use one_kvm_bluetooth_hid::bonds::{Bond, BondStore, Operation};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HidBondStore(pub Pool<Sqlite>);
|
||||
impl BondStore for HidBondStore {
|
||||
fn list(&self) -> Operation<'_, Vec<Bond>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<DatabasePool> {
|
||||
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<String> = 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"));
|
||||
}
|
||||
}
|
||||
|
||||
194
src/db/pool.rs
194
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<Sqlite> {
|
||||
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))"],
|
||||
];
|
||||
|
||||
39
src/db/wol_history.rs
Normal file
39
src/db/wol_history.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use sqlx::{Pool, Sqlite};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
const MAX_ENTRIES: i64 = 50;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WolHistoryStore {
|
||||
pool: Pool<Sqlite>,
|
||||
}
|
||||
|
||||
impl WolHistoryStore {
|
||||
pub(crate) fn new(pool: Pool<Sqlite>) -> 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<Vec<(String, i64)>> {
|
||||
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?)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -17,13 +17,21 @@ fn default_ch9329_baud_rate() -> u32 {
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum HidBackendType {
|
||||
Otg,
|
||||
Otg {
|
||||
#[serde(default)]
|
||||
macos_drag: bool,
|
||||
},
|
||||
Bluetooth {
|
||||
config: crate::config::BluetoothHidConfig,
|
||||
},
|
||||
Ch9329 {
|
||||
port: String,
|
||||
#[serde(default = "default_ch9329_baud_rate")]
|
||||
baud_rate: u32,
|
||||
#[serde(default)]
|
||||
hybrid_mouse: bool,
|
||||
#[serde(default)]
|
||||
macos_drag: bool,
|
||||
},
|
||||
#[default]
|
||||
None,
|
||||
@@ -32,7 +40,8 @@ pub enum HidBackendType {
|
||||
impl HidBackendType {
|
||||
pub fn name_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Otg => "otg",
|
||||
Self::Otg { .. } => "otg",
|
||||
Self::Bluetooth { .. } => "bluetooth",
|
||||
Self::Ch9329 { .. } => "ch9329",
|
||||
Self::None => "none",
|
||||
}
|
||||
@@ -81,6 +90,17 @@ pub trait HidBackend: Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||
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<()> {
|
||||
|
||||
283
src/hid/bluetooth.rs
Normal file
283
src/hid/bluetooth.rs
Normal file
@@ -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<InputState>,
|
||||
runtime: watch::Sender<()>,
|
||||
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
impl BluetoothBackend {
|
||||
pub fn new(
|
||||
config: BluetoothHidConfig,
|
||||
bonds: Option<std::sync::Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
|
||||
) -> Result<Self> {
|
||||
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::Value> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -235,6 +238,8 @@ pub struct Ch9329Backend {
|
||||
last_abs_y: Arc<AtomicU16>,
|
||||
relative_mouse_active: Arc<AtomicBool>,
|
||||
hybrid_mouse: bool,
|
||||
macos_drag: bool,
|
||||
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
|
||||
runtime: Arc<Ch9329RuntimeState>,
|
||||
}
|
||||
|
||||
@@ -248,6 +253,15 @@ impl Ch9329Backend {
|
||||
}
|
||||
|
||||
pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result<Self> {
|
||||
Self::with_compatibility_options(port_path, baud_rate, hybrid_mouse, false)
|
||||
}
|
||||
|
||||
pub fn with_compatibility_options(
|
||||
port_path: &str,
|
||||
baud_rate: u32,
|
||||
hybrid_mouse: bool,
|
||||
macos_drag: bool,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
port_path: port_path.to_string(),
|
||||
baud_rate,
|
||||
@@ -263,6 +277,8 @@ impl Ch9329Backend {
|
||||
last_abs_y: Arc::new(AtomicU16::new(0)),
|
||||
relative_mouse_active: Arc::new(AtomicBool::new(false)),
|
||||
hybrid_mouse,
|
||||
macos_drag,
|
||||
macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()),
|
||||
runtime: Arc::new(Ch9329RuntimeState::new()),
|
||||
})
|
||||
}
|
||||
@@ -368,33 +384,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));
|
||||
}
|
||||
}
|
||||
@@ -410,15 +434,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);
|
||||
@@ -698,7 +726,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,
|
||||
@@ -869,7 +896,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"
|
||||
@@ -892,7 +919,6 @@ impl Ch9329Backend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_worker_port(
|
||||
mut port: Box<dyn serialport::SerialPort>,
|
||||
rx: &mpsc::Receiver<WorkerCommand>,
|
||||
@@ -965,11 +991,12 @@ impl Ch9329Backend {
|
||||
}
|
||||
|
||||
fn should_send_button_wheel_relative(&self) -> bool {
|
||||
self.hybrid_mouse || self.relative_mouse_active.load(Ordering::Relaxed)
|
||||
(self.hybrid_mouse && !self.macos_drag)
|
||||
|| self.relative_mouse_active.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn absolute_move_buttons(&self, buttons: u8) -> u8 {
|
||||
if self.hybrid_mouse {
|
||||
if self.hybrid_mouse && !self.macos_drag {
|
||||
0
|
||||
} else {
|
||||
buttons
|
||||
@@ -1170,7 +1197,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"
|
||||
@@ -1189,13 +1216,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);
|
||||
@@ -1205,9 +1232,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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1256,6 +1286,31 @@ impl HidBackend for Ch9329Backend {
|
||||
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
|
||||
|
||||
if self.macos_drag {
|
||||
use super::macos_drag::MouseReport;
|
||||
let mut state = self.macos_drag_state.lock();
|
||||
let (buttons, reports) = state.plan(event, buttons, *self.screen_resolution.read());
|
||||
self.mouse_buttons.store(buttons, Ordering::Relaxed);
|
||||
for report in reports {
|
||||
match report {
|
||||
MouseReport::Absolute { buttons, x, y } => {
|
||||
let x = (u32::from(x) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
|
||||
let y = (u32::from(y) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
|
||||
self.send_mouse_absolute(buttons, x, y, 0)?;
|
||||
}
|
||||
MouseReport::Relative {
|
||||
buttons,
|
||||
dx,
|
||||
dy,
|
||||
wheel,
|
||||
} => {
|
||||
self.send_mouse_relative(buttons, dx, dy, wheel)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match event.event_type {
|
||||
MouseEventType::Move => {
|
||||
self.relative_mouse_active.store(true, Ordering::Relaxed);
|
||||
@@ -1345,6 +1400,7 @@ impl HidBackend for Ch9329Backend {
|
||||
}
|
||||
|
||||
self.mouse_buttons.store(0, Ordering::Relaxed);
|
||||
self.macos_drag_state.lock().reset();
|
||||
self.last_abs_x.store(0, Ordering::Relaxed);
|
||||
self.last_abs_y.store(0, Ordering::Relaxed);
|
||||
self.relative_mouse_active.store(false, Ordering::Relaxed);
|
||||
@@ -1650,13 +1706,67 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hybrid_mouse_routes_buttons_and_wheel_to_relative_reports() {
|
||||
fn test_hybrid_mouse_preserves_linux_compatibility_routing() {
|
||||
let backend = Ch9329Backend::with_options("/dev/null", DEFAULT_BAUD_RATE, true).unwrap();
|
||||
|
||||
assert!(backend.should_send_button_wheel_relative());
|
||||
assert_eq!(backend.absolute_move_buttons(0x07), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_macos_drag_uses_absolute_edges_and_relative_motion() {
|
||||
let backend =
|
||||
Ch9329Backend::with_compatibility_options("/dev/null", DEFAULT_BAUD_RATE, false, true)
|
||||
.unwrap();
|
||||
let (worker_tx, worker_rx) = mpsc::channel();
|
||||
*backend.worker_tx.lock() = Some(worker_tx);
|
||||
backend.set_screen_resolution(1920, 1080);
|
||||
|
||||
backend
|
||||
.send_mouse(MouseEvent::move_abs(8000, 8000))
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.send_mouse(MouseEvent::button_down(crate::hid::MouseButton::Left))
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.send_mouse(MouseEvent::move_abs(8064, 8064))
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.send_mouse(MouseEvent::button_up(crate::hid::MouseButton::Left))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let packets: Vec<_> = worker_rx
|
||||
.try_iter()
|
||||
.filter_map(|command| match command {
|
||||
WorkerCommand::Packet { cmd, data } => Some((cmd, data)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
packets,
|
||||
vec![
|
||||
(
|
||||
cmd::SEND_MS_ABS_DATA,
|
||||
vec![0x02, 0x00, 0xE8, 0x03, 0xE8, 0x03, 0x00],
|
||||
),
|
||||
(
|
||||
cmd::SEND_MS_ABS_DATA,
|
||||
vec![0x02, 0x01, 0xE8, 0x03, 0xE8, 0x03, 0x00],
|
||||
),
|
||||
(cmd::SEND_MS_REL_DATA, vec![0x01, 0x01, 0x03, 0x02, 0x00]),
|
||||
(cmd::SEND_MS_REL_DATA, vec![0x01, 0x00, 0x00, 0x00, 0x00]),
|
||||
(
|
||||
cmd::SEND_MS_ABS_DATA,
|
||||
vec![0x02, 0x00, 0xF0, 0x03, 0xF0, 0x03, 0x00],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_mouse_mode_preserves_absolute_report_buttons() {
|
||||
let backend = Ch9329Backend::with_baud_rate("/dev/null", DEFAULT_BAUD_RATE).unwrap();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
|
||||
#[cfg(unix)]
|
||||
otg_service: Option<Arc<OtgService>>,
|
||||
}
|
||||
@@ -15,7 +17,11 @@ pub struct HidBackendFactory {
|
||||
impl HidBackendFactory {
|
||||
#[cfg(unix)]
|
||||
pub fn new(otg_service: Option<Arc<OtgService>>) -> Self {
|
||||
Self { otg_service }
|
||||
Self {
|
||||
otg_service,
|
||||
#[cfg(target_os = "linux")]
|
||||
bonds: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
@@ -38,21 +44,43 @@ impl HidBackendFactory {
|
||||
|
||||
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
|
||||
match backend_type {
|
||||
HidBackendType::Otg => self.create_otg_backend().await.map(Some),
|
||||
HidBackendType::Otg { macos_drag } => {
|
||||
self.create_otg_backend(*macos_drag).await.map(Some)
|
||||
}
|
||||
HidBackendType::Ch9329 {
|
||||
port,
|
||||
baud_rate,
|
||||
hybrid_mouse,
|
||||
macos_drag,
|
||||
} => {
|
||||
info!(
|
||||
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}",
|
||||
port, baud_rate, hybrid_mouse
|
||||
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}, macos_drag={}",
|
||||
port, baud_rate, hybrid_mouse, macos_drag
|
||||
);
|
||||
Ok(Some(Arc::new(ch9329::Ch9329Backend::with_options(
|
||||
port,
|
||||
*baud_rate,
|
||||
*hybrid_mouse,
|
||||
)?)))
|
||||
Ok(Some(Arc::new(
|
||||
ch9329::Ch9329Backend::with_compatibility_options(
|
||||
port,
|
||||
*baud_rate,
|
||||
*hybrid_mouse,
|
||||
*macos_drag,
|
||||
)?,
|
||||
)))
|
||||
}
|
||||
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");
|
||||
@@ -62,7 +90,7 @@ impl HidBackendFactory {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn create_otg_backend(&self) -> Result<Arc<dyn HidBackend>> {
|
||||
async fn create_otg_backend(&self, macos_drag: bool) -> Result<Arc<dyn HidBackend>> {
|
||||
let otg_service = self
|
||||
.otg_service
|
||||
.as_ref()
|
||||
@@ -74,11 +102,13 @@ impl HidBackendFactory {
|
||||
.ok_or_else(|| AppError::Config("OTG HID paths are not available".to_string()))?;
|
||||
|
||||
info!("Creating OTG HID backend from device paths");
|
||||
Ok(Arc::new(super::otg::OtgBackend::from_handles(handles)?))
|
||||
Ok(Arc::new(super::otg::OtgBackend::with_macos_drag(
|
||||
handles, macos_drag,
|
||||
)?))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn create_otg_backend(&self) -> Result<Arc<dyn HidBackend>> {
|
||||
async fn create_otg_backend(&self, _macos_drag: bool) -> Result<Arc<dyn HidBackend>> {
|
||||
Err(AppError::Config(
|
||||
"OTG HID is only available on Linux".to_string(),
|
||||
))
|
||||
|
||||
256
src/hid/macos_drag.rs
Normal file
256
src/hid/macos_drag.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! Experimental absolute-button / relative-drag routing shared by USB backends.
|
||||
//! Relative counts are subject to host acceleration; they are not screen pixels.
|
||||
|
||||
use super::{MouseEvent, MouseEventType};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum MouseReport {
|
||||
Absolute {
|
||||
buttons: u8,
|
||||
x: u16,
|
||||
y: u16,
|
||||
},
|
||||
Relative {
|
||||
buttons: u8,
|
||||
dx: i8,
|
||||
dy: i8,
|
||||
wheel: i8,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MacosDrag {
|
||||
position: (u16, u16),
|
||||
remainder: (i64, i64),
|
||||
extent: (u32, u32),
|
||||
relative_input: bool,
|
||||
absolute_drag: bool,
|
||||
}
|
||||
|
||||
impl MacosDrag {
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
pub fn plan(
|
||||
&mut self,
|
||||
event: MouseEvent,
|
||||
buttons: u8,
|
||||
extent: (u32, u32),
|
||||
) -> (u8, Vec<MouseReport>) {
|
||||
let mut reports = Vec::new();
|
||||
let mut next_buttons = buttons;
|
||||
if extent != self.extent || buttons == 0 {
|
||||
self.remainder = (0, 0);
|
||||
self.extent = extent;
|
||||
}
|
||||
match event.event_type {
|
||||
MouseEventType::MoveAbs => {
|
||||
let position = (
|
||||
event.x.clamp(0, 32767) as u16,
|
||||
event.y.clamp(0, 32767) as u16,
|
||||
);
|
||||
self.relative_input = false;
|
||||
if buttons != 0 {
|
||||
let dx =
|
||||
Self::delta(position.0, self.position.0, extent.0, &mut self.remainder.0);
|
||||
let dy =
|
||||
Self::delta(position.1, self.position.1, extent.1, &mut self.remainder.1);
|
||||
Self::motion(&mut reports, buttons, dx, dy);
|
||||
} else {
|
||||
reports.push(MouseReport::Absolute {
|
||||
buttons: 0,
|
||||
x: position.0,
|
||||
y: position.1,
|
||||
});
|
||||
}
|
||||
self.position = position;
|
||||
}
|
||||
MouseEventType::Move => {
|
||||
self.relative_input = true;
|
||||
Self::motion(
|
||||
&mut reports,
|
||||
buttons,
|
||||
i64::from(event.x.clamp(-127, 127)),
|
||||
i64::from(event.y.clamp(-127, 127)),
|
||||
);
|
||||
}
|
||||
MouseEventType::Down | MouseEventType::Up => {
|
||||
if let Some(button) = event.button {
|
||||
let down = event.event_type == MouseEventType::Down;
|
||||
next_buttons = if down {
|
||||
buttons | button.to_hid_bit()
|
||||
} else {
|
||||
buttons & !button.to_hid_bit()
|
||||
};
|
||||
if down && buttons == 0 {
|
||||
self.absolute_drag = !self.relative_input;
|
||||
}
|
||||
// Latch the button route until all buttons are released, even
|
||||
// if the client changes pointer mode during the drag.
|
||||
if self.absolute_drag {
|
||||
if !down {
|
||||
// Relative drag reports also carry buttons. Clear their
|
||||
// state before releasing the absolute collection.
|
||||
reports.push(MouseReport::Relative {
|
||||
buttons: next_buttons,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
wheel: 0,
|
||||
});
|
||||
}
|
||||
reports.push(MouseReport::Absolute {
|
||||
buttons: next_buttons,
|
||||
x: self.position.0,
|
||||
y: self.position.1,
|
||||
});
|
||||
} else {
|
||||
reports.push(MouseReport::Relative {
|
||||
buttons: next_buttons,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
wheel: 0,
|
||||
});
|
||||
}
|
||||
if next_buttons == 0 {
|
||||
self.absolute_drag = false;
|
||||
self.remainder = (0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventType::Scroll => {
|
||||
reports.push(MouseReport::Relative {
|
||||
buttons,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
wheel: event.scroll,
|
||||
});
|
||||
}
|
||||
}
|
||||
(next_buttons, reports)
|
||||
}
|
||||
|
||||
fn delta(current: u16, previous: u16, extent: u32, remainder: &mut i64) -> i64 {
|
||||
// Keep the original 15-bit input precision and carry fractional counts.
|
||||
let scaled =
|
||||
(i64::from(current) - i64::from(previous)) * i64::from(extent.max(1)) + *remainder;
|
||||
*remainder = scaled % 32768;
|
||||
scaled / 32768
|
||||
}
|
||||
|
||||
fn motion(reports: &mut Vec<MouseReport>, buttons: u8, dx: i64, dy: i64) {
|
||||
// Distribute both axes over the same packets to preserve diagonal paths.
|
||||
let count = (dx.abs().max(dy.abs()) + 126) / 127;
|
||||
let mut previous = (0, 0);
|
||||
for index in 1..=count {
|
||||
let position = (dx * index / count, dy * index / count);
|
||||
reports.push(MouseReport::Relative {
|
||||
buttons,
|
||||
dx: (position.0 - previous.0) as i8,
|
||||
dy: (position.1 - previous.1) as i8,
|
||||
wheel: 0,
|
||||
});
|
||||
previous = position;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hid::MouseButton;
|
||||
|
||||
fn drag(steps: &[i32], extent: (u32, u32)) -> (i64, i64) {
|
||||
let mut state = MacosDrag::default();
|
||||
state.plan(MouseEvent::move_abs(8000, 8000), 0, extent);
|
||||
state.plan(MouseEvent::button_down(MouseButton::Left), 0, extent);
|
||||
let mut total = (0, 0);
|
||||
for &x in steps {
|
||||
for report in state.plan(MouseEvent::move_abs(x, x), 1, extent).1 {
|
||||
if let MouseReport::Relative { dx, dy, .. } = report {
|
||||
total.0 += i64::from(dx);
|
||||
total.1 += i64::from(dy);
|
||||
} else {
|
||||
panic!("absolute movement during drag");
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_total_across_event_splitting_and_large_moves() {
|
||||
let steps: Vec<_> = (8001..=16000).collect();
|
||||
assert_eq!(drag(&[16000], (1920, 1080)), (468, 263));
|
||||
assert_eq!(drag(&steps, (1920, 1080)), (468, 263));
|
||||
assert_eq!(drag(&[16000, 8000], (1920, 1080)), (0, 0));
|
||||
assert_eq!(drag(&[16000], (3840, 2160)), (937, 527));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_and_mode_switch_keep_absolute_drag_release() {
|
||||
let mut state = MacosDrag::default();
|
||||
let extent = (1920, 1080);
|
||||
state.plan(MouseEvent::move_abs(8000, 8000), 0, extent);
|
||||
state.plan(MouseEvent::button_down(MouseButton::Left), 0, extent);
|
||||
let mut wheel = MouseEvent::move_abs(0, 0);
|
||||
wheel.event_type = MouseEventType::Scroll;
|
||||
wheel.scroll = 1;
|
||||
assert_eq!(
|
||||
state.plan(wheel, 1, extent).1,
|
||||
vec![MouseReport::Relative {
|
||||
buttons: 1,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
wheel: 1
|
||||
}]
|
||||
);
|
||||
state.plan(MouseEvent::move_rel(10, 0), 1, extent);
|
||||
assert_eq!(
|
||||
state
|
||||
.plan(MouseEvent::button_up(MouseButton::Left), 1, extent)
|
||||
.1,
|
||||
vec![
|
||||
MouseReport::Relative {
|
||||
buttons: 0,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
wheel: 0
|
||||
},
|
||||
MouseReport::Absolute {
|
||||
buttons: 0,
|
||||
x: 8000,
|
||||
y: 8000
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_relative_clicks_and_reset() {
|
||||
let mut state = MacosDrag::default();
|
||||
state.plan(MouseEvent::move_rel(1, 1), 0, (1920, 1080));
|
||||
assert_eq!(
|
||||
state
|
||||
.plan(MouseEvent::button_down(MouseButton::Left), 0, (1920, 1080))
|
||||
.1,
|
||||
vec![MouseReport::Relative {
|
||||
buttons: 1,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
wheel: 0
|
||||
}]
|
||||
);
|
||||
state.reset();
|
||||
assert_eq!(
|
||||
state
|
||||
.plan(MouseEvent::button_down(MouseButton::Left), 0, (1920, 1080))
|
||||
.1,
|
||||
vec![MouseReport::Absolute {
|
||||
buttons: 1,
|
||||
x: 0,
|
||||
y: 0
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
225
src/hid/mod.rs
225
src/hid/mod.rs
@@ -1,12 +1,15 @@
|
||||
//! 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;
|
||||
pub mod datachannel;
|
||||
mod factory;
|
||||
pub mod keyboard;
|
||||
mod macos_drag;
|
||||
#[cfg(unix)]
|
||||
pub mod otg;
|
||||
#[cfg(unix)]
|
||||
@@ -132,6 +135,8 @@ pub struct HidController {
|
||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
backend_available: Arc<AtomicBool>,
|
||||
reset_requested: Arc<AtomicBool>,
|
||||
screen_resolution: parking_lot::RwLock<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl HidController {
|
||||
@@ -153,6 +158,8 @@ 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)),
|
||||
screen_resolution: parking_lot::RwLock::new((1920, 1080)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +181,16 @@ 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)),
|
||||
screen_resolution: parking_lot::RwLock::new((1920, 1080)),
|
||||
}
|
||||
}
|
||||
|
||||
#[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<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
@@ -200,7 +214,12 @@ impl HidController {
|
||||
}
|
||||
};
|
||||
|
||||
*self.backend.write().await = Some(backend);
|
||||
{
|
||||
let mut slot = self.backend.write().await;
|
||||
let (width, height) = *self.screen_resolution.read();
|
||||
backend.set_screen_resolution(width, height);
|
||||
*slot = Some(backend);
|
||||
}
|
||||
self.sync_runtime_state_from_backend().await;
|
||||
|
||||
self.start_event_worker().await;
|
||||
@@ -235,7 +254,8 @@ impl HidController {
|
||||
}
|
||||
|
||||
pub async fn prepare_otg_rebuild(&self) -> Result<()> {
|
||||
if !matches!(*self.backend_type.read().await, HidBackendType::Otg) {
|
||||
let backend_type = self.backend_type.read().await.clone();
|
||||
if !matches!(backend_type, HidBackendType::Otg { .. }) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -249,7 +269,7 @@ impl HidController {
|
||||
|
||||
let current = self.runtime_state.read().await.clone();
|
||||
let rebuilding_state = HidRuntimeState::with_error(
|
||||
&HidBackendType::Otg,
|
||||
&backend_type,
|
||||
¤t,
|
||||
"OTG gadget is rebuilding",
|
||||
"rebuilding",
|
||||
@@ -293,6 +313,25 @@ impl HidController {
|
||||
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
|
||||
}
|
||||
|
||||
pub async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||
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(());
|
||||
@@ -308,6 +347,24 @@ impl HidController {
|
||||
self.backend_type.read().await.clone()
|
||||
}
|
||||
|
||||
/// Keep the active capture dimensions across HID reloads and USB rebuilds.
|
||||
pub async fn set_screen_resolution(&self, width: u32, height: u32) {
|
||||
if width == 0 || height == 0 || width > 65535 || height > 65535 {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut resolution = self.screen_resolution.write();
|
||||
if *resolution == (width, height) {
|
||||
return;
|
||||
}
|
||||
*resolution = (width, height);
|
||||
}
|
||||
if let Some(backend) = self.backend.read().await.as_ref() {
|
||||
let (width, height) = *self.screen_resolution.read();
|
||||
backend.set_screen_resolution(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> HidRuntimeState {
|
||||
self.runtime_state.read().await.clone()
|
||||
}
|
||||
@@ -349,6 +406,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);
|
||||
}
|
||||
}
|
||||
@@ -366,7 +431,14 @@ impl HidController {
|
||||
}
|
||||
};
|
||||
|
||||
*self.backend.write().await = new_backend;
|
||||
{
|
||||
let mut slot = self.backend.write().await;
|
||||
if let Some(backend) = new_backend.as_ref() {
|
||||
let (width, height) = *self.screen_resolution.read();
|
||||
backend.set_screen_resolution(width, height);
|
||||
}
|
||||
*slot = new_backend;
|
||||
}
|
||||
|
||||
if matches!(new_backend_type, HidBackendType::None) {
|
||||
*self.backend_type.write().await = HidBackendType::None;
|
||||
@@ -437,6 +509,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 +519,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 +586,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 +606,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 +707,121 @@ async fn apply_runtime_state(
|
||||
events.mark_device_info_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_pending_move(pending: &mut Option<MouseEvent>, 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::*;
|
||||
#[tokio::test]
|
||||
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
|
||||
#[cfg(unix)]
|
||||
let controller = HidController::new(HidBackendType::None, None);
|
||||
#[cfg(not(unix))]
|
||||
let controller = HidController::new(HidBackendType::None);
|
||||
let backend = Arc::new(ch9329::Ch9329Backend::new("/dev/null").unwrap());
|
||||
*controller.backend.write().await = Some(backend.clone());
|
||||
controller.set_screen_resolution(3840, 2160).await;
|
||||
assert_eq!(
|
||||
backend.runtime_snapshot().screen_resolution,
|
||||
Some((3840, 2160))
|
||||
);
|
||||
controller.set_screen_resolution(0, 0).await;
|
||||
assert_eq!(*controller.screen_resolution.read(), (3840, 2160));
|
||||
controller.set_screen_resolution(1280, 720).await;
|
||||
assert_eq!(
|
||||
backend.runtime_snapshot().screen_resolution,
|
||||
Some((1280, 720))
|
||||
);
|
||||
}
|
||||
struct TestBackend {
|
||||
pressed: Arc<AtomicBool>,
|
||||
reset_done: Arc<tokio::sync::Notify>,
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ pub struct OtgBackend {
|
||||
keyboard_leds_enabled: bool,
|
||||
keyboard_state: Mutex<KeyboardReport>,
|
||||
mouse_buttons: AtomicU8,
|
||||
macos_drag: bool,
|
||||
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
|
||||
led_state: Arc<parking_lot::RwLock<LedState>>,
|
||||
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
||||
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
|
||||
@@ -99,6 +101,15 @@ const OTG_RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
impl OtgBackend {
|
||||
/// Gadget must already exist; paths come from `OtgService`.
|
||||
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
|
||||
Self::with_macos_drag(paths, false)
|
||||
}
|
||||
|
||||
pub fn with_macos_drag(paths: HidDevicePaths, macos_drag: bool) -> Result<Self> {
|
||||
if macos_drag && (paths.mouse_relative.is_none() || paths.mouse_absolute.is_none()) {
|
||||
return Err(AppError::Config(
|
||||
"macOS drag compatibility requires both OTG mouse interfaces".into(),
|
||||
));
|
||||
}
|
||||
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
|
||||
Ok(Self {
|
||||
keyboard_path: paths.keyboard,
|
||||
@@ -112,6 +123,8 @@ impl OtgBackend {
|
||||
keyboard_leds_enabled: paths.keyboard_leds_enabled,
|
||||
keyboard_state: Mutex::new(KeyboardReport::default()),
|
||||
mouse_buttons: AtomicU8::new(0),
|
||||
macos_drag,
|
||||
macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()),
|
||||
led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
|
||||
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
||||
udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
|
||||
@@ -852,6 +865,28 @@ impl HidBackend for OtgBackend {
|
||||
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
|
||||
|
||||
if self.macos_drag {
|
||||
use super::macos_drag::MouseReport;
|
||||
let mut state = self.macos_drag_state.lock();
|
||||
let extent = self.screen_resolution.read().unwrap_or((1920, 1080));
|
||||
let (buttons, reports) = state.plan(event, buttons, extent);
|
||||
self.mouse_buttons.store(buttons, Ordering::Relaxed);
|
||||
for report in reports {
|
||||
match report {
|
||||
MouseReport::Absolute { buttons, x, y } => {
|
||||
self.send_mouse_report_absolute(buttons, x, y, 0)?
|
||||
}
|
||||
MouseReport::Relative {
|
||||
buttons,
|
||||
dx,
|
||||
dy,
|
||||
wheel,
|
||||
} => self.send_mouse_report_relative(buttons, dx, dy, wheel)?,
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match event.event_type {
|
||||
MouseEventType::Move => {
|
||||
let dx = event.x.clamp(-127, 127) as i8;
|
||||
@@ -896,6 +931,7 @@ impl HidBackend for OtgBackend {
|
||||
}
|
||||
|
||||
self.mouse_buttons.store(0, Ordering::Relaxed);
|
||||
self.macos_drag_state.lock().reset();
|
||||
self.send_mouse_report_relative(0, 0, 0, 0)?;
|
||||
self.send_mouse_report_absolute(0, 0, 0, 0)?;
|
||||
|
||||
@@ -988,6 +1024,51 @@ mod tests {
|
||||
assert_eq!(kb_report.to_bytes().len(), 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mouse_compatibility_writes_both_endpoints_and_preserves_default() {
|
||||
use crate::hid::MouseButton;
|
||||
for enabled in [false, true] {
|
||||
let relative = tempfile::NamedTempFile::new().unwrap();
|
||||
let absolute = tempfile::NamedTempFile::new().unwrap();
|
||||
let backend = OtgBackend::with_macos_drag(
|
||||
HidDevicePaths {
|
||||
mouse_relative: Some(relative.path().to_path_buf()),
|
||||
mouse_absolute: Some(absolute.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
enabled,
|
||||
)
|
||||
.unwrap();
|
||||
for event in [
|
||||
MouseEvent::move_abs(8000, 8000),
|
||||
MouseEvent::button_down(MouseButton::Left),
|
||||
MouseEvent::move_abs(16000, 16000),
|
||||
MouseEvent::button_up(MouseButton::Left),
|
||||
] {
|
||||
backend.send_mouse(event).await.unwrap();
|
||||
}
|
||||
let abs = fs::read(absolute.path()).unwrap();
|
||||
let rel = fs::read(relative.path()).unwrap();
|
||||
let buttons: Vec<_> = abs.chunks_exact(6).map(|packet| packet[0]).collect();
|
||||
assert_eq!(buttons, if enabled { vec![0, 1, 0] } else { vec![0, 0] });
|
||||
let dx: i32 = rel
|
||||
.chunks_exact(4)
|
||||
.map(|packet| i32::from(packet[1] as i8))
|
||||
.sum();
|
||||
let dy: i32 = rel
|
||||
.chunks_exact(4)
|
||||
.map(|packet| i32::from(packet[2] as i8))
|
||||
.sum();
|
||||
assert_eq!((dx, dy), if enabled { (468, 263) } else { (0, 0) });
|
||||
assert_eq!(&rel[rel.len() - 4..], &[0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_requires_both_mouse_endpoints() {
|
||||
assert!(OtgBackend::with_macos_drag(HidDevicePaths::default(), true).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
|
||||
let mut file = tempfile::tempfile().unwrap();
|
||||
|
||||
@@ -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;
|
||||
|
||||
878
src/main.rs
878
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<PathBuf>,
|
||||
|
||||
/// 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,500 +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::<ShutdownAction>(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,
|
||||
},
|
||||
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::<AudioQuality>() {
|
||||
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<dyn one_kvm::video::traits::VideoOutput>,
|
||||
);
|
||||
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! {
|
||||
@@ -740,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 {
|
||||
@@ -752,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)?;
|
||||
@@ -762,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)
|
||||
@@ -830,24 +298,16 @@ async fn shutdown_signal() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn open_database_pool(data_dir: &Path) -> anyhow::Result<DatabasePool> {
|
||||
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<F, E>(
|
||||
mut servers: FuturesUnordered<F>,
|
||||
shutdown_signal: impl Future<Output = ShutdownAction>,
|
||||
state: &Arc<AppState>,
|
||||
protocol: &'static str,
|
||||
) -> ShutdownAction
|
||||
where
|
||||
F: Future<Output = Result<(), E>> + Send,
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
let action = tokio::select! {
|
||||
tokio::select! {
|
||||
action = shutdown_signal => {
|
||||
action
|
||||
}
|
||||
@@ -857,9 +317,7 @@ where
|
||||
}
|
||||
ShutdownAction::Exit
|
||||
}
|
||||
};
|
||||
cleanup(state).await;
|
||||
action
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_current_process(exe_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
@@ -883,7 +341,6 @@ fn restart_current_process(exe_path: Option<PathBuf>) -> 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());
|
||||
@@ -896,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,
|
||||
@@ -1065,17 +464,6 @@ fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result<Vec<std::ne
|
||||
Ok(listeners)
|
||||
}
|
||||
|
||||
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
|
||||
let format = config
|
||||
.video
|
||||
.format
|
||||
.as_ref()
|
||||
.and_then(|f: &String| f.parse::<PixelFormat>().ok())
|
||||
.unwrap_or(PixelFormat::Mjpeg);
|
||||
let resolution = Resolution::new(config.video.width, config.video.height);
|
||||
(format, resolution)
|
||||
}
|
||||
|
||||
fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyPair>> {
|
||||
use rcgen::generate_simple_self_signed;
|
||||
|
||||
@@ -1088,197 +476,3 @@ fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyP
|
||||
let certified_key = generate_simple_self_signed(subject_alt_names)?;
|
||||
Ok(certified_key)
|
||||
}
|
||||
|
||||
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||
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<AppState>) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DriveInfo> {
|
||||
VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown)
|
||||
}
|
||||
|
||||
pub async fn set_drive_info(&self, drive_info: Option<DriveInfo>) {
|
||||
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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<u64>,
|
||||
pub free: Option<u64>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64> {
|
||||
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<DriveInfo> {
|
||||
raw_drive_info(&self.path, file_access)
|
||||
}
|
||||
|
||||
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
|
||||
@@ -60,9 +59,10 @@ impl VentoyDrive {
|
||||
|
||||
Ok::<DriveInfo, AppError>(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<DriveInfo> {
|
||||
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<DriveInfo> {
|
||||
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() {
|
||||
|
||||
594
src/runtime/builder.rs
Normal file
594
src/runtime/builder.rs
Normal file
@@ -0,0 +1,594 @@
|
||||
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<String>,
|
||||
pub http_port: Option<u16>,
|
||||
pub https_port: Option<u16>,
|
||||
pub enable_https: bool,
|
||||
pub ssl_cert: Option<PathBuf>,
|
||||
pub ssl_key: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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<ApplicationRuntime> {
|
||||
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::<ShutdownAction>(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;
|
||||
hid.set_screen_resolution(video_resolution.width, video_resolution.height)
|
||||
.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<dyn crate::video::traits::VideoOutput>,
|
||||
);
|
||||
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<AppState>,
|
||||
config: AppConfig,
|
||||
data_dir: PathBuf,
|
||||
supervisor: RuntimeSupervisor,
|
||||
}
|
||||
|
||||
impl ApplicationRuntime {
|
||||
pub fn state(&self) -> &Arc<AppState> {
|
||||
&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::<PixelFormat>().ok())
|
||||
.unwrap_or(PixelFormat::Mjpeg);
|
||||
(
|
||||
format,
|
||||
Resolution::new(config.video.width, config.video.height),
|
||||
)
|
||||
}
|
||||
|
||||
async fn build_streamer(
|
||||
config: &AppConfig,
|
||||
events: &Arc<EventBus>,
|
||||
format: PixelFormat,
|
||||
resolution: Resolution,
|
||||
) -> Arc<Streamer> {
|
||||
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<WebRtcStreamer> {
|
||||
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<OtgService> {
|
||||
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 {
|
||||
macos_drag: config.hid.mouse_macos_drag,
|
||||
},
|
||||
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.mouse_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<OtgService>,
|
||||
events: &Arc<EventBus>,
|
||||
) -> Option<MsdController> {
|
||||
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<AtxController> {
|
||||
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<EventBus>) -> Arc<AudioController> {
|
||||
let quality = config
|
||||
.audio
|
||||
.quality
|
||||
.parse::<AudioQuality>()
|
||||
.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<AudioController>,
|
||||
stream_manager: &Arc<VideoStreamManager>,
|
||||
) {
|
||||
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<AppState>, 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<AppState>, _config: &AppConfig) {}
|
||||
|
||||
async fn start_watchdog(state: &Arc<AppState>, 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());
|
||||
}
|
||||
}
|
||||
44
src/runtime/config_apply.rs
Normal file
44
src/runtime/config_apply.rs
Normal file
@@ -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<Mutex<()>>, domain: &str) -> Result<OwnedMutexGuard<()>> {
|
||||
lock.clone().try_lock_owned().map_err(|_| {
|
||||
AppError::ServiceUnavailable(format!("{domain} configuration is already applying"))
|
||||
})
|
||||
}
|
||||
10
src/runtime/mod.rs
Normal file
10
src/runtime/mod.rs
Normal file
@@ -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;
|
||||
509
src/runtime/remote_access.rs
Normal file
509
src/runtime/remote_access.rs
Normal file
@@ -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<String>,
|
||||
pub connection_count: usize,
|
||||
pub listening: bool,
|
||||
pub listen_port: Option<u16>,
|
||||
}
|
||||
|
||||
pub struct RemoteAccessCoordinator {
|
||||
config: ConfigStore,
|
||||
stream_manager: Arc<VideoStreamManager>,
|
||||
hid: Arc<HidController>,
|
||||
audio: Arc<AudioController>,
|
||||
rustdesk: RwLock<Option<Arc<RustDeskService>>>,
|
||||
vnc: RwLock<Option<Arc<VncService>>>,
|
||||
rtsp: RwLock<Option<Arc<RtspService>>>,
|
||||
}
|
||||
|
||||
impl RemoteAccessCoordinator {
|
||||
pub fn new(
|
||||
config: ConfigStore,
|
||||
stream_manager: Arc<VideoStreamManager>,
|
||||
hid: Arc<HidController>,
|
||||
audio: Arc<AudioController>,
|
||||
) -> Arc<Self> {
|
||||
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<Option<String>> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
228
src/runtime/supervisor.rs
Normal file
228
src/runtime/supervisor.rs
Normal file
@@ -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<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RuntimeSupervisor {
|
||||
pub(super) fn start(
|
||||
state: Arc<AppState>,
|
||||
events: Arc<EventBus>,
|
||||
extensions: Arc<ExtensionManager>,
|
||||
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<AppState>) {
|
||||
for task in self.tasks.drain(..) {
|
||||
task.abort();
|
||||
}
|
||||
cleanup(state).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_extension_health_check(
|
||||
extensions: Arc<ExtensionManager>,
|
||||
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<AppState>,
|
||||
events: Arc<EventBus>,
|
||||
) -> Vec<JoinHandle<()>> {
|
||||
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<AppState>) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
336
src/runtime/usb.rs
Normal file
336
src/runtime/usb.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
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<HidController>,
|
||||
#[cfg(unix)]
|
||||
otg: Arc<OtgService>,
|
||||
#[cfg(unix)]
|
||||
msd: Arc<RwLock<Option<MsdController>>>,
|
||||
#[cfg(unix)]
|
||||
uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
|
||||
events: Arc<EventBus>,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl UsbCoordinator {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
hid: Arc<HidController>,
|
||||
#[cfg(unix)] otg: Arc<OtgService>,
|
||||
#[cfg(unix)] msd: Arc<RwLock<Option<MsdController>>>,
|
||||
#[cfg(unix)] uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
|
||||
events: Arc<EventBus>,
|
||||
data_dir: PathBuf,
|
||||
) -> Arc<Self> {
|
||||
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 mouse_compatibility_changed = old_config.ch9329_hybrid_mouse
|
||||
!= new_config.ch9329_hybrid_mouse
|
||||
|| old_config.mouse_macos_drag != new_config.mouse_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
|
||||
&& !mouse_compatibility_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(hid_backend_type(hid_config))
|
||||
.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 {
|
||||
macos_drag: config.mouse_macos_drag,
|
||||
},
|
||||
HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||
port: config.ch9329_port.clone(),
|
||||
baud_rate: config.ch9329_baudrate,
|
||||
hybrid_mouse: config.ch9329_hybrid_mouse,
|
||||
macos_drag: config.mouse_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()
|
||||
}
|
||||
@@ -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<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<BytesMut> {
|
||||
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<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
max_packet_length: usize,
|
||||
) -> io::Result<BytesMut> {
|
||||
let mut first_byte = [0u8; 1];
|
||||
reader.read_exact(&mut first_byte).await?;
|
||||
|
||||
@@ -65,10 +77,10 @@ pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<Byte
|
||||
|
||||
let (_, msg_len) = decode_header(first_byte[0], &header_rest);
|
||||
|
||||
if msg_len > 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<W: AsyncWrite + Unpin>(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<W: AsyncWrite + Unpin>(
|
||||
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<W: AsyncWrite + Unpin>(
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,6 @@ pub struct VideoFrameAdapter {
|
||||
codec: VideoCodec,
|
||||
seq: u32,
|
||||
timestamp_base: u64,
|
||||
h264_sps: Option<Bytes>,
|
||||
h264_pps: Option<Bytes>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<MouseButton> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert using the code space associated with One-KVM's compatibility platform.
|
||||
pub fn convert_key_events(event: &KeyEvent) -> Vec<KeyboardEvent> {
|
||||
convert_key_events_in_code_space(event, KeyboardCodeSpace::WindowsSet1)
|
||||
}
|
||||
|
||||
pub(super) fn convert_key_events_in_code_space(
|
||||
event: &KeyEvent,
|
||||
code_space: KeyboardCodeSpace,
|
||||
) -> Vec<KeyboardEvent> {
|
||||
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<KeyboardEvent> {
|
||||
},
|
||||
]
|
||||
} 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<KeyMapping> {
|
||||
fn key_event_to_mapping(
|
||||
event: &KeyEvent,
|
||||
code_space: KeyboardCodeSpace,
|
||||
modifiers: KeyboardModifiers,
|
||||
) -> Option<KeyMapping> {
|
||||
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<KeyMapping> {
|
||||
Some(KeyMapping {
|
||||
key,
|
||||
modifiers,
|
||||
added_shift: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
|
||||
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<KeyMapping> {
|
||||
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<KeyMapping> {
|
||||
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<KeyMapping> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
424
src/rustdesk/keyboard_mapping.rs
Normal file
424
src/rustdesk/keyboard_mapping.rs
Normal file
@@ -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<CanonicalKey> {
|
||||
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<CanonicalKey> {
|
||||
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<CanonicalKey> {
|
||||
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<CharacterMapping> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<RwLock<RustDeskConfig>>,
|
||||
status: Arc<RwLock<ServiceStatus>>,
|
||||
rendezvous: Arc<RwLock<Option<Arc<RendezvousMediator>>>>,
|
||||
rendezvous_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||
tcp_listener_handle: Arc<RwLock<Option<Vec<JoinHandle<()>>>>>,
|
||||
listener_start_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
listen_port: Arc<RwLock<u16>>,
|
||||
connection_manager: Arc<ConnectionManager>,
|
||||
video_manager: Arc<VideoStreamManager>,
|
||||
@@ -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<Self>) -> 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<JoinHandle<()>>, u16)> {
|
||||
let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) {
|
||||
async fn ensure_tcp_listener(
|
||||
self: &Arc<Self>,
|
||||
preferred_port: u16,
|
||||
allow_ephemeral_fallback: bool,
|
||||
) -> anyhow::Result<u16> {
|
||||
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<TcpListener>, 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<Self>, config: RustDeskConfig) -> anyhow::Result<()> {
|
||||
self.stop().await?;
|
||||
self.update_config(config);
|
||||
self.start().await
|
||||
|
||||
@@ -128,7 +128,7 @@ pub type RelayCallback = Arc<dyn Fn(String, String, String, Vec<u8>, String) + S
|
||||
pub type PunchCallback =
|
||||
Arc<dyn Fn(Option<SocketAddr>, String, String, String, Vec<u8>, String) + Send + Sync>;
|
||||
|
||||
pub type IntranetCallback = Arc<dyn Fn(String, Vec<u8>, SocketAddr, String, String) + Send + Sync>;
|
||||
pub type IntranetCallback = Arc<dyn Fn(String, Vec<u8>, IpAddr, String, String) + Send + Sync>;
|
||||
|
||||
pub struct RendezvousMediator {
|
||||
config: Arc<RwLock<RustDeskConfig>>,
|
||||
@@ -143,7 +143,6 @@ pub struct RendezvousMediator {
|
||||
relay_callback: Arc<RwLock<Option<RelayCallback>>>,
|
||||
punch_callback: Arc<RwLock<Option<PunchCallback>>>,
|
||||
intranet_callback: Arc<RwLock<Option<IntranetCallback>>>,
|
||||
listen_port: Arc<RwLock<u16>>,
|
||||
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,
|
||||
);
|
||||
|
||||
75
src/state.rs
75
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<AudioController>,
|
||||
#[cfg(unix)]
|
||||
pub uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
|
||||
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
|
||||
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
|
||||
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
||||
pub usb: Arc<UsbCoordinator>,
|
||||
pub remote_access: Arc<RemoteAccessCoordinator>,
|
||||
pub extensions: Arc<ExtensionManager>,
|
||||
pub events: Arc<EventBus>,
|
||||
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
||||
@@ -111,9 +108,6 @@ impl AppState {
|
||||
#[cfg(unix)] msd: Option<MsdController>,
|
||||
atx: Option<AtxController>,
|
||||
audio: Arc<AudioController>,
|
||||
rustdesk: Option<Arc<RustDeskService>>,
|
||||
vnc: Option<Arc<VncService>>,
|
||||
rtsp: Option<Arc<RtspService>>,
|
||||
extensions: Arc<ExtensionManager>,
|
||||
events: Arc<EventBus>,
|
||||
update: Arc<UpdateService>,
|
||||
@@ -122,6 +116,28 @@ impl AppState {
|
||||
) -> Arc<Self> {
|
||||
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<Option<SystemEvent>> {
|
||||
self.device_info_tx.subscribe()
|
||||
}
|
||||
@@ -230,6 +218,11 @@ impl AppState {
|
||||
|
||||
pub async fn publish_device_info(&self) {
|
||||
let device_info = self.get_device_info().await;
|
||||
if let SystemEvent::DeviceInfo { video, .. } = &device_info {
|
||||
if let Some((width, height)) = video.resolution {
|
||||
self.hid.set_screen_resolution(width, height).await;
|
||||
}
|
||||
}
|
||||
let _ = self.device_info_tx.send(Some(device_info));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,29 +14,5 @@ pub fn encoder_type_to_backend(encoder: EncoderType) -> Option<EncoderBackend> {
|
||||
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::<EncoderType>(&json).unwrap(),
|
||||
EncoderType::Amlogic
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
201
src/video/capture/dmabuf_layout.rs
Normal file
201
src/video/capture/dmabuf_layout.rs
Normal file
@@ -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<usize> {
|
||||
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<usize>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<CsiBridgeKind>,
|
||||
native_hdmirx_state: Option<NativeHdmirxState>,
|
||||
native_hdmirx_next_state_check: Option<Instant>,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
dma_layout_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
fn open_capture_device(path: &Path) -> io::Result<File> {
|
||||
@@ -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<u8>,
|
||||
) -> std::result::Result<CaptureMeta, CaptureReadError> {
|
||||
fn dequeue_buffer(&mut self) -> std::result::Result<V4l2Buffer, CaptureReadError> {
|
||||
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<Vec<(OwnedFd, usize)>> {
|
||||
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<T>(
|
||||
&mut self,
|
||||
consume: impl FnOnce(usize, usize, Option<OwnedFd>) -> 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<u8>,
|
||||
) -> std::result::Result<CaptureMeta, CaptureReadError> {
|
||||
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<F: FnOnce() -> io::Result<()>>(Option<F>);
|
||||
|
||||
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
|
||||
impl<F: FnOnce() -> io::Result<()>> BufferReturn<F> {
|
||||
fn finish(mut self) -> io::Result<()> {
|
||||
self.0.take().expect("capture lease already returned")()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
|
||||
impl<F: FnOnce() -> io::Result<()>> Drop for BufferReturn<F> {
|
||||
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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<usize> {
|
||||
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<T: Copy>(library: &Library, name: &[u8], path: &Path) -> Result<T> {
|
||||
// SAFETY: the caller supplies the signature from the fixed upstream headers.
|
||||
unsafe { library.get::<T>(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<Self> {
|
||||
// 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<Self> {
|
||||
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<u8>,
|
||||
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<Self> {
|
||||
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<OsStr>) -> Result<Self> {
|
||||
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<Option<(Bytes, bool)>> {
|
||||
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<Option<(Bytes, bool)>> {
|
||||
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<bool> {
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -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<EncoderBackend> 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<CodecInfo> {
|
||||
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,
|
||||
|
||||
@@ -252,13 +252,37 @@ pub fn avcc_to_annex_b(data: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_for_webrtc(data: &[u8]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EncoderBackend> for H265EncoderType {
|
||||
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
||||
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
||||
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
||||
EncoderBackend::Amlogic => H265EncoderType::Amlogic,
|
||||
EncoderBackend::Software => H265EncoderType::Software,
|
||||
}
|
||||
}
|
||||
|
||||
112
src/video/codec/h265_bitstream.rs
Normal file
112
src/video/codec/h265_bitstream.rs
Normal file
@@ -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<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<NodeExists, SmokeTest>(
|
||||
&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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
392
src/video/pipeline/dmabuf.rs
Normal file
392
src/video/pipeline/dmabuf.rs
Normal file
@@ -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<DmaEncoder> {
|
||||
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<EncoderThreadState>),
|
||||
}
|
||||
|
||||
// Field order is intentional, including during unwinding: destroy the encoder
|
||||
// and its imported FDs before STREAMOFF/unmap/REQBUFS(0).
|
||||
struct ActiveCapture {
|
||||
encoder: Option<CaptureEncoder>,
|
||||
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<SharedVideoPipeline>);
|
||||
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<SharedVideoPipeline>,
|
||||
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<SharedVideoPipeline>,
|
||||
initial: ActiveCapture,
|
||||
mut config: SharedVideoPipelineConfig,
|
||||
device: std::path::PathBuf,
|
||||
buffer_count: u32,
|
||||
bridge: BridgeContext,
|
||||
mut commands: mpsc::UnboundedReceiver<PipelineCmd>,
|
||||
) -> 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<Instant> = 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(())
|
||||
}
|
||||
@@ -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<Vec<EncodedFrame>> {
|
||||
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<Box<dyn VideoEncoderTrait + Send>> {
|
||||
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<dyn VideoEncoderTrait + Send> = 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
|
||||
|
||||
@@ -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<EncoderBackend>,
|
||||
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<u8>,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
fn mjpeg_decode_worker_count(available_parallelism: usize) -> usize {
|
||||
available_parallelism.max(1)
|
||||
}
|
||||
|
||||
fn spawn_mjpeg_decode_workers(
|
||||
pipeline: &Arc<SharedVideoPipeline>,
|
||||
latest_frame: &Arc<ParkingRwLock<Option<Arc<VideoFrame>>>>,
|
||||
frame_seq_tx: &watch::Sender<u64>,
|
||||
buffer_pool: &Arc<FrameBufferPool>,
|
||||
resolution: Resolution,
|
||||
) -> Vec<SyncSender<MjpegDecodeJob>> {
|
||||
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::<MjpegDecodeJob>(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<f64>) {
|
||||
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<Vec<u8>>,
|
||||
h264_pps: Option<Vec<u8>>,
|
||||
h265_vps: Option<Vec<u8>>,
|
||||
h265_sps: Option<Vec<u8>>,
|
||||
h265_pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Universal shared video pipeline
|
||||
pub struct SharedVideoPipeline {
|
||||
config: RwLock<SharedVideoPipelineConfig>,
|
||||
@@ -287,9 +483,8 @@ pub struct SharedVideoPipeline {
|
||||
stats: Mutex<SharedVideoPipelineStats>,
|
||||
running: watch::Sender<bool>,
|
||||
running_rx: watch::Receiver<bool>,
|
||||
/// 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<bool>,
|
||||
encoder_done_rx: watch::Receiver<bool>,
|
||||
h264_profile_level_id: watch::Sender<Option<String>>,
|
||||
@@ -301,6 +496,9 @@ pub struct SharedVideoPipeline {
|
||||
sequence: AtomicU64,
|
||||
/// Atomic flag for keyframe request (avoids lock contention)
|
||||
keyframe_requested: AtomicBool,
|
||||
parameter_sets: ParkingMutex<CachedH26xParameterSets>,
|
||||
/// Most recent random-access frame with all decoder parameter sets.
|
||||
bootstrap_frame: ParkingRwLock<Option<Arc<EncodedVideoFrame>>>,
|
||||
/// 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<EncodedVideoFrame>) {
|
||||
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<CaptureStream> = match open_capture_stream(
|
||||
#[allow(unused_mut)]
|
||||
let mut preopened: Option<CaptureStream> = 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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,96 +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<Mutex<()>>, domain: &str) -> Result<OwnedMutexGuard<()>> {
|
||||
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,
|
||||
},
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
@@ -189,305 +102,6 @@ pub async fn apply_stream_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn apply_hid_config(
|
||||
state: &Arc<AppState>,
|
||||
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;
|
||||
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
_old_config: &AtxConfig,
|
||||
@@ -554,336 +168,3 @@ pub async fn apply_audio_config(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn enforce_stream_codec_constraints(state: &Arc<AppState>) -> Result<Option<String>> {
|
||||
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<T>(
|
||||
state: &Arc<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>, 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<AppState>, 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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<Arc<AppState>>) -> Json<HidConfig> {
|
||||
pub async fn get_hid_config(State(state): State<UsbApiState>) -> Json<HidConfig> {
|
||||
Json(state.config.get().hid.clone())
|
||||
}
|
||||
|
||||
pub async fn update_hid_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<UsbApiState>,
|
||||
Json(req): Json<HidConfigUpdate>,
|
||||
) -> Result<Json<HidConfig>> {
|
||||
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?;
|
||||
|
||||
@@ -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<Arc<AppState>>) -> Json<MsdConfig> {
|
||||
pub async fn get_msd_config(State(state): State<UsbApiState>) -> Json<MsdConfig> {
|
||||
Json(state.config.get().msd.clone())
|
||||
}
|
||||
|
||||
pub async fn update_msd_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<UsbApiState>,
|
||||
Json(req): Json<MsdConfigUpdate>,
|
||||
) -> Result<Json<MsdConfig>> {
|
||||
let response = update_otg_config_inner(
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
State(state): State<UsbApiState>,
|
||||
Json(request): Json<OtgConfigUpdate>,
|
||||
) -> Result<Json<OtgConfigResponse>> {
|
||||
update_otg_config_inner(&state, request).await.map(Json)
|
||||
}
|
||||
|
||||
pub(super) async fn update_otg_config_inner(
|
||||
state: &Arc<AppState>,
|
||||
state: &UsbApiState,
|
||||
request: OtgConfigUpdate,
|
||||
) -> Result<OtgConfigResponse> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Arc<AppState>>) -> Json<OtgNetworkConfig> {
|
||||
pub async fn get_otg_network_config(State(state): State<UsbApiState>) -> Json<OtgNetworkConfig> {
|
||||
Json(state.config.get().otg_network.clone())
|
||||
}
|
||||
|
||||
pub async fn update_otg_network_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<UsbApiState>,
|
||||
Json(request): Json<OtgNetworkConfigUpdate>,
|
||||
) -> Result<Json<OtgNetworkConfig>> {
|
||||
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<Arc<AppState>>) -> Json<OtgNetworkStatus> {
|
||||
Json(state.otg_service.network_status().await)
|
||||
pub async fn get_otg_network_status(State(state): State<UsbApiState>) -> Json<OtgNetworkStatus> {
|
||||
Json(state.otg.network_status().await)
|
||||
}
|
||||
|
||||
@@ -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<AppState>, 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<AppState>,
|
||||
state: &RemoteAccessApiState,
|
||||
old_config: crate::config::RtspConfig,
|
||||
new_config: crate::config::RtspConfig,
|
||||
) -> Result<crate::config::RtspConfig> {
|
||||
@@ -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<AppState>) -> 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<Arc<AppState>>) -> Json<RtspConfigResponse> {
|
||||
pub async fn get_rtsp_config(
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Json<RtspConfigResponse> {
|
||||
let config = state.config.get();
|
||||
Json(RtspConfigResponse::from(&config.rtsp))
|
||||
}
|
||||
|
||||
pub async fn get_rtsp_status(State(state): State<Arc<AppState>>) -> Json<RtspStatusResponse> {
|
||||
pub async fn get_rtsp_status(
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Json<RtspStatusResponse> {
|
||||
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<Arc<AppState>>) -> Json<RtspSta
|
||||
}
|
||||
|
||||
pub async fn update_rtsp_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
Json(req): Json<RtspConfigUpdate>,
|
||||
) -> Result<Json<RtspConfigResponse>> {
|
||||
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<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Result<Json<RtspStatusResponse>> {
|
||||
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<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Result<Json<RtspStatusResponse>> {
|
||||
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)))
|
||||
|
||||
@@ -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<AppState>, 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<AppState>,
|
||||
state: &RemoteAccessApiState,
|
||||
old_config: RustDeskConfig,
|
||||
new_config: RustDeskConfig,
|
||||
) -> Result<RustDeskConfig> {
|
||||
@@ -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<AppState>, 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<String>,
|
||||
pub device_id: String,
|
||||
pub has_password: bool,
|
||||
pub has_keypair: bool,
|
||||
pub relay_key: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub connection_count: usize,
|
||||
pub listening: bool,
|
||||
pub listen_port: Option<u16>,
|
||||
}
|
||||
|
||||
pub async fn get_rustdesk_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Json<RustDeskConfigResponse> {
|
||||
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
|
||||
}
|
||||
|
||||
pub async fn get_rustdesk_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Json<RustDeskStatusResponse> {
|
||||
let config = state.config.get().rustdesk.clone();
|
||||
Json(current_status(&state, config).await)
|
||||
}
|
||||
|
||||
pub async fn update_rustdesk_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
Json(req): Json<RustDeskConfigUpdate>,
|
||||
) -> Result<Json<RustDeskConfigResponse>> {
|
||||
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<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Result<Json<RustDeskConfigResponse>> {
|
||||
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<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Result<Json<RustDeskConfigResponse>> {
|
||||
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<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||
pub async fn get_device_password(
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
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<Arc<AppState>>) -> Json<ser
|
||||
}
|
||||
|
||||
pub async fn start_rustdesk_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Result<Json<RustDeskStatusResponse>> {
|
||||
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<Arc<AppState>>,
|
||||
State(state): State<RemoteAccessApiState>,
|
||||
) -> Result<Json<RustDeskStatusResponse>> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -402,10 +402,15 @@ impl Ch9329DescriptorConfigUpdate {
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HidConfigUpdate {
|
||||
/// Request-only; never persisted or replayed during startup.
|
||||
pub bluetooth_reset_pairing: Option<bool>,
|
||||
pub bluetooth: Option<crate::config::BluetoothHidConfig>,
|
||||
pub backend: Option<HidBackend>,
|
||||
pub ch9329_port: Option<String>,
|
||||
pub ch9329_baudrate: Option<u32>,
|
||||
pub ch9329_hybrid_mouse: Option<bool>,
|
||||
#[serde(alias = "ch9329_macos_drag")]
|
||||
pub mouse_macos_drag: Option<bool>,
|
||||
pub ch9329_descriptor: Option<Ch9329DescriptorConfigUpdate>,
|
||||
pub otg_udc: Option<String>,
|
||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||
@@ -426,6 +431,9 @@ pub struct OtgConfigUpdate {
|
||||
|
||||
impl HidConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
if let Some(config) = &self.bluetooth {
|
||||
config.validate()?;
|
||||
}
|
||||
if let Some(baudrate) = self.ch9329_baudrate {
|
||||
let valid_rates = [9600, 19200, 38400, 57600, 115200];
|
||||
if !valid_rates.contains(&baudrate) {
|
||||
@@ -444,6 +452,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;
|
||||
}
|
||||
@@ -456,6 +467,9 @@ impl HidConfigUpdate {
|
||||
if let Some(enabled) = self.ch9329_hybrid_mouse {
|
||||
config.ch9329_hybrid_mouse = enabled;
|
||||
}
|
||||
if let Some(enabled) = self.mouse_macos_drag {
|
||||
config.mouse_macos_drag = enabled;
|
||||
}
|
||||
if let Some(ref desc) = self.ch9329_descriptor {
|
||||
desc.apply_to(&mut config.ch9329_descriptor);
|
||||
}
|
||||
@@ -899,7 +913,9 @@ fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RustDeskConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub mode: Option<crate::rustdesk::config::RustDeskMode>,
|
||||
pub codec: Option<crate::rustdesk::config::RustDeskCodec>,
|
||||
pub direct_access_port: Option<u16>,
|
||||
pub rendezvous_server: Option<String>,
|
||||
pub relay_server: Option<String>,
|
||||
pub relay_key: Option<String>,
|
||||
@@ -908,6 +924,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(':') {
|
||||
@@ -944,10 +965,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(())
|
||||
}
|
||||
@@ -956,9 +991,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();
|
||||
}
|
||||
@@ -1466,7 +1507,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()),
|
||||
@@ -1481,7 +1524,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),
|
||||
@@ -1490,6 +1535,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"] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user