mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 19:14: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:v4l2r",
|
||||||
"dep:alsa",
|
"dep:alsa",
|
||||||
"dep:gpio-cdev",
|
"dep:gpio-cdev",
|
||||||
|
"dep:one-kvm-bluetooth-hid",
|
||||||
"dep:cpal",
|
"dep:cpal",
|
||||||
"dep:windows-sys",
|
"dep:windows-sys",
|
||||||
]
|
]
|
||||||
@@ -179,6 +180,8 @@ turbojpeg = { version = "1.3", optional = true }
|
|||||||
audiopus = { version = "0.2", optional = true }
|
audiopus = { version = "0.2", optional = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
one-kvm-bluetooth-hid = { path = "libs/bluetooth-hid", optional = true }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }
|
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 \
|
apt-get install -y --no-install-recommends \
|
||||||
# Core runtime (all platforms) - no codec libs needed
|
# Core runtime (all platforms) - no codec libs needed
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
# Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter.
|
||||||
|
bluez \
|
||||||
libudev1 \
|
libudev1 \
|
||||||
libasound2 \
|
libasound2 \
|
||||||
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
# 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 \
|
apt-get install -y --no-install-recommends \
|
||||||
# Core runtime (all platforms) - no codec libs needed
|
# Core runtime (all platforms) - no codec libs needed
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
# Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter.
|
||||||
|
bluez \
|
||||||
libudev1 \
|
libudev1 \
|
||||||
libasound2 \
|
libasound2 \
|
||||||
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
ARG DEBIAN_IMAGE=debian:11
|
ARG DEBIAN_IMAGE=debian:11
|
||||||
FROM ${DEBIAN_IMAGE}
|
FROM ${DEBIAN_IMAGE}
|
||||||
|
|
||||||
|
ARG DEBIAN_SNAPSHOT=20260713T000000Z
|
||||||
ARG CHINAMIRRO=0
|
ARG CHINAMIRRO=0
|
||||||
ARG GH_PROXY=https://gh-proxy.com/
|
ARG GH_PROXY=https://gh-proxy.com/
|
||||||
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
||||||
@@ -19,12 +20,11 @@ ARG RKMPP_BRANCH=jellyfin-mpp
|
|||||||
ARG RKRGA_BRANCH=jellyfin-rga
|
ARG RKRGA_BRANCH=jellyfin-rga
|
||||||
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
||||||
|
|
||||||
# Optionally use China mirrors for builds in China.
|
RUN printf '%s\n' \
|
||||||
RUN if [ "$CHINAMIRRO" = "1" ]; then \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
|
||||||
sed -i -E \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
|
||||||
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
|
||||||
/etc/apt/sources.list; \
|
> /etc/apt/sources.list
|
||||||
fi
|
|
||||||
|
|
||||||
# Install Rust toolchain
|
# Install Rust toolchain
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
ARG DEBIAN_IMAGE=debian:11
|
ARG DEBIAN_IMAGE=debian:11
|
||||||
FROM ${DEBIAN_IMAGE}
|
FROM ${DEBIAN_IMAGE}
|
||||||
|
|
||||||
|
ARG DEBIAN_SNAPSHOT=20260713T000000Z
|
||||||
ARG CHINAMIRRO=0
|
ARG CHINAMIRRO=0
|
||||||
ARG GH_PROXY=https://gh-proxy.com/
|
ARG GH_PROXY=https://gh-proxy.com/
|
||||||
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
||||||
@@ -19,12 +20,11 @@ ARG RKMPP_BRANCH=jellyfin-mpp
|
|||||||
ARG RKRGA_BRANCH=jellyfin-rga
|
ARG RKRGA_BRANCH=jellyfin-rga
|
||||||
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
||||||
|
|
||||||
# Optionally use China mirrors for builds in China.
|
RUN printf '%s\n' \
|
||||||
RUN if [ "$CHINAMIRRO" = "1" ]; then \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
|
||||||
sed -i -E \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
|
||||||
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
|
||||||
/etc/apt/sources.list; \
|
> /etc/apt/sources.list
|
||||||
fi
|
|
||||||
|
|
||||||
# Install Rust toolchain
|
# Install Rust toolchain
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
ARG DEBIAN_IMAGE=debian:11
|
ARG DEBIAN_IMAGE=debian:11
|
||||||
FROM ${DEBIAN_IMAGE}
|
FROM ${DEBIAN_IMAGE}
|
||||||
|
|
||||||
|
ARG DEBIAN_SNAPSHOT=20260713T000000Z
|
||||||
ARG CHINAMIRRO=0
|
ARG CHINAMIRRO=0
|
||||||
ARG GH_PROXY=https://gh-proxy.com/
|
ARG GH_PROXY=https://gh-proxy.com/
|
||||||
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
|
||||||
@@ -17,12 +18,11 @@ ARG X265_VERSION=3.4
|
|||||||
ARG OPUS_VERSION=1.5.2
|
ARG OPUS_VERSION=1.5.2
|
||||||
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
|
||||||
|
|
||||||
# Optionally use China mirrors for builds in China.
|
RUN printf '%s\n' \
|
||||||
RUN if [ "$CHINAMIRRO" = "1" ]; then \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
|
||||||
sed -i -E \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
|
||||||
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
|
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
|
||||||
/etc/apt/sources.list; \
|
> /etc/apt/sources.list
|
||||||
fi
|
|
||||||
|
|
||||||
# Install Rust toolchain
|
# Install Rust toolchain
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Maintainer: SilentWind <admin@mofeng.run>
|
|||||||
Package: one-kvm
|
Package: one-kvm
|
||||||
Architecture: {arch}
|
Architecture: {arch}
|
||||||
Depends: ${{auto}}, ca-certificates{distsuffix}
|
Depends: ${{auto}}, ca-certificates{distsuffix}
|
||||||
|
Recommends: bluez
|
||||||
Description: A open and lightweight IP-KVM solution written in Rust
|
Description: A open and lightweight IP-KVM solution written in Rust
|
||||||
Enables BIOS-level remote management of servers and workstations.
|
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)
|
* Hardware-accelerated video encoding (VAAPI, QSV, RKMPP)
|
||||||
* WebRTC and MJPEG streaming with low latency
|
* WebRTC and MJPEG streaming with low latency
|
||||||
* USB HID emulation via OTG gadget
|
* USB HID emulation via OTG gadget
|
||||||
|
* Classic Bluetooth keyboard and mouse via BlueZ
|
||||||
* Mass storage device for ISO/IMG mounting
|
* Mass storage device for ISO/IMG mounting
|
||||||
* ATX power control via GPIO or USB relay
|
* ATX power control via GPIO or USB relay
|
||||||
Homepage: https://github.com/mofeng-git/One-KVM
|
Homepage: https://github.com/mofeng-git/One-KVM
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=One-KVM IP-KVM Service
|
Description=One-KVM IP-KVM Service
|
||||||
Documentation=https://github.com/mofeng-git/One-KVM
|
Documentation=https://github.com/mofeng-git/One-KVM
|
||||||
After=network-online.target
|
After=network-online.target bluetooth.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ Section: admin
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: $DEB_ARCH
|
Architecture: $DEB_ARCH
|
||||||
Depends: $DEPS
|
Depends: $DEPS
|
||||||
|
Recommends: bluez
|
||||||
Maintainer: SilentWind <admin@mofeng.run>
|
Maintainer: SilentWind <admin@mofeng.run>
|
||||||
Description: A open and lightweight IP-KVM solution
|
Description: A open and lightweight IP-KVM solution
|
||||||
Enables BIOS-level remote management of servers and workstations.
|
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() {
|
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");
|
let cpp_dir = manifest_dir.join("cpp");
|
||||||
println!("cargo:rerun-if-changed=src");
|
println!("cargo:rerun-if-changed=src");
|
||||||
println!("cargo:rerun-if-changed={}", cpp_dir.display());
|
println!("cargo:rerun-if-changed={}", cpp_dir.display());
|
||||||
@@ -17,7 +18,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_common(builder: &mut Build) {
|
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 target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||||
let common_dir = manifest_dir.join("cpp").join("common");
|
let common_dir = manifest_dir.join("cpp").join("common");
|
||||||
|
|
||||||
@@ -367,7 +368,7 @@ mod ffmpeg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ffmpeg_ffi() {
|
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 ffmpeg_ram_dir = manifest_dir.join("cpp").join("common");
|
||||||
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
|
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
|
||||||
println!("cargo:rerun-if-changed={}", ffi_header_path.display());
|
println!("cargo:rerun-if-changed={}", ffi_header_path.display());
|
||||||
@@ -381,7 +382,7 @@ mod ffmpeg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_ffmpeg_ram(builder: &mut Build) {
|
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 ffmpeg_ram_dir = manifest_dir.join("cpp").join("ffmpeg_ram");
|
||||||
let ffi_header = ffmpeg_ram_dir
|
let ffi_header = ffmpeg_ram_dir
|
||||||
.join("ffmpeg_ram_ffi.h")
|
.join("ffmpeg_ram_ffi.h")
|
||||||
@@ -420,7 +421,7 @@ mod ffmpeg {
|
|||||||
return;
|
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
|
let capture_header = manifest_dir
|
||||||
.join("cpp")
|
.join("cpp")
|
||||||
.join("ffmpeg_capture_ffi.h")
|
.join("ffmpeg_capture_ffi.h")
|
||||||
@@ -443,7 +444,7 @@ mod ffmpeg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_ffmpeg_hw(builder: &mut Build) {
|
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 ffmpeg_hw_dir = manifest_dir.join("cpp").join("ffmpeg_hw");
|
||||||
let ffi_header = ffmpeg_hw_dir
|
let ffi_header = ffmpeg_hw_dir
|
||||||
.join("ffmpeg_hw_ffi.h")
|
.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("ffmpeg_hw_mjpeg_h26x.cpp"));
|
||||||
|
builder.file(ffmpeg_hw_dir.join("rkmpp_dmabuf.cpp"));
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"cargo:info=Skipping ffmpeg_hw_mjpeg_h26x.cpp (RKMPP) for arch {}",
|
"cargo:info=Skipping ffmpeg_hw_mjpeg_h26x.cpp (RKMPP) for arch {}",
|
||||||
|
|||||||
@@ -146,6 +146,67 @@ int linux_support_v4l2m2m() {
|
|||||||
return false;
|
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 {
|
auto is_amlogic_platform = [&]() -> bool {
|
||||||
const char *platform_hints[] = {
|
const char *platform_hints[] = {
|
||||||
"amlogic",
|
"amlogic",
|
||||||
@@ -210,7 +271,8 @@ int linux_support_v4l2m2m() {
|
|||||||
return false;
|
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()) {
|
if (amlogic_platform && !v4l2m2m_allowed()) {
|
||||||
LOG_WARN(std::string(
|
LOG_WARN(std::string(
|
||||||
"V4L2 M2M: skipped probe on Amlogic platform; set ONE_KVM_V4L2M2M_ALLOW=1 to enable"));
|
"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 "util.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <fstream>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
@@ -45,17 +48,46 @@ bool is_software_hevc(const std::string &name) {
|
|||||||
return true;
|
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
|
} // anonymous namespace
|
||||||
|
|
||||||
namespace util_encode {
|
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,
|
void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs,
|
||||||
int gop, int fps, int thread_count) {
|
int gop, int fps, int thread_count) {
|
||||||
c->has_b_frames = 0;
|
c->has_b_frames = 0;
|
||||||
c->max_b_frames = 0;
|
c->max_b_frames = 0;
|
||||||
if (gop > 0 && gop < std::numeric_limits<int16_t>::max()) {
|
const bool qcom_iris_v4l2 =
|
||||||
c->gop_size = gop;
|
name.find("v4l2m2m") != std::string::npos && is_qcom_iris_platform();
|
||||||
c->keyint_min = gop; // Match keyint_min to gop for consistent keyframe interval
|
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) {
|
} else if (name.find("vaapi") != std::string::npos) {
|
||||||
c->gop_size = fps > 0 ? fps : 30; // Default to 1 second keyframe interval
|
c->gop_size = fps > 0 ? fps : 30; // Default to 1 second keyframe interval
|
||||||
c->keyint_min = c->gop_size;
|
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 (name.find("amf") != std::string::npos) {
|
||||||
if ((ret = av_opt_set(priv_data, "query_timeout", "1000", 0)) < 0) {
|
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) {
|
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) {
|
if (name.find("rkmpp") != std::string::npos) {
|
||||||
// Set async_depth to 1 for minimal buffering (0 = synchronous, higher = more buffering)
|
// 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) {
|
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
|
// 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) {
|
if (name.find("v4l2m2m") != std::string::npos) {
|
||||||
// Minimize number of output buffers for lower latency
|
// Minimize number of output buffers for lower latency
|
||||||
if ((ret = av_opt_set_int(priv_data, "num_output_buffers", 4, 0)) < 0) {
|
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
|
// Not fatal
|
||||||
}
|
}
|
||||||
if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 4, 0)) < 0) {
|
const int capture_buffers = is_qcom_iris_driver() ? 12 : 8;
|
||||||
LOG_WARN(std::string("v4l2m2m set num_capture_buffers failed, ret = ") + av_err2str(ret));
|
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
|
// Not fatal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,6 +397,14 @@ struct CodecOptions {
|
|||||||
|
|
||||||
bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
|
bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
|
||||||
int q) {
|
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) {
|
if (name.find("vaapi") != std::string::npos && rc == RC_CQ) {
|
||||||
// Used only after the normal bitrate-based VAAPI initialization fails.
|
// Used only after the normal bitrate-based VAAPI initialization fails.
|
||||||
// Some drivers, including Intel iHD on Jasper Lake, expose CQP as their
|
// 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) {
|
bool change_bit_rate(AVCodecContext *c, const std::string &name, int kbs) {
|
||||||
if (kbs > 0) {
|
if (kbs > 0) {
|
||||||
c->bit_rate = kbs * 1000;
|
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) {
|
if (name.find("qsv") != std::string::npos) {
|
||||||
c->rc_max_rate = c->bit_rate;
|
c->rc_max_rate = c->bit_rate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ extern "C" {
|
|||||||
|
|
||||||
namespace util_encode {
|
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,
|
void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs,
|
||||||
int gop, int fps, int thread_count);
|
int gop, int fps, int thread_count);
|
||||||
bool set_lantency_free(void *priv_data, const std::string &name);
|
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) {
|
static void set_encoder_last_error(const std::string &message) {
|
||||||
g_encoder_last_error = message;
|
g_encoder_last_error = message;
|
||||||
LOG_ERROR(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
|
static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
|
||||||
@@ -380,12 +379,12 @@ private:
|
|||||||
frame->pts = ms;
|
frame->pts = ms;
|
||||||
|
|
||||||
// Force keyframe if requested
|
// Force keyframe if requested
|
||||||
if (force_keyframe_) {
|
if (force_keyframe_ && util_encode::supports_forced_keyframe(name_)) {
|
||||||
frame->pict_type = AV_PICTURE_TYPE_I;
|
frame->pict_type = AV_PICTURE_TYPE_I;
|
||||||
force_keyframe_ = false;
|
|
||||||
} else {
|
} else {
|
||||||
frame->pict_type = AV_PICTURE_TYPE_NONE;
|
frame->pict_type = AV_PICTURE_TYPE_NONE;
|
||||||
}
|
}
|
||||||
|
force_keyframe_ = false;
|
||||||
|
|
||||||
ret = avcodec_send_frame(c_, frame);
|
ret = avcodec_send_frame(c_, frame);
|
||||||
if (ret == AVERROR(EAGAIN)) {
|
if (ret == AVERROR(EAGAIN)) {
|
||||||
@@ -646,7 +645,7 @@ ffmpeg_ram_new_encoder(const char *name, int width,
|
|||||||
// allowing CQP-only drivers to pass probing and normal encoder creation.
|
// allowing CQP-only drivers to pass probing and normal encoder creation.
|
||||||
if (name && std::string(name).find("vaapi") != std::string::npos &&
|
if (name && std::string(name).find("vaapi") != std::string::npos &&
|
||||||
rc != RC_CQ) {
|
rc != RC_CQ) {
|
||||||
LOG_WARN(std::string("VAAPI bitrate-based rate control failed for ") +
|
LOG_DEBUG(std::string("VAAPI bitrate-based rate control failed for ") +
|
||||||
name + ", retrying with CQP");
|
name + ", retrying with CQP");
|
||||||
encoder = try_create(RC_CQ, 0);
|
encoder = try_create(RC_CQ, 0);
|
||||||
if (encoder) {
|
if (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() {
|
if let Ok(str_slice) = c_str.to_str() {
|
||||||
let string = String::from(str_slice);
|
let string = String::from(str_slice);
|
||||||
if level == AV_LOG_ERROR as i32 {
|
if level == AV_LOG_ERROR as i32 {
|
||||||
log::error!("{}", string);
|
|
||||||
if string.contains(could_not_find_ref_with_poc) {
|
if string.contains(could_not_find_ref_with_poc) {
|
||||||
hwcodec_set_flag_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 {
|
} else if level == AV_LOG_PANIC as i32 || level == AV_LOG_FATAL as i32 {
|
||||||
log::error!("{}", string);
|
log::error!("{}", string);
|
||||||
} else if level == AV_LOG_WARNING as i32 {
|
} else if level == AV_LOG_WARNING as i32 {
|
||||||
log::warn!("{}", string);
|
log::debug!("{}", string);
|
||||||
} else if level == AV_LOG_INFO as i32 {
|
} else if level == AV_LOG_INFO as i32 {
|
||||||
log::info!("{}", string);
|
log::info!("{}", string);
|
||||||
} else if level == AV_LOG_VERBOSE as i32 || level == AV_LOG_DEBUG as i32 {
|
} 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 {
|
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
|
||||||
use log::{debug, warn};
|
use log::debug;
|
||||||
|
|
||||||
debug!("Testing encoder: {}", codec.name);
|
debug!("Testing encoder: {}", codec.name);
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
last_err = Some(err);
|
last_err = Some(err);
|
||||||
warn!(
|
debug!(
|
||||||
"Encoder {} test attempt {} returned error: {}",
|
"Encoder {} test attempt {} returned error: {}",
|
||||||
codec.name, attempt_no, err
|
codec.name, attempt_no, err
|
||||||
);
|
);
|
||||||
@@ -412,10 +412,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
|
|||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => false,
|
||||||
warn!("Failed to create encoder {}", codec.name);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +540,7 @@ impl Encoder {
|
|||||||
if codec.is_null() {
|
if codec.is_null() {
|
||||||
let message = encoder_last_error_message();
|
let message = encoder_last_error_message();
|
||||||
if !message.is_empty() {
|
if !message.is_empty() {
|
||||||
log::error!("ffmpeg_ram_new_encoder failed: {}", message);
|
log::debug!("ffmpeg_ram_new_encoder failed: {}", message);
|
||||||
}
|
}
|
||||||
return Err(());
|
return Err(());
|
||||||
}
|
}
|
||||||
@@ -595,6 +592,16 @@ impl Encoder {
|
|||||||
Some(Encoder::packet_callback),
|
Some(Encoder::packet_callback),
|
||||||
);
|
);
|
||||||
if result == -11 || result == 0 {
|
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);
|
return Ok(frames);
|
||||||
}
|
}
|
||||||
Err(result)
|
Err(result)
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ pub mod ffmpeg;
|
|||||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
|
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
|
||||||
pub mod ffmpeg_hw;
|
pub mod ffmpeg_hw;
|
||||||
pub mod ffmpeg_ram;
|
pub mod ffmpeg_ram;
|
||||||
|
#[cfg(all(
|
||||||
|
target_os = "linux",
|
||||||
|
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")
|
||||||
|
))]
|
||||||
|
pub mod rkmpp_dmabuf;
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "C" fn hwcodec_log(level: i32, message: *const std::os::raw::c_char) {
|
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() {
|
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");
|
let cpp_dir = manifest_dir.join("cpp");
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=cpp/yuv_ffi.h");
|
println!("cargo:rerun-if-changed=cpp/yuv_ffi.h");
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub use types::{
|
|||||||
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig,
|
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig,
|
||||||
AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, LCUS_RELAY_MAX_CHANNEL,
|
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))]
|
#[cfg(any(unix, test))]
|
||||||
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {
|
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ use tracing::info;
|
|||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
const WOL_HISTORY_MAX_ENTRIES: i64 = 50;
|
|
||||||
|
|
||||||
const MAGIC_PACKET_SIZE: usize = 102;
|
const MAGIC_PACKET_SIZE: usize = 102;
|
||||||
|
|
||||||
fn parse_mac_address(mac: &str) -> Result<[u8; 6]> {
|
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(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ fn is_setup_public_endpoint(path: &str) -> bool {
|
|||||||
"/setup"
|
"/setup"
|
||||||
| "/setup/init"
|
| "/setup/init"
|
||||||
| "/devices"
|
| "/devices"
|
||||||
|
| "/hid/bluetooth/adapters"
|
||||||
| "/video/input-status"
|
| "/video/input-status"
|
||||||
| "/stream/codecs"
|
| "/stream/codecs"
|
||||||
| "/video/codecs"
|
| "/video/codecs"
|
||||||
|
|||||||
@@ -227,15 +227,14 @@ impl TwoFactorService {
|
|||||||
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
|
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut transaction = self.pool.begin().await?;
|
|
||||||
let result =
|
let result =
|
||||||
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
|
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(secret.to_string())
|
.bind(secret.to_string())
|
||||||
.execute(&mut *transaction)
|
.execute(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => transaction.commit().await?,
|
Ok(_) => {}
|
||||||
Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
|
Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
|
||||||
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
|
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Pool, Sqlite};
|
use sqlx::{Pool, Sqlite};
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::password::{hash_password, verify_password};
|
use super::password::{hash_password, verify_password};
|
||||||
@@ -112,12 +110,10 @@ impl UserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let password_hash = hash_password(new_password)?;
|
let password_hash = hash_password(new_password)?;
|
||||||
let now = OffsetDateTime::now_utc();
|
let result = sqlx::query(
|
||||||
|
"UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE id = ?2",
|
||||||
let result =
|
)
|
||||||
sqlx::query("UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3")
|
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.bind(now.format(&Rfc3339).expect("RFC3339 format"))
|
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -143,10 +139,10 @@ impl UserStore {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
let result = sqlx::query(
|
||||||
let result = sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3")
|
"UPDATE users SET username = ?1, updated_at = datetime('now') WHERE id = ?2",
|
||||||
|
)
|
||||||
.bind(new_username)
|
.bind(new_username)
|
||||||
.bind(now.format(&Rfc3339).expect("RFC3339 format"))
|
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1,6 +1,54 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use typeshare::typeshare;
|
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]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -8,6 +56,7 @@ use typeshare::typeshare;
|
|||||||
pub enum HidBackend {
|
pub enum HidBackend {
|
||||||
Otg,
|
Otg,
|
||||||
Ch9329,
|
Ch9329,
|
||||||
|
Bluetooth,
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
@@ -166,6 +215,7 @@ impl OtgHidProfile {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct HidConfig {
|
pub struct HidConfig {
|
||||||
|
pub bluetooth: BluetoothHidConfig,
|
||||||
pub backend: HidBackend,
|
pub backend: HidBackend,
|
||||||
pub otg_udc: Option<String>,
|
pub otg_udc: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -181,6 +231,9 @@ pub struct HidConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ch9329_hybrid_mouse: bool,
|
pub ch9329_hybrid_mouse: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "ch9329_macos_drag")]
|
||||||
|
pub mouse_macos_drag: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub ch9329_descriptor: Ch9329DescriptorConfig,
|
pub ch9329_descriptor: Ch9329DescriptorConfig,
|
||||||
pub mouse_absolute: bool,
|
pub mouse_absolute: bool,
|
||||||
}
|
}
|
||||||
@@ -189,6 +242,7 @@ impl Default for HidConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
backend: HidBackend::None,
|
backend: HidBackend::None,
|
||||||
|
bluetooth: BluetoothHidConfig::default(),
|
||||||
otg_udc: None,
|
otg_udc: None,
|
||||||
otg_descriptor: OtgDescriptorConfig::default(),
|
otg_descriptor: OtgDescriptorConfig::default(),
|
||||||
otg_profile: OtgHidProfile::default(),
|
otg_profile: OtgHidProfile::default(),
|
||||||
@@ -197,6 +251,7 @@ impl Default for HidConfig {
|
|||||||
ch9329_port: "/dev/ttyUSB0".to_string(),
|
ch9329_port: "/dev/ttyUSB0".to_string(),
|
||||||
ch9329_baudrate: 9600,
|
ch9329_baudrate: 9600,
|
||||||
ch9329_hybrid_mouse: false,
|
ch9329_hybrid_mouse: false,
|
||||||
|
mouse_macos_drag: false,
|
||||||
ch9329_descriptor: Ch9329DescriptorConfig::default(),
|
ch9329_descriptor: Ch9329DescriptorConfig::default(),
|
||||||
mouse_absolute: true,
|
mouse_absolute: true,
|
||||||
}
|
}
|
||||||
@@ -222,6 +277,11 @@ impl HidConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let functions = self.effective_otg_functions();
|
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() {
|
if functions.is_empty() {
|
||||||
return Err(crate::error::AppError::BadRequest(
|
return Err(crate::error::AppError::BadRequest(
|
||||||
"OTG HID functions cannot be empty".to_string(),
|
"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.otg_network.enabled = false;
|
||||||
self.uac.enabled = false;
|
self.uac.enabled = false;
|
||||||
}
|
}
|
||||||
|
if self.hid.backend == HidBackend::Bluetooth {
|
||||||
|
self.hid.mouse_absolute = false;
|
||||||
|
}
|
||||||
self.atx.normalize();
|
self.atx.normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ pub enum EncoderType {
|
|||||||
Qsv,
|
Qsv,
|
||||||
Amf,
|
Amf,
|
||||||
Rkmpp,
|
Rkmpp,
|
||||||
|
#[serde(alias = "amlogic")]
|
||||||
V4l2m2m,
|
V4l2m2m,
|
||||||
Amlogic,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EncoderType {
|
impl EncoderType {
|
||||||
@@ -117,7 +117,6 @@ impl EncoderType {
|
|||||||
EncoderType::Amf => "AMD AMF",
|
EncoderType::Amf => "AMD AMF",
|
||||||
EncoderType::Rkmpp => "Rockchip MPP",
|
EncoderType::Rkmpp => "Rockchip MPP",
|
||||||
EncoderType::V4l2m2m => "V4L2 M2M",
|
EncoderType::V4l2m2m => "V4L2 M2M",
|
||||||
EncoderType::Amlogic => "AMLENC",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ pub struct ConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigStore {
|
impl ConfigStore {
|
||||||
pub fn new(pool: Pool<Sqlite>) -> Result<Self> {
|
pub fn new(pool: Pool<Sqlite>) -> Self {
|
||||||
Ok(Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
cache: Arc::new(ArcSwap::from_pointee(AppConfig::default())),
|
cache: Arc::new(ArcSwap::from_pointee(AppConfig::default())),
|
||||||
change_tx: broadcast::channel(16).0,
|
change_tx: broadcast::channel(16).0,
|
||||||
write_lock: Arc::new(Mutex::new(())),
|
write_lock: Arc::new(Mutex::new(())),
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load(&self) -> Result<()> {
|
pub async fn load(&self) -> Result<()> {
|
||||||
@@ -83,6 +83,11 @@ impl ConfigStore {
|
|||||||
Ok(())
|
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> {
|
pub fn get(&self) -> Arc<AppConfig> {
|
||||||
self.cache.load_full()
|
self.cache.load_full()
|
||||||
}
|
}
|
||||||
@@ -145,7 +150,7 @@ mod tests {
|
|||||||
let db = DatabasePool::new(&db_path).await.unwrap();
|
let db = DatabasePool::new(&db_path).await.unwrap();
|
||||||
db.init_schema().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();
|
store.load().await.unwrap();
|
||||||
|
|
||||||
let config = store.get();
|
let config = store.get();
|
||||||
@@ -163,7 +168,7 @@ mod tests {
|
|||||||
assert!(config.initialized);
|
assert!(config.initialized);
|
||||||
assert_eq!(config.web.http_port, 9000);
|
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();
|
store2.load().await.unwrap();
|
||||||
let config = store2.get();
|
let config = store2.get();
|
||||||
assert!(config.initialized);
|
assert!(config.initialized);
|
||||||
@@ -176,7 +181,7 @@ mod tests {
|
|||||||
let db_path = dir.path().join("test.db");
|
let db_path = dir.path().join("test.db");
|
||||||
let db = DatabasePool::new(&db_path).await.unwrap();
|
let db = DatabasePool::new(&db_path).await.unwrap();
|
||||||
db.init_schema().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();
|
store.load().await.unwrap();
|
||||||
|
|
||||||
sqlx::query("DROP TABLE config")
|
sqlx::query("DROP TABLE config")
|
||||||
@@ -210,7 +215,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let store = ConfigStore::new(db.clone_pool()).unwrap();
|
let store = ConfigStore::new(db.clone_pool());
|
||||||
store.load().await.unwrap();
|
store.load().await.unwrap();
|
||||||
let (persisted,): (String,) =
|
let (persisted,): (String,) =
|
||||||
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
|
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 pool;
|
||||||
|
mod wol_history;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
pub use pool::DatabasePool;
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
148
src/db/pool.rs
148
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::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -15,29 +18,82 @@ impl DatabasePool {
|
|||||||
tokio::fs::create_dir_all(parent).await?;
|
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()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(4)
|
.max_connections(4)
|
||||||
.acquire_timeout(Duration::from_secs(5))
|
.acquire_timeout(Duration::from_secs(5))
|
||||||
.idle_timeout(Duration::from_secs(300))
|
.idle_timeout(Duration::from_secs(300))
|
||||||
.connect(&db_url)
|
.connect_with(options)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn init_schema(&self) -> Result<()> {
|
pub async fn init_schema(&self) -> Result<()> {
|
||||||
self.create_config_table().await?;
|
// Keep migrations embedded in the binary so deployments do not need an
|
||||||
self.create_users_table().await?;
|
// extra migrations directory or another runtime dependency.
|
||||||
self.create_user_totp_credentials_table().await?;
|
let mut transaction = self.pool.begin().await?;
|
||||||
self.create_api_tokens_table().await?;
|
sqlx::query(
|
||||||
self.create_wol_history_table().await?;
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_config_table(&self) -> Result<()> {
|
pub fn pool(&self) -> &Pool<Sqlite> {
|
||||||
sqlx::query(
|
&self.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
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#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -45,14 +101,6 @@ impl DatabasePool {
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_users_table(&self) -> Result<()> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -62,14 +110,14 @@ impl DatabasePool {
|
|||||||
updated_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
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
"#,
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_api_tokens_table(&self) -> Result<()> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -81,56 +129,20 @@ impl DatabasePool {
|
|||||||
last_used TEXT
|
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#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS wol_history (
|
CREATE TABLE IF NOT EXISTS wol_history (
|
||||||
mac_address TEXT PRIMARY KEY,
|
mac_address TEXT PRIMARY KEY,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
r#"
|
||||||
CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at
|
CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at
|
||||||
ON wol_history(updated_at DESC)
|
ON wol_history(updated_at DESC)
|
||||||
"#,
|
"#,
|
||||||
)
|
],
|
||||||
.execute(&self.pool)
|
&[r#"
|
||||||
.await?;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_singleton
|
||||||
Ok(())
|
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))"],
|
||||||
pub fn pool(&self) -> &Pool<Sqlite> {
|
];
|
||||||
&self.pool
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clone_pool(&self) -> Pool<Sqlite> {
|
|
||||||
self.pool.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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::MsdDownloadIncomplete => "The remote image download was incomplete.",
|
||||||
Self::MsdDriveNotInitialized => "The virtual drive is not initialized.",
|
Self::MsdDriveNotInitialized => "The virtual drive is not initialized.",
|
||||||
Self::MsdDriveConnected => "The virtual drive is connected to the controlled computer.",
|
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::MsdDriveSizeInvalid => "The virtual drive size is invalid.",
|
||||||
Self::MsdStorageSpaceUnavailable => {
|
Self::MsdStorageSpaceUnavailable => {
|
||||||
"Available virtual media storage space could not be determined."
|
"Available virtual media storage space could not be determined."
|
||||||
@@ -183,7 +185,7 @@ impl MsdErrorCode {
|
|||||||
"Verify the remote server and network connection, then retry."
|
"Verify the remote server and network connection, then retry."
|
||||||
}
|
}
|
||||||
Self::MsdDriveFilesystemUnsupported => {
|
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 => {
|
Self::MsdStorageSpaceUnavailable => {
|
||||||
"Verify that virtual media storage is available, then retry."
|
"Verify that virtual media storage is available, then retry."
|
||||||
@@ -386,7 +388,7 @@ mod tests {
|
|||||||
(
|
(
|
||||||
MsdDriveFilesystemUnsupported,
|
MsdDriveFilesystemUnsupported,
|
||||||
"MSD_DRIVE_FILESYSTEM_UNSUPPORTED",
|
"MSD_DRIVE_FILESYSTEM_UNSUPPORTED",
|
||||||
"The virtual drive filesystem is unsupported.",
|
"Web file management does not support this virtual drive format.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
MsdDriveSizeInvalid,
|
MsdDriveSizeInvalid,
|
||||||
|
|||||||
@@ -17,13 +17,21 @@ fn default_ch9329_baud_rate() -> u32 {
|
|||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub enum HidBackendType {
|
pub enum HidBackendType {
|
||||||
Otg,
|
Otg {
|
||||||
|
#[serde(default)]
|
||||||
|
macos_drag: bool,
|
||||||
|
},
|
||||||
|
Bluetooth {
|
||||||
|
config: crate::config::BluetoothHidConfig,
|
||||||
|
},
|
||||||
Ch9329 {
|
Ch9329 {
|
||||||
port: String,
|
port: String,
|
||||||
#[serde(default = "default_ch9329_baud_rate")]
|
#[serde(default = "default_ch9329_baud_rate")]
|
||||||
baud_rate: u32,
|
baud_rate: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
hybrid_mouse: bool,
|
hybrid_mouse: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
macos_drag: bool,
|
||||||
},
|
},
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
@@ -32,7 +40,8 @@ pub enum HidBackendType {
|
|||||||
impl HidBackendType {
|
impl HidBackendType {
|
||||||
pub fn name_str(&self) -> &str {
|
pub fn name_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Otg => "otg",
|
Self::Otg { .. } => "otg",
|
||||||
|
Self::Bluetooth { .. } => "bluetooth",
|
||||||
Self::Ch9329 { .. } => "ch9329",
|
Self::Ch9329 { .. } => "ch9329",
|
||||||
Self::None => "none",
|
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 reset(&self) -> Result<()>;
|
||||||
|
|
||||||
async fn prepare_rebuild(&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 PARAM_CFG_STRING_FLAGS_OFFSET: usize = 36;
|
||||||
const DESCRIPTOR_READ_RETRIES: usize = 3;
|
const DESCRIPTOR_READ_RETRIES: usize = 3;
|
||||||
const DESCRIPTOR_RETRY_DELAY_MS: u64 = 80;
|
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_MAX_LEN: usize = 23;
|
||||||
const USB_STRING_FLAG_ENABLE: u8 = 0x80;
|
const USB_STRING_FLAG_ENABLE: u8 = 0x80;
|
||||||
const USB_STRING_FLAG_MANUFACTURER: u8 = 0x04;
|
const USB_STRING_FLAG_MANUFACTURER: u8 = 0x04;
|
||||||
@@ -235,6 +238,8 @@ pub struct Ch9329Backend {
|
|||||||
last_abs_y: Arc<AtomicU16>,
|
last_abs_y: Arc<AtomicU16>,
|
||||||
relative_mouse_active: Arc<AtomicBool>,
|
relative_mouse_active: Arc<AtomicBool>,
|
||||||
hybrid_mouse: bool,
|
hybrid_mouse: bool,
|
||||||
|
macos_drag: bool,
|
||||||
|
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
|
||||||
runtime: Arc<Ch9329RuntimeState>,
|
runtime: Arc<Ch9329RuntimeState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +253,15 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result<Self> {
|
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 {
|
Ok(Self {
|
||||||
port_path: port_path.to_string(),
|
port_path: port_path.to_string(),
|
||||||
baud_rate,
|
baud_rate,
|
||||||
@@ -263,6 +277,8 @@ impl Ch9329Backend {
|
|||||||
last_abs_y: Arc::new(AtomicU16::new(0)),
|
last_abs_y: Arc::new(AtomicU16::new(0)),
|
||||||
relative_mouse_active: Arc::new(AtomicBool::new(false)),
|
relative_mouse_active: Arc::new(AtomicBool::new(false)),
|
||||||
hybrid_mouse,
|
hybrid_mouse,
|
||||||
|
macos_drag,
|
||||||
|
macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()),
|
||||||
runtime: Arc::new(Ch9329RuntimeState::new()),
|
runtime: Arc::new(Ch9329RuntimeState::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -368,33 +384,41 @@ impl Ch9329Backend {
|
|||||||
|
|
||||||
Self::write_packet(port, address, cmd, data)?;
|
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 deadline = Instant::now() + Duration::from_millis(RESPONSE_TIMEOUT_MS);
|
||||||
let expected_ok = expected_response_cmd(cmd, false);
|
let expected_ok = expected_response_cmd(cmd, false);
|
||||||
let expected_err = expected_response_cmd(cmd, true);
|
let expected_err = expected_response_cmd(cmd, true);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut chunk = [0u8; 128];
|
let mut chunk = [0u8; 256];
|
||||||
match port.read(&mut chunk) {
|
match port.read(&mut chunk) {
|
||||||
Ok(n) if n > 0 => {
|
Ok(n) if n > 0 => {
|
||||||
pending.extend_from_slice(&chunk[..n]);
|
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) {
|
while let Some((response, consumed)) = try_extract_response(&pending) {
|
||||||
|
let current_response_cmd = response.cmd;
|
||||||
pending.drain(..consumed);
|
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);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
trace!(
|
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_ok,
|
||||||
expected_err,
|
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 {
|
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));
|
pending.drain(..pending.len().saturating_sub(keep));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,15 +434,19 @@ impl Ch9329Backend {
|
|||||||
|
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
return Err(Self::backend_error(
|
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",
|
"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) {
|
fn try_best_effort_reset(port: &mut dyn serialport::SerialPort, address: u8) {
|
||||||
if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) {
|
if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) {
|
||||||
trace!("CH9329 best-effort reset failed: {}", err);
|
trace!("CH9329 best-effort reset failed: {}", err);
|
||||||
@@ -698,7 +726,6 @@ impl Ch9329Backend {
|
|||||||
let mut port = Self::open_port(port_path, baud_rate)?;
|
let mut port = Self::open_port(port_path, baud_rate)?;
|
||||||
Self::read_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR)
|
Self::read_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_ready_port(
|
fn open_ready_port(
|
||||||
port_path: &str,
|
port_path: &str,
|
||||||
baud_rate: u32,
|
baud_rate: u32,
|
||||||
@@ -869,7 +896,7 @@ impl Ch9329Backend {
|
|||||||
match Self::open_ready_port(port_path, baud_rate, address) {
|
match Self::open_ready_port(port_path, baud_rate, address) {
|
||||||
Ok((port, info)) => {
|
Ok((port, info)) => {
|
||||||
info!(
|
info!(
|
||||||
"CH9329 reconnected: {}, USB: {}",
|
"CH9329-compatible chip reconnected: {}, USB: {}",
|
||||||
info.version,
|
info.version,
|
||||||
if info.usb_connected {
|
if info.usb_connected {
|
||||||
"connected"
|
"connected"
|
||||||
@@ -892,7 +919,6 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recover_worker_port(
|
fn recover_worker_port(
|
||||||
mut port: Box<dyn serialport::SerialPort>,
|
mut port: Box<dyn serialport::SerialPort>,
|
||||||
rx: &mpsc::Receiver<WorkerCommand>,
|
rx: &mpsc::Receiver<WorkerCommand>,
|
||||||
@@ -965,11 +991,12 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_send_button_wheel_relative(&self) -> bool {
|
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 {
|
fn absolute_move_buttons(&self, buttons: u8) -> u8 {
|
||||||
if self.hybrid_mouse {
|
if self.hybrid_mouse && !self.macos_drag {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
buttons
|
buttons
|
||||||
@@ -1170,7 +1197,7 @@ impl HidBackend for Ch9329Backend {
|
|||||||
match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) {
|
match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) {
|
||||||
Ok(Ok(info)) => {
|
Ok(Ok(info)) => {
|
||||||
info!(
|
info!(
|
||||||
"CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
|
"CH9329-compatible chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
|
||||||
info.version,
|
info.version,
|
||||||
if info.usb_connected {
|
if info.usb_connected {
|
||||||
"connected"
|
"connected"
|
||||||
@@ -1189,13 +1216,13 @@ impl HidBackend for Ch9329Backend {
|
|||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
self.record_error(
|
self.record_error(
|
||||||
format!(
|
format!(
|
||||||
"CH9329 not responding on {} @ {} baud: {}",
|
"CH9329-compatible chip not responding on {} @ {} baud: {}",
|
||||||
self.port_path, self.baud_rate, err
|
self.port_path, self.baud_rate, err
|
||||||
),
|
),
|
||||||
"init_failed",
|
"init_failed",
|
||||||
);
|
);
|
||||||
warn!(
|
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.port_path, self.baud_rate, err
|
||||||
);
|
);
|
||||||
*self.worker_tx.lock() = Some(tx);
|
*self.worker_tx.lock() = Some(tx);
|
||||||
@@ -1205,9 +1232,12 @@ impl HidBackend for Ch9329Backend {
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = tx.send(WorkerCommand::Shutdown);
|
let _ = tx.send(WorkerCommand::Shutdown);
|
||||||
let _ = handle.join();
|
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(
|
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<()> {
|
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||||
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
|
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 {
|
match event.event_type {
|
||||||
MouseEventType::Move => {
|
MouseEventType::Move => {
|
||||||
self.relative_mouse_active.store(true, Ordering::Relaxed);
|
self.relative_mouse_active.store(true, Ordering::Relaxed);
|
||||||
@@ -1345,6 +1400,7 @@ impl HidBackend for Ch9329Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.mouse_buttons.store(0, Ordering::Relaxed);
|
self.mouse_buttons.store(0, Ordering::Relaxed);
|
||||||
|
self.macos_drag_state.lock().reset();
|
||||||
self.last_abs_x.store(0, Ordering::Relaxed);
|
self.last_abs_x.store(0, Ordering::Relaxed);
|
||||||
self.last_abs_y.store(0, Ordering::Relaxed);
|
self.last_abs_y.store(0, Ordering::Relaxed);
|
||||||
self.relative_mouse_active.store(false, Ordering::Relaxed);
|
self.relative_mouse_active.store(false, Ordering::Relaxed);
|
||||||
@@ -1650,13 +1706,67 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let backend = Ch9329Backend::with_options("/dev/null", DEFAULT_BAUD_RATE, true).unwrap();
|
||||||
|
|
||||||
assert!(backend.should_send_button_wheel_relative());
|
assert!(backend.should_send_button_wheel_relative());
|
||||||
assert_eq!(backend.absolute_move_buttons(0x07), 0);
|
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]
|
#[test]
|
||||||
fn test_default_mouse_mode_preserves_absolute_report_buttons() {
|
fn test_default_mouse_mode_preserves_absolute_report_buttons() {
|
||||||
let backend = Ch9329Backend::with_baud_rate("/dev/null", DEFAULT_BAUD_RATE).unwrap();
|
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 DEFAULT_BAUD_RATE: u32 = 9600;
|
||||||
pub const MAX_DATA_LEN: usize = 64;
|
pub const MAX_DATA_LEN: usize = 64;
|
||||||
pub const MAX_PACKET_SIZE: usize = 70;
|
pub const MAX_PACKET_SIZE: usize = 70;
|
||||||
|
const EXTENDED_PARAMETER_RESPONSE_SIZES: [usize; 2] = [72, 88];
|
||||||
|
|
||||||
pub mod cmd {
|
pub mod cmd {
|
||||||
pub const GET_INFO: u8 = 0x01;
|
pub const GET_INFO: u8 = 0x01;
|
||||||
@@ -130,7 +131,8 @@ impl Response {
|
|||||||
|
|
||||||
let cmd = bytes[3];
|
let cmd = bytes[3];
|
||||||
let len = bytes[4] as usize;
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ impl Response {
|
|||||||
.iter()
|
.iter()
|
||||||
.fold(0u8, |acc, &x| acc.wrapping_add(x));
|
.fold(0u8, |acc, &x| acc.wrapping_add(x));
|
||||||
if expected_checksum != calculated_checksum {
|
if expected_checksum != calculated_checksum {
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
"CH9329 checksum mismatch: expected {:02X}, got {:02X}",
|
"CH9329 checksum mismatch: expected {:02X}, got {:02X}",
|
||||||
expected_checksum,
|
expected_checksum,
|
||||||
calculated_checksum
|
calculated_checksum
|
||||||
@@ -215,6 +217,11 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let len = buffer[offset + 4] as usize;
|
let len = buffer[offset + 4] as usize;
|
||||||
|
if len > MAX_DATA_LEN {
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let frame_len = 6 + len;
|
let frame_len = 6 + len;
|
||||||
if offset + frame_len > buffer.len() {
|
if offset + frame_len > buffer.len() {
|
||||||
return None;
|
return None;
|
||||||
@@ -225,8 +232,103 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> {
|
|||||||
return Some((response, offset + frame_len));
|
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;
|
offset += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
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;
|
use crate::otg::OtgService;
|
||||||
|
|
||||||
pub struct HidBackendFactory {
|
pub struct HidBackendFactory {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub bonds: std::sync::OnceLock<Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
otg_service: Option<Arc<OtgService>>,
|
otg_service: Option<Arc<OtgService>>,
|
||||||
}
|
}
|
||||||
@@ -15,7 +17,11 @@ pub struct HidBackendFactory {
|
|||||||
impl HidBackendFactory {
|
impl HidBackendFactory {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn new(otg_service: Option<Arc<OtgService>>) -> Self {
|
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))]
|
#[cfg(not(unix))]
|
||||||
@@ -38,22 +44,44 @@ impl HidBackendFactory {
|
|||||||
|
|
||||||
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
|
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
|
||||||
match backend_type {
|
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 {
|
HidBackendType::Ch9329 {
|
||||||
port,
|
port,
|
||||||
baud_rate,
|
baud_rate,
|
||||||
hybrid_mouse,
|
hybrid_mouse,
|
||||||
|
macos_drag,
|
||||||
} => {
|
} => {
|
||||||
info!(
|
info!(
|
||||||
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}",
|
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}, macos_drag={}",
|
||||||
port, baud_rate, hybrid_mouse
|
port, baud_rate, hybrid_mouse, macos_drag
|
||||||
);
|
);
|
||||||
Ok(Some(Arc::new(ch9329::Ch9329Backend::with_options(
|
Ok(Some(Arc::new(
|
||||||
|
ch9329::Ch9329Backend::with_compatibility_options(
|
||||||
port,
|
port,
|
||||||
*baud_rate,
|
*baud_rate,
|
||||||
*hybrid_mouse,
|
*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 => {
|
HidBackendType::None => {
|
||||||
warn!("HID backend disabled");
|
warn!("HID backend disabled");
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -62,7 +90,7 @@ impl HidBackendFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[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
|
let otg_service = self
|
||||||
.otg_service
|
.otg_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -74,11 +102,13 @@ impl HidBackendFactory {
|
|||||||
.ok_or_else(|| AppError::Config("OTG HID paths are not available".to_string()))?;
|
.ok_or_else(|| AppError::Config("OTG HID paths are not available".to_string()))?;
|
||||||
|
|
||||||
info!("Creating OTG HID backend from device paths");
|
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))]
|
#[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(
|
Err(AppError::Config(
|
||||||
"OTG HID is only available on Linux".to_string(),
|
"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.
|
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
|
||||||
|
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod bluetooth;
|
||||||
pub mod ch9329;
|
pub mod ch9329;
|
||||||
mod ch9329_proto;
|
mod ch9329_proto;
|
||||||
pub mod consumer;
|
pub mod consumer;
|
||||||
pub mod datachannel;
|
pub mod datachannel;
|
||||||
mod factory;
|
mod factory;
|
||||||
pub mod keyboard;
|
pub mod keyboard;
|
||||||
|
mod macos_drag;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod otg;
|
pub mod otg;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -132,6 +135,8 @@ pub struct HidController {
|
|||||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
backend_available: Arc<AtomicBool>,
|
backend_available: Arc<AtomicBool>,
|
||||||
|
reset_requested: Arc<AtomicBool>,
|
||||||
|
screen_resolution: parking_lot::RwLock<(u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidController {
|
impl HidController {
|
||||||
@@ -153,6 +158,8 @@ impl HidController {
|
|||||||
hid_worker: Mutex::new(None),
|
hid_worker: Mutex::new(None),
|
||||||
runtime_worker: Mutex::new(None),
|
runtime_worker: Mutex::new(None),
|
||||||
backend_available: Arc::new(AtomicBool::new(false)),
|
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),
|
hid_worker: Mutex::new(None),
|
||||||
runtime_worker: Mutex::new(None),
|
runtime_worker: Mutex::new(None),
|
||||||
backend_available: Arc::new(AtomicBool::new(false)),
|
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>) {
|
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||||
*self.events.write().await = Some(events);
|
*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.sync_runtime_state_from_backend().await;
|
||||||
|
|
||||||
self.start_event_worker().await;
|
self.start_event_worker().await;
|
||||||
@@ -235,7 +254,8 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prepare_otg_rebuild(&self) -> Result<()> {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +269,7 @@ impl HidController {
|
|||||||
|
|
||||||
let current = self.runtime_state.read().await.clone();
|
let current = self.runtime_state.read().await.clone();
|
||||||
let rebuilding_state = HidRuntimeState::with_error(
|
let rebuilding_state = HidRuntimeState::with_error(
|
||||||
&HidBackendType::Otg,
|
&backend_type,
|
||||||
¤t,
|
¤t,
|
||||||
"OTG gadget is rebuilding",
|
"OTG gadget is rebuilding",
|
||||||
"rebuilding",
|
"rebuilding",
|
||||||
@@ -293,6 +313,25 @@ impl HidController {
|
|||||||
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
|
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<()> {
|
pub async fn reset(&self) -> Result<()> {
|
||||||
if !self.backend_available.load(Ordering::Acquire) {
|
if !self.backend_available.load(Ordering::Acquire) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -308,6 +347,24 @@ impl HidController {
|
|||||||
self.backend_type.read().await.clone()
|
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 {
|
pub async fn snapshot(&self) -> HidRuntimeState {
|
||||||
self.runtime_state.read().await.clone()
|
self.runtime_state.read().await.clone()
|
||||||
}
|
}
|
||||||
@@ -349,6 +406,14 @@ impl HidController {
|
|||||||
|
|
||||||
if let Some(backend) = self.backend.write().await.take() {
|
if let Some(backend) = self.backend.write().await.take() {
|
||||||
if let Err(e) = backend.shutdown().await {
|
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);
|
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) {
|
if matches!(new_backend_type, HidBackendType::None) {
|
||||||
*self.backend_type.write().await = HidBackendType::None;
|
*self.backend_type.write().await = HidBackendType::None;
|
||||||
@@ -437,6 +509,7 @@ impl HidController {
|
|||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
let pending_move = self.pending_move.clone();
|
let pending_move = self.pending_move.clone();
|
||||||
let pending_move_flag = self.pending_move_flag.clone();
|
let pending_move_flag = self.pending_move_flag.clone();
|
||||||
|
let reset_requested = self.reset_requested.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let mut rx = rx;
|
let mut rx = rx;
|
||||||
@@ -446,6 +519,15 @@ impl HidController {
|
|||||||
None => break,
|
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;
|
process_hid_event(event, &backend).await;
|
||||||
|
|
||||||
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
||||||
@@ -504,7 +586,7 @@ impl HidController {
|
|||||||
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
|
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
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);
|
self.pending_move_flag.store(true, Ordering::Release);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -524,11 +606,16 @@ impl HidController {
|
|||||||
tx.send(ev),
|
tx.send(ev),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if send_result.is_ok() {
|
match send_result {
|
||||||
Ok(())
|
Ok(Ok(())) => Ok(()),
|
||||||
} else {
|
Ok(Err(_)) => Err(AppError::BadRequest("HID event queue closed".into())),
|
||||||
warn!("HID event queue full, dropping event");
|
Err(_) => {
|
||||||
Ok(())
|
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(_)) => {
|
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||||
@@ -620,3 +707,121 @@ async fn apply_runtime_state(
|
|||||||
events.mark_device_info_dirty();
|
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_leds_enabled: bool,
|
||||||
keyboard_state: Mutex<KeyboardReport>,
|
keyboard_state: Mutex<KeyboardReport>,
|
||||||
mouse_buttons: AtomicU8,
|
mouse_buttons: AtomicU8,
|
||||||
|
macos_drag: bool,
|
||||||
|
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
|
||||||
led_state: Arc<parking_lot::RwLock<LedState>>,
|
led_state: Arc<parking_lot::RwLock<LedState>>,
|
||||||
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
||||||
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
|
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
|
||||||
@@ -99,6 +101,15 @@ const OTG_RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
|||||||
impl OtgBackend {
|
impl OtgBackend {
|
||||||
/// Gadget must already exist; paths come from `OtgService`.
|
/// Gadget must already exist; paths come from `OtgService`.
|
||||||
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
|
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(());
|
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
keyboard_path: paths.keyboard,
|
keyboard_path: paths.keyboard,
|
||||||
@@ -112,6 +123,8 @@ impl OtgBackend {
|
|||||||
keyboard_leds_enabled: paths.keyboard_leds_enabled,
|
keyboard_leds_enabled: paths.keyboard_leds_enabled,
|
||||||
keyboard_state: Mutex::new(KeyboardReport::default()),
|
keyboard_state: Mutex::new(KeyboardReport::default()),
|
||||||
mouse_buttons: AtomicU8::new(0),
|
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())),
|
led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
|
||||||
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
||||||
udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
|
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<()> {
|
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||||
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
|
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 {
|
match event.event_type {
|
||||||
MouseEventType::Move => {
|
MouseEventType::Move => {
|
||||||
let dx = event.x.clamp(-127, 127) as i8;
|
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.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_relative(0, 0, 0, 0)?;
|
||||||
self.send_mouse_report_absolute(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);
|
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]
|
#[tokio::test]
|
||||||
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
|
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
|
||||||
let mut file = tempfile::tempfile().unwrap();
|
let mut file = tempfile::tempfile().unwrap();
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ pub mod redfish;
|
|||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod rtsp;
|
pub mod rtsp;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
|
pub mod runtime;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
pub mod rustdesk;
|
pub mod rustdesk;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod state;
|
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::future::Future;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
use futures::{stream::FuturesUnordered, StreamExt};
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
use rustls::crypto::{ring, CryptoProvider};
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
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::auth::{SessionStore, TwoFactorService, UserStore};
|
||||||
use one_kvm::computer_use::ComputerUseManager;
|
use one_kvm::config;
|
||||||
use one_kvm::config::{self, AppConfig, ConfigStore};
|
use one_kvm::db::open_database_pool;
|
||||||
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::platform::PlatformCapabilities;
|
use one_kvm::platform::PlatformCapabilities;
|
||||||
use one_kvm::rtsp::RtspService;
|
use one_kvm::runtime::{RuntimeBuilder, WebConfigOverrides};
|
||||||
use one_kvm::rustdesk::RustDeskService;
|
use one_kvm::state::ShutdownAction;
|
||||||
use one_kvm::state::{AppState, ShutdownAction};
|
|
||||||
use one_kvm::update::UpdateService;
|
|
||||||
use one_kvm::utils::bind_tcp_listener;
|
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)]
|
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
|
||||||
enum LogLevel {
|
enum LogLevel {
|
||||||
@@ -47,7 +24,6 @@ enum LogLevel {
|
|||||||
Warn,
|
Warn,
|
||||||
#[default]
|
#[default]
|
||||||
Info,
|
Info,
|
||||||
Verbose,
|
|
||||||
Debug,
|
Debug,
|
||||||
Trace,
|
Trace,
|
||||||
}
|
}
|
||||||
@@ -93,13 +69,9 @@ struct CliArgs {
|
|||||||
#[arg(short = 'd', long, value_name = "DIR")]
|
#[arg(short = 'd', long, value_name = "DIR")]
|
||||||
data_dir: Option<PathBuf>,
|
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")]
|
#[arg(short = 'l', long, value_name = "LEVEL", default_value = "info")]
|
||||||
log_level: LogLevel,
|
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)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -126,7 +98,7 @@ enum UserAction {
|
|||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let args = CliArgs::parse();
|
let args = CliArgs::parse();
|
||||||
|
|
||||||
init_logging(args.log_level, args.verbose);
|
init_logging(args.log_level);
|
||||||
|
|
||||||
CryptoProvider::install_default(ring::default_provider())
|
CryptoProvider::install_default(ring::default_provider())
|
||||||
.expect("Failed to install rustls crypto provider");
|
.expect("Failed to install rustls crypto provider");
|
||||||
@@ -147,28 +119,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
|
let overrides = WebConfigOverrides {
|
||||||
|
address: args.address,
|
||||||
if let Some(addr) = args.address {
|
http_port: args.http_port,
|
||||||
config.web.bind_address = addr.clone();
|
https_port: args.https_port,
|
||||||
config.web.bind_addresses = vec![addr];
|
enable_https: args.enable_https,
|
||||||
}
|
ssl_cert: args.ssl_cert,
|
||||||
if let Some(port) = args.http_port {
|
ssl_key: args.ssl_key,
|
||||||
config.web.http_port = port;
|
};
|
||||||
}
|
let mut runtime = RuntimeBuilder::new(data_dir.clone())
|
||||||
if let Some(port) = args.https_port {
|
.with_web_overrides(overrides)
|
||||||
config.web.https_port = port;
|
.build()
|
||||||
}
|
.await?;
|
||||||
if args.enable_https {
|
let config = runtime.config();
|
||||||
config.web.https_enabled = true;
|
let state = runtime.state().clone();
|
||||||
}
|
|
||||||
|
|
||||||
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 bind_ips = resolve_bind_addresses(&config.web)?;
|
let bind_ips = resolve_bind_addresses(&config.web)?;
|
||||||
let scheme = if config.web.https_enabled {
|
let scheme = if config.web.https_enabled {
|
||||||
@@ -187,500 +151,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Server will listen on: {}://{}", scheme, addr);
|
tracing::info!("Server will listen on: {}://{}", scheme, addr);
|
||||||
}
|
}
|
||||||
|
|
||||||
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
|
let app = runtime.router();
|
||||||
|
|
||||||
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 listeners = bind_tcp_listeners(&bind_ips, bind_port)?;
|
let listeners = bind_tcp_listeners(&bind_ips, bind_port)?;
|
||||||
|
|
||||||
let shutdown_signal = {
|
let shutdown_signal = {
|
||||||
|
let shutdown_tx = state.shutdown_tx.clone();
|
||||||
let mut shutdown_rx = shutdown_tx.subscribe();
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
||||||
async move {
|
async move {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -740,7 +216,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
servers.push(server);
|
servers.push(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
run_servers_until_shutdown(servers, shutdown_signal, &state, "HTTPS").await
|
run_servers_until_shutdown(servers, shutdown_signal, "HTTPS").await
|
||||||
} else {
|
} else {
|
||||||
let servers = FuturesUnordered::new();
|
let servers = FuturesUnordered::new();
|
||||||
for listener in listeners {
|
for listener in listeners {
|
||||||
@@ -752,9 +228,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
servers.push(async move { server.await });
|
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");
|
tracing::info!("Server shutdown complete");
|
||||||
if let ShutdownAction::Restart { exe_path } = shutdown_action {
|
if let ShutdownAction::Restart { exe_path } = shutdown_action {
|
||||||
restart_current_process(exe_path)?;
|
restart_current_process(exe_path)?;
|
||||||
@@ -762,25 +239,16 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_logging(level: LogLevel, verbose_count: u8) {
|
fn init_logging(level: LogLevel) {
|
||||||
let effective_level = match verbose_count {
|
let app_level = match level {
|
||||||
0 => level,
|
LogLevel::Error => "error",
|
||||||
1 => LogLevel::Verbose,
|
LogLevel::Warn => "warn",
|
||||||
2 => LogLevel::Debug,
|
LogLevel::Info => "info",
|
||||||
_ => LogLevel::Trace,
|
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 =
|
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()
|
if let Err(err) = tracing_subscriber::registry()
|
||||||
.with(env_filter)
|
.with(env_filter)
|
||||||
@@ -830,24 +298,16 @@ async fn shutdown_signal() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
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>(
|
async fn run_servers_until_shutdown<F, E>(
|
||||||
mut servers: FuturesUnordered<F>,
|
mut servers: FuturesUnordered<F>,
|
||||||
shutdown_signal: impl Future<Output = ShutdownAction>,
|
shutdown_signal: impl Future<Output = ShutdownAction>,
|
||||||
state: &Arc<AppState>,
|
|
||||||
protocol: &'static str,
|
protocol: &'static str,
|
||||||
) -> ShutdownAction
|
) -> ShutdownAction
|
||||||
where
|
where
|
||||||
F: Future<Output = Result<(), E>> + Send,
|
F: Future<Output = Result<(), E>> + Send,
|
||||||
E: std::fmt::Display,
|
E: std::fmt::Display,
|
||||||
{
|
{
|
||||||
let action = tokio::select! {
|
tokio::select! {
|
||||||
action = shutdown_signal => {
|
action = shutdown_signal => {
|
||||||
action
|
action
|
||||||
}
|
}
|
||||||
@@ -857,9 +317,7 @@ where
|
|||||||
}
|
}
|
||||||
ShutdownAction::Exit
|
ShutdownAction::Exit
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
cleanup(state).await;
|
|
||||||
action
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restart_current_process(exe_path: Option<PathBuf>) -> anyhow::Result<()> {
|
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<()> {
|
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 db = open_database_pool(&data_dir).await?;
|
||||||
let users = UserStore::new(db.clone_pool());
|
let users = UserStore::new(db.clone_pool());
|
||||||
let two_factor = TwoFactorService::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(
|
async fn run_user_action(
|
||||||
action: UserAction,
|
action: UserAction,
|
||||||
users: &UserStore,
|
users: &UserStore,
|
||||||
@@ -1065,17 +464,6 @@ fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result<Vec<std::ne
|
|||||||
Ok(listeners)
|
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>> {
|
fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyPair>> {
|
||||||
use rcgen::generate_simple_self_signed;
|
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)?;
|
let certified_key = generate_simple_self_signed(subject_alt_names)?;
|
||||||
Ok(certified_key)
|
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::image::ImageManager;
|
||||||
use super::monitor::MsdHealthMonitor;
|
use super::monitor::MsdHealthMonitor;
|
||||||
use super::types::{
|
use super::types::{
|
||||||
DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia,
|
DiskMode, DownloadProgress, DownloadStatus, DriveFileAccess, DriveInfo, ImageInfo,
|
||||||
MountedMediaKind, MsdState,
|
MountedMedia, MountedMediaKind, MsdState,
|
||||||
};
|
};
|
||||||
|
use super::ventoy_drive::VentoyDrive;
|
||||||
use crate::error::{AppError, MsdErrorCode, Result};
|
use crate::error::{AppError, MsdErrorCode, Result};
|
||||||
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
||||||
|
|
||||||
@@ -62,12 +63,8 @@ impl MsdController {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = std::fs::create_dir_all(&self.images_path) {
|
tokio::fs::create_dir_all(&self.images_path).await?;
|
||||||
warn!("Failed to create images directory: {}", e);
|
tokio::fs::create_dir_all(&self.ventoy_dir).await?;
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::create_dir_all(&self.ventoy_dir) {
|
|
||||||
warn!("Failed to create ventoy directory: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Fetching MSD function from OtgService");
|
info!("Fetching MSD function from OtgService");
|
||||||
let msd_func = self
|
let msd_func = self
|
||||||
@@ -87,14 +84,9 @@ impl MsdController {
|
|||||||
state.available = true;
|
state.available = true;
|
||||||
|
|
||||||
if self.drive_path.exists() {
|
if self.drive_path.exists() {
|
||||||
if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
|
if let Ok(drive_info) =
|
||||||
let drive_info = DriveInfo {
|
VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown)
|
||||||
size: metadata.len(),
|
{
|
||||||
used: 0,
|
|
||||||
free: metadata.len(),
|
|
||||||
initialized: true,
|
|
||||||
path: self.drive_path.clone(),
|
|
||||||
};
|
|
||||||
state.drive_info = Some(drive_info.clone());
|
state.drive_info = Some(drive_info.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found existing virtual drive: {}",
|
"Found existing virtual drive: {}",
|
||||||
@@ -203,28 +195,6 @@ impl MsdController {
|
|||||||
|
|
||||||
self.assert_available(&state).await?;
|
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
|
if state
|
||||||
.mounted_media
|
.mounted_media
|
||||||
.iter()
|
.iter()
|
||||||
@@ -233,8 +203,22 @@ impl MsdController {
|
|||||||
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
|
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let drive_info =
|
let drive_info = match self.drive_mount_info() {
|
||||||
drive_info.ok_or_else(|| AppError::from(MsdErrorCode::MsdDriveNotInitialized))?;
|
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)
|
let lun = Self::lowest_free_lun(&state)
|
||||||
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?;
|
||||||
|
|
||||||
@@ -244,6 +228,8 @@ impl MsdController {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
state.mounted_media.push(media);
|
state.mounted_media.push(media);
|
||||||
|
state.drive_info =
|
||||||
|
Some(drive_info.with_file_access(DriveFileAccess::BlockedWhileConnected));
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Mounted virtual drive on LUN {}: {}",
|
"Mounted virtual drive on LUN {}: {}",
|
||||||
@@ -258,6 +244,15 @@ impl MsdController {
|
|||||||
Ok(())
|
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<()> {
|
async fn assert_available(&self, state: &MsdState) -> Result<()> {
|
||||||
if !state.available {
|
if !state.available {
|
||||||
self.monitor
|
self.monitor
|
||||||
@@ -297,6 +292,16 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) {
|
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.disk_mode = disk_mode;
|
||||||
state.mounted_media.clear();
|
state.mounted_media.clear();
|
||||||
}
|
}
|
||||||
@@ -401,6 +406,12 @@ impl MsdController {
|
|||||||
|
|
||||||
self.disconnect_lun(media.lun).await?;
|
self.disconnect_lun(media.lun).await?;
|
||||||
state.mounted_media.remove(index);
|
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");
|
info!("Unmounted media");
|
||||||
|
|
||||||
drop(state);
|
drop(state);
|
||||||
@@ -494,6 +505,16 @@ impl MsdController {
|
|||||||
disconnected.push(media.clone());
|
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();
|
state.mounted_media.clear();
|
||||||
info!("Disconnected all mounted media");
|
info!("Disconnected all mounted media");
|
||||||
|
|
||||||
@@ -748,6 +769,29 @@ mod tests {
|
|||||||
assert!(state.mounted_media.is_empty());
|
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]
|
#[test]
|
||||||
fn single_disk_mode_only_exposes_lun_zero() {
|
fn single_disk_mode_only_exposes_lun_zero() {
|
||||||
let mut state = MsdState::default();
|
let mut state = MsdState::default();
|
||||||
@@ -846,13 +890,7 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let drive_path = temp_dir.path().join("ventoy.img");
|
let drive_path = temp_dir.path().join("ventoy.img");
|
||||||
std::fs::write(&drive_path, b"drive").unwrap();
|
std::fs::write(&drive_path, b"drive").unwrap();
|
||||||
let drive = DriveInfo {
|
let drive = DriveInfo::from_raw(drive_path, 5, DriveFileAccess::Unknown);
|
||||||
size: 5,
|
|
||||||
used: 0,
|
|
||||||
free: 5,
|
|
||||||
initialized: true,
|
|
||||||
path: drive_path,
|
|
||||||
};
|
|
||||||
let mut state = MsdState::default();
|
let mut state = MsdState::default();
|
||||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
|
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
|
||||||
state.mounted_media.push(MountedMedia::drive(0, &drive));
|
state.mounted_media.push(MountedMedia::drive(0, &drive));
|
||||||
@@ -904,13 +942,11 @@ mod tests {
|
|||||||
let image_path = temp_dir.path().join("test.img");
|
let image_path = temp_dir.path().join("test.img");
|
||||||
std::fs::write(&image_path, b"img").unwrap();
|
std::fs::write(&image_path, b"img").unwrap();
|
||||||
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
|
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
|
||||||
let drive = DriveInfo {
|
let drive = DriveInfo::from_raw(
|
||||||
size: 5,
|
temp_dir.path().join("ventoy.img"),
|
||||||
used: 0,
|
5,
|
||||||
free: 5,
|
DriveFileAccess::Unknown,
|
||||||
initialized: true,
|
);
|
||||||
path: temp_dir.path().join("ventoy.img"),
|
|
||||||
};
|
|
||||||
let mut state = MsdState::default();
|
let mut state = MsdState::default();
|
||||||
state
|
state
|
||||||
.mounted_media
|
.mounted_media
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ pub use controller::MsdController;
|
|||||||
pub use image::ImageManager;
|
pub use image::ImageManager;
|
||||||
pub use monitor::MsdHealthMonitor;
|
pub use monitor::MsdHealthMonitor;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo,
|
DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveFileAccess,
|
||||||
DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia,
|
DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia,
|
||||||
MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS,
|
MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS,
|
||||||
};
|
};
|
||||||
pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DriveInfo {
|
pub struct DriveInfo {
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub used: u64,
|
pub used: Option<u64>,
|
||||||
pub free: u64,
|
pub free: Option<u64>,
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
|
pub file_access: DriveFileAccess,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DriveInfo {
|
impl DriveInfo {
|
||||||
pub fn new(path: PathBuf, size: u64) -> Self {
|
pub fn from_raw(path: PathBuf, size: u64, file_access: DriveFileAccess) -> Self {
|
||||||
Self {
|
Self {
|
||||||
size,
|
size,
|
||||||
used: 0,
|
used: None,
|
||||||
free: size,
|
free: None,
|
||||||
initialized: false,
|
initialized: true,
|
||||||
|
file_access,
|
||||||
path,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -267,4 +285,36 @@ mod tests {
|
|||||||
assert!(json.get("current_image").is_none());
|
assert!(json.get("current_image").is_none());
|
||||||
assert!(json.get("slots").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 ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage};
|
||||||
|
|
||||||
use super::types::{DriveFile, DriveInfo};
|
use super::types::{DriveFile, DriveFileAccess, DriveInfo};
|
||||||
use crate::error::{AppError, MsdErrorCode, Result};
|
use crate::error::{AppError, MsdErrorCode, Result};
|
||||||
|
|
||||||
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
|
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
|
||||||
@@ -35,11 +35,10 @@ impl VentoyDrive {
|
|||||||
&self.path
|
&self.path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns just the raw file size without attempting to parse the filesystem.
|
/// Read and validate only the backing file metadata, without parsing its
|
||||||
/// Used as a fallback when the image has been reformatted to an unsupported
|
/// partition table or filesystem.
|
||||||
/// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open.
|
pub fn raw_info(&self, file_access: DriveFileAccess) -> Result<DriveInfo> {
|
||||||
pub fn raw_size(&self) -> Option<u64> {
|
raw_drive_info(&self.path, file_access)
|
||||||
std::fs::metadata(&self.path).ok().map(|m| m.len())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
|
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
|
||||||
@@ -60,9 +59,10 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
Ok::<DriveInfo, AppError>(DriveInfo {
|
Ok::<DriveInfo, AppError>(DriveInfo {
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
used: 0,
|
used: Some(0),
|
||||||
free: metadata.len(),
|
free: Some(metadata.len()),
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
file_access: DriveFileAccess::Available,
|
||||||
path,
|
path,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -74,20 +74,23 @@ impl VentoyDrive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn info(&self) -> Result<DriveInfo> {
|
pub async fn info(&self) -> Result<DriveInfo> {
|
||||||
if !self.exists() {
|
|
||||||
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
let _lock = self.lock.read().await;
|
let _lock = self.lock.read().await;
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let metadata = std::fs::metadata(&path)
|
let raw = raw_drive_info(&path, DriveFileAccess::Unsupported)?;
|
||||||
.map_err(|error| drive_io_error("read drive metadata", error))?;
|
|
||||||
|
|
||||||
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
|
let used: u64 = files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -95,14 +98,15 @@ impl VentoyDrive {
|
|||||||
.map(|f| f.size)
|
.map(|f| f.size)
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
let size = metadata.len();
|
let size = raw.size;
|
||||||
let free = size.saturating_sub(used);
|
let free = size.saturating_sub(used);
|
||||||
|
|
||||||
Ok(DriveInfo {
|
Ok(DriveInfo {
|
||||||
size,
|
size,
|
||||||
used,
|
used: Some(used),
|
||||||
free,
|
free: Some(free),
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
file_access: DriveFileAccess::Available,
|
||||||
path,
|
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 {
|
fn ventoy_to_app_error(err: VentoyError) -> AppError {
|
||||||
warn!(%err, "Virtual drive filesystem operation failed");
|
warn!(%err, "Virtual drive filesystem operation failed");
|
||||||
match err {
|
match err {
|
||||||
@@ -575,9 +608,71 @@ mod tests {
|
|||||||
|
|
||||||
let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
||||||
assert!(info.initialized);
|
assert!(info.initialized);
|
||||||
|
assert_eq!(info.file_access, DriveFileAccess::Available);
|
||||||
|
assert_eq!(info.used, Some(0));
|
||||||
|
assert!(info.free.is_some());
|
||||||
assert!(drive.exists());
|
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]
|
#[tokio::test]
|
||||||
async fn test_drive_mkdir() {
|
async fn test_drive_mkdir() {
|
||||||
if !ensure_resources() {
|
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).
|
//! Variable-length TCP framing (RustDesk wire format).
|
||||||
|
|
||||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||||
use std::io;
|
use std::io::{self, IoSlice};
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||||
|
|
||||||
const MAX_PACKET_LENGTH: usize = 0x3FFFFFFF;
|
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> {
|
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];
|
let mut first_byte = [0u8; 1];
|
||||||
reader.read_exact(&mut first_byte).await?;
|
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);
|
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(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidData,
|
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(())
|
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>(
|
pub async fn write_frame_buffered<W: AsyncWrite + Unpin>(
|
||||||
writer: &mut W,
|
writer: &mut W,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
@@ -281,4 +340,32 @@ mod tests {
|
|||||||
let decoded = codec.decode(&mut buf).unwrap().unwrap();
|
let decoded = codec.decode(&mut buf).unwrap().unwrap();
|
||||||
assert_eq!(decoded.len(), 100000);
|
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,
|
H265,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum RustDeskMode {
|
||||||
|
#[default]
|
||||||
|
Id,
|
||||||
|
DirectIp,
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct RustDeskConfig {
|
pub struct RustDeskConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub mode: RustDeskMode,
|
||||||
pub codec: RustDeskCodec,
|
pub codec: RustDeskCodec,
|
||||||
|
pub direct_access_port: u16,
|
||||||
pub rendezvous_server: String,
|
pub rendezvous_server: String,
|
||||||
pub relay_server: Option<String>,
|
pub relay_server: Option<String>,
|
||||||
#[typeshare(skip)]
|
#[typeshare(skip)]
|
||||||
@@ -40,7 +52,9 @@ impl Default for RustDeskConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
mode: RustDeskMode::Id,
|
||||||
codec: RustDeskCodec::H264,
|
codec: RustDeskCodec::H264,
|
||||||
|
direct_access_port: 21118,
|
||||||
rendezvous_server: String::new(),
|
rendezvous_server: String::new(),
|
||||||
relay_server: None,
|
relay_server: None,
|
||||||
relay_key: None,
|
relay_key: None,
|
||||||
@@ -58,9 +72,12 @@ impl Default for RustDeskConfig {
|
|||||||
impl RustDeskConfig {
|
impl RustDeskConfig {
|
||||||
pub fn is_valid(&self) -> bool {
|
pub fn is_valid(&self) -> bool {
|
||||||
self.enabled
|
self.enabled
|
||||||
&& !self.rendezvous_server.is_empty()
|
|
||||||
&& !self.device_id.is_empty()
|
&& !self.device_id.is_empty()
|
||||||
&& !self.device_password.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 {
|
pub fn effective_rendezvous_server(&self) -> &str {
|
||||||
@@ -214,4 +231,43 @@ mod tests {
|
|||||||
config.rendezvous_server = String::new();
|
config.rendezvous_server = String::new();
|
||||||
assert_eq!(config.effective_rendezvous_server(), "");
|
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,
|
codec: VideoCodec,
|
||||||
seq: u32,
|
seq: u32,
|
||||||
timestamp_base: u64,
|
timestamp_base: u64,
|
||||||
h264_sps: Option<Bytes>,
|
|
||||||
h264_pps: Option<Bytes>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VideoFrameAdapter {
|
impl VideoFrameAdapter {
|
||||||
@@ -41,8 +39,6 @@ impl VideoFrameAdapter {
|
|||||||
codec,
|
codec,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
timestamp_base: 0,
|
timestamp_base: 0,
|
||||||
h264_sps: None,
|
|
||||||
h264_pps: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +52,6 @@ impl VideoFrameAdapter {
|
|||||||
is_keyframe: bool,
|
is_keyframe: bool,
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
) -> Message {
|
) -> Message {
|
||||||
let data = self.prepare_h264_frame(data, is_keyframe);
|
|
||||||
if self.seq == 0 {
|
if self.seq == 0 {
|
||||||
self.timestamp_base = timestamp_ms;
|
self.timestamp_base = timestamp_ms;
|
||||||
}
|
}
|
||||||
@@ -86,39 +81,6 @@ impl VideoFrameAdapter {
|
|||||||
msg
|
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 {
|
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)
|
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::hbb::message::key_event as ke_union;
|
||||||
use super::protocol::{ControlKey, KeyEvent, KeyboardMode, MouseEvent};
|
use super::protocol::{ControlKey, KeyEvent, KeyboardMode, MouseEvent};
|
||||||
use crate::hid::{
|
use crate::hid::{
|
||||||
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
|
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
|
||||||
MouseEvent as OneKvmMouseEvent, MouseEventType,
|
MouseEvent as OneKvmMouseEvent, MouseEventType,
|
||||||
};
|
};
|
||||||
use protobuf::Enum;
|
use tracing::debug;
|
||||||
|
|
||||||
pub mod mouse_type {
|
pub mod mouse_type {
|
||||||
pub const MOVE: i32 = 0;
|
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> {
|
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) {
|
let base_modifiers = if is_modifier_control_key(event) {
|
||||||
KeyboardModifiers::default()
|
KeyboardModifiers::default()
|
||||||
} else {
|
} else {
|
||||||
parse_modifiers(event)
|
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();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,13 +145,12 @@ pub fn convert_key_events(event: &KeyEvent) -> Vec<KeyboardEvent> {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
let event_type = if event.down {
|
vec![KeyboardEvent {
|
||||||
|
event_type: if event.down {
|
||||||
KeyEventType::Down
|
KeyEventType::Down
|
||||||
} else {
|
} else {
|
||||||
KeyEventType::Up
|
KeyEventType::Up
|
||||||
};
|
},
|
||||||
vec![KeyboardEvent {
|
|
||||||
event_type,
|
|
||||||
key: mapping.key,
|
key: mapping.key,
|
||||||
modifiers: mapping.modifiers,
|
modifiers: mapping.modifiers,
|
||||||
}]
|
}]
|
||||||
@@ -159,534 +168,337 @@ struct KeyMapping {
|
|||||||
added_shift: bool,
|
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 {
|
match &event.union {
|
||||||
Some(ke_union::Union::ControlKey(ck)) => {
|
Some(ke_union::Union::ControlKey(key)) => {
|
||||||
let key = CanonicalKey::from_hid_usage(control_key_to_hid(ck.value())?)?;
|
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,
|
||||||
|
),
|
||||||
|
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 {
|
Some(KeyMapping {
|
||||||
key,
|
key,
|
||||||
modifiers,
|
modifiers,
|
||||||
added_shift: false,
|
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);
|
||||||
}
|
}
|
||||||
Some(ke_union::Union::Chr(chr)) => {
|
|
||||||
if event.mode.value() != KeyboardMode::Map.value() {
|
let added_shift = !modifiers.left_shift && !modifiers.right_shift;
|
||||||
if let Some(mapping) = shifted_printable_char_to_hid(*chr, modifiers) {
|
let mut shifted_modifiers = modifiers;
|
||||||
return Some(mapping);
|
shifted_modifiers.left_shift = true;
|
||||||
}
|
|
||||||
}
|
|
||||||
let key = CanonicalKey::from_hid_usage(keycode_to_hid(*chr)?)?;
|
|
||||||
Some(KeyMapping {
|
Some(KeyMapping {
|
||||||
key,
|
key,
|
||||||
modifiers,
|
modifiers: shifted_modifiers,
|
||||||
added_shift: false,
|
added_shift,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Some(ke_union::Union::Unicode(unicode)) => {
|
|
||||||
let mapping = printable_char_to_hid(*unicode, modifiers)?;
|
fn legacy_character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
|
||||||
Some(mapping)
|
let CharacterMapping { key, needs_shift } = keyboard_mapping::character(ch)?;
|
||||||
}
|
// Legacy Chr historically relied on the event's modifier list for uppercase
|
||||||
_ => None,
|
// 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 {
|
fn is_modifier_control_key(event: &KeyEvent) -> bool {
|
||||||
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
|
let Some(ke_union::Union::ControlKey(key)) = &event.union else {
|
||||||
let val = ck.value();
|
return false;
|
||||||
return val == ControlKey::Control.value()
|
};
|
||||||
|| val == ControlKey::Shift.value()
|
matches!(
|
||||||
|| val == ControlKey::Alt.value()
|
key.enum_value(),
|
||||||
|| val == ControlKey::Meta.value()
|
Ok(ControlKey::Control)
|
||||||
|| val == ControlKey::RControl.value()
|
| Ok(ControlKey::Shift)
|
||||||
|| val == ControlKey::RShift.value()
|
| Ok(ControlKey::Alt)
|
||||||
|| val == ControlKey::RAlt.value();
|
| Ok(ControlKey::Meta)
|
||||||
}
|
| Ok(ControlKey::RControl)
|
||||||
false
|
| Ok(ControlKey::RShift)
|
||||||
|
| Ok(ControlKey::RAlt)
|
||||||
|
| Ok(ControlKey::RWin)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_modifiers(event: &KeyEvent) -> KeyboardModifiers {
|
fn parse_modifiers(event: &KeyEvent) -> KeyboardModifiers {
|
||||||
let mut modifiers = KeyboardModifiers::default();
|
let mut modifiers = KeyboardModifiers::default();
|
||||||
|
|
||||||
for modifier in &event.modifiers {
|
for modifier in &event.modifiers {
|
||||||
let val = modifier.value();
|
match modifier.enum_value() {
|
||||||
match val {
|
Ok(ControlKey::Control) => modifiers.left_ctrl = true,
|
||||||
x if x == ControlKey::Control.value() => modifiers.left_ctrl = true,
|
Ok(ControlKey::Shift) => modifiers.left_shift = true,
|
||||||
x if x == ControlKey::Shift.value() => modifiers.left_shift = true,
|
Ok(ControlKey::Alt) => modifiers.left_alt = true,
|
||||||
x if x == ControlKey::Alt.value() => modifiers.left_alt = true,
|
Ok(ControlKey::Meta) => modifiers.left_meta = true,
|
||||||
x if x == ControlKey::Meta.value() => modifiers.left_meta = true,
|
Ok(ControlKey::RControl) => modifiers.right_ctrl = true,
|
||||||
x if x == ControlKey::RControl.value() => modifiers.right_ctrl = true,
|
Ok(ControlKey::RShift) => modifiers.right_shift = true,
|
||||||
x if x == ControlKey::RShift.value() => modifiers.right_shift = true,
|
Ok(ControlKey::RAlt) => modifiers.right_alt = true,
|
||||||
x if x == ControlKey::RAlt.value() => modifiers.right_alt = true,
|
Ok(ControlKey::RWin) => modifiers.right_meta = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modifiers
|
modifiers
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_shift(mut modifiers: KeyboardModifiers) -> KeyboardModifiers {
|
fn log_rejected_key_event(event: &KeyEvent, code_space: KeyboardCodeSpace) {
|
||||||
modifiers.left_shift = true;
|
let mode = event.mode.value();
|
||||||
modifiers
|
match &event.union {
|
||||||
}
|
Some(ke_union::Union::ControlKey(key)) => debug!(
|
||||||
|
mode,
|
||||||
fn shifted_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping {
|
union = "ControlKey",
|
||||||
let added_shift = !modifiers.left_shift && !modifiers.right_shift;
|
raw = format_args!("0x{:X}", key.value()),
|
||||||
KeyMapping {
|
?code_space,
|
||||||
key,
|
"Dropping unsupported RustDesk keyboard event"
|
||||||
modifiers: with_shift(modifiers),
|
),
|
||||||
added_shift,
|
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")
|
||||||
}
|
}
|
||||||
}
|
Some(ke_union::Union::Win2winHotkey(code)) => debug!(
|
||||||
|
mode,
|
||||||
fn plain_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping {
|
union = "Win2winHotkey",
|
||||||
KeyMapping {
|
raw = format_args!("0x{code:X}"),
|
||||||
key,
|
?code_space,
|
||||||
modifiers,
|
"Dropping unsupported RustDesk keyboard event"
|
||||||
added_shift: false,
|
),
|
||||||
}
|
None => debug!(
|
||||||
}
|
mode,
|
||||||
|
union = "None",
|
||||||
fn shifted_printable_char_to_hid(ch: u32, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
|
?code_space,
|
||||||
match ch {
|
"Dropping unsupported RustDesk keyboard event"
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use protobuf::EnumOrUnknown;
|
||||||
|
|
||||||
#[test]
|
fn key_event(mode: KeyboardMode, union: ke_union::Union, down: bool) -> KeyEvent {
|
||||||
fn test_control_key_mapping() {
|
let mut event = KeyEvent::new();
|
||||||
assert_eq!(control_key_to_hid(ControlKey::Escape.value()), Some(0x29));
|
event.mode = EnumOrUnknown::new(mode);
|
||||||
assert_eq!(control_key_to_hid(ControlKey::Return.value()), Some(0x28));
|
event.union = Some(union);
|
||||||
assert_eq!(control_key_to_hid(ControlKey::Space.value()), Some(0x2C));
|
event.down = down;
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_chr(code: u32, down: bool) -> KeyEvent {
|
||||||
|
key_event(KeyboardMode::Map, ke_union::Union::Chr(code), down)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_convert_mouse_move() {
|
fn mouse_events_keep_existing_semantics() {
|
||||||
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() {
|
|
||||||
let mut event = MouseEvent::new();
|
let mut event = MouseEvent::new();
|
||||||
event.x = -12;
|
event.x = -12;
|
||||||
event.y = 8;
|
event.y = 8;
|
||||||
event.mask = mouse_type::MOVE_RELATIVE;
|
event.mask = mouse_type::MOVE_RELATIVE;
|
||||||
|
|
||||||
let events = convert_mouse_event(&event, 1920, 1080);
|
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].event_type, MouseEventType::Move);
|
||||||
assert_eq!(events[0].x, -12);
|
assert_eq!((events[0].x, events[0].y), (-12, 8));
|
||||||
assert_eq!(events[0].y, 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]
|
#[test]
|
||||||
fn test_convert_key_event() {
|
fn map_delete_generates_only_delete_down_and_up() {
|
||||||
use protobuf::EnumOrUnknown;
|
let down = convert_key_events(&map_chr(0xE053, true));
|
||||||
let mut key_event = KeyEvent::new();
|
let up = convert_key_events(&map_chr(0xE053, false));
|
||||||
key_event.down = true;
|
assert_eq!((down.len(), up.len()), (1, 1));
|
||||||
key_event.press = false;
|
assert_eq!(
|
||||||
key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new(
|
(down[0].event_type, up[0].event_type),
|
||||||
ControlKey::Return,
|
(KeyEventType::Down, KeyEventType::Up)
|
||||||
)));
|
);
|
||||||
|
assert_eq!(
|
||||||
let result = convert_key_event(&key_event);
|
(down[0].key, up[0].key),
|
||||||
assert!(result.is_some());
|
(CanonicalKey::Delete, CanonicalKey::Delete)
|
||||||
|
);
|
||||||
let kb_event = result.unwrap();
|
assert_eq!(
|
||||||
assert_eq!(kb_event.event_type, KeyEventType::Down);
|
(down[0].key.to_hid_usage(), up[0].key.to_hid_usage()),
|
||||||
assert_eq!(kb_event.key, CanonicalKey::Enter);
|
(0x4C, 0x4C)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_convert_at_press_to_shift_digit2() {
|
fn map_scan_codes_do_not_become_ascii_digits() {
|
||||||
let mut key_event = KeyEvent::new();
|
let alt = convert_key_events(&map_chr(0x38, true));
|
||||||
key_event.press = true;
|
let shift = convert_key_events(&map_chr(0x36, true));
|
||||||
key_event.union = Some(ke_union::Union::Unicode('@' as u32));
|
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.len(), 2);
|
||||||
assert_eq!(events[0].event_type, KeyEventType::Down);
|
assert_eq!(
|
||||||
assert_eq!(events[0].key, CanonicalKey::Digit2);
|
(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!(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);
|
assert!(!events[1].modifiers.left_shift);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_convert_shifted_chr_to_shift_digit2() {
|
fn modifier_control_key_does_not_duplicate_state() {
|
||||||
let mut key_event = KeyEvent::new();
|
let mut event = key_event(
|
||||||
key_event.down = true;
|
KeyboardMode::Legacy,
|
||||||
key_event.union = Some(ke_union::Union::Chr('@' as u32));
|
ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::RWin)),
|
||||||
|
true,
|
||||||
let events = convert_key_events(&key_event);
|
);
|
||||||
assert_eq!(events.len(), 1);
|
event.modifiers.push(EnumOrUnknown::new(ControlKey::RWin));
|
||||||
assert_eq!(events[0].event_type, KeyEventType::Down);
|
let events = convert_key_events(&event);
|
||||||
assert_eq!(events[0].key, CanonicalKey::Digit2);
|
assert_eq!(events[0].key, CanonicalKey::MetaRight);
|
||||||
assert!(events[0].modifiers.left_shift);
|
assert_eq!(events[0].modifiers, KeyboardModifiers::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_convert_map_mode_chr_as_physical_key() {
|
fn legacy_delete_and_audited_controls_are_mapped() {
|
||||||
use protobuf::EnumOrUnknown;
|
for (control, expected) in [
|
||||||
let mut key_event = KeyEvent::new();
|
(ControlKey::Delete, CanonicalKey::Delete),
|
||||||
key_event.down = true;
|
(ControlKey::Snapshot, CanonicalKey::PrintScreen),
|
||||||
key_event.mode = EnumOrUnknown::new(KeyboardMode::Map);
|
(ControlKey::RWin, CanonicalKey::MetaRight),
|
||||||
key_event.union = Some(ke_union::Union::Chr(0x41));
|
(ControlKey::Apps, CanonicalKey::ContextMenu),
|
||||||
|
] {
|
||||||
let events = convert_key_events(&key_event);
|
let event = key_event(
|
||||||
assert_eq!(events.len(), 1);
|
KeyboardMode::Legacy,
|
||||||
assert_eq!(events[0].event_type, KeyEventType::Down);
|
ke_union::Union::ControlKey(EnumOrUnknown::new(control)),
|
||||||
assert_eq!(events[0].key, CanonicalKey::KeyA);
|
true,
|
||||||
assert!(!events[0].modifiers.left_shift);
|
);
|
||||||
|
assert_eq!(convert_key_events(&event)[0].key, expected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_convert_press_generates_down_and_up() {
|
fn rejects_unknown_modes_and_unsupported_unions() {
|
||||||
use protobuf::EnumOrUnknown;
|
let mut unknown = map_chr(0x1E, true);
|
||||||
let mut key_event = KeyEvent::new();
|
unknown.mode = EnumOrUnknown::from_i32(99);
|
||||||
key_event.press = true;
|
assert!(convert_key_events(&unknown).is_empty());
|
||||||
key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new(
|
|
||||||
ControlKey::Return,
|
|
||||||
)));
|
|
||||||
|
|
||||||
let events = convert_key_events(&key_event);
|
let seq = key_event(
|
||||||
assert_eq!(events.len(), 2);
|
KeyboardMode::Translate,
|
||||||
assert_eq!(events[0].event_type, KeyEventType::Down);
|
ke_union::Union::Seq("a".to_string()),
|
||||||
assert_eq!(events[1].event_type, KeyEventType::Up);
|
true,
|
||||||
assert_eq!(events[0].key, CanonicalKey::Enter);
|
);
|
||||||
assert_eq!(events[1].key, CanonicalKey::Enter);
|
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 crypto;
|
||||||
pub mod frame_adapters;
|
pub mod frame_adapters;
|
||||||
pub mod hid_adapter;
|
pub mod hid_adapter;
|
||||||
|
mod keyboard_mapping;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod punch;
|
pub mod punch;
|
||||||
pub mod rendezvous;
|
pub mod rendezvous;
|
||||||
@@ -17,7 +18,7 @@ use std::time::Duration;
|
|||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use protobuf::Message;
|
use protobuf::Message;
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::{broadcast, Semaphore};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
@@ -26,13 +27,14 @@ use crate::hid::HidController;
|
|||||||
use crate::utils::bind_tcp_listener;
|
use crate::utils::bind_tcp_listener;
|
||||||
use crate::video::stream_manager::VideoStreamManager;
|
use crate::video::stream_manager::VideoStreamManager;
|
||||||
|
|
||||||
use self::config::RustDeskConfig;
|
use self::config::{RustDeskConfig, RustDeskMode};
|
||||||
use self::connection::ConnectionManager;
|
use self::connection::ConnectionManager;
|
||||||
use self::protocol::{make_local_addr, make_relay_response, make_request_relay};
|
use self::protocol::{make_local_addr, make_relay_response, make_request_relay};
|
||||||
use self::rendezvous::{AddrMangle, RendezvousMediator, RendezvousStatus};
|
use self::rendezvous::{AddrMangle, RendezvousMediator, RendezvousStatus};
|
||||||
|
|
||||||
const RELAY_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
const RELAY_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
||||||
const SERVICE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
const SERVICE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
const MAX_PENDING_CONNECTION_ATTEMPTS: usize = 8;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ServiceStatus {
|
pub enum ServiceStatus {
|
||||||
@@ -53,14 +55,13 @@ impl std::fmt::Display for ServiceStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECT_LISTEN_PORT: u16 = 21118;
|
|
||||||
|
|
||||||
pub struct RustDeskService {
|
pub struct RustDeskService {
|
||||||
config: Arc<RwLock<RustDeskConfig>>,
|
config: Arc<RwLock<RustDeskConfig>>,
|
||||||
status: Arc<RwLock<ServiceStatus>>,
|
status: Arc<RwLock<ServiceStatus>>,
|
||||||
rendezvous: Arc<RwLock<Option<Arc<RendezvousMediator>>>>,
|
rendezvous: Arc<RwLock<Option<Arc<RendezvousMediator>>>>,
|
||||||
rendezvous_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
rendezvous_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||||
tcp_listener_handle: Arc<RwLock<Option<Vec<JoinHandle<()>>>>>,
|
tcp_listener_handle: Arc<RwLock<Option<Vec<JoinHandle<()>>>>>,
|
||||||
|
listener_start_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
listen_port: Arc<RwLock<u16>>,
|
listen_port: Arc<RwLock<u16>>,
|
||||||
connection_manager: Arc<ConnectionManager>,
|
connection_manager: Arc<ConnectionManager>,
|
||||||
video_manager: Arc<VideoStreamManager>,
|
video_manager: Arc<VideoStreamManager>,
|
||||||
@@ -78,6 +79,7 @@ impl RustDeskService {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let (shutdown_tx, _) = broadcast::channel(1);
|
let (shutdown_tx, _) = broadcast::channel(1);
|
||||||
let connection_manager = Arc::new(ConnectionManager::new(config.clone()));
|
let connection_manager = Arc::new(ConnectionManager::new(config.clone()));
|
||||||
|
let direct_access_port = config.direct_access_port;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config: Arc::new(RwLock::new(config)),
|
config: Arc::new(RwLock::new(config)),
|
||||||
@@ -85,7 +87,8 @@ impl RustDeskService {
|
|||||||
rendezvous: Arc::new(RwLock::new(None)),
|
rendezvous: Arc::new(RwLock::new(None)),
|
||||||
rendezvous_handle: Arc::new(RwLock::new(None)),
|
rendezvous_handle: Arc::new(RwLock::new(None)),
|
||||||
tcp_listener_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,
|
connection_manager,
|
||||||
video_manager,
|
video_manager,
|
||||||
hid,
|
hid,
|
||||||
@@ -107,6 +110,7 @@ impl RustDeskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_config(&self, config: RustDeskConfig) {
|
pub fn update_config(&self, config: RustDeskConfig) {
|
||||||
|
self.connection_manager.update_config(config.clone());
|
||||||
*self.config.write() = config;
|
*self.config.write() = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +130,11 @@ impl RustDeskService {
|
|||||||
self.tcp_listener_handle.read().is_some()
|
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();
|
let config = self.config.read().clone();
|
||||||
|
|
||||||
if !config.enabled {
|
if !config.enabled {
|
||||||
@@ -146,9 +154,8 @@ impl RustDeskService {
|
|||||||
|
|
||||||
*self.status.write() = ServiceStatus::Starting;
|
*self.status.write() = ServiceStatus::Starting;
|
||||||
info!(
|
info!(
|
||||||
"Starting RustDesk service with ID: {} -> {}",
|
"Starting RustDesk service in {:?} mode with ID: {}",
|
||||||
config.device_id,
|
config.mode, config.device_id,
|
||||||
config.rendezvous_addr()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(e) = crypto::init() {
|
if let Err(e) = crypto::init() {
|
||||||
@@ -157,6 +164,29 @@ impl RustDeskService {
|
|||||||
return Err(e.into());
|
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 mediator = Arc::new(RendezvousMediator::new(config.clone()));
|
||||||
|
|
||||||
let keypair = mediator.ensure_keypair();
|
let keypair = mediator.ensure_keypair();
|
||||||
@@ -165,36 +195,27 @@ impl RustDeskService {
|
|||||||
let signing_keypair = mediator.ensure_signing_keypair();
|
let signing_keypair = mediator.ensure_signing_keypair();
|
||||||
self.connection_manager.set_signing_keypair(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());
|
*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 connection_manager = self.connection_manager.clone();
|
||||||
let service_config = self.config.clone();
|
let service_config = self.config.clone();
|
||||||
|
let connection_attempts = Arc::new(Semaphore::new(MAX_PENDING_CONNECTION_ATTEMPTS));
|
||||||
|
|
||||||
mediator.set_punch_callback(Arc::new({
|
mediator.set_punch_callback(Arc::new({
|
||||||
let connection_manager = connection_manager.clone();
|
let connection_manager = connection_manager.clone();
|
||||||
let service_config = service_config.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| {
|
move |peer_addr, rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
|
||||||
let conn_mgr = connection_manager.clone();
|
let conn_mgr = connection_manager.clone();
|
||||||
let config = service_config.clone();
|
let config = service_config.clone();
|
||||||
|
let attempts = connection_attempts.clone();
|
||||||
tokio::spawn(async move {
|
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 {
|
if let Some(addr) = peer_addr {
|
||||||
info!("Attempting P2P direct connection to {}", addr);
|
info!("Attempting P2P direct connection to {}", addr);
|
||||||
match punch::try_direct_connection(addr).await {
|
match punch::try_direct_connection(addr).await {
|
||||||
@@ -232,10 +253,18 @@ impl RustDeskService {
|
|||||||
mediator.set_relay_callback(Arc::new({
|
mediator.set_relay_callback(Arc::new({
|
||||||
let connection_manager = connection_manager.clone();
|
let connection_manager = connection_manager.clone();
|
||||||
let service_config = service_config.clone();
|
let service_config = service_config.clone();
|
||||||
|
let connection_attempts = connection_attempts.clone();
|
||||||
move |rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
|
move |rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
|
||||||
let conn_mgr = connection_manager.clone();
|
let conn_mgr = connection_manager.clone();
|
||||||
let config = service_config.clone();
|
let config = service_config.clone();
|
||||||
|
let attempts = connection_attempts.clone();
|
||||||
tokio::spawn(async move {
|
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);
|
let relay_key = rustdesk_relay_key(&config);
|
||||||
if let Err(e) = handle_relay_request(
|
if let Err(e) = handle_relay_request(
|
||||||
&rendezvous_addr,
|
&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(
|
mediator.set_intranet_callback(Arc::new(
|
||||||
move |rendezvous_addr, peer_socket_addr, local_addr, relay_server, device_id| {
|
move |rendezvous_addr, peer_socket_addr, local_ip, relay_server, device_id| {
|
||||||
let conn_mgr = connection_manager2.clone();
|
let weak_service = weak_service.clone();
|
||||||
|
let attempts = intranet_attempts.clone();
|
||||||
tokio::spawn(async move {
|
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(
|
if let Err(e) = handle_intranet_request(
|
||||||
&rendezvous_addr,
|
&rendezvous_addr,
|
||||||
&peer_socket_addr,
|
&peer_socket_addr,
|
||||||
local_addr,
|
local_addr,
|
||||||
&relay_server,
|
&relay_server,
|
||||||
&device_id,
|
&device_id,
|
||||||
conn_mgr,
|
service.connection_manager.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -298,16 +347,29 @@ impl RustDeskService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec<JoinHandle<()>>, u16)> {
|
async fn ensure_tcp_listener(
|
||||||
let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) {
|
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,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(error) if allow_ephemeral_fallback => {
|
||||||
warn!(
|
warn!(
|
||||||
"Failed to bind RustDesk TCP on port {}: {}, falling back to random port",
|
"RustDesk port {} unavailable for on-demand listening: {}; using an ephemeral port",
|
||||||
DIRECT_LISTEN_PORT, err
|
preferred_port, error
|
||||||
);
|
);
|
||||||
self.bind_direct_listeners(0)?
|
self.bind_direct_listeners(0)?
|
||||||
}
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
};
|
};
|
||||||
|
|
||||||
*self.listen_port.write() = listen_port;
|
*self.listen_port.write() = listen_port;
|
||||||
@@ -328,15 +390,13 @@ impl RustDeskService {
|
|||||||
match result {
|
match result {
|
||||||
Ok((stream, peer_addr)) => {
|
Ok((stream, peer_addr)) => {
|
||||||
info!("Accepted direct connection from {}", peer_addr);
|
info!("Accepted direct connection from {}", peer_addr);
|
||||||
let conn_mgr = conn_mgr.clone();
|
if let Err(e) = conn_mgr.accept_listener_connection(stream, peer_addr).await {
|
||||||
tokio::spawn(async move {
|
warn!("Rejected direct connection from {}: {}", peer_addr, e);
|
||||||
if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await {
|
|
||||||
error!("Failed to handle direct connection from {}: {}", peer_addr, e);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("TCP accept error: {}", e);
|
error!("TCP accept error: {}", e);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +410,8 @@ impl RustDeskService {
|
|||||||
handles.push(handle);
|
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)> {
|
fn bind_direct_listeners(&self, port: u16) -> anyhow::Result<(Vec<TcpListener>, u16)> {
|
||||||
@@ -384,8 +445,8 @@ impl RustDeskService {
|
|||||||
info!("Stopping RustDesk service");
|
info!("Stopping RustDesk service");
|
||||||
|
|
||||||
let _ = self.shutdown_tx.send(());
|
let _ = self.shutdown_tx.send(());
|
||||||
|
let _listener_guard = self.listener_start_lock.lock().await;
|
||||||
self.connection_manager.close_all();
|
*self.status.write() = ServiceStatus::Stopped;
|
||||||
|
|
||||||
if let Some(mediator) = self.rendezvous.read().as_ref() {
|
if let Some(mediator) = self.rendezvous.read().as_ref() {
|
||||||
mediator.stop();
|
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.rendezvous.write() = None;
|
||||||
*self.status.write() = ServiceStatus::Stopped;
|
|
||||||
|
|
||||||
Ok(())
|
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.stop().await?;
|
||||||
self.update_config(config);
|
self.update_config(config);
|
||||||
self.start().await
|
self.start().await
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ pub type RelayCallback = Arc<dyn Fn(String, String, String, Vec<u8>, String) + S
|
|||||||
pub type PunchCallback =
|
pub type PunchCallback =
|
||||||
Arc<dyn Fn(Option<SocketAddr>, String, String, String, Vec<u8>, String) + Send + Sync>;
|
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 {
|
pub struct RendezvousMediator {
|
||||||
config: Arc<RwLock<RustDeskConfig>>,
|
config: Arc<RwLock<RustDeskConfig>>,
|
||||||
@@ -143,7 +143,6 @@ pub struct RendezvousMediator {
|
|||||||
relay_callback: Arc<RwLock<Option<RelayCallback>>>,
|
relay_callback: Arc<RwLock<Option<RelayCallback>>>,
|
||||||
punch_callback: Arc<RwLock<Option<PunchCallback>>>,
|
punch_callback: Arc<RwLock<Option<PunchCallback>>>,
|
||||||
intranet_callback: Arc<RwLock<Option<IntranetCallback>>>,
|
intranet_callback: Arc<RwLock<Option<IntranetCallback>>>,
|
||||||
listen_port: Arc<RwLock<u16>>,
|
|
||||||
shutdown_tx: broadcast::Sender<()>,
|
shutdown_tx: broadcast::Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,23 +165,10 @@ impl RendezvousMediator {
|
|||||||
relay_callback: Arc::new(RwLock::new(None)),
|
relay_callback: Arc::new(RwLock::new(None)),
|
||||||
punch_callback: Arc::new(RwLock::new(None)),
|
punch_callback: Arc::new(RwLock::new(None)),
|
||||||
intranet_callback: Arc::new(RwLock::new(None)),
|
intranet_callback: Arc::new(RwLock::new(None)),
|
||||||
listen_port: Arc::new(RwLock::new(21118)),
|
|
||||||
shutdown_tx,
|
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) {
|
pub fn increment_serial(&self) {
|
||||||
let mut serial = self.serial.write();
|
let mut serial = self.serial.write();
|
||||||
*serial = serial.wrapping_add(1);
|
*serial = serial.wrapping_add(1);
|
||||||
@@ -430,7 +416,9 @@ impl RendezvousMediator {
|
|||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let id = self.device_id();
|
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() {
|
if local_addrs.is_empty() {
|
||||||
debug!("No local addresses available for LocalAddr response");
|
debug!("No local addresses available for LocalAddr response");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -439,21 +427,18 @@ impl RendezvousMediator {
|
|||||||
let config = self.config.read().clone();
|
let config = self.config.read().clone();
|
||||||
let rendezvous_addr = config.rendezvous_addr();
|
let rendezvous_addr = config.rendezvous_addr();
|
||||||
|
|
||||||
let listen_port = self.listen_port();
|
|
||||||
|
|
||||||
let local_ip = local_addrs[0];
|
let local_ip = local_addrs[0];
|
||||||
let local_sock_addr = SocketAddr::new(local_ip, listen_port);
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"FetchLocalAddr: calling intranet callback with local_addr={}, rendezvous={}",
|
"FetchLocalAddr: requesting an on-demand listener for {}, rendezvous={}",
|
||||||
local_sock_addr, rendezvous_addr
|
local_ip, rendezvous_addr
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(callback) = self.intranet_callback.read().as_ref() {
|
if let Some(callback) = self.intranet_callback.read().as_ref() {
|
||||||
callback(
|
callback(
|
||||||
rendezvous_addr,
|
rendezvous_addr,
|
||||||
peer_socket_addr.to_vec(),
|
peer_socket_addr.to_vec(),
|
||||||
local_sock_addr,
|
local_ip,
|
||||||
relay_server.to_string(),
|
relay_server.to_string(),
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|||||||
75
src/state.rs
75
src/state.rs
@@ -19,11 +19,9 @@ use crate::hid::HidController;
|
|||||||
use crate::msd::MsdController;
|
use crate::msd::MsdController;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::otg::OtgService;
|
use crate::otg::OtgService;
|
||||||
use crate::rtsp::RtspService;
|
use crate::runtime::{RemoteAccessCoordinator, UsbCoordinator};
|
||||||
use crate::rustdesk::RustDeskService;
|
|
||||||
use crate::update::UpdateService;
|
use crate::update::UpdateService;
|
||||||
use crate::video::VideoStreamManager;
|
use crate::video::VideoStreamManager;
|
||||||
use crate::vnc::VncService;
|
|
||||||
use crate::watchdog::WatchdogController;
|
use crate::watchdog::WatchdogController;
|
||||||
use crate::webrtc::WebRtcStreamer;
|
use crate::webrtc::WebRtcStreamer;
|
||||||
|
|
||||||
@@ -81,9 +79,8 @@ pub struct AppState {
|
|||||||
pub audio: Arc<AudioController>,
|
pub audio: Arc<AudioController>,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
|
pub uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
|
||||||
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
|
pub usb: Arc<UsbCoordinator>,
|
||||||
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
|
pub remote_access: Arc<RemoteAccessCoordinator>,
|
||||||
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
|
||||||
pub extensions: Arc<ExtensionManager>,
|
pub extensions: Arc<ExtensionManager>,
|
||||||
pub events: Arc<EventBus>,
|
pub events: Arc<EventBus>,
|
||||||
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
||||||
@@ -111,9 +108,6 @@ impl AppState {
|
|||||||
#[cfg(unix)] msd: Option<MsdController>,
|
#[cfg(unix)] msd: Option<MsdController>,
|
||||||
atx: Option<AtxController>,
|
atx: Option<AtxController>,
|
||||||
audio: Arc<AudioController>,
|
audio: Arc<AudioController>,
|
||||||
rustdesk: Option<Arc<RustDeskService>>,
|
|
||||||
vnc: Option<Arc<VncService>>,
|
|
||||||
rtsp: Option<Arc<RtspService>>,
|
|
||||||
extensions: Arc<ExtensionManager>,
|
extensions: Arc<ExtensionManager>,
|
||||||
events: Arc<EventBus>,
|
events: Arc<EventBus>,
|
||||||
update: Arc<UpdateService>,
|
update: Arc<UpdateService>,
|
||||||
@@ -122,6 +116,28 @@ impl AppState {
|
|||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
let (device_info_tx, _device_info_rx) = watch::channel(None);
|
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 {
|
Arc::new(Self {
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
@@ -135,12 +151,11 @@ impl AppState {
|
|||||||
hid,
|
hid,
|
||||||
computer_use,
|
computer_use,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
msd: Arc::new(RwLock::new(msd)),
|
msd,
|
||||||
atx: Arc::new(RwLock::new(atx)),
|
atx: Arc::new(RwLock::new(atx)),
|
||||||
audio,
|
audio,
|
||||||
rustdesk: Arc::new(RwLock::new(rustdesk)),
|
usb,
|
||||||
vnc: Arc::new(RwLock::new(vnc)),
|
remote_access,
|
||||||
rtsp: Arc::new(RwLock::new(rtsp)),
|
|
||||||
extensions,
|
extensions,
|
||||||
events,
|
events,
|
||||||
device_info_tx,
|
device_info_tx,
|
||||||
@@ -151,7 +166,7 @@ impl AppState {
|
|||||||
config_apply_locks: ConfigApplyLocks::new(),
|
config_apply_locks: ConfigApplyLocks::new(),
|
||||||
data_dir,
|
data_dir,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
uac_playback: Arc::new(RwLock::new(None)),
|
uac_playback,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,33 +174,6 @@ impl AppState {
|
|||||||
&self.data_dir
|
&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>> {
|
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
|
||||||
self.device_info_tx.subscribe()
|
self.device_info_tx.subscribe()
|
||||||
}
|
}
|
||||||
@@ -230,6 +218,11 @@ impl AppState {
|
|||||||
|
|
||||||
pub async fn publish_device_info(&self) {
|
pub async fn publish_device_info(&self) {
|
||||||
let device_info = self.get_device_info().await;
|
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));
|
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::Amf => Some(EncoderBackend::Amf),
|
||||||
EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp),
|
EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp),
|
||||||
EncoderType::V4l2m2m => Some(EncoderBackend::V4l2m2m),
|
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::fs::File;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::AsFd;
|
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::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -29,6 +31,10 @@ use crate::video::device::VideoControlMode;
|
|||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::signal::SignalStatus;
|
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.
|
/// Metadata for a captured frame.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct CaptureMeta {
|
pub struct CaptureMeta {
|
||||||
@@ -67,6 +73,8 @@ pub struct CaptureStream {
|
|||||||
bridge_kind: Option<CsiBridgeKind>,
|
bridge_kind: Option<CsiBridgeKind>,
|
||||||
native_hdmirx_state: Option<NativeHdmirxState>,
|
native_hdmirx_state: Option<NativeHdmirxState>,
|
||||||
native_hdmirx_next_state_check: Option<Instant>,
|
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> {
|
fn open_capture_device(path: &Path) -> io::Result<File> {
|
||||||
@@ -319,6 +327,24 @@ impl CaptureStream {
|
|||||||
mappings.push(plane_maps);
|
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 {
|
let mut stream = Self {
|
||||||
fd,
|
fd,
|
||||||
queue,
|
queue,
|
||||||
@@ -332,6 +358,8 @@ impl CaptureStream {
|
|||||||
bridge_kind: bridge.kind,
|
bridge_kind: bridge.kind,
|
||||||
native_hdmirx_state,
|
native_hdmirx_state,
|
||||||
native_hdmirx_next_state_check,
|
native_hdmirx_next_state_check,
|
||||||
|
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||||
|
dma_layout_bytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
stream.queue_all_buffers()?;
|
stream.queue_all_buffers()?;
|
||||||
@@ -421,10 +449,7 @@ impl CaptureStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_into(
|
fn dequeue_buffer(&mut self) -> std::result::Result<V4l2Buffer, CaptureReadError> {
|
||||||
&mut self,
|
|
||||||
dst: &mut Vec<u8>,
|
|
||||||
) -> std::result::Result<CaptureMeta, CaptureReadError> {
|
|
||||||
self.wait_ready()?;
|
self.wait_ready()?;
|
||||||
|
|
||||||
// Several vendor BSPs update G_FMT/DV timings without making the
|
// Several vendor BSPs update G_FMT/DV timings without making the
|
||||||
@@ -455,6 +480,143 @@ impl CaptureStream {
|
|||||||
};
|
};
|
||||||
CaptureReadError::Io(error)
|
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 index = dqbuf.as_v4l2_buffer().index as usize;
|
||||||
let sequence = dqbuf.as_v4l2_buffer().sequence as u64;
|
let sequence = dqbuf.as_v4l2_buffer().sequence as u64;
|
||||||
|
|
||||||
@@ -664,7 +826,7 @@ impl CaptureStream {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_buffer(&mut self, index: u32) -> Result<()> {
|
fn queue_buffer(&self, index: u32) -> Result<()> {
|
||||||
let handle = MmapHandle;
|
let handle = MmapHandle;
|
||||||
let planes = self.mappings[index as usize]
|
let planes = self.mappings[index as usize]
|
||||||
.iter()
|
.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 {
|
impl Drop for CaptureStream {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Release ordering matters on rkcif: a subsequent open()/S_FMT from a
|
// Release ordering matters on rkcif: a subsequent open()/S_FMT from a
|
||||||
|
|||||||
@@ -2,8 +2,32 @@
|
|||||||
|
|
||||||
use std::io;
|
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;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CaptureIoErrorKind {
|
pub enum CaptureIoErrorKind {
|
||||||
DeviceLost,
|
DeviceLost,
|
||||||
@@ -52,6 +76,33 @@ pub fn capture_error_log_key(err: &io::Error) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn maps_known_signal_status_strings() {
|
fn maps_known_signal_status_strings() {
|
||||||
assert_eq!(
|
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,
|
Rkmpp,
|
||||||
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
||||||
V4l2M2m,
|
V4l2M2m,
|
||||||
/// Amlogic S912/GXM AMLENC
|
|
||||||
Amlogic,
|
|
||||||
/// Software encoding (libx264/openh264)
|
/// Software encoding (libx264/openh264)
|
||||||
Software,
|
Software,
|
||||||
/// No encoder available
|
/// No encoder available
|
||||||
@@ -66,7 +64,6 @@ impl std::fmt::Display for H264EncoderType {
|
|||||||
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||||
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||||
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||||
H264EncoderType::Amlogic => write!(f, "AMLENC"),
|
|
||||||
H264EncoderType::Software => write!(f, "Software"),
|
H264EncoderType::Software => write!(f, "Software"),
|
||||||
H264EncoderType::None => write!(f, "None"),
|
H264EncoderType::None => write!(f, "None"),
|
||||||
}
|
}
|
||||||
@@ -83,7 +80,6 @@ impl From<EncoderBackend> for H264EncoderType {
|
|||||||
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
||||||
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
||||||
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
||||||
EncoderBackend::Amlogic => H264EncoderType::Amlogic,
|
|
||||||
EncoderBackend::Software => H264EncoderType::Software,
|
EncoderBackend::Software => H264EncoderType::Software,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +198,7 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
align: 1,
|
align: 1,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop: 30,
|
gop: 30,
|
||||||
rc: RateControl::RC_CBR,
|
rc: RateControl::RC_VBR,
|
||||||
quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (ultrafast)
|
quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (ultrafast)
|
||||||
kbs: 2000,
|
kbs: 2000,
|
||||||
q: 23,
|
q: 23,
|
||||||
@@ -301,7 +297,7 @@ impl H264Encoder {
|
|||||||
align: 1,
|
align: 1,
|
||||||
fps: config.fps as i32,
|
fps: config.fps as i32,
|
||||||
gop: config.gop_size 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)
|
quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (lowest latency)
|
||||||
kbs: config.bitrate_kbps as i32,
|
kbs: config.bitrate_kbps as i32,
|
||||||
q: 23,
|
q: 23,
|
||||||
|
|||||||
@@ -252,14 +252,38 @@ pub fn avcc_to_annex_b(data: &[u8]) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn normalize_for_webrtc(data: &[u8]) -> Vec<u8> {
|
/// Normalize a length-prefixed H.264 access unit to Annex-B when necessary.
|
||||||
if is_annex_b(data) {
|
///
|
||||||
return strip_aud_nal_units(data);
|
/// 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.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) {
|
if let Some(annex_b) = avcc_to_annex_b(data) {
|
||||||
return strip_aud_nal_units(&annex_b);
|
return strip_aud_nal_units(&annex_b);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_annex_b(data) {
|
||||||
|
return strip_aud_nal_units(data);
|
||||||
|
}
|
||||||
|
|
||||||
data.to_vec()
|
data.to_vec()
|
||||||
}
|
}
|
||||||
@@ -296,4 +320,36 @@ mod tests {
|
|||||||
Some("42402a".to_string())
|
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,
|
Rkmpp,
|
||||||
/// V4L2 M2M (ARM generic)
|
/// V4L2 M2M (ARM generic)
|
||||||
V4l2M2m,
|
V4l2M2m,
|
||||||
/// Amlogic S912/GXM AMLENC
|
|
||||||
Amlogic,
|
|
||||||
/// Software encoder (libx265)
|
/// Software encoder (libx265)
|
||||||
Software,
|
Software,
|
||||||
/// No encoder available
|
/// No encoder available
|
||||||
@@ -63,7 +61,6 @@ impl std::fmt::Display for H265EncoderType {
|
|||||||
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||||
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||||
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||||
H265EncoderType::Amlogic => write!(f, "AMLENC"),
|
|
||||||
H265EncoderType::Software => write!(f, "Software"),
|
H265EncoderType::Software => write!(f, "Software"),
|
||||||
H265EncoderType::None => write!(f, "None"),
|
H265EncoderType::None => write!(f, "None"),
|
||||||
}
|
}
|
||||||
@@ -79,7 +76,6 @@ impl From<EncoderBackend> for H265EncoderType {
|
|||||||
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
||||||
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
||||||
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
||||||
EncoderBackend::Amlogic => H265EncoderType::Amlogic,
|
|
||||||
EncoderBackend::Software => H265EncoderType::Software,
|
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::common::DataFormat;
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
pub mod amlenc;
|
|
||||||
pub mod convert;
|
pub mod convert;
|
||||||
|
|
||||||
pub mod h264;
|
pub mod h264;
|
||||||
pub mod h264_bitstream;
|
pub mod h264_bitstream;
|
||||||
pub mod h265;
|
pub mod h265;
|
||||||
|
pub mod h265_bitstream;
|
||||||
pub mod jpeg;
|
pub mod jpeg;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod self_check;
|
pub mod self_check;
|
||||||
@@ -20,7 +20,6 @@ pub mod vp9;
|
|||||||
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
|
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
|
||||||
pub mod mjpeg_rkmpp;
|
pub mod mjpeg_rkmpp;
|
||||||
|
|
||||||
pub use amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder};
|
|
||||||
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
|
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
|
||||||
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||||
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
||||||
|
|||||||
@@ -10,17 +10,11 @@ use std::sync::OnceLock;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{debug, info, warn};
|
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::common::{DataFormat, Quality, RateControl};
|
||||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
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
|
/// Video encoder format type
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum VideoEncoderType {
|
pub enum VideoEncoderType {
|
||||||
@@ -102,8 +96,6 @@ pub enum EncoderBackend {
|
|||||||
Rkmpp,
|
Rkmpp,
|
||||||
/// V4L2 Memory-to-Memory (ARM)
|
/// V4L2 Memory-to-Memory (ARM)
|
||||||
V4l2m2m,
|
V4l2m2m,
|
||||||
/// Amlogic S912/GXM vendor AMLENC
|
|
||||||
Amlogic,
|
|
||||||
/// Software encoding (libx264, libx265, libvpx)
|
/// Software encoding (libx264, libx265, libvpx)
|
||||||
Software,
|
Software,
|
||||||
}
|
}
|
||||||
@@ -123,8 +115,6 @@ impl EncoderBackend {
|
|||||||
EncoderBackend::Rkmpp
|
EncoderBackend::Rkmpp
|
||||||
} else if name.contains("v4l2m2m") {
|
} else if name.contains("v4l2m2m") {
|
||||||
EncoderBackend::V4l2m2m
|
EncoderBackend::V4l2m2m
|
||||||
} else if name.contains("amlenc") {
|
|
||||||
EncoderBackend::Amlogic
|
|
||||||
} else {
|
} else {
|
||||||
EncoderBackend::Software
|
EncoderBackend::Software
|
||||||
}
|
}
|
||||||
@@ -144,7 +134,6 @@ impl EncoderBackend {
|
|||||||
EncoderBackend::Amf => "AMF",
|
EncoderBackend::Amf => "AMF",
|
||||||
EncoderBackend::Rkmpp => "RKMPP",
|
EncoderBackend::Rkmpp => "RKMPP",
|
||||||
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
||||||
EncoderBackend::Amlogic => "AMLENC",
|
|
||||||
EncoderBackend::Software => "Software",
|
EncoderBackend::Software => "Software",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +148,6 @@ impl EncoderBackend {
|
|||||||
"amf" => Some(EncoderBackend::Amf),
|
"amf" => Some(EncoderBackend::Amf),
|
||||||
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
||||||
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
||||||
"amlogic" | "amlenc" => Some(EncoderBackend::Amlogic),
|
|
||||||
"software" | "cpu" => Some(EncoderBackend::Software),
|
"software" | "cpu" => Some(EncoderBackend::Software),
|
||||||
_ => None,
|
_ => 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
|
/// Get the global registry instance
|
||||||
///
|
///
|
||||||
/// The registry is initialized lazily on first access with 1280x720 detection.
|
/// 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)
|
// Sort encoders by priority (lower is better)
|
||||||
for encoders in self.encoders.values_mut() {
|
for encoders in self.encoders.values_mut() {
|
||||||
encoders.sort_by_key(|e| e.priority);
|
encoders.sort_by_key(|e| e.priority);
|
||||||
@@ -624,14 +537,6 @@ mod tests {
|
|||||||
EncoderBackend::from_codec_name("libx264"),
|
EncoderBackend::from_codec_name("libx264"),
|
||||||
EncoderBackend::Software
|
EncoderBackend::Software
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
EncoderBackend::from_codec_name("h264_amlenc"),
|
|
||||||
EncoderBackend::Amlogic
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
EncoderBackend::from_str("amlogic"),
|
|
||||||
Some(EncoderBackend::Amlogic)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -656,65 +561,4 @@ mod tests {
|
|||||||
println!("Available formats: {:?}", registry.available_formats(false));
|
println!("Available formats: {:?}", registry.available_formats(false));
|
||||||
println!("Selectable formats: {:?}", registry.selectable_formats());
|
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 std::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AmlencCodec, AmlencConfig, AmlencEncoder, EncoderRegistry, H264Config, H264Encoder, H265Config,
|
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
||||||
H265Encoder, VP8Config, VP8Encoder, VP9Config, VP9Encoder, VideoEncoderType,
|
VP9Config, VP9Encoder, VideoEncoderType,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
@@ -226,9 +226,6 @@ fn run_smoke_test(
|
|||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
codec_name_ffmpeg: &str,
|
codec_name_ffmpeg: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if codec_name_ffmpeg.contains("amlenc") {
|
|
||||||
return run_amlenc_smoke_test(codec, resolution);
|
|
||||||
}
|
|
||||||
match codec {
|
match codec {
|
||||||
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
|
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
|
||||||
VideoEncoderType::H265 => run_h265_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<()> {
|
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||||
let mut encoder = H264Encoder::with_codec(
|
let mut encoder = H264Encoder::with_codec(
|
||||||
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
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::error::{AppError, Result};
|
||||||
use crate::video::codec::amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder};
|
|
||||||
use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter};
|
use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter};
|
||||||
use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat};
|
use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat};
|
||||||
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
|
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);
|
struct VP8EncoderWrapper(VP8Encoder);
|
||||||
|
|
||||||
impl VideoEncoderTrait for VP8EncoderWrapper {
|
impl VideoEncoderTrait for VP8EncoderWrapper {
|
||||||
@@ -231,9 +189,9 @@ fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, Pix
|
|||||||
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
|
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AMLENC and libjpeg-turbo use independent CPU/hardware resources. Decode
|
/// V4L2 M2M hardware encoding and libjpeg-turbo use independent CPU/hardware
|
||||||
/// MJPEG in the capture worker so encoding the previous NV12 frame can overlap
|
/// resources. Decode MJPEG outside the encoder worker so encoding the previous
|
||||||
/// with decoding the next frame.
|
/// NV12 frame can overlap with decoding subsequent frames.
|
||||||
pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -> bool {
|
pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -> bool {
|
||||||
if !config.input_format.is_compressed()
|
if !config.input_format.is_compressed()
|
||||||
|| !matches!(
|
|| !matches!(
|
||||||
@@ -248,7 +206,7 @@ pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -
|
|||||||
Some(backend) => registry.encoder_with_backend(config.output_codec, backend),
|
Some(backend) => registry.encoder_with_backend(config.output_codec, backend),
|
||||||
None => registry.best_available_encoder(config.output_codec),
|
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(
|
pub(super) fn build_encoder_state(
|
||||||
@@ -415,11 +373,7 @@ pub(super) fn build_encoder_state(
|
|||||||
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
|
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
|
||||||
VideoEncoderType::H264 => {
|
VideoEncoderType::H264 => {
|
||||||
let codec_name = selected_codec_name.clone();
|
let codec_name = selected_codec_name.clone();
|
||||||
if codec_name == crate::video::codec::amlenc::AMLENC_H264_CODEC_NAME {
|
let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format);
|
||||||
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(|| {
|
let input_format = direct_input_format.unwrap_or_else(|| {
|
||||||
if codec_name.contains("libx264") {
|
if codec_name.contains("libx264") {
|
||||||
H264InputFormat::Yuv420p
|
H264InputFormat::Yuv420p
|
||||||
@@ -442,14 +396,9 @@ pub(super) fn build_encoder_state(
|
|||||||
|
|
||||||
create_h264_encoder(config, input_format, &codec_name)?
|
create_h264_encoder(config, input_format, &codec_name)?
|
||||||
}
|
}
|
||||||
}
|
|
||||||
VideoEncoderType::H265 => {
|
VideoEncoderType::H265 => {
|
||||||
let codec_name = selected_codec_name.clone();
|
let codec_name = selected_codec_name.clone();
|
||||||
if codec_name == crate::video::codec::amlenc::AMLENC_H265_CODEC_NAME {
|
let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format);
|
||||||
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(|| {
|
let input_format = direct_input_format.unwrap_or_else(|| {
|
||||||
if codec_name.contains("libx265") {
|
if codec_name.contains("libx265") {
|
||||||
H265InputFormat::Yuv420p
|
H265InputFormat::Yuv420p
|
||||||
@@ -489,7 +438,6 @@ pub(super) fn build_encoder_state(
|
|||||||
info!("Created H265 encoder: {}", encoder.codec_name());
|
info!("Created H265 encoder: {}", encoder.codec_name());
|
||||||
Box::new(H265EncoderWrapper(encoder))
|
Box::new(H265EncoderWrapper(encoder))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
VideoEncoderType::VP8 => {
|
VideoEncoderType::VP8 => {
|
||||||
let codec_name = selected_codec_name.clone();
|
let codec_name = selected_codec_name.clone();
|
||||||
if let Some(ref backend) = config.encoder_backend {
|
if let Some(ref backend) = config.encoder_backend {
|
||||||
@@ -523,9 +471,7 @@ pub(super) fn build_encoder_state(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let codec_name = encoder.codec_name();
|
let codec_name = encoder.codec_name();
|
||||||
let use_direct_input = if codec_name.contains("amlenc") {
|
let use_direct_input = if codec_name.contains("rkmpp") {
|
||||||
pipeline_input_format == PixelFormat::Nv12
|
|
||||||
} else if codec_name.contains("rkmpp") {
|
|
||||||
matches!(
|
matches!(
|
||||||
pipeline_input_format,
|
pipeline_input_format,
|
||||||
PixelFormat::Yuyv
|
PixelFormat::Yuyv
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use parking_lot::Mutex as ParkingMutex;
|
|||||||
use parking_lot::RwLock as ParkingRwLock;
|
use parking_lot::RwLock as ParkingRwLock;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::{mpsc, watch, Mutex, RwLock};
|
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};
|
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)
|
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
|
||||||
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
|
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
|
||||||
const AMLENC_MAX_FPS: u32 = 60;
|
|
||||||
/// After this many consecutive timeouts, log a prominent warning.
|
/// After this many consecutive timeouts, log a prominent warning.
|
||||||
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
|
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
|
||||||
const CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD: u32 = 3;
|
const CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD: u32 = 3;
|
||||||
@@ -49,21 +139,104 @@ use crate::video::capture::status::{
|
|||||||
signal_status_from_capture_kind, CaptureIoErrorKind,
|
signal_status_from_capture_kind, CaptureIoErrorKind,
|
||||||
};
|
};
|
||||||
use crate::video::capture::{BridgeContext, CaptureReadError, CaptureStream};
|
use crate::video::capture::{BridgeContext, CaptureReadError, CaptureStream};
|
||||||
use crate::video::codec::h264_bitstream;
|
|
||||||
use crate::video::codec::registry::{EncoderBackend, VideoEncoderType};
|
use crate::video::codec::registry::{EncoderBackend, VideoEncoderType};
|
||||||
use crate::video::codec::MjpegToNv12Decoder;
|
use crate::video::codec::MjpegToNv12Decoder;
|
||||||
|
use crate::video::codec::{h264_bitstream, h265_bitstream};
|
||||||
use crate::video::device::parse_bridge_kind;
|
use crate::video::device::parse_bridge_kind;
|
||||||
use crate::video::device::VideoControlMode;
|
use crate::video::device::VideoControlMode;
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
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::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
||||||
use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy};
|
use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy};
|
||||||
use crate::video::signal::SignalStatus;
|
use crate::video::signal::SignalStatus;
|
||||||
|
|
||||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
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"))]
|
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||||
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
|
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
|
||||||
@@ -75,7 +248,10 @@ pub struct EncodedVideoFrame {
|
|||||||
pub data: Bytes,
|
pub data: Bytes,
|
||||||
/// Presentation timestamp in milliseconds
|
/// Presentation timestamp in milliseconds
|
||||||
pub pts_ms: i64,
|
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,
|
pub is_keyframe: bool,
|
||||||
/// Frame sequence number
|
/// Frame sequence number
|
||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
@@ -86,7 +262,9 @@ pub struct EncodedVideoFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum PipelineCmd {
|
enum PipelineCmd {
|
||||||
SetBitrate { bitrate_kbps: u32, gop: u32 },
|
SetBitrate {
|
||||||
|
preset: crate::video::codec::BitratePreset,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -169,6 +347,15 @@ impl Default for SharedVideoPipelineConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
/// Get effective bitrate in kbps
|
||||||
pub fn bitrate_kbps(&self) -> u32 {
|
pub fn bitrate_kbps(&self) -> u32 {
|
||||||
self.bitrate_preset.bitrate_kbps()
|
self.bitrate_preset.bitrate_kbps()
|
||||||
@@ -280,6 +467,15 @@ pub struct SharedVideoPipelineStats {
|
|||||||
pub current_fps: f32,
|
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
|
/// Universal shared video pipeline
|
||||||
pub struct SharedVideoPipeline {
|
pub struct SharedVideoPipeline {
|
||||||
config: RwLock<SharedVideoPipelineConfig>,
|
config: RwLock<SharedVideoPipelineConfig>,
|
||||||
@@ -287,9 +483,8 @@ pub struct SharedVideoPipeline {
|
|||||||
stats: Mutex<SharedVideoPipelineStats>,
|
stats: Mutex<SharedVideoPipelineStats>,
|
||||||
running: watch::Sender<bool>,
|
running: watch::Sender<bool>,
|
||||||
running_rx: watch::Receiver<bool>,
|
running_rx: watch::Receiver<bool>,
|
||||||
/// Becomes true only after the synchronous encoder worker has dropped its
|
/// Becomes true only after the synchronous encoder worker has exited and
|
||||||
/// vendor handles. Capture teardown alone is not sufficient for AMLENC:
|
/// dropped its encoder handles.
|
||||||
/// a blocked dequeue/encode can otherwise overlap the next pipeline.
|
|
||||||
encoder_done: watch::Sender<bool>,
|
encoder_done: watch::Sender<bool>,
|
||||||
encoder_done_rx: watch::Receiver<bool>,
|
encoder_done_rx: watch::Receiver<bool>,
|
||||||
h264_profile_level_id: watch::Sender<Option<String>>,
|
h264_profile_level_id: watch::Sender<Option<String>>,
|
||||||
@@ -301,6 +496,9 @@ pub struct SharedVideoPipeline {
|
|||||||
sequence: AtomicU64,
|
sequence: AtomicU64,
|
||||||
/// Atomic flag for keyframe request (avoids lock contention)
|
/// Atomic flag for keyframe request (avoids lock contention)
|
||||||
keyframe_requested: AtomicBool,
|
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).
|
/// Pipeline start time for monotonic PTS calculation (microseconds from process start).
|
||||||
/// Uses AtomicI64 instead of Mutex for lock-free access.
|
/// Uses AtomicI64 instead of Mutex for lock-free access.
|
||||||
pipeline_start_time_us: AtomicI64,
|
pipeline_start_time_us: AtomicI64,
|
||||||
@@ -340,6 +538,8 @@ impl SharedVideoPipeline {
|
|||||||
running_flag: AtomicBool::new(false),
|
running_flag: AtomicBool::new(false),
|
||||||
sequence: AtomicU64::new(0),
|
sequence: AtomicU64::new(0),
|
||||||
keyframe_requested: AtomicBool::new(false),
|
keyframe_requested: AtomicBool::new(false),
|
||||||
|
parameter_sets: ParkingMutex::new(CachedH26xParameterSets::default()),
|
||||||
|
bootstrap_frame: ParkingRwLock::new(None),
|
||||||
pipeline_start_time_us: AtomicI64::new(0),
|
pipeline_start_time_us: AtomicI64::new(0),
|
||||||
pending_sync_geometry: ParkingMutex::new(None),
|
pending_sync_geometry: ParkingMutex::new(None),
|
||||||
device_lost_reason: 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
|
// Keep at most one pending frame so a slow WebRTC writer cannot make
|
||||||
// the encoder wait or accumulate seconds of latency.
|
// the encoder wait or accumulate seconds of latency.
|
||||||
let (tx, rx) = mpsc::channel(1);
|
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);
|
self.subscribers.write().push(tx);
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
@@ -436,14 +639,13 @@ impl SharedVideoPipeline {
|
|||||||
|
|
||||||
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
|
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
PipelineCmd::SetBitrate { bitrate_kbps, gop } => {
|
PipelineCmd::SetBitrate { preset } => {
|
||||||
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
|
let bitrate_kbps = preset.bitrate_kbps();
|
||||||
let _ = gop;
|
|
||||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||||
if state.ffmpeg_hw_enabled {
|
if state.ffmpeg_hw_enabled {
|
||||||
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
|
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
|
||||||
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| {
|
.map_err(|e| {
|
||||||
let detail = if e.is_empty() {
|
let detail = if e.is_empty() {
|
||||||
ffmpeg_hw_last_error()
|
ffmpeg_hw_last_error()
|
||||||
@@ -512,7 +714,105 @@ impl SharedVideoPipeline {
|
|||||||
let _ = self.h264_profile_level_id.send(Some(profile_level_id));
|
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>) {
|
fn broadcast_encoded(&self, frame: Arc<EncodedVideoFrame>) {
|
||||||
|
if frame.is_keyframe {
|
||||||
|
*self.bootstrap_frame.write() = Some(frame.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let subscribers = {
|
let subscribers = {
|
||||||
let guard = self.subscribers.read();
|
let guard = self.subscribers.read();
|
||||||
if guard.is_empty() {
|
if guard.is_empty() {
|
||||||
@@ -552,19 +852,11 @@ impl SharedVideoPipeline {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*self.parameter_sets.lock() = CachedH26xParameterSets::default();
|
||||||
|
*self.bootstrap_frame.write() = None;
|
||||||
|
|
||||||
let mut config = self.config.read().await.clone();
|
let mut config = self.config.read().await.clone();
|
||||||
let parallel_mjpeg_decode = should_parallel_decode_mjpeg(&config);
|
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();
|
let mut last = self.last_state_notification.lock();
|
||||||
*last = None;
|
*last = None;
|
||||||
@@ -575,7 +867,8 @@ impl SharedVideoPipeline {
|
|||||||
subdev_path.clone(),
|
subdev_path.clone(),
|
||||||
parse_bridge_kind(bridge_kind.as_deref()),
|
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,
|
&device_path,
|
||||||
config.resolution,
|
config.resolution,
|
||||||
config.input_format,
|
config.input_format,
|
||||||
@@ -589,16 +882,9 @@ impl SharedVideoPipeline {
|
|||||||
let negotiated_res = s.resolution();
|
let negotiated_res = s.resolution();
|
||||||
let negotiated_fmt = s.format();
|
let negotiated_fmt = s.format();
|
||||||
let previous = (config.resolution, config.input_format, config.fps);
|
let previous = (config.resolution, config.input_format, config.fps);
|
||||||
if config.control_mode == VideoControlMode::SourceFollowing {
|
config.align_source_fps(s.source_fps());
|
||||||
if let Some(source_fps) = s.source_fps() {
|
|
||||||
config.fps = source_fps.round().clamp(1.0, 120.0) as u32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.resolution = negotiated_res;
|
config.resolution = negotiated_res;
|
||||||
config.input_format = negotiated_fmt;
|
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) {
|
if previous != (config.resolution, config.input_format, config.fps) {
|
||||||
info!(
|
info!(
|
||||||
"Negotiated capture {}x{} {:?} @ {} fps (configured {}x{} {:?} @ {} fps) — aligning encoder to source",
|
"Negotiated capture {}x{} {:?} @ {} fps (configured {}x{} {:?} @ {} fps) — aligning encoder to source",
|
||||||
@@ -633,10 +919,36 @@ impl SharedVideoPipeline {
|
|||||||
Err(e) => return Err(e),
|
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();
|
let mut encoder_config = config.clone();
|
||||||
if parallel_mjpeg_decode {
|
if parallel_mjpeg_decode {
|
||||||
encoder_config.input_format = PixelFormat::Nv12;
|
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 mut encoder_state = build_encoder_state(&encoder_config)?;
|
||||||
let _ = self.running.send(true);
|
let _ = self.running.send(true);
|
||||||
@@ -741,8 +1053,7 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pipeline.clear_cmd_tx();
|
pipeline.clear_cmd_tx();
|
||||||
// Dropping encoder_state here releases AMLENC before a caller
|
// Release encoder resources before allowing a replacement pipeline.
|
||||||
// is allowed to construct a replacement pipeline.
|
|
||||||
drop(encoder_state);
|
drop(encoder_state);
|
||||||
let _ = pipeline.encoder_done.send(true);
|
let _ = pipeline.encoder_done.send(true);
|
||||||
});
|
});
|
||||||
@@ -763,8 +1074,25 @@ impl SharedVideoPipeline {
|
|||||||
let mut pixel_format = config.input_format;
|
let mut pixel_format = config.input_format;
|
||||||
let mut active_fps = config.fps;
|
let mut active_fps = config.fps;
|
||||||
let mut stride: u32 = 0;
|
let mut stride: u32 = 0;
|
||||||
let mut mjpeg_decoder =
|
let mut mjpeg_decode_senders = parallel_mjpeg_decode
|
||||||
parallel_mjpeg_decode.then(|| MjpegToNv12Decoder::new(config.resolution));
|
.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 {
|
if let Some(s) = preopened {
|
||||||
resolution = s.resolution();
|
resolution = s.resolution();
|
||||||
@@ -1109,6 +1437,32 @@ impl SharedVideoPipeline {
|
|||||||
pixel_format,
|
pixel_format,
|
||||||
active_fps,
|
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) =
|
let (frame_data, frame_format, frame_stride) =
|
||||||
if let Some(decoder) = mjpeg_decoder.as_mut() {
|
if let Some(decoder) = mjpeg_decoder.as_mut() {
|
||||||
let nv12_size =
|
let nv12_size =
|
||||||
@@ -1169,23 +1523,7 @@ impl SharedVideoPipeline {
|
|||||||
let input_format = state.input_format;
|
let input_format = state.input_format;
|
||||||
let raw_frame = frame.data();
|
let raw_frame = frame.data();
|
||||||
|
|
||||||
let process_start = PROCESS_START.get_or_init(Instant::now);
|
let pts_ms = self.pts_ms();
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||||
if state.ffmpeg_hw_enabled {
|
if state.ffmpeg_hw_enabled {
|
||||||
@@ -1213,9 +1551,11 @@ impl SharedVideoPipeline {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some((data, is_keyframe)) = packet {
|
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;
|
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
return Ok(vec![EncodedVideoFrame {
|
return Ok(vec![EncodedVideoFrame {
|
||||||
data: Bytes::from(data),
|
data,
|
||||||
pts_ms,
|
pts_ms,
|
||||||
is_keyframe,
|
is_keyframe,
|
||||||
sequence,
|
sequence,
|
||||||
@@ -1295,14 +1635,15 @@ impl SharedVideoPipeline {
|
|||||||
|
|
||||||
let mut encoded_frames = Vec::with_capacity(frames.len());
|
let mut encoded_frames = Vec::with_capacity(frames.len());
|
||||||
for encoded in frames {
|
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;
|
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if codec == VideoEncoderType::H264 {
|
if codec == VideoEncoderType::H264 {
|
||||||
self.update_h264_profile_level_id(&encoded.data);
|
self.update_h264_profile_level_id(&data);
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded_frames.push(EncodedVideoFrame {
|
encoded_frames.push(EncodedVideoFrame {
|
||||||
data: encoded.data,
|
data,
|
||||||
pts_ms,
|
pts_ms,
|
||||||
is_keyframe,
|
is_keyframe,
|
||||||
sequence,
|
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)
|
/// Stop the pipeline (non-blocking, does not wait for capture thread to exit)
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
if self.running_flag.swap(false, Ordering::AcqRel) {
|
if self.running_flag.swap(false, Ordering::AcqRel) {
|
||||||
@@ -1396,13 +1759,11 @@ impl SharedVideoPipeline {
|
|||||||
&self,
|
&self,
|
||||||
preset: crate::video::codec::BitratePreset,
|
preset: crate::video::codec::BitratePreset,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let bitrate_kbps = preset.bitrate_kbps();
|
{
|
||||||
let gop = {
|
|
||||||
let mut config = self.config.write().await;
|
let mut config = self.config.write().await;
|
||||||
config.bitrate_preset = preset;
|
config.bitrate_preset = preset;
|
||||||
config.gop_size()
|
}
|
||||||
};
|
self.send_cmd(PipelineCmd::SetBitrate { preset });
|
||||||
self.send_cmd(PipelineCmd::SetBitrate { bitrate_kbps, gop });
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1597,6 +1958,60 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::video::codec::BitratePreset;
|
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]
|
#[test]
|
||||||
fn test_pipeline_config() {
|
fn test_pipeline_config() {
|
||||||
let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, BitratePreset::Balanced);
|
let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, BitratePreset::Balanced);
|
||||||
@@ -1604,11 +2019,136 @@ mod tests {
|
|||||||
|
|
||||||
let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, BitratePreset::Speed);
|
let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, BitratePreset::Speed);
|
||||||
assert_eq!(h265.output_codec, VideoEncoderType::H265);
|
assert_eq!(h265.output_codec, VideoEncoderType::H265);
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(amlenc_supported_fps(30), 30);
|
#[test]
|
||||||
assert_eq!(amlenc_supported_fps(50), 50);
|
fn h264_keyframe_requires_idr_and_parameter_sets() {
|
||||||
assert_eq!(amlenc_supported_fps(60), 60);
|
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
|
||||||
assert_eq!(amlenc_supported_fps(120), 60);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -195,25 +195,15 @@ impl VideoStreamManager {
|
|||||||
info!("Initializing video stream manager with mode: {:?}", mode);
|
info!("Initializing video stream manager with mode: {:?}", mode);
|
||||||
*self.mode.write().await = mode.clone();
|
*self.mode.write().await = mode.clone();
|
||||||
|
|
||||||
// Check if streamer is already initialized (capturer exists)
|
// A failed fixed-device configuration can leave the streamer in a transient
|
||||||
let needs_init = self.streamer.state().await == StreamerState::Uninitialized;
|
// 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 {
|
if needs_init {
|
||||||
match mode {
|
self.streamer.init_auto().await?;
|
||||||
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.sync_webrtc_capture_source("after init").await;
|
self.sync_webrtc_capture_source("after init").await;
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ pub async fn atx_wol(
|
|||||||
// Send WOL packet
|
// Send WOL packet
|
||||||
crate::atx::send_wol(&mac_address, interface)?;
|
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);
|
warn!("Failed to persist WOL history: {}", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ pub async fn atx_wol_history(
|
|||||||
.unwrap_or(WOL_HISTORY_DEFAULT_LIMIT)
|
.unwrap_or(WOL_HISTORY_DEFAULT_LIMIT)
|
||||||
.clamp(1, WOL_HISTORY_MAX_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
|
let history = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -2,96 +2,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::config::*;
|
use crate::config::*;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::rtsp::RtspService;
|
pub use crate::runtime::{try_apply_lock, ConfigApplyOptions};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::stream_encoder::encoder_type_to_backend;
|
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(
|
pub async fn apply_video_config(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
@@ -189,305 +102,6 @@ pub async fn apply_stream_config(
|
|||||||
Ok(())
|
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(
|
pub async fn apply_atx_config(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
_old_config: &AtxConfig,
|
_old_config: &AtxConfig,
|
||||||
@@ -554,336 +168,3 @@ pub async fn apply_audio_config(
|
|||||||
|
|
||||||
Ok(())
|
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 axum::{extract::State, Json};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::config::HidConfig;
|
use crate::config::HidConfig;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::state::AppState;
|
use crate::web::state::UsbApiState;
|
||||||
|
|
||||||
use super::types::HidConfigUpdate;
|
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())
|
Json(state.config.get().hid.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_hid_config(
|
pub async fn update_hid_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<UsbApiState>,
|
||||||
Json(req): Json<HidConfigUpdate>,
|
Json(req): Json<HidConfigUpdate>,
|
||||||
) -> Result<Json<HidConfig>> {
|
) -> 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)
|
stage_hid_config_update(&mut staged.hid, &req)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::config::MsdConfig;
|
use crate::config::MsdConfig;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::state::AppState;
|
use crate::web::state::UsbApiState;
|
||||||
|
|
||||||
use super::otg::update_otg_config_inner;
|
use super::otg::update_otg_config_inner;
|
||||||
use super::types::{MsdConfigUpdate, OtgConfigUpdate};
|
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())
|
Json(state.config.get().msd.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_msd_config(
|
pub async fn update_msd_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<UsbApiState>,
|
||||||
Json(req): Json<MsdConfigUpdate>,
|
Json(req): Json<MsdConfigUpdate>,
|
||||||
) -> Result<Json<MsdConfig>> {
|
) -> Result<Json<MsdConfig>> {
|
||||||
let response = update_otg_config_inner(
|
let response = update_otg_config_inner(
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
@@ -7,10 +5,10 @@ use typeshare::typeshare;
|
|||||||
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig};
|
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::otg::OtgNetworkStatus;
|
use crate::otg::OtgNetworkStatus;
|
||||||
use crate::state::AppState;
|
use crate::web::state::UsbApiState;
|
||||||
|
|
||||||
use super::types::OtgConfigUpdate;
|
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]
|
#[typeshare]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -22,17 +20,22 @@ pub struct OtgConfigResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_otg_config(
|
pub async fn update_otg_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<UsbApiState>,
|
||||||
Json(request): Json<OtgConfigUpdate>,
|
Json(request): Json<OtgConfigUpdate>,
|
||||||
) -> Result<Json<OtgConfigResponse>> {
|
) -> Result<Json<OtgConfigResponse>> {
|
||||||
update_otg_config_inner(&state, request).await.map(Json)
|
update_otg_config_inner(&state, request).await.map(Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn update_otg_config_inner(
|
pub(super) async fn update_otg_config_inner(
|
||||||
state: &Arc<AppState>,
|
state: &UsbApiState,
|
||||||
request: OtgConfigUpdate,
|
request: OtgConfigUpdate,
|
||||||
) -> Result<OtgConfigResponse> {
|
) -> 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() {
|
let requested_ch9329_descriptor = match request.hid.as_ref() {
|
||||||
Some(update) => stage_hid_config_update(&mut staged.hid, update)?,
|
Some(update) => stage_hid_config_update(&mut staged.hid, update)?,
|
||||||
None => None,
|
None => None,
|
||||||
@@ -54,6 +57,6 @@ pub(super) async fn update_otg_config_inner(
|
|||||||
hid: staged_config.hid,
|
hid: staged_config.hid,
|
||||||
msd: staged_config.msd,
|
msd: staged_config.msd,
|
||||||
network: staged_config.otg_network,
|
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 axum::{extract::State, Json};
|
||||||
|
|
||||||
use crate::config::OtgNetworkConfig;
|
use crate::config::OtgNetworkConfig;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::otg::OtgNetworkStatus;
|
use crate::otg::OtgNetworkStatus;
|
||||||
use crate::state::AppState;
|
use crate::web::state::UsbApiState;
|
||||||
|
|
||||||
use super::otg::update_otg_config_inner;
|
use super::otg::update_otg_config_inner;
|
||||||
use super::types::{OtgConfigUpdate, OtgNetworkConfigUpdate};
|
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())
|
Json(state.config.get().otg_network.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_otg_network_config(
|
pub async fn update_otg_network_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<UsbApiState>,
|
||||||
Json(request): Json<OtgNetworkConfigUpdate>,
|
Json(request): Json<OtgNetworkConfigUpdate>,
|
||||||
) -> Result<Json<OtgNetworkConfig>> {
|
) -> Result<Json<OtgNetworkConfig>> {
|
||||||
let response = update_otg_config_inner(
|
let response = update_otg_config_inner(
|
||||||
@@ -29,6 +27,6 @@ pub async fn update_otg_network_config(
|
|||||||
Ok(Json(response.network))
|
Ok(Json(response.network))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_otg_network_status(State(state): State<Arc<AppState>>) -> Json<OtgNetworkStatus> {
|
pub async fn get_otg_network_status(State(state): State<UsbApiState>) -> Json<OtgNetworkStatus> {
|
||||||
Json(state.otg_service.network_status().await)
|
Json(state.otg.network_status().await)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::error::Result;
|
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 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();
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
candidate.rtsp = config.clone();
|
candidate.rtsp = config.clone();
|
||||||
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
|
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_and_apply(
|
async fn persist_and_apply(
|
||||||
state: &Arc<AppState>,
|
state: &RemoteAccessApiState,
|
||||||
old_config: crate::config::RtspConfig,
|
old_config: crate::config::RtspConfig,
|
||||||
new_config: crate::config::RtspConfig,
|
new_config: crate::config::RtspConfig,
|
||||||
) -> Result<crate::config::RtspConfig> {
|
) -> Result<crate::config::RtspConfig> {
|
||||||
@@ -26,8 +28,9 @@ async fn persist_and_apply(
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let stored_config = state.config.get().rtsp.clone();
|
let stored_config = state.config.get().rtsp.clone();
|
||||||
apply_rtsp_config(
|
state
|
||||||
state,
|
.coordinator
|
||||||
|
.apply_rtsp(
|
||||||
&old_config,
|
&old_config,
|
||||||
&stored_config,
|
&stored_config,
|
||||||
ConfigApplyOptions::preserving_service_state(),
|
ConfigApplyOptions::preserving_service_state(),
|
||||||
@@ -36,21 +39,20 @@ async fn persist_and_apply(
|
|||||||
Ok(stored_config)
|
Ok(stored_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_status(state: &Arc<AppState>) -> crate::rtsp::RtspServiceStatus {
|
async fn current_status(state: &RemoteAccessApiState) -> crate::rtsp::RtspServiceStatus {
|
||||||
let guard = state.rtsp.read().await;
|
state.coordinator.rtsp_status().await
|
||||||
if let Some(ref service) = *guard {
|
|
||||||
service.status().await
|
|
||||||
} else {
|
|
||||||
crate::rtsp::RtspServiceStatus::Stopped
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
let config = state.config.get();
|
||||||
Json(RtspConfigResponse::from(&config.rtsp))
|
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 config = state.config.get().rtsp.clone();
|
||||||
let status = current_status(&state).await;
|
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(
|
pub async fn update_rtsp_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
Json(req): Json<RtspConfigUpdate>,
|
Json(req): Json<RtspConfigUpdate>,
|
||||||
) -> Result<Json<RtspConfigResponse>> {
|
) -> Result<Json<RtspConfigResponse>> {
|
||||||
req.validate()?;
|
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 old_config = state.config.get().rtsp.clone();
|
||||||
let mut merged_config = old_config.clone();
|
let mut merged_config = old_config.clone();
|
||||||
req.apply_to(&mut merged_config);
|
req.apply_to(&mut merged_config);
|
||||||
@@ -73,15 +75,16 @@ pub async fn update_rtsp_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_rtsp_service(
|
pub async fn start_rtsp_service(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Result<Json<RtspStatusResponse>> {
|
) -> 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 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();
|
let mut start_config = stored_config.clone();
|
||||||
start_config.enabled = true;
|
start_config.enabled = true;
|
||||||
apply_rtsp_config(
|
state
|
||||||
&state,
|
.coordinator
|
||||||
|
.apply_rtsp(
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
&start_config,
|
&start_config,
|
||||||
ConfigApplyOptions::runtime_only(),
|
ConfigApplyOptions::runtime_only(),
|
||||||
@@ -93,15 +96,16 @@ pub async fn start_rtsp_service(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_rtsp_service(
|
pub async fn stop_rtsp_service(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Result<Json<RtspStatusResponse>> {
|
) -> 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 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();
|
let mut stop_config = stored_config.clone();
|
||||||
stop_config.enabled = false;
|
stop_config.enabled = false;
|
||||||
apply_rtsp_config(
|
state
|
||||||
&state,
|
.coordinator
|
||||||
|
.apply_rtsp(
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
&stop_config,
|
&stop_config,
|
||||||
ConfigApplyOptions::runtime_only(),
|
ConfigApplyOptions::runtime_only(),
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::rustdesk::config::RustDeskConfig;
|
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 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();
|
let mut candidate = state.config.get().as_ref().clone();
|
||||||
candidate.rustdesk = config.clone();
|
candidate.rustdesk = config.clone();
|
||||||
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
|
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_and_apply(
|
async fn persist_and_apply(
|
||||||
state: &Arc<AppState>,
|
state: &RemoteAccessApiState,
|
||||||
old_config: RustDeskConfig,
|
old_config: RustDeskConfig,
|
||||||
new_config: RustDeskConfig,
|
new_config: RustDeskConfig,
|
||||||
) -> Result<RustDeskConfig> {
|
) -> Result<RustDeskConfig> {
|
||||||
@@ -27,8 +26,9 @@ async fn persist_and_apply(
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let stored_config = state.config.get().rustdesk.clone();
|
let stored_config = state.config.get().rustdesk.clone();
|
||||||
apply_rustdesk_config(
|
state
|
||||||
state,
|
.coordinator
|
||||||
|
.apply_rustdesk(
|
||||||
&old_config,
|
&old_config,
|
||||||
&stored_config,
|
&stored_config,
|
||||||
ConfigApplyOptions::preserving_service_state(),
|
ConfigApplyOptions::preserving_service_state(),
|
||||||
@@ -37,34 +37,32 @@ async fn persist_and_apply(
|
|||||||
Ok(stored_config)
|
Ok(stored_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_status(state: &Arc<AppState>, config: RustDeskConfig) -> RustDeskStatusResponse {
|
async fn current_status(
|
||||||
let (service_status, rendezvous_status) = {
|
state: &RemoteAccessApiState,
|
||||||
let guard = state.rustdesk.read().await;
|
config: RustDeskConfig,
|
||||||
if let Some(ref service) = *guard {
|
) -> RustDeskStatusResponse {
|
||||||
let status = format!("{}", service.status());
|
let runtime = state.coordinator.rustdesk_status().await;
|
||||||
let rv_status = service.rendezvous_status().map(|s| format!("{}", s));
|
|
||||||
(status, rv_status)
|
|
||||||
} else {
|
|
||||||
("not_initialized".to_string(), None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
RustDeskStatusResponse {
|
RustDeskStatusResponse {
|
||||||
config: RustDeskConfigResponse::from(&config),
|
config: RustDeskConfigResponse::from(&config),
|
||||||
service_status,
|
service_status: runtime.service_status,
|
||||||
rendezvous_status,
|
rendezvous_status: runtime.rendezvous_status,
|
||||||
|
connection_count: runtime.connection_count,
|
||||||
|
listening: runtime.listening,
|
||||||
|
listen_port: runtime.listen_port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct RustDeskConfigResponse {
|
pub struct RustDeskConfigResponse {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub mode: crate::rustdesk::config::RustDeskMode,
|
||||||
pub codec: crate::rustdesk::config::RustDeskCodec,
|
pub codec: crate::rustdesk::config::RustDeskCodec,
|
||||||
|
pub direct_access_port: u16,
|
||||||
pub rendezvous_server: String,
|
pub rendezvous_server: String,
|
||||||
pub relay_server: Option<String>,
|
pub relay_server: Option<String>,
|
||||||
pub device_id: String,
|
pub device_id: String,
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
pub has_keypair: bool,
|
|
||||||
pub relay_key: Option<String>,
|
pub relay_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +70,13 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse {
|
|||||||
fn from(config: &RustDeskConfig) -> Self {
|
fn from(config: &RustDeskConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
|
mode: config.mode,
|
||||||
codec: config.codec,
|
codec: config.codec,
|
||||||
|
direct_access_port: config.direct_access_port,
|
||||||
rendezvous_server: config.rendezvous_server.clone(),
|
rendezvous_server: config.rendezvous_server.clone(),
|
||||||
relay_server: config.relay_server.clone(),
|
relay_server: config.relay_server.clone(),
|
||||||
device_id: config.device_id.clone(),
|
device_id: config.device_id.clone(),
|
||||||
has_password: !config.device_password.is_empty(),
|
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(),
|
relay_key: config.relay_key.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,28 +87,31 @@ pub struct RustDeskStatusResponse {
|
|||||||
pub config: RustDeskConfigResponse,
|
pub config: RustDeskConfigResponse,
|
||||||
pub service_status: String,
|
pub service_status: String,
|
||||||
pub rendezvous_status: Option<String>,
|
pub rendezvous_status: Option<String>,
|
||||||
|
pub connection_count: usize,
|
||||||
|
pub listening: bool,
|
||||||
|
pub listen_port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_rustdesk_config(
|
pub async fn get_rustdesk_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Json<RustDeskConfigResponse> {
|
) -> Json<RustDeskConfigResponse> {
|
||||||
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
|
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_rustdesk_status(
|
pub async fn get_rustdesk_status(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Json<RustDeskStatusResponse> {
|
) -> Json<RustDeskStatusResponse> {
|
||||||
let config = state.config.get().rustdesk.clone();
|
let config = state.config.get().rustdesk.clone();
|
||||||
Json(current_status(&state, config).await)
|
Json(current_status(&state, config).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_rustdesk_config(
|
pub async fn update_rustdesk_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
Json(req): Json<RustDeskConfigUpdate>,
|
Json(req): Json<RustDeskConfigUpdate>,
|
||||||
) -> Result<Json<RustDeskConfigResponse>> {
|
) -> Result<Json<RustDeskConfigResponse>> {
|
||||||
req.validate()?;
|
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 old_config = state.config.get().rustdesk.clone();
|
||||||
let mut merged_config = old_config.clone();
|
let mut merged_config = old_config.clone();
|
||||||
req.apply_to(&mut merged_config);
|
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 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)))
|
Ok(Json(RustDeskConfigResponse::from(&new_config)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn regenerate_device_id(
|
pub async fn regenerate_device_id(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Result<Json<RustDeskConfigResponse>> {
|
) -> Result<Json<RustDeskConfigResponse>> {
|
||||||
state
|
let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?;
|
||||||
.config
|
let old_config = state.config.get().rustdesk.clone();
|
||||||
.update(|config| {
|
let mut regenerated = old_config.clone();
|
||||||
config.rustdesk.device_id = RustDeskConfig::generate_device_id();
|
regenerated.device_id = RustDeskConfig::generate_device_id();
|
||||||
})
|
let new_config = persist_and_apply(&state, old_config, regenerated).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let new_config = state.config.get().rustdesk.clone();
|
|
||||||
Ok(Json(RustDeskConfigResponse::from(&new_config)))
|
Ok(Json(RustDeskConfigResponse::from(&new_config)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn regenerate_device_password(
|
pub async fn regenerate_device_password(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Result<Json<RustDeskConfigResponse>> {
|
) -> Result<Json<RustDeskConfigResponse>> {
|
||||||
state
|
let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?;
|
||||||
.config
|
let old_config = state.config.get().rustdesk.clone();
|
||||||
.update(|config| {
|
let mut regenerated = old_config.clone();
|
||||||
config.rustdesk.device_password = RustDeskConfig::generate_password();
|
regenerated.device_password = RustDeskConfig::generate_password();
|
||||||
})
|
let new_config = persist_and_apply(&state, old_config, regenerated).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let new_config = state.config.get().rustdesk.clone();
|
|
||||||
Ok(Json(RustDeskConfigResponse::from(&new_config)))
|
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();
|
let config = state.config.get().rustdesk.clone();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"device_id": config.device_id,
|
"device_id": config.device_id,
|
||||||
@@ -165,15 +155,16 @@ pub async fn get_device_password(State(state): State<Arc<AppState>>) -> Json<ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_rustdesk_service(
|
pub async fn start_rustdesk_service(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Result<Json<RustDeskStatusResponse>> {
|
) -> 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 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();
|
let mut start_config = stored_config.clone();
|
||||||
start_config.enabled = true;
|
start_config.enabled = true;
|
||||||
apply_rustdesk_config(
|
state
|
||||||
&state,
|
.coordinator
|
||||||
|
.apply_rustdesk(
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
&start_config,
|
&start_config,
|
||||||
ConfigApplyOptions::runtime_only(),
|
ConfigApplyOptions::runtime_only(),
|
||||||
@@ -184,15 +175,16 @@ pub async fn start_rustdesk_service(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_rustdesk_service(
|
pub async fn stop_rustdesk_service(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<RemoteAccessApiState>,
|
||||||
) -> Result<Json<RustDeskStatusResponse>> {
|
) -> 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 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();
|
let mut stop_config = stored_config.clone();
|
||||||
stop_config.enabled = false;
|
stop_config.enabled = false;
|
||||||
apply_rustdesk_config(
|
state
|
||||||
&state,
|
.coordinator
|
||||||
|
.apply_rustdesk(
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
&stop_config,
|
&stop_config,
|
||||||
ConfigApplyOptions::runtime_only(),
|
ConfigApplyOptions::runtime_only(),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub async fn update_stream_config(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
super::apply::enforce_stream_codec_constraints(&state).await?;
|
state.remote_access.enforce_codec_constraints().await?;
|
||||||
|
|
||||||
Ok(Json(StreamConfigResponse::from(&new_stream_config)))
|
Ok(Json(StreamConfigResponse::from(&new_stream_config)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,10 +402,15 @@ impl Ch9329DescriptorConfigUpdate {
|
|||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct HidConfigUpdate {
|
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 backend: Option<HidBackend>,
|
||||||
pub ch9329_port: Option<String>,
|
pub ch9329_port: Option<String>,
|
||||||
pub ch9329_baudrate: Option<u32>,
|
pub ch9329_baudrate: Option<u32>,
|
||||||
pub ch9329_hybrid_mouse: Option<bool>,
|
pub ch9329_hybrid_mouse: Option<bool>,
|
||||||
|
#[serde(alias = "ch9329_macos_drag")]
|
||||||
|
pub mouse_macos_drag: Option<bool>,
|
||||||
pub ch9329_descriptor: Option<Ch9329DescriptorConfigUpdate>,
|
pub ch9329_descriptor: Option<Ch9329DescriptorConfigUpdate>,
|
||||||
pub otg_udc: Option<String>,
|
pub otg_udc: Option<String>,
|
||||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||||
@@ -426,6 +431,9 @@ pub struct OtgConfigUpdate {
|
|||||||
|
|
||||||
impl HidConfigUpdate {
|
impl HidConfigUpdate {
|
||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
|
if let Some(config) = &self.bluetooth {
|
||||||
|
config.validate()?;
|
||||||
|
}
|
||||||
if let Some(baudrate) = self.ch9329_baudrate {
|
if let Some(baudrate) = self.ch9329_baudrate {
|
||||||
let valid_rates = [9600, 19200, 38400, 57600, 115200];
|
let valid_rates = [9600, 19200, 38400, 57600, 115200];
|
||||||
if !valid_rates.contains(&baudrate) {
|
if !valid_rates.contains(&baudrate) {
|
||||||
@@ -444,6 +452,9 @@ impl HidConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_to(&self, config: &mut HidConfig) {
|
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() {
|
if let Some(backend) = self.backend.clone() {
|
||||||
config.backend = backend;
|
config.backend = backend;
|
||||||
}
|
}
|
||||||
@@ -456,6 +467,9 @@ impl HidConfigUpdate {
|
|||||||
if let Some(enabled) = self.ch9329_hybrid_mouse {
|
if let Some(enabled) = self.ch9329_hybrid_mouse {
|
||||||
config.ch9329_hybrid_mouse = enabled;
|
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 {
|
if let Some(ref desc) = self.ch9329_descriptor {
|
||||||
desc.apply_to(&mut config.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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RustDeskConfigUpdate {
|
pub struct RustDeskConfigUpdate {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
pub mode: Option<crate::rustdesk::config::RustDeskMode>,
|
||||||
pub codec: Option<crate::rustdesk::config::RustDeskCodec>,
|
pub codec: Option<crate::rustdesk::config::RustDeskCodec>,
|
||||||
|
pub direct_access_port: Option<u16>,
|
||||||
pub rendezvous_server: Option<String>,
|
pub rendezvous_server: Option<String>,
|
||||||
pub relay_server: Option<String>,
|
pub relay_server: Option<String>,
|
||||||
pub relay_key: Option<String>,
|
pub relay_key: Option<String>,
|
||||||
@@ -908,6 +924,11 @@ pub struct RustDeskConfigUpdate {
|
|||||||
|
|
||||||
impl RustDeskConfigUpdate {
|
impl RustDeskConfigUpdate {
|
||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
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)
|
// Validate rendezvous server format (should be host:port)
|
||||||
if let Some(ref server) = self.rendezvous_server {
|
if let Some(ref server) = self.rendezvous_server {
|
||||||
if !server.is_empty() && !server.contains(':') {
|
if !server.is_empty() && !server.contains(':') {
|
||||||
@@ -944,11 +965,25 @@ impl RustDeskConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_merged(&self, config: &RustDeskConfig) -> crate::error::Result<()> {
|
pub fn validate_merged(&self, config: &RustDeskConfig) -> crate::error::Result<()> {
|
||||||
if config.enabled && config.rendezvous_server.trim().is_empty() {
|
if config.enabled {
|
||||||
|
match config.mode {
|
||||||
|
crate::rustdesk::config::RustDeskMode::Id
|
||||||
|
if config.rendezvous_server.trim().is_empty() =>
|
||||||
|
{
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"RustDesk ID server is required".into(),
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,9 +991,15 @@ impl RustDeskConfigUpdate {
|
|||||||
if let Some(enabled) = self.enabled {
|
if let Some(enabled) = self.enabled {
|
||||||
config.enabled = enabled;
|
config.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(mode) = self.mode {
|
||||||
|
config.mode = mode;
|
||||||
|
}
|
||||||
if let Some(codec) = self.codec {
|
if let Some(codec) = self.codec {
|
||||||
config.codec = 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 {
|
if let Some(ref server) = self.rendezvous_server {
|
||||||
config.rendezvous_server = server.clone();
|
config.rendezvous_server = server.clone();
|
||||||
}
|
}
|
||||||
@@ -1466,7 +1507,9 @@ mod tests {
|
|||||||
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
||||||
let update = RustDeskConfigUpdate {
|
let update = RustDeskConfigUpdate {
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
mode: None,
|
||||||
codec: None,
|
codec: None,
|
||||||
|
direct_access_port: None,
|
||||||
rendezvous_server: None,
|
rendezvous_server: None,
|
||||||
relay_server: None,
|
relay_server: None,
|
||||||
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
||||||
@@ -1481,7 +1524,9 @@ mod tests {
|
|||||||
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
||||||
let update = RustDeskConfigUpdate {
|
let update = RustDeskConfigUpdate {
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
mode: None,
|
||||||
codec: None,
|
codec: None,
|
||||||
|
direct_access_port: None,
|
||||||
rendezvous_server: None,
|
rendezvous_server: None,
|
||||||
relay_server: None,
|
relay_server: None,
|
||||||
relay_key: Some(not_32),
|
relay_key: Some(not_32),
|
||||||
@@ -1490,6 +1535,49 @@ mod tests {
|
|||||||
assert!(update.validate().is_err());
|
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]
|
#[test]
|
||||||
fn ipv6_bind_vnc_config_accepts_ipv6_literals() {
|
fn ipv6_bind_vnc_config_accepts_ipv6_literals() {
|
||||||
for bind in ["::", "::1", "2001:db8::1"] {
|
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