Compare commits

..

39 Commits
v260802 ... dev

Author SHA1 Message Date
SilentWind
aef136241a Merge pull request #304 from drgnchan/fix/ch9329-macos-drag
fix(hid): add macOS drag compatibility for OTG and CH9329
2026-09-10 23:02:29 +08:00
mofeng-git
78af9f67de fix(hid): share macOS drag compatibility across OTG and CH9329 2026-09-10 23:00:34 +08:00
mofeng-git
1967d22dff Merge remote-tracking branch 'origin/dev' into review/pr304-macos 2026-09-10 22:42:18 +08:00
mofeng-git
0cba8ab127 fix: 固定 Debian 11 软件源快照以修复交叉编译 2026-09-10 14:08:17 +08:00
raymond
d002226acb merge: resolve dev conflicts for CH9329 macOS drag compatibility 2026-09-07 23:26:57 +08:00
mofeng-git
1db572e020 feat(web): 完善控制台视频与电源控件 2026-09-06 11:20:11 +08:00
mofeng-git
5de5ee42c2 feat(web): 新增 HID 配置引导 2026-09-06 11:19:55 +08:00
mofeng-git
2c19208094 feat(bluetooth-hid): 支持经典蓝牙 HID 后端 2026-09-06 11:17:58 +08:00
mofeng-git
3014edffbb fix(db): 使用 FULL 同步确保事务提交时落盘 2026-09-05 22:40:01 +08:00
mofeng-git
ece4f303ce feat(web): 新增控制台布局切换并完善状态与磁盘交互
新增经典、悬浮和侧栏布局,保存用户偏好,并调整工具栏、状态摘要及窄屏交互。
统一控制台弹层外观、焦点行为和中英文文案。
根据虚拟磁盘文件访问能力显示浏览入口、容量信息和不可用状态。

验证:npm run build 通过(vue-tsc 类型检查和 Vite 生产构建)。
2026-09-05 20:59:15 +08:00
mofeng-git
db9d79554a perf(video): 新增 RKMPP DMA 采集编码通路并完善恢复逻辑
支持原生 HDMI 和 UVC 缓冲区导出,增加同步 RKMPP 编码及可选 MJPEG 硬件转码。
校验帧布局和缓冲区租约,在 DMA 不可用或编码失败时回退到复制通路。
保留自定义码率和 GOP 策略,重开采集时同步 HDMI 源帧率,并区分 UVC 超时状态。

验证:88 个视频测试通过(含 4 个新增回归测试);ARM64 cargo check --tests 通过。
2026-09-05 20:55:30 +08:00
mofeng-git
620fe0be54 perf(video): 并行解码 MJPEG 并丢弃积压帧
按可用 CPU 数量启动独立 MJPEG 解码线程,通过无缓冲通道分发任务;线程全忙时丢弃新帧,按序列号拒绝过时解码结果。

复用帧缓冲池,在线程启动失败时回退到采集线程解码,并更新并行解码说明及工作线程数量测试。
2026-09-05 14:10:53 +08:00
mofeng-git
79a4dcf2b0 fix(video): 统一关键帧参数集处理并缓存启动帧
在共享管线中将长度前缀 H264 转为 Annex-B,要求编码器标记与 IDR/IRAP 内容一致,并补齐参数集后才标记可独立解码的关键帧。

向新订阅者提供缓存启动帧,启动管线时清理缓存,并移除 RustDesk 会话内重复的 SPS/PPS 处理。补充格式归一化、关键帧判定和订阅测试。
2026-09-05 14:10:53 +08:00
mofeng-git
a8ee9552a0 perf(video): 使用有界 VBR 优化 H264 码率
H264 编码器探测与初始化使用 VBR,将峰值码率和缓冲区限制为目标码率的两倍。动态调整码率时同步更新限制,并保留 QSV 的专用处理。
2026-09-05 14:10:53 +08:00
mofeng-git
3749bafae0 fix(rustdesk): 按键盘模式和码空间转换 HID 输入
将对外兼容平台与 Windows Set-1 扫描码映射绑定,分离物理键、字符和控制键语义,拒绝未知码值跨码空间回退。

保留按下、释放、重复输入与临时 Shift 的状态语义,补充键位矩阵和事件转换回归测试,并记录输入模式及原始码值。
2026-09-05 14:10:53 +08:00
mofeng-git
b573f478fc fix(msd): 区分虚拟磁盘挂载能力与文件访问状态
使用原始文件元数据挂载虚拟磁盘,允许主机使用网页文件管理不支持的文件系统。连接期间只读取元数据;初始化和删除持有外层写锁,并同步控制器缓存。

API 的 used/free 改为可空值,新增 file_access 表示 available、unsupported、blocked_while_connected 或 unknown,需要前端配套处理。补充状态序列化、元数据校验和文件系统兼容测试。
2026-09-05 14:10:53 +08:00
mofeng-git
d67a3ab7f8 fix(build): 在运行时解析编解码依赖源码路径
hwcodec 与 libyuv 构建脚本在运行时读取 CARGO_MANIFEST_DIR,避免复用或迁移构建产物后继续引用旧源码目录。
2026-09-05 14:10:53 +08:00
mofeng-git
dbe5672df4 refactor: 优化数据库初始化与 WOL 存储 2026-08-30 21:27:00 +08:00
mofeng-git
48fd64319e merge: 合并 RustDesk 直连与会话优化 2026-08-30 20:50:03 +08:00
mofeng-git
ccf821ab22 refactor(rustdesk): 移除密钥对状态展示 2026-08-30 20:48:55 +08:00
mofeng-git
b074aa5779 fix(rustdesk): 降低视频直连延迟 2026-08-30 08:50:38 +08:00
mofeng-git
95a1fdf42d feat(rustdesk): 强化会话管理并优化流媒体传输 2026-08-30 08:21:37 +08:00
mofeng-git
e678ec394d feat(rustdesk): 新增独占式 IP 直连模式 2026-08-30 08:21:27 +08:00
raymond
628909a192 fix(hid): add CH9329 macOS drag compatibility 2026-08-29 01:30:18 +08:00
mofeng-git
5cab585115 Merge remote-tracking branch 'origin/main' into dev 2026-08-26 16:39:31 +08:00
Carbon
dcfa3eadaf fix: 为 RKCIF/RK628 选择受支持的视频格式 (#292)
RK628 通过 RKCIF 接入时,如果 One-KVM 启动时 HDMI 信号尚未锁定,
SourceFollowing 路径可能回退到默认 MJPEG。由于 RKCIF 不支持 MJPEG,
后续 S_FMT 无法建立有效的采集链路。

解决方案:
在解析 SourceFollowing 配置后,根据设备的 VIDIOC_ENUM_FMT 枚举结果
校验最终格式。如果该格式不受支持且格式列表非空,则选择按优先级排序
的首个可用格式,对于 RKCIF/RK628 通常为 NV12。

该方法只替换 FourCC,保留 HDMI DV timings 提供的分辨率和帧率。
同时覆盖无信号启动和当前格式已失效两种场景。
2026-08-26 16:37:57 +08:00
Carbon
1647e70243 fix: RK3588 原生 HDMIRX 被错误地关联到了 RK628 subdev (#293)
* fix: RK3588 原生 HDMIRX 被错误地关联到了 RK628 subdev

当 RK628 和 RK3588 原生 HDMI IN 同时存在时,页面配置为 rk_hdmirx,
会无法使用,提示无信号。

是因为原来的 subdev 发现逻辑会全局扫描 v4l-subdev,并返回首个匹配节点。
原生 HDMI IN 可能错误使用 RK628 subdev 的信号状态,最终被判断为无信号。

解决方案:
根据 video 节点的设备号找到对应 media device,读取 Media Controller
中的 entity 和已启用链路,只返回与当前 video 节点实际连通的 HDMI
bridge subdev。

* fix: 精确匹配视频设备的 Media 拓扑

---------

Co-authored-by: mofeng-git <mofeng654321@hotmail.com>
2026-08-26 16:32:03 +08:00
BigfootACA
9c6d8a614c fix(webrtc): preserve H.26x headers for new sessions
Detect random-access frames from Annex-B data and cache codec headers.
Prepend missing headers to later H.264 IDR and H.265 IRAP frames.
Forward standalone headers while WebRTC waits for a decodable frame.

Signed-off-by: BigfootACA <bigfoot@radxa.com>
2026-08-26 15:51:55 +08:00
BigfootACA
db7a845d3b fix(hwcodec): release V4L2M2M packets before delivery
Copy compressed V4L2M2M output before asynchronous delivery.
This releases driver capture buffers and prevents encoder stalls.

Signed-off-by: BigfootACA <bigfoot@radxa.com>
2026-08-26 15:51:55 +08:00
a15355447898
1d48d45592 feat: 支持高通 iris v4l2m2m 硬件编码器
- 新增高通平台检测,避免 Amlogic v4l2m2m 检测误判
- 对高通 Iris 编码器调整 GOP 和 capture buffers
- 强制关键帧仅在高通 Iris 之外启用
2026-08-26 15:51:55 +08:00
TXIuTnVsbA
d613739b06 fix(hid): 兼容 CH9329/CH9329F 串口响应 (#300)
支持 CH9329/CH9329F 扩展参数响应、粘包与乱序处理,保留严格校验和及有界接收缓冲。
2026-08-26 15:19:41 +08:00
mofeng-git
6e1194fe4c refactor: 移除 AMLENC 私有编码后端 2026-08-26 12:06:10 +08:00
mofeng-git
f101ddb918 refactor: 降低 hwcodec 探测日志噪音 2026-08-26 12:04:52 +08:00
mofeng-git
0054100414 refactor: 精简运行时重复初始化
统一服务与 CLI 的数据库初始化入口,由视频流管理器负责 WebRTC 采集源同步,并将 MSD 目录创建收敛到控制器内部。
2026-08-26 11:37:39 +08:00
mofeng-git
486f3887c2 refactor: 简化日志级别配置并移除 verbose 参数 2026-08-26 11:04:40 +08:00
mofeng-git
37af369b43 refactor: 简化运行时与服务协调
提取运行时生命周期管理,集中远程访问与 USB 协调,并缩小 Web 路由状态依赖。
2026-08-26 10:53:08 +08:00
mofeng-git
827d24fde4 feat: 增加 Amlogic 硬件编码的并行解码支持 2026-08-25 14:58:09 +08:00
mofeng-git
b8ea38b5f3 feat: 增加 s905 晶晨 H.264 硬件编码支持 2026-08-15 21:26:34 +08:00
mofeng-git
4530d8663d fix: 将电源管理选项自动隐藏修改为手动控制,避免 WOL 功能无法显示 #289 2026-08-07 15:49:58 +08:00
163 changed files with 13240 additions and 5297 deletions

View File

@@ -70,6 +70,7 @@ desktop = [
"dep:v4l2r",
"dep:alsa",
"dep:gpio-cdev",
"dep:one-kvm-bluetooth-hid",
"dep:cpal",
"dep:windows-sys",
]
@@ -179,6 +180,8 @@ turbojpeg = { version = "1.3", optional = true }
audiopus = { version = "0.2", optional = true }
[target.'cfg(target_os = "linux")'.dependencies]
one-kvm-bluetooth-hid = { path = "libs/bluetooth-hid", optional = true }
# Utilities
nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }

View File

@@ -17,6 +17,8 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
apt-get install -y --no-install-recommends \
# Core runtime (all platforms) - no codec libs needed
ca-certificates \
# Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter.
bluez \
libudev1 \
libasound2 \
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)

View File

@@ -17,6 +17,8 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
apt-get install -y --no-install-recommends \
# Core runtime (all platforms) - no codec libs needed
ca-certificates \
# Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter.
bluez \
libudev1 \
libasound2 \
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)

View File

@@ -4,6 +4,7 @@
ARG DEBIAN_IMAGE=debian:11
FROM ${DEBIAN_IMAGE}
ARG DEBIAN_SNAPSHOT=20260713T000000Z
ARG CHINAMIRRO=0
ARG GH_PROXY=https://gh-proxy.com/
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
@@ -19,12 +20,11 @@ ARG RKMPP_BRANCH=jellyfin-mpp
ARG RKRGA_BRANCH=jellyfin-rga
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
# Optionally use China mirrors for builds in China.
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i -E \
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
/etc/apt/sources.list; \
fi
RUN printf '%s\n' \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
> /etc/apt/sources.list
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \

View File

@@ -4,6 +4,7 @@
ARG DEBIAN_IMAGE=debian:11
FROM ${DEBIAN_IMAGE}
ARG DEBIAN_SNAPSHOT=20260713T000000Z
ARG CHINAMIRRO=0
ARG GH_PROXY=https://gh-proxy.com/
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
@@ -19,12 +20,11 @@ ARG RKMPP_BRANCH=jellyfin-mpp
ARG RKRGA_BRANCH=jellyfin-rga
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
# Optionally use China mirrors for builds in China.
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i -E \
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
/etc/apt/sources.list; \
fi
RUN printf '%s\n' \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
> /etc/apt/sources.list
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \

View File

@@ -4,6 +4,7 @@
ARG DEBIAN_IMAGE=debian:11
FROM ${DEBIAN_IMAGE}
ARG DEBIAN_SNAPSHOT=20260713T000000Z
ARG CHINAMIRRO=0
ARG GH_PROXY=https://gh-proxy.com/
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
@@ -17,12 +18,11 @@ ARG X265_VERSION=3.4
ARG OPUS_VERSION=1.5.2
ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b
# Optionally use China mirrors for builds in China.
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i -E \
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
/etc/apt/sources.list; \
fi
RUN printf '%s\n' \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye main" \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ bullseye-security main" \
"deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ bullseye-updates main" \
> /etc/apt/sources.list
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \

View File

@@ -6,6 +6,7 @@ Maintainer: SilentWind <admin@mofeng.run>
Package: one-kvm
Architecture: {arch}
Depends: ${{auto}}, ca-certificates{distsuffix}
Recommends: bluez
Description: A open and lightweight IP-KVM solution written in Rust
Enables BIOS-level remote management of servers and workstations.
.
@@ -17,6 +18,7 @@ Description: A open and lightweight IP-KVM solution written in Rust
* Hardware-accelerated video encoding (VAAPI, QSV, RKMPP)
* WebRTC and MJPEG streaming with low latency
* USB HID emulation via OTG gadget
* Classic Bluetooth keyboard and mouse via BlueZ
* Mass storage device for ISO/IMG mounting
* ATX power control via GPIO or USB relay
Homepage: https://github.com/mofeng-git/One-KVM

View File

@@ -1,7 +1,7 @@
[Unit]
Description=One-KVM IP-KVM Service
Documentation=https://github.com/mofeng-git/One-KVM
After=network-online.target
After=network-online.target bluetooth.service
Wants=network-online.target
[Service]

View File

@@ -139,6 +139,7 @@ Section: admin
Priority: optional
Architecture: $DEB_ARCH
Depends: $DEPS
Recommends: bluez
Maintainer: SilentWind <admin@mofeng.run>
Description: A open and lightweight IP-KVM solution
Enables BIOS-level remote management of servers and workstations.

View 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 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。

View 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"

View 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)
}

View 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(())
}

View 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());
}
}

View 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());
}
}

View 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("; "))
}
}
}

View 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 164 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 10300 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)
}

View 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]);
}
}

View File

@@ -5,7 +5,8 @@ use std::{
};
fn main() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Read the current source path when running, since build artifacts may move.
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let cpp_dir = manifest_dir.join("cpp");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed={}", cpp_dir.display());
@@ -17,7 +18,7 @@ fn main() {
}
fn build_common(builder: &mut Build) {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let common_dir = manifest_dir.join("cpp").join("common");
@@ -367,7 +368,7 @@ mod ffmpeg {
}
fn ffmpeg_ffi() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let ffmpeg_ram_dir = manifest_dir.join("cpp").join("common");
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
println!("cargo:rerun-if-changed={}", ffi_header_path.display());
@@ -381,7 +382,7 @@ mod ffmpeg {
}
fn build_ffmpeg_ram(builder: &mut Build) {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let ffmpeg_ram_dir = manifest_dir.join("cpp").join("ffmpeg_ram");
let ffi_header = ffmpeg_ram_dir
.join("ffmpeg_ram_ffi.h")
@@ -420,7 +421,7 @@ mod ffmpeg {
return;
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let capture_header = manifest_dir
.join("cpp")
.join("ffmpeg_capture_ffi.h")
@@ -443,7 +444,7 @@ mod ffmpeg {
}
fn build_ffmpeg_hw(builder: &mut Build) {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let ffmpeg_hw_dir = manifest_dir.join("cpp").join("ffmpeg_hw");
let ffi_header = ffmpeg_hw_dir
.join("ffmpeg_hw_ffi.h")
@@ -487,6 +488,7 @@ mod ffmpeg {
}
}
builder.file(ffmpeg_hw_dir.join("ffmpeg_hw_mjpeg_h26x.cpp"));
builder.file(ffmpeg_hw_dir.join("rkmpp_dmabuf.cpp"));
} else {
println!(
"cargo:info=Skipping ffmpeg_hw_mjpeg_h26x.cpp (RKMPP) for arch {}",

View File

@@ -146,6 +146,67 @@ int linux_support_v4l2m2m() {
return false;
};
auto is_qcom_platform = [&]() -> bool {
const char *platform_hints[] = {
"qcom",
"qualcomm",
"venus",
"iris",
"sc7280",
"qcm6490",
"qcs6490",
};
const char *platform_files[] = {
"/proc/device-tree/compatible",
"/proc/device-tree/model",
"/sys/firmware/devicetree/base/compatible",
"/sys/firmware/devicetree/base/model",
};
for (size_t i = 0; i < sizeof(platform_files) / sizeof(platform_files[0]); i++) {
std::string value;
if (read_text_file(platform_files[i], &value) &&
contains_any(to_lower(value), platform_hints,
sizeof(platform_hints) / sizeof(platform_hints[0]))) {
return true;
}
}
const char *video_nodes[] = {
"video0",
"video1",
"video2",
"video3",
"video10",
"video11",
"video32",
};
const char *video_hints[] = {
"qcom-iris",
"qcom,",
"venus",
"iris",
};
for (size_t i = 0; i < sizeof(video_nodes) / sizeof(video_nodes[0]); i++) {
std::string name;
std::string modalias;
const std::string base = std::string("/sys/class/video4linux/") + video_nodes[i];
if (read_text_file((base + "/name").c_str(), &name) &&
contains_any(to_lower(name), video_hints, sizeof(video_hints) / sizeof(video_hints[0]))) {
return true;
}
if (read_text_file((base + "/device/modalias").c_str(), &modalias) &&
contains_any(to_lower(modalias), video_hints,
sizeof(video_hints) / sizeof(video_hints[0]))) {
return true;
}
}
return false;
};
auto is_amlogic_platform = [&]() -> bool {
const char *platform_hints[] = {
"amlogic",
@@ -210,7 +271,8 @@ int linux_support_v4l2m2m() {
return false;
};
const bool amlogic_platform = is_amlogic_platform();
const bool qcom_platform = is_qcom_platform();
const bool amlogic_platform = !qcom_platform && is_amlogic_platform();
if (amlogic_platform && !v4l2m2m_allowed()) {
LOG_WARN(std::string(
"V4L2 M2M: skipped probe on Amlogic platform; set ONE_KVM_V4L2M2M_ALLOW=1 to enable"));

View File

@@ -4,6 +4,9 @@ extern "C" {
}
#include "util.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <limits>
#include <map>
#include <string.h>
@@ -45,17 +48,46 @@ bool is_software_hevc(const std::string &name) {
return true;
}
bool is_qcom_iris_driver() {
const char *driver_path = "/sys/class/video4linux/video1/name";
std::ifstream file(driver_path);
if (!file.is_open()) return false;
std::string value;
std::getline(file, value, '\0');
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return value.find("qcom-iris") != std::string::npos ||
value.find("iris-encoder") != std::string::npos ||
value.find("iris") != std::string::npos;
}
} // anonymous namespace
namespace util_encode {
bool is_qcom_iris_platform() {
return is_qcom_iris_driver();
}
bool supports_forced_keyframe(const std::string &name) {
if (name.find("v4l2m2m") != std::string::npos && is_qcom_iris_platform()) {
return false;
}
return true;
}
void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs,
int gop, int fps, int thread_count) {
c->has_b_frames = 0;
c->max_b_frames = 0;
if (gop > 0 && gop < std::numeric_limits<int16_t>::max()) {
c->gop_size = gop;
c->keyint_min = gop; // Match keyint_min to gop for consistent keyframe interval
const bool qcom_iris_v4l2 =
name.find("v4l2m2m") != std::string::npos && is_qcom_iris_platform();
const int effective_gop = qcom_iris_v4l2 ? std::max(5, fps / 3) : gop;
if (effective_gop > 0 && effective_gop < std::numeric_limits<int16_t>::max()) {
c->gop_size = effective_gop;
c->keyint_min = effective_gop; // Match keyint_min to gop for consistent keyframe interval
} else if (name.find("vaapi") != std::string::npos) {
c->gop_size = fps > 0 ? fps : 30; // Default to 1 second keyframe interval
c->keyint_min = c->gop_size;
@@ -120,7 +152,8 @@ bool set_lantency_free(void *priv_data, const std::string &name) {
}
if (name.find("amf") != std::string::npos) {
if ((ret = av_opt_set(priv_data, "query_timeout", "1000", 0)) < 0) {
LOG_WARN(std::string("amf query_timeout option is unavailable, ret = ") + av_err2str(ret));
LOG_DEBUG(std::string("amf query_timeout option is unavailable, ret = ") +
av_err2str(ret));
}
}
if (name.find("qsv") != std::string::npos) {
@@ -139,7 +172,8 @@ bool set_lantency_free(void *priv_data, const std::string &name) {
if (name.find("rkmpp") != std::string::npos) {
// Set async_depth to 1 for minimal buffering (0 = synchronous, higher = more buffering)
if ((ret = av_opt_set(priv_data, "async_depth", "1", 0)) < 0) {
LOG_WARN(std::string("rkmpp set async_depth failed, ret = ") + av_err2str(ret));
LOG_DEBUG(std::string("rkmpp async_depth option is unavailable, ret = ") +
av_err2str(ret));
// Not fatal - older FFmpeg versions may not support this option
}
}
@@ -147,11 +181,14 @@ bool set_lantency_free(void *priv_data, const std::string &name) {
if (name.find("v4l2m2m") != std::string::npos) {
// Minimize number of output buffers for lower latency
if ((ret = av_opt_set_int(priv_data, "num_output_buffers", 4, 0)) < 0) {
LOG_WARN(std::string("v4l2m2m set num_output_buffers failed, ret = ") + av_err2str(ret));
LOG_DEBUG(std::string("v4l2m2m num_output_buffers option is unavailable, ret = ") +
av_err2str(ret));
// Not fatal
}
if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 4, 0)) < 0) {
LOG_WARN(std::string("v4l2m2m set num_capture_buffers failed, ret = ") + av_err2str(ret));
const int capture_buffers = is_qcom_iris_driver() ? 12 : 8;
if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", capture_buffers, 0)) < 0) {
LOG_DEBUG(std::string("v4l2m2m num_capture_buffers option is unavailable, ret = ") +
av_err2str(ret));
// Not fatal
}
}
@@ -360,6 +397,14 @@ struct CodecOptions {
bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
int q) {
// Remote-desktop content is usually sparse. VBR avoids padding static
// frames up to the target bitrate while allowing short bursts for screen
// changes. Keep those bursts bounded at twice the target bitrate.
if (rc == RC_VBR && c->bit_rate > 0) {
c->rc_max_rate = c->bit_rate * 2;
c->rc_buffer_size = c->rc_max_rate;
}
if (name.find("vaapi") != std::string::npos && rc == RC_CQ) {
// Used only after the normal bitrate-based VAAPI initialization fails.
// Some drivers, including Intel iHD on Jasper Lake, expose CQP as their
@@ -458,6 +503,10 @@ bool set_others(void *priv_data, const std::string &name) {
bool change_bit_rate(AVCodecContext *c, const std::string &name, int kbs) {
if (kbs > 0) {
c->bit_rate = kbs * 1000;
if (c->rc_max_rate > 0 && name.find("qsv") == std::string::npos) {
c->rc_max_rate = c->bit_rate * 2;
c->rc_buffer_size = c->rc_max_rate;
}
if (name.find("qsv") != std::string::npos) {
c->rc_max_rate = c->bit_rate;
}

View File

@@ -9,6 +9,9 @@ extern "C" {
namespace util_encode {
bool is_qcom_iris_platform();
bool supports_forced_keyframe(const std::string &name);
void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs,
int gop, int fps, int thread_count);
bool set_lantency_free(void *priv_data, const std::string &name);

View 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;
}

View 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; }

View 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

View File

@@ -26,7 +26,6 @@ static thread_local std::string g_encoder_last_error;
static void set_encoder_last_error(const std::string &message) {
g_encoder_last_error = message;
LOG_ERROR(message);
}
static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
@@ -380,12 +379,12 @@ private:
frame->pts = ms;
// Force keyframe if requested
if (force_keyframe_) {
if (force_keyframe_ && util_encode::supports_forced_keyframe(name_)) {
frame->pict_type = AV_PICTURE_TYPE_I;
force_keyframe_ = false;
} else {
frame->pict_type = AV_PICTURE_TYPE_NONE;
}
force_keyframe_ = false;
ret = avcodec_send_frame(c_, frame);
if (ret == AVERROR(EAGAIN)) {
@@ -646,8 +645,8 @@ ffmpeg_ram_new_encoder(const char *name, int width,
// allowing CQP-only drivers to pass probing and normal encoder creation.
if (name && std::string(name).find("vaapi") != std::string::npos &&
rc != RC_CQ) {
LOG_WARN(std::string("VAAPI bitrate-based rate control failed for ") +
name + ", retrying with CQP");
LOG_DEBUG(std::string("VAAPI bitrate-based rate control failed for ") +
name + ", retrying with CQP");
encoder = try_create(RC_CQ, 0);
if (encoder) {
return encoder;

View File

@@ -32,14 +32,14 @@ pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const std::os::r
if let Ok(str_slice) = c_str.to_str() {
let string = String::from(str_slice);
if level == AV_LOG_ERROR as i32 {
log::error!("{}", string);
if string.contains(could_not_find_ref_with_poc) {
hwcodec_set_flag_could_not_find_ref_with_poc();
}
log::debug!("{}", string);
} else if level == AV_LOG_PANIC as i32 || level == AV_LOG_FATAL as i32 {
log::error!("{}", string);
} else if level == AV_LOG_WARNING as i32 {
log::warn!("{}", string);
log::debug!("{}", string);
} else if level == AV_LOG_INFO as i32 {
log::info!("{}", string);
} else if level == AV_LOG_VERBOSE as i32 || level == AV_LOG_DEBUG as i32 {

View File

@@ -343,7 +343,7 @@ fn log_failed_probe_attempt(
}
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
use log::{debug, warn};
use log::debug;
debug!("Testing encoder: {}", codec.name);
@@ -395,7 +395,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
}
Err(err) => {
last_err = Some(err);
warn!(
debug!(
"Encoder {} test attempt {} returned error: {}",
codec.name, attempt_no, err
);
@@ -412,10 +412,7 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
);
false
}
Err(_) => {
warn!("Failed to create encoder {}", codec.name);
false
}
Err(_) => false,
}
}
@@ -543,7 +540,7 @@ impl Encoder {
if codec.is_null() {
let message = encoder_last_error_message();
if !message.is_empty() {
log::error!("ffmpeg_ram_new_encoder failed: {}", message);
log::debug!("ffmpeg_ram_new_encoder failed: {}", message);
}
return Err(());
}
@@ -595,6 +592,16 @@ impl Encoder {
Some(Encoder::packet_callback),
);
if result == -11 || result == 0 {
if self.ctx.name.contains("v4l2m2m") {
return Ok(frames
.into_iter()
.map(|frame| EncodeBytesFrame {
data: Bytes::copy_from_slice(frame.data.as_ref()),
pts: frame.pts,
key: frame.key,
})
.collect());
}
return Ok(frames);
}
Err(result)

View File

@@ -5,6 +5,11 @@ pub mod ffmpeg;
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
pub mod ffmpeg_hw;
pub mod ffmpeg_ram;
#[cfg(all(
target_os = "linux",
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")
))]
pub mod rkmpp_dmabuf;
#[no_mangle]
pub extern "C" fn hwcodec_log(level: i32, message: *const std::os::raw::c_char) {

View 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);
}
}

View File

@@ -5,7 +5,8 @@ use std::{
};
fn main() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Read the current source path when running, since build artifacts may move.
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let cpp_dir = manifest_dir.join("cpp");
println!("cargo:rerun-if-changed=cpp/yuv_ffi.h");

View File

@@ -27,7 +27,7 @@ pub use types::{
ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig,
AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, LCUS_RELAY_MAX_CHANNEL,
};
pub use wol::{list_wol_history, record_wol_history, send_wol};
pub use wol::send_wol;
#[cfg(any(unix, test))]
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {

View File

@@ -7,8 +7,6 @@ use tracing::info;
use crate::error::{AppError, Result};
const WOL_HISTORY_MAX_ENTRIES: i64 = 50;
const MAGIC_PACKET_SIZE: usize = 102;
fn parse_mac_address(mac: &str) -> Result<[u8; 6]> {
@@ -118,55 +116,6 @@ pub fn send_wol(mac_address: &str, interface: Option<&str>) -> Result<()> {
Ok(())
}
pub async fn record_wol_history(pool: &sqlx::Pool<sqlx::Sqlite>, mac_address: &str) -> Result<()> {
sqlx::query(
r#"
INSERT INTO wol_history (mac_address, updated_at)
VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER))
ON CONFLICT(mac_address) DO UPDATE SET
updated_at = excluded.updated_at
"#,
)
.bind(mac_address)
.execute(pool)
.await?;
sqlx::query(
r#"
DELETE FROM wol_history
WHERE mac_address NOT IN (
SELECT mac_address FROM wol_history
ORDER BY updated_at DESC
LIMIT ?1
)
"#,
)
.bind(WOL_HISTORY_MAX_ENTRIES)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_wol_history(
pool: &sqlx::Pool<sqlx::Sqlite>,
limit: usize,
) -> Result<Vec<(String, i64)>> {
let rows = sqlx::query_as(
r#"
SELECT mac_address, updated_at
FROM wol_history
ORDER BY updated_at DESC
LIMIT ?1
"#,
)
.bind(limit as i64)
.fetch_all(pool)
.await?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -96,6 +96,7 @@ fn is_setup_public_endpoint(path: &str) -> bool {
"/setup"
| "/setup/init"
| "/devices"
| "/hid/bluetooth/adapters"
| "/video/input-status"
| "/stream/codecs"
| "/video/codecs"

View File

@@ -227,15 +227,14 @@ impl TwoFactorService {
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
}
let mut transaction = self.pool.begin().await?;
let result =
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
.bind(user_id)
.bind(secret.to_string())
.execute(&mut *transaction)
.execute(&self.pool)
.await;
match result {
Ok(_) => transaction.commit().await?,
Ok(_) => {}
Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
}

View File

@@ -1,7 +1,5 @@
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use uuid::Uuid;
use super::password::{hash_password, verify_password};
@@ -112,15 +110,13 @@ impl UserStore {
}
let password_hash = hash_password(new_password)?;
let now = OffsetDateTime::now_utc();
let result =
sqlx::query("UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3")
.bind(&password_hash)
.bind(now.format(&Rfc3339).expect("RFC3339 format"))
.bind(user_id)
.execute(&self.pool)
.await?;
let result = sqlx::query(
"UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE id = ?2",
)
.bind(&password_hash)
.bind(user_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("User not found".to_string()));
@@ -143,13 +139,13 @@ impl UserStore {
return Ok(());
}
let now = OffsetDateTime::now_utc();
let result = sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3")
.bind(new_username)
.bind(now.format(&Rfc3339).expect("RFC3339 format"))
.bind(user_id)
.execute(&self.pool)
.await?;
let result = sqlx::query(
"UPDATE users SET username = ?1, updated_at = datetime('now') WHERE id = ?2",
)
.bind(new_username)
.bind(user_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("User not found".to_string()));

View File

@@ -1,6 +1,54 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BluetoothHidConfig {
pub adapter: String,
pub name: String,
pub peer: Option<String>,
}
impl Default for BluetoothHidConfig {
fn default() -> Self {
Self {
adapter: "hci0".into(),
name: "One-KVM HID".into(),
peer: None,
}
}
}
impl BluetoothHidConfig {
pub fn validate(&self) -> crate::error::Result<()> {
let invalid = |reason: &str| crate::error::AppError::BadRequest(reason.into());
if !self
.adapter
.strip_prefix("hci")
.is_some_and(|s| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()))
{
return Err(invalid(
"Bluetooth adapter must be hci followed by an index",
));
}
if self.name.is_empty() || self.name.len() > 64 || self.name.chars().any(char::is_control) {
return Err(invalid(
"Bluetooth name must contain 164 UTF-8 bytes without control characters",
));
}
if let Some(peer) = &self.peer {
let parts: Vec<_> = peer.split(':').collect();
if parts.len() != 6
|| parts
.iter()
.any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_hexdigit()))
{
return Err(invalid("Invalid Bluetooth peer address"));
}
}
Ok(())
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
@@ -8,6 +56,7 @@ use typeshare::typeshare;
pub enum HidBackend {
Otg,
Ch9329,
Bluetooth,
#[default]
None,
}
@@ -166,6 +215,7 @@ impl OtgHidProfile {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct HidConfig {
pub bluetooth: BluetoothHidConfig,
pub backend: HidBackend,
pub otg_udc: Option<String>,
#[serde(default)]
@@ -181,6 +231,9 @@ pub struct HidConfig {
#[serde(default)]
pub ch9329_hybrid_mouse: bool,
#[serde(default)]
#[serde(alias = "ch9329_macos_drag")]
pub mouse_macos_drag: bool,
#[serde(default)]
pub ch9329_descriptor: Ch9329DescriptorConfig,
pub mouse_absolute: bool,
}
@@ -189,6 +242,7 @@ impl Default for HidConfig {
fn default() -> Self {
Self {
backend: HidBackend::None,
bluetooth: BluetoothHidConfig::default(),
otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(),
@@ -197,6 +251,7 @@ impl Default for HidConfig {
ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600,
ch9329_hybrid_mouse: false,
mouse_macos_drag: false,
ch9329_descriptor: Ch9329DescriptorConfig::default(),
mouse_absolute: true,
}
@@ -222,6 +277,11 @@ impl HidConfig {
}
let functions = self.effective_otg_functions();
if self.mouse_macos_drag && (!functions.mouse_relative || !functions.mouse_absolute) {
return Err(crate::error::AppError::BadRequest(
"macOS drag compatibility requires both OTG mouse interfaces".to_string(),
));
}
if functions.is_empty() {
return Err(crate::error::AppError::BadRequest(
"OTG HID functions cannot be empty".to_string(),
@@ -252,3 +312,65 @@ impl HidConfig {
})
}
}
#[cfg(test)]
mod bluetooth_tests {
use super::*;
#[test]
fn mouse_compatibility_defaults_alias_and_otg_validation() {
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
assert!(!defaults.mouse_macos_drag);
let mut config: HidConfig =
serde_json::from_str(r#"{"backend":"otg","ch9329_macos_drag":true}"#).unwrap();
assert!(config.mouse_macos_drag);
assert!(config.validate_otg_functions().is_ok());
config.otg_profile = OtgHidProfile::LegacyMouseRelative;
assert!(config.validate_otg_functions().is_err());
config.backend = HidBackend::Ch9329;
assert!(config.validate_otg_functions().is_ok());
let saved = serde_json::to_value(&config).unwrap();
assert_eq!(saved["mouse_macos_drag"], true);
assert!(saved.get("ch9329_macos_drag").is_none());
}
#[test]
fn old_configs_keep_bluetooth_disabled_and_get_defaults() {
let config: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
assert_eq!(config.backend, HidBackend::Otg);
assert_eq!(config.bluetooth, BluetoothHidConfig::default());
}
#[test]
fn obsolete_ble_flag_is_ignored_and_not_saved() {
let config: BluetoothHidConfig =
serde_json::from_str(r#"{"adapter":"hci0","name":"My keyboard","le_only":true}"#)
.unwrap();
config.validate().unwrap();
assert!(serde_json::to_value(config)
.unwrap()
.get("le_only")
.is_none());
}
#[test]
fn bluetooth_uses_relative_mouse_and_existing_usb_constraints() {
let mut config = crate::config::AppConfig::default();
config.hid.backend = HidBackend::Bluetooth;
config.hid.mouse_absolute = true;
config.msd.enabled = true;
config.uac.enabled = true;
config.otg_network.enabled = true;
config.enforce_invariants();
assert!(!config.hid.mouse_absolute);
assert!(!config.msd.enabled && !config.uac.enabled && !config.otg_network.enabled);
}
#[test]
fn reject_invalid_adapter_address_and_oversize_advertisement_name() {
let mut config = BluetoothHidConfig::default();
config.adapter = "/dev/hci0".into();
assert!(config.validate().is_err());
config.adapter = "hci0".into();
config.peer = Some("not-a-mac".into());
assert!(config.validate().is_err());
config.peer = None;
config.name = "".repeat(24);
assert!(config.validate().is_err());
}
}

View File

@@ -56,6 +56,9 @@ impl AppConfig {
self.otg_network.enabled = false;
self.uac.enabled = false;
}
if self.hid.backend == HidBackend::Bluetooth {
self.hid.mouse_absolute = false;
}
self.atx.normalize();
}

View File

@@ -102,8 +102,8 @@ pub enum EncoderType {
Qsv,
Amf,
Rkmpp,
#[serde(alias = "amlogic")]
V4l2m2m,
Amlogic,
}
impl EncoderType {
@@ -117,7 +117,6 @@ impl EncoderType {
EncoderType::Amf => "AMD AMF",
EncoderType::Rkmpp => "Rockchip MPP",
EncoderType::V4l2m2m => "V4L2 M2M",
EncoderType::Amlogic => "AMLENC",
}
}
}

View File

@@ -17,13 +17,13 @@ pub struct ConfigStore {
}
impl ConfigStore {
pub fn new(pool: Pool<Sqlite>) -> Result<Self> {
Ok(Self {
pub fn new(pool: Pool<Sqlite>) -> Self {
Self {
pool,
cache: Arc::new(ArcSwap::from_pointee(AppConfig::default())),
change_tx: broadcast::channel(16).0,
write_lock: Arc::new(Mutex::new(())),
})
}
}
pub async fn load(&self) -> Result<()> {
@@ -83,6 +83,11 @@ impl ConfigStore {
Ok(())
}
#[cfg(target_os = "linux")]
pub fn hid_bonds(&self) -> crate::db::hid_bonds::HidBondStore {
crate::db::hid_bonds::HidBondStore(self.pool.clone())
}
pub fn get(&self) -> Arc<AppConfig> {
self.cache.load_full()
}
@@ -145,7 +150,7 @@ mod tests {
let db = DatabasePool::new(&db_path).await.unwrap();
db.init_schema().await.unwrap();
let store = ConfigStore::new(db.clone_pool()).unwrap();
let store = ConfigStore::new(db.clone_pool());
store.load().await.unwrap();
let config = store.get();
@@ -163,7 +168,7 @@ mod tests {
assert!(config.initialized);
assert_eq!(config.web.http_port, 9000);
let store2 = ConfigStore::new(db.clone_pool()).unwrap();
let store2 = ConfigStore::new(db.clone_pool());
store2.load().await.unwrap();
let config = store2.get();
assert!(config.initialized);
@@ -176,7 +181,7 @@ mod tests {
let db_path = dir.path().join("test.db");
let db = DatabasePool::new(&db_path).await.unwrap();
db.init_schema().await.unwrap();
let store = ConfigStore::new(db.clone_pool()).unwrap();
let store = ConfigStore::new(db.clone_pool());
store.load().await.unwrap();
sqlx::query("DROP TABLE config")
@@ -210,7 +215,7 @@ mod tests {
.await
.unwrap();
let store = ConfigStore::new(db.clone_pool()).unwrap();
let store = ConfigStore::new(db.clone_pool());
store.load().await.unwrap();
let (persisted,): (String,) =
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")

80
src/db/hid_bonds.rs Normal file
View 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());
}
}

View File

@@ -1,3 +1,40 @@
#[cfg(target_os = "linux")]
pub mod hid_bonds;
mod pool;
mod wol_history;
use std::path::Path;
use crate::error::Result;
pub use pool::DatabasePool;
pub use wol_history::WolHistoryStore;
/// Open the application database stored in `data_dir` and ensure its schema exists.
pub async fn open_database_pool(data_dir: &Path) -> Result<DatabasePool> {
let db = DatabasePool::new(&data_dir.join("one-kvm.db")).await?;
db.init_schema().await?;
Ok(db)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn open_database_pool_creates_data_dir_and_initializes_schema() {
let temp_dir = tempfile::tempdir().unwrap();
let data_dir = temp_dir.path().join("nested").join("data");
let db = open_database_pool(&data_dir).await.unwrap();
assert!(data_dir.join("one-kvm.db").is_file());
let users_table: Option<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'",
)
.fetch_optional(db.pool())
.await
.unwrap();
assert_eq!(users_table.as_deref(), Some("users"));
}
}

View File

@@ -1,4 +1,7 @@
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite};
use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
Pool, Sqlite,
};
use std::path::Path;
use std::time::Duration;
@@ -15,114 +18,62 @@ impl DatabasePool {
tokio::fs::create_dir_all(parent).await?;
}
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Full)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(4)
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(300))
.connect(&db_url)
.connect_with(options)
.await?;
Ok(Self { pool })
}
pub async fn init_schema(&self) -> Result<()> {
self.create_config_table().await?;
self.create_users_table().await?;
self.create_user_totp_credentials_table().await?;
self.create_api_tokens_table().await?;
self.create_wol_history_table().await?;
Ok(())
}
async fn create_config_table(&self) -> Result<()> {
// Keep migrations embedded in the binary so deployments do not need an
// extra migrations directory or another runtime dependency.
let mut transaction = self.pool.begin().await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_users_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_api_tokens_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
permissions TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_user_totp_credentials_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS user_totp_credentials (
user_id TEXT PRIMARY KEY,
secret TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_wol_history_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS wol_history (
mac_address TEXT PRIMARY KEY,
updated_at INTEGER NOT NULL
)
"#,
)
.execute(&self.pool)
.execute(&mut *transaction)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at
ON wol_history(updated_at DESC)
"#,
)
.execute(&self.pool)
.await?;
let current_version: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_migrations")
.fetch_one(&mut *transaction)
.await?;
for (version, statements) in SCHEMA_MIGRATIONS
.iter()
.enumerate()
.map(|(index, statements)| ((index + 1) as i64, *statements))
{
if version <= current_version {
continue;
}
for &statement in statements {
sqlx::query(statement).execute(&mut *transaction).await?;
}
sqlx::query("INSERT INTO schema_migrations (version) VALUES (?1)")
.bind(version)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
@@ -133,4 +84,65 @@ impl DatabasePool {
pub fn clone_pool(&self) -> Pool<Sqlite> {
self.pool.clone()
}
pub fn wol_history(&self) -> super::WolHistoryStore {
super::WolHistoryStore::new(self.pool.clone())
}
}
// Each item is one version; statements within a version run atomically.
// New schema changes should be appended as a new item, never edited in place.
const SCHEMA_MIGRATIONS: &[&[&str]] = &[
&[
r#"
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
r#"
CREATE TABLE IF NOT EXISTS user_totp_credentials (
user_id TEXT PRIMARY KEY,
secret TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
r#"
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
permissions TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT
)
"#,
r#"
CREATE TABLE IF NOT EXISTS wol_history (
mac_address TEXT PRIMARY KEY,
updated_at INTEGER NOT NULL
)
"#,
r#"
CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at
ON wol_history(updated_at DESC)
"#,
],
&[r#"
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_singleton
ON users ((1))
"#],
&["CREATE TABLE hid_bonds (adapter TEXT NOT NULL, peer TEXT NOT NULL, pending INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(adapter, peer))"],
];

39
src/db/wol_history.rs Normal file
View 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?)
}
}

View File

@@ -102,7 +102,9 @@ impl MsdErrorCode {
Self::MsdDownloadIncomplete => "The remote image download was incomplete.",
Self::MsdDriveNotInitialized => "The virtual drive is not initialized.",
Self::MsdDriveConnected => "The virtual drive is connected to the controlled computer.",
Self::MsdDriveFilesystemUnsupported => "The virtual drive filesystem is unsupported.",
Self::MsdDriveFilesystemUnsupported => {
"Web file management does not support this virtual drive format."
}
Self::MsdDriveSizeInvalid => "The virtual drive size is invalid.",
Self::MsdStorageSpaceUnavailable => {
"Available virtual media storage space could not be determined."
@@ -183,7 +185,7 @@ impl MsdErrorCode {
"Verify the remote server and network connection, then retry."
}
Self::MsdDriveFilesystemUnsupported => {
"Reinitialize the virtual drive with a supported filesystem, then retry."
"Mount the drive on the controlled computer, or use a supported format for web file management."
}
Self::MsdStorageSpaceUnavailable => {
"Verify that virtual media storage is available, then retry."
@@ -386,7 +388,7 @@ mod tests {
(
MsdDriveFilesystemUnsupported,
"MSD_DRIVE_FILESYSTEM_UNSUPPORTED",
"The virtual drive filesystem is unsupported.",
"Web file management does not support this virtual drive format.",
),
(
MsdDriveSizeInvalid,

View File

@@ -17,13 +17,21 @@ fn default_ch9329_baud_rate() -> u32 {
#[serde(tag = "type", rename_all = "lowercase")]
#[derive(Default)]
pub enum HidBackendType {
Otg,
Otg {
#[serde(default)]
macos_drag: bool,
},
Bluetooth {
config: crate::config::BluetoothHidConfig,
},
Ch9329 {
port: String,
#[serde(default = "default_ch9329_baud_rate")]
baud_rate: u32,
#[serde(default)]
hybrid_mouse: bool,
#[serde(default)]
macos_drag: bool,
},
#[default]
None,
@@ -32,7 +40,8 @@ pub enum HidBackendType {
impl HidBackendType {
pub fn name_str(&self) -> &str {
match self {
Self::Otg => "otg",
Self::Otg { .. } => "otg",
Self::Bluetooth { .. } => "bluetooth",
Self::Ch9329 { .. } => "ch9329",
Self::None => "none",
}
@@ -81,6 +90,17 @@ pub trait HidBackend: Send + Sync {
))
}
async fn bluetooth_status(&self) -> Result<serde_json::Value> {
Err(crate::error::AppError::BadRequest(
"Bluetooth HID is not active".into(),
))
}
async fn bluetooth_action(&self, _action: &str, _seconds: u32) -> Result<()> {
Err(crate::error::AppError::BadRequest(
"Bluetooth HID is not active".into(),
))
}
async fn reset(&self) -> Result<()>;
async fn prepare_rebuild(&self) -> Result<()> {

283
src/hid/bluetooth.rs Normal file
View 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 10300 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);
}
}

View File

@@ -44,7 +44,10 @@ const PARAM_CFG_VID_PID_OFFSET: usize = 11;
const PARAM_CFG_STRING_FLAGS_OFFSET: usize = 36;
const DESCRIPTOR_READ_RETRIES: usize = 3;
const DESCRIPTOR_RETRY_DELAY_MS: u64 = 80;
const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 3000;
// CH9329/CH9329F can take several seconds to restart after a descriptor update.
const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 5000;
const USB_STRING_MAX_LEN: usize = 23;
const USB_STRING_FLAG_ENABLE: u8 = 0x80;
const USB_STRING_FLAG_MANUFACTURER: u8 = 0x04;
@@ -235,6 +238,8 @@ pub struct Ch9329Backend {
last_abs_y: Arc<AtomicU16>,
relative_mouse_active: Arc<AtomicBool>,
hybrid_mouse: bool,
macos_drag: bool,
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
runtime: Arc<Ch9329RuntimeState>,
}
@@ -248,6 +253,15 @@ impl Ch9329Backend {
}
pub fn with_options(port_path: &str, baud_rate: u32, hybrid_mouse: bool) -> Result<Self> {
Self::with_compatibility_options(port_path, baud_rate, hybrid_mouse, false)
}
pub fn with_compatibility_options(
port_path: &str,
baud_rate: u32,
hybrid_mouse: bool,
macos_drag: bool,
) -> Result<Self> {
Ok(Self {
port_path: port_path.to_string(),
baud_rate,
@@ -263,6 +277,8 @@ impl Ch9329Backend {
last_abs_y: Arc::new(AtomicU16::new(0)),
relative_mouse_active: Arc::new(AtomicBool::new(false)),
hybrid_mouse,
macos_drag,
macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()),
runtime: Arc::new(Ch9329RuntimeState::new()),
})
}
@@ -368,33 +384,41 @@ impl Ch9329Backend {
Self::write_packet(port, address, cmd, data)?;
let mut pending = Vec::with_capacity(128);
// Keep enough room for a full parameter response and adjacent packets.
let mut pending = Vec::with_capacity(256);
let deadline = Instant::now() + Duration::from_millis(RESPONSE_TIMEOUT_MS);
let expected_ok = expected_response_cmd(cmd, false);
let expected_err = expected_response_cmd(cmd, true);
loop {
let mut chunk = [0u8; 128];
let mut chunk = [0u8; 256];
match port.read(&mut chunk) {
Ok(n) if n > 0 => {
pending.extend_from_slice(&chunk[..n]);
// Drain every complete frame so adjacent/out-of-order responses
// cannot block the response for the current command.
while let Some((response, consumed)) = try_extract_response(&pending) {
let current_response_cmd = response.cmd;
pending.drain(..consumed);
if response.cmd == expected_ok || response.cmd == expected_err {
if current_response_cmd == expected_ok
|| current_response_cmd == expected_err
{
return Ok(response);
}
trace!(
"CH9329 ignored out-of-order response: expected 0x{:02X}/0x{:02X}, got 0x{:02X}",
"CH9329 filtered an overlapping packet: expected 0x{:02X}/0x{:02X}, bypass 0x{:02X}",
expected_ok,
expected_err,
response.cmd
current_response_cmd
);
}
// Bound memory use if a noisy or disconnected port keeps delivering bytes.
if pending.len() > MAX_PACKET_SIZE * 4 {
let keep = MAX_PACKET_SIZE;
let keep = MAX_PACKET_SIZE * 2;
pending.drain(..pending.len().saturating_sub(keep));
}
}
@@ -410,15 +434,19 @@ impl Ch9329Backend {
if Instant::now() >= deadline {
return Err(Self::backend_error(
format!("No matching response from CH9329 for cmd 0x{:02X}", cmd),
format!(
"No matching response from CH9329 for cmd 0x{:02X}. Remaining buffer: {}",
cmd,
Self::hex_bytes(&pending)
),
"no_response",
));
}
thread::sleep(Duration::from_millis(1));
// Give the serial driver a short opportunity to deliver the next chunk.
thread::sleep(Duration::from_micros(200));
}
}
fn try_best_effort_reset(port: &mut dyn serialport::SerialPort, address: u8) {
if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) {
trace!("CH9329 best-effort reset failed: {}", err);
@@ -698,7 +726,6 @@ impl Ch9329Backend {
let mut port = Self::open_port(port_path, baud_rate)?;
Self::read_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR)
}
fn open_ready_port(
port_path: &str,
baud_rate: u32,
@@ -869,7 +896,7 @@ impl Ch9329Backend {
match Self::open_ready_port(port_path, baud_rate, address) {
Ok((port, info)) => {
info!(
"CH9329 reconnected: {}, USB: {}",
"CH9329-compatible chip reconnected: {}, USB: {}",
info.version,
if info.usb_connected {
"connected"
@@ -892,7 +919,6 @@ impl Ch9329Backend {
}
}
}
fn recover_worker_port(
mut port: Box<dyn serialport::SerialPort>,
rx: &mpsc::Receiver<WorkerCommand>,
@@ -965,11 +991,12 @@ impl Ch9329Backend {
}
fn should_send_button_wheel_relative(&self) -> bool {
self.hybrid_mouse || self.relative_mouse_active.load(Ordering::Relaxed)
(self.hybrid_mouse && !self.macos_drag)
|| self.relative_mouse_active.load(Ordering::Relaxed)
}
fn absolute_move_buttons(&self, buttons: u8) -> u8 {
if self.hybrid_mouse {
if self.hybrid_mouse && !self.macos_drag {
0
} else {
buttons
@@ -1170,7 +1197,7 @@ impl HidBackend for Ch9329Backend {
match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) {
Ok(Ok(info)) => {
info!(
"CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
"CH9329-compatible chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
info.version,
if info.usb_connected {
"connected"
@@ -1189,13 +1216,13 @@ impl HidBackend for Ch9329Backend {
Ok(Err(err)) => {
self.record_error(
format!(
"CH9329 not responding on {} @ {} baud: {}",
"CH9329-compatible chip not responding on {} @ {} baud: {}",
self.port_path, self.baud_rate, err
),
"init_failed",
);
warn!(
"CH9329 not responding on {} @ {} baud, retrying in background: {}",
"CH9329-compatible chip not responding on {} @ {} baud, retrying in background: {}",
self.port_path, self.baud_rate, err
);
*self.worker_tx.lock() = Some(tx);
@@ -1205,9 +1232,12 @@ impl HidBackend for Ch9329Backend {
Err(_) => {
let _ = tx.send(WorkerCommand::Shutdown);
let _ = handle.join();
self.record_error("Timed out waiting for CH9329 worker init", "init_timeout");
self.record_error(
"Timed out waiting for CH9329-compatible worker init",
"init_timeout",
);
Err(AppError::Internal(
"Timed out waiting for CH9329 initialization".to_string(),
"Timed out waiting for CH9329-compatible initialization".to_string(),
))
}
}
@@ -1256,6 +1286,31 @@ impl HidBackend for Ch9329Backend {
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
if self.macos_drag {
use super::macos_drag::MouseReport;
let mut state = self.macos_drag_state.lock();
let (buttons, reports) = state.plan(event, buttons, *self.screen_resolution.read());
self.mouse_buttons.store(buttons, Ordering::Relaxed);
for report in reports {
match report {
MouseReport::Absolute { buttons, x, y } => {
let x = (u32::from(x) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
let y = (u32::from(y) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
self.send_mouse_absolute(buttons, x, y, 0)?;
}
MouseReport::Relative {
buttons,
dx,
dy,
wheel,
} => {
self.send_mouse_relative(buttons, dx, dy, wheel)?;
}
}
}
return Ok(());
}
match event.event_type {
MouseEventType::Move => {
self.relative_mouse_active.store(true, Ordering::Relaxed);
@@ -1345,6 +1400,7 @@ impl HidBackend for Ch9329Backend {
}
self.mouse_buttons.store(0, Ordering::Relaxed);
self.macos_drag_state.lock().reset();
self.last_abs_x.store(0, Ordering::Relaxed);
self.last_abs_y.store(0, Ordering::Relaxed);
self.relative_mouse_active.store(false, Ordering::Relaxed);
@@ -1650,13 +1706,67 @@ mod tests {
}
#[test]
fn test_hybrid_mouse_routes_buttons_and_wheel_to_relative_reports() {
fn test_hybrid_mouse_preserves_linux_compatibility_routing() {
let backend = Ch9329Backend::with_options("/dev/null", DEFAULT_BAUD_RATE, true).unwrap();
assert!(backend.should_send_button_wheel_relative());
assert_eq!(backend.absolute_move_buttons(0x07), 0);
}
#[tokio::test]
async fn test_macos_drag_uses_absolute_edges_and_relative_motion() {
let backend =
Ch9329Backend::with_compatibility_options("/dev/null", DEFAULT_BAUD_RATE, false, true)
.unwrap();
let (worker_tx, worker_rx) = mpsc::channel();
*backend.worker_tx.lock() = Some(worker_tx);
backend.set_screen_resolution(1920, 1080);
backend
.send_mouse(MouseEvent::move_abs(8000, 8000))
.await
.unwrap();
backend
.send_mouse(MouseEvent::button_down(crate::hid::MouseButton::Left))
.await
.unwrap();
backend
.send_mouse(MouseEvent::move_abs(8064, 8064))
.await
.unwrap();
backend
.send_mouse(MouseEvent::button_up(crate::hid::MouseButton::Left))
.await
.unwrap();
let packets: Vec<_> = worker_rx
.try_iter()
.filter_map(|command| match command {
WorkerCommand::Packet { cmd, data } => Some((cmd, data)),
_ => None,
})
.collect();
assert_eq!(
packets,
vec![
(
cmd::SEND_MS_ABS_DATA,
vec![0x02, 0x00, 0xE8, 0x03, 0xE8, 0x03, 0x00],
),
(
cmd::SEND_MS_ABS_DATA,
vec![0x02, 0x01, 0xE8, 0x03, 0xE8, 0x03, 0x00],
),
(cmd::SEND_MS_REL_DATA, vec![0x01, 0x01, 0x03, 0x02, 0x00]),
(cmd::SEND_MS_REL_DATA, vec![0x01, 0x00, 0x00, 0x00, 0x00]),
(
cmd::SEND_MS_ABS_DATA,
vec![0x02, 0x00, 0xF0, 0x03, 0xF0, 0x03, 0x00],
),
]
);
}
#[test]
fn test_default_mouse_mode_preserves_absolute_report_buttons() {
let backend = Ch9329Backend::with_baud_rate("/dev/null", DEFAULT_BAUD_RATE).unwrap();

View File

@@ -10,6 +10,7 @@ pub const DEFAULT_ADDR: u8 = 0x00;
pub const DEFAULT_BAUD_RATE: u32 = 9600;
pub const MAX_DATA_LEN: usize = 64;
pub const MAX_PACKET_SIZE: usize = 70;
const EXTENDED_PARAMETER_RESPONSE_SIZES: [usize; 2] = [72, 88];
pub mod cmd {
pub const GET_INFO: u8 = 0x01;
@@ -130,7 +131,8 @@ impl Response {
let cmd = bytes[3];
let len = bytes[4] as usize;
if bytes.len() < 5 + len + 1 {
let expected_frame_len = 6 + len;
if bytes.len() < expected_frame_len {
return None;
}
@@ -139,7 +141,7 @@ impl Response {
.iter()
.fold(0u8, |acc, &x| acc.wrapping_add(x));
if expected_checksum != calculated_checksum {
tracing::warn!(
tracing::debug!(
"CH9329 checksum mismatch: expected {:02X}, got {:02X}",
expected_checksum,
calculated_checksum
@@ -215,6 +217,11 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> {
}
let len = buffer[offset + 4] as usize;
if len > MAX_DATA_LEN {
offset += 1;
continue;
}
let frame_len = 6 + len;
if offset + frame_len > buffer.len() {
return None;
@@ -225,8 +232,103 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> {
return Some((response, offset + frame_len));
}
// Some CH9329F firmware appends reserved bytes to GET_PARA_CFG while
// retaining the protocol LEN value of 50. Locate and validate the real
// checksum, return the documented 50-byte payload, and consume the
// complete extended frame. Other commands keep strict framing.
let cmd = buffer[offset + 3];
let data_start = offset + 5;
let parameter_payload_is_plausible = cmd == expected_response_cmd(cmd::GET_PARA_CFG, false)
&& len == 50
&& matches!(buffer[data_start], 0x00..=0x03 | 0x80..=0x83)
&& matches!(buffer[data_start + 1], 0x00..=0x02 | 0x80..=0x82);
if parameter_payload_is_plausible {
for extended_size in EXTENDED_PARAMETER_RESPONSE_SIZES {
let extended_end = offset + extended_size;
if buffer.len() >= extended_end {
let checksum_index = extended_end - 1;
if calculate_checksum(&buffer[offset..checksum_index]) != buffer[checksum_index]
{
continue;
}
return Some((
Response {
cmd,
data: buffer[data_start..data_start + len].to_vec(),
is_error: false,
error_code: None,
},
extended_end,
));
}
}
if buffer.len() < offset + EXTENDED_PARAMETER_RESPONSE_SIZES[1] {
return None;
}
}
offset += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_standard_response_and_checksum() {
let frame = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0x00, 0, 0, 0, 0, 0]);
let response = Response::parse(&frame).expect("valid response");
assert_eq!(response.cmd, 0x81);
assert_eq!(response.data, vec![0x30, 0x01, 0x00, 0, 0, 0, 0, 0]);
assert!(!response.is_error);
}
#[test]
fn extracts_extended_parameter_response_with_valid_trailing_checksum() {
let payload = [
0x80, 0x80, 0x00, 0x00, 0x00, 0x25, 0x80, 0x08, 0x00, 0x00, 0x03, 0x86, 0x1A, 0x2A,
0xE1, 0x00, 0x00, 0x00, 0x01, 0x00, 0x0D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
for reserved_len in [16, 32] {
let mut frame = vec![0x57, 0xAB, DEFAULT_ADDR, 0x88, 50];
frame.extend_from_slice(&payload);
frame.extend_from_slice(&vec![0; reserved_len]);
frame.push(calculate_checksum(&frame));
assert!(Response::parse(&frame[..56]).is_none());
let (response, consumed) = try_extract_response(&frame).expect("extended response");
assert_eq!(response.cmd, 0x88);
assert_eq!(response.data, payload);
assert!(!response.is_error);
assert_eq!(consumed, frame.len());
}
}
#[test]
fn rejects_bad_checksum_for_other_commands() {
let mut frame = build_packet(DEFAULT_ADDR, 0x89, &[0x00; 50]);
*frame.last_mut().unwrap() ^= 0xFF;
assert!(Response::parse(&frame).is_none());
assert!(try_extract_response(&frame).is_none());
}
#[test]
fn extracts_noise_and_adjacent_packets() {
let first = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0, 0, 0, 0, 0, 0]);
let second = build_packet(DEFAULT_ADDR, 0x82, &[0x00]);
let mut buffer = vec![0x00, 0xFF];
buffer.extend_from_slice(&first);
buffer.extend_from_slice(&second);
let (_, consumed) = try_extract_response(&buffer).expect("first response");
assert_eq!(consumed, 2 + first.len());
let (response, _) = try_extract_response(&buffer[consumed..]).expect("second response");
assert_eq!(response.cmd, 0x82);
}
}

View File

@@ -8,6 +8,8 @@ use crate::error::{AppError, Result};
use crate::otg::OtgService;
pub struct HidBackendFactory {
#[cfg(target_os = "linux")]
pub bonds: std::sync::OnceLock<Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
#[cfg(unix)]
otg_service: Option<Arc<OtgService>>,
}
@@ -15,7 +17,11 @@ pub struct HidBackendFactory {
impl HidBackendFactory {
#[cfg(unix)]
pub fn new(otg_service: Option<Arc<OtgService>>) -> Self {
Self { otg_service }
Self {
otg_service,
#[cfg(target_os = "linux")]
bonds: Default::default(),
}
}
#[cfg(not(unix))]
@@ -38,21 +44,43 @@ impl HidBackendFactory {
async fn create(&self, backend_type: &HidBackendType) -> Result<Option<Arc<dyn HidBackend>>> {
match backend_type {
HidBackendType::Otg => self.create_otg_backend().await.map(Some),
HidBackendType::Otg { macos_drag } => {
self.create_otg_backend(*macos_drag).await.map(Some)
}
HidBackendType::Ch9329 {
port,
baud_rate,
hybrid_mouse,
macos_drag,
} => {
info!(
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}",
port, baud_rate, hybrid_mouse
"Initializing CH9329 HID backend on {} @ {} baud, hybrid_mouse={}, macos_drag={}",
port, baud_rate, hybrid_mouse, macos_drag
);
Ok(Some(Arc::new(ch9329::Ch9329Backend::with_options(
port,
*baud_rate,
*hybrid_mouse,
)?)))
Ok(Some(Arc::new(
ch9329::Ch9329Backend::with_compatibility_options(
port,
*baud_rate,
*hybrid_mouse,
*macos_drag,
)?,
)))
}
HidBackendType::Bluetooth { config } => {
#[cfg(target_os = "linux")]
{
Ok(Some(Arc::new(super::bluetooth::BluetoothBackend::new(
config.clone(),
self.bonds.get().cloned(),
)?)))
}
#[cfg(not(target_os = "linux"))]
{
let _ = config;
Err(AppError::Config(
"Bluetooth HID requires Linux and BlueZ".into(),
))
}
}
HidBackendType::None => {
warn!("HID backend disabled");
@@ -62,7 +90,7 @@ impl HidBackendFactory {
}
#[cfg(unix)]
async fn create_otg_backend(&self) -> Result<Arc<dyn HidBackend>> {
async fn create_otg_backend(&self, macos_drag: bool) -> Result<Arc<dyn HidBackend>> {
let otg_service = self
.otg_service
.as_ref()
@@ -74,11 +102,13 @@ impl HidBackendFactory {
.ok_or_else(|| AppError::Config("OTG HID paths are not available".to_string()))?;
info!("Creating OTG HID backend from device paths");
Ok(Arc::new(super::otg::OtgBackend::from_handles(handles)?))
Ok(Arc::new(super::otg::OtgBackend::with_macos_drag(
handles, macos_drag,
)?))
}
#[cfg(not(unix))]
async fn create_otg_backend(&self) -> Result<Arc<dyn HidBackend>> {
async fn create_otg_backend(&self, _macos_drag: bool) -> Result<Arc<dyn HidBackend>> {
Err(AppError::Config(
"OTG HID is only available on Linux".to_string(),
))

256
src/hid/macos_drag.rs Normal file
View 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
}]
);
}
}

View File

@@ -1,12 +1,15 @@
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
pub mod backend;
#[cfg(target_os = "linux")]
mod bluetooth;
pub mod ch9329;
mod ch9329_proto;
pub mod consumer;
pub mod datachannel;
mod factory;
pub mod keyboard;
mod macos_drag;
#[cfg(unix)]
pub mod otg;
#[cfg(unix)]
@@ -132,6 +135,8 @@ pub struct HidController {
hid_worker: Mutex<Option<JoinHandle<()>>>,
runtime_worker: Mutex<Option<JoinHandle<()>>>,
backend_available: Arc<AtomicBool>,
reset_requested: Arc<AtomicBool>,
screen_resolution: parking_lot::RwLock<(u32, u32)>,
}
impl HidController {
@@ -153,6 +158,8 @@ impl HidController {
hid_worker: Mutex::new(None),
runtime_worker: Mutex::new(None),
backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)),
}
}
@@ -174,9 +181,16 @@ impl HidController {
hid_worker: Mutex::new(None),
runtime_worker: Mutex::new(None),
backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)),
}
}
#[cfg(target_os = "linux")]
pub fn set_bond_store(&self, store: crate::db::hid_bonds::HidBondStore) {
let _ = self.backend_factory.bonds.set(Arc::new(store));
}
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
@@ -200,7 +214,12 @@ impl HidController {
}
};
*self.backend.write().await = Some(backend);
{
let mut slot = self.backend.write().await;
let (width, height) = *self.screen_resolution.read();
backend.set_screen_resolution(width, height);
*slot = Some(backend);
}
self.sync_runtime_state_from_backend().await;
self.start_event_worker().await;
@@ -235,7 +254,8 @@ impl HidController {
}
pub async fn prepare_otg_rebuild(&self) -> Result<()> {
if !matches!(*self.backend_type.read().await, HidBackendType::Otg) {
let backend_type = self.backend_type.read().await.clone();
if !matches!(backend_type, HidBackendType::Otg { .. }) {
return Ok(());
}
@@ -249,7 +269,7 @@ impl HidController {
let current = self.runtime_state.read().await.clone();
let rebuilding_state = HidRuntimeState::with_error(
&HidBackendType::Otg,
&backend_type,
&current,
"OTG gadget is rebuilding",
"rebuilding",
@@ -293,6 +313,25 @@ impl HidController {
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
}
pub async fn bluetooth_status(&self) -> Result<serde_json::Value> {
let backend = self
.backend
.read()
.await
.clone()
.ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?;
backend.bluetooth_status().await
}
pub async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> {
let backend = self
.backend
.read()
.await
.clone()
.ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?;
backend.bluetooth_action(action, seconds).await
}
pub async fn reset(&self) -> Result<()> {
if !self.backend_available.load(Ordering::Acquire) {
return Ok(());
@@ -308,6 +347,24 @@ impl HidController {
self.backend_type.read().await.clone()
}
/// Keep the active capture dimensions across HID reloads and USB rebuilds.
pub async fn set_screen_resolution(&self, width: u32, height: u32) {
if width == 0 || height == 0 || width > 65535 || height > 65535 {
return;
}
{
let mut resolution = self.screen_resolution.write();
if *resolution == (width, height) {
return;
}
*resolution = (width, height);
}
if let Some(backend) = self.backend.read().await.as_ref() {
let (width, height) = *self.screen_resolution.read();
backend.set_screen_resolution(width, height);
}
}
pub async fn snapshot(&self) -> HidRuntimeState {
self.runtime_state.read().await.clone()
}
@@ -349,6 +406,14 @@ impl HidController {
if let Some(backend) = self.backend.write().await.take() {
if let Err(e) = backend.shutdown().await {
// A Bluetooth shutdown may fail to restore adapter settings. Surface
// that failure so the config transaction can roll back.
if matches!(
*self.backend_type.read().await,
HidBackendType::Bluetooth { .. }
) {
return Err(e);
}
warn!("Error shutting down old HID backend: {}", e);
}
}
@@ -366,7 +431,14 @@ impl HidController {
}
};
*self.backend.write().await = new_backend;
{
let mut slot = self.backend.write().await;
if let Some(backend) = new_backend.as_ref() {
let (width, height) = *self.screen_resolution.read();
backend.set_screen_resolution(width, height);
}
*slot = new_backend;
}
if matches!(new_backend_type, HidBackendType::None) {
*self.backend_type.write().await = HidBackendType::None;
@@ -437,6 +509,7 @@ impl HidController {
let backend = self.backend.clone();
let pending_move = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone();
let reset_requested = self.reset_requested.clone();
let handle = tokio::spawn(async move {
let mut rx = rx;
@@ -446,6 +519,15 @@ impl HidController {
None => break,
};
if reset_requested.swap(false, Ordering::AcqRel) {
// A full input queue must not lose a key/button release and leave
// the host stuck. Discard the obsolete batch and send all-up.
while rx.try_recv().is_ok() {}
*pending_move.lock() = None;
pending_move_flag.store(false, Ordering::Release);
process_hid_event(QueuedHidEvent::Reset, &backend).await;
continue;
}
process_hid_event(event, &backend).await;
if pending_move_flag.swap(false, Ordering::AcqRel) {
@@ -504,7 +586,7 @@ impl HidController {
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => {
*self.pending_move.lock() = Some(event);
merge_pending_move(&mut self.pending_move.lock(), event);
self.pending_move_flag.store(true, Ordering::Release);
Ok(())
}
@@ -524,11 +606,16 @@ impl HidController {
tx.send(ev),
)
.await;
if send_result.is_ok() {
Ok(())
} else {
warn!("HID event queue full, dropping event");
Ok(())
match send_result {
Ok(Ok(())) => Ok(()),
Ok(Err(_)) => Err(AppError::BadRequest("HID event queue closed".into())),
Err(_) => {
self.reset_requested.store(true, Ordering::Release);
warn!("HID event queue full; scheduling all-input release");
Err(AppError::ServiceUnavailable(
"HID input queue full; input state will be reset".into(),
))
}
}
}
Err(mpsc::error::TrySendError::Closed(_)) => {
@@ -620,3 +707,121 @@ async fn apply_runtime_state(
events.mark_device_info_dirty();
}
}
fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
if let Some(previous) = pending {
if previous.event_type == MouseEventType::Move && event.event_type == MouseEventType::Move {
previous.x = previous.x.saturating_add(event.x).clamp(-32767, 32767);
previous.y = previous.y.saturating_add(event.y).clamp(-32767, 32767);
return;
}
}
*pending = Some(event);
}
#[cfg(test)]
mod queue_tests {
use super::*;
#[tokio::test]
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
#[cfg(unix)]
let controller = HidController::new(HidBackendType::None, None);
#[cfg(not(unix))]
let controller = HidController::new(HidBackendType::None);
let backend = Arc::new(ch9329::Ch9329Backend::new("/dev/null").unwrap());
*controller.backend.write().await = Some(backend.clone());
controller.set_screen_resolution(3840, 2160).await;
assert_eq!(
backend.runtime_snapshot().screen_resolution,
Some((3840, 2160))
);
controller.set_screen_resolution(0, 0).await;
assert_eq!(*controller.screen_resolution.read(), (3840, 2160));
controller.set_screen_resolution(1280, 720).await;
assert_eq!(
backend.runtime_snapshot().screen_resolution,
Some((1280, 720))
);
}
struct TestBackend {
pressed: Arc<AtomicBool>,
reset_done: Arc<tokio::sync::Notify>,
runtime: tokio::sync::watch::Sender<()>,
}
#[async_trait::async_trait]
impl HidBackend for TestBackend {
async fn init(&self) -> Result<()> {
Ok(())
}
async fn send_keyboard(&self, _: KeyboardEvent) -> Result<()> {
self.pressed.store(true, Ordering::Release);
Ok(())
}
async fn send_mouse(&self, _: MouseEvent) -> Result<()> {
Ok(())
}
async fn reset(&self) -> Result<()> {
self.pressed.store(false, Ordering::Release);
self.reset_done.notify_one();
Ok(())
}
async fn shutdown(&self) -> Result<()> {
Ok(())
}
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
HidBackendRuntimeSnapshot::default()
}
fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> {
self.runtime.subscribe()
}
}
#[tokio::test]
async fn congested_queue_releases_host_instead_of_replaying_keydowns() {
#[cfg(unix)]
let controller = HidController::new(HidBackendType::None, None);
#[cfg(not(unix))]
let controller = HidController::new(HidBackendType::None);
let pressed = Arc::new(AtomicBool::new(true));
let done = Arc::new(tokio::sync::Notify::new());
let (runtime, _) = tokio::sync::watch::channel(());
*controller.backend.write().await = Some(Arc::new(TestBackend {
pressed: pressed.clone(),
reset_done: done.clone(),
runtime,
}));
for _ in 0..HID_EVENT_QUEUE_CAPACITY {
controller
.enqueue_event(QueuedHidEvent::Keyboard(KeyboardEvent::key_down(
CanonicalKey::KeyA,
KeyboardModifiers::default(),
)))
.await
.unwrap();
}
assert!(controller
.enqueue_event(QueuedHidEvent::Reset)
.await
.is_err());
controller.start_event_worker().await;
tokio::time::timeout(Duration::from_secs(1), done.notified())
.await
.unwrap();
assert!(!pressed.load(Ordering::Acquire));
}
#[test]
fn relative_motion_is_accumulated_but_absolute_is_replaced() {
let mut pending = Some(MouseEvent::move_rel(100, -20));
merge_pending_move(&mut pending, MouseEvent::move_rel(80, 30));
assert_eq!(
(pending.as_ref().unwrap().x, pending.as_ref().unwrap().y),
(180, 10)
);
merge_pending_move(&mut pending, MouseEvent::move_abs(10, 20));
merge_pending_move(&mut pending, MouseEvent::move_abs(30, 40));
assert_eq!(
(pending.as_ref().unwrap().x, pending.as_ref().unwrap().y),
(30, 40)
);
}
}

View File

@@ -80,6 +80,8 @@ pub struct OtgBackend {
keyboard_leds_enabled: bool,
keyboard_state: Mutex<KeyboardReport>,
mouse_buttons: AtomicU8,
macos_drag: bool,
macos_drag_state: Mutex<super::macos_drag::MacosDrag>,
led_state: Arc<parking_lot::RwLock<LedState>>,
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
@@ -99,6 +101,15 @@ const OTG_RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(500);
impl OtgBackend {
/// Gadget must already exist; paths come from `OtgService`.
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
Self::with_macos_drag(paths, false)
}
pub fn with_macos_drag(paths: HidDevicePaths, macos_drag: bool) -> Result<Self> {
if macos_drag && (paths.mouse_relative.is_none() || paths.mouse_absolute.is_none()) {
return Err(AppError::Config(
"macOS drag compatibility requires both OTG mouse interfaces".into(),
));
}
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
Ok(Self {
keyboard_path: paths.keyboard,
@@ -112,6 +123,8 @@ impl OtgBackend {
keyboard_leds_enabled: paths.keyboard_leds_enabled,
keyboard_state: Mutex::new(KeyboardReport::default()),
mouse_buttons: AtomicU8::new(0),
macos_drag,
macos_drag_state: Mutex::new(super::macos_drag::MacosDrag::default()),
led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
@@ -852,6 +865,28 @@ impl HidBackend for OtgBackend {
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
let buttons = self.mouse_buttons.load(Ordering::Relaxed);
if self.macos_drag {
use super::macos_drag::MouseReport;
let mut state = self.macos_drag_state.lock();
let extent = self.screen_resolution.read().unwrap_or((1920, 1080));
let (buttons, reports) = state.plan(event, buttons, extent);
self.mouse_buttons.store(buttons, Ordering::Relaxed);
for report in reports {
match report {
MouseReport::Absolute { buttons, x, y } => {
self.send_mouse_report_absolute(buttons, x, y, 0)?
}
MouseReport::Relative {
buttons,
dx,
dy,
wheel,
} => self.send_mouse_report_relative(buttons, dx, dy, wheel)?,
}
}
return Ok(());
}
match event.event_type {
MouseEventType::Move => {
let dx = event.x.clamp(-127, 127) as i8;
@@ -896,6 +931,7 @@ impl HidBackend for OtgBackend {
}
self.mouse_buttons.store(0, Ordering::Relaxed);
self.macos_drag_state.lock().reset();
self.send_mouse_report_relative(0, 0, 0, 0)?;
self.send_mouse_report_absolute(0, 0, 0, 0)?;
@@ -988,6 +1024,51 @@ mod tests {
assert_eq!(kb_report.to_bytes().len(), 8);
}
#[tokio::test]
async fn mouse_compatibility_writes_both_endpoints_and_preserves_default() {
use crate::hid::MouseButton;
for enabled in [false, true] {
let relative = tempfile::NamedTempFile::new().unwrap();
let absolute = tempfile::NamedTempFile::new().unwrap();
let backend = OtgBackend::with_macos_drag(
HidDevicePaths {
mouse_relative: Some(relative.path().to_path_buf()),
mouse_absolute: Some(absolute.path().to_path_buf()),
..Default::default()
},
enabled,
)
.unwrap();
for event in [
MouseEvent::move_abs(8000, 8000),
MouseEvent::button_down(MouseButton::Left),
MouseEvent::move_abs(16000, 16000),
MouseEvent::button_up(MouseButton::Left),
] {
backend.send_mouse(event).await.unwrap();
}
let abs = fs::read(absolute.path()).unwrap();
let rel = fs::read(relative.path()).unwrap();
let buttons: Vec<_> = abs.chunks_exact(6).map(|packet| packet[0]).collect();
assert_eq!(buttons, if enabled { vec![0, 1, 0] } else { vec![0, 0] });
let dx: i32 = rel
.chunks_exact(4)
.map(|packet| i32::from(packet[1] as i8))
.sum();
let dy: i32 = rel
.chunks_exact(4)
.map(|packet| i32::from(packet[2] as i8))
.sum();
assert_eq!((dx, dy), if enabled { (468, 263) } else { (0, 0) });
assert_eq!(&rel[rel.len() - 4..], &[0, 0, 0, 0]);
}
}
#[test]
fn compatibility_requires_both_mouse_endpoints() {
assert!(OtgBackend::with_macos_drag(HidDevicePaths::default(), true).is_err());
}
#[tokio::test]
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
let mut file = tempfile::tempfile().unwrap();

View File

@@ -36,6 +36,8 @@ pub mod redfish;
#[cfg(feature = "desktop")]
pub mod rtsp;
#[cfg(feature = "desktop")]
pub mod runtime;
#[cfg(feature = "desktop")]
pub mod rustdesk;
#[cfg(feature = "desktop")]
pub mod state;

View File

@@ -2,44 +2,21 @@ use std::collections::HashSet;
use std::future::Future;
use std::io::Write;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::path::PathBuf;
use axum_server::tls_rustls::RustlsConfig;
use clap::{Args, Parser, Subcommand, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::{broadcast, mpsc};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use one_kvm::atx::AtxController;
use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality};
use one_kvm::auth::{SessionStore, TwoFactorService, UserStore};
use one_kvm::computer_use::ComputerUseManager;
use one_kvm::config::{self, AppConfig, ConfigStore};
use one_kvm::db::DatabasePool;
use one_kvm::events::EventBus;
use one_kvm::extensions::ExtensionManager;
use one_kvm::hid::{HidBackendType, HidController};
#[cfg(unix)]
use one_kvm::msd::MsdController;
#[cfg(unix)]
use one_kvm::otg::OtgService;
use one_kvm::config;
use one_kvm::db::open_database_pool;
use one_kvm::platform::PlatformCapabilities;
use one_kvm::rtsp::RtspService;
use one_kvm::rustdesk::RustDeskService;
use one_kvm::state::{AppState, ShutdownAction};
use one_kvm::update::UpdateService;
use one_kvm::runtime::{RuntimeBuilder, WebConfigOverrides};
use one_kvm::state::ShutdownAction;
use one_kvm::utils::bind_tcp_listener;
use one_kvm::video::codec_constraints::{
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
StreamCodecConstraints,
};
use one_kvm::video::format::{PixelFormat, Resolution};
use one_kvm::video::{Streamer, VideoStreamManager};
use one_kvm::vnc::VncService;
use one_kvm::web;
use one_kvm::webrtc::{WebRtcStreamer, WebRtcStreamerConfig};
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
enum LogLevel {
@@ -47,7 +24,6 @@ enum LogLevel {
Warn,
#[default]
Info,
Verbose,
Debug,
Trace,
}
@@ -93,13 +69,9 @@ struct CliArgs {
#[arg(short = 'd', long, value_name = "DIR")]
data_dir: Option<PathBuf>,
/// Log level (error, warn, info, verbose, debug, trace)
/// Log level (error, warn, info, debug, trace)
#[arg(short = 'l', long, value_name = "LEVEL", default_value = "info")]
log_level: LogLevel,
/// Increase verbosity (-v for verbose, -vv for debug, -vvv for trace)
#[arg(short = 'v', long, action = clap::ArgAction::Count)]
verbose: u8,
}
#[derive(Subcommand, Debug)]
@@ -126,7 +98,7 @@ enum UserAction {
async fn main() -> anyhow::Result<()> {
let args = CliArgs::parse();
init_logging(args.log_level, args.verbose);
init_logging(args.log_level);
CryptoProvider::install_default(ring::default_provider())
.expect("Failed to install rustls crypto provider");
@@ -147,28 +119,20 @@ async fn main() -> anyhow::Result<()> {
return Ok(());
}
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
if let Some(addr) = args.address {
config.web.bind_address = addr.clone();
config.web.bind_addresses = vec![addr];
}
if let Some(port) = args.http_port {
config.web.http_port = port;
}
if let Some(port) = args.https_port {
config.web.https_port = port;
}
if args.enable_https {
config.web.https_enabled = true;
}
if let Some(cert_path) = args.ssl_cert {
config.web.ssl_cert_path = Some(cert_path.to_string_lossy().to_string());
}
if let Some(key_path) = args.ssl_key {
config.web.ssl_key_path = Some(key_path.to_string_lossy().to_string());
}
let overrides = WebConfigOverrides {
address: args.address,
http_port: args.http_port,
https_port: args.https_port,
enable_https: args.enable_https,
ssl_cert: args.ssl_cert,
ssl_key: args.ssl_key,
};
let mut runtime = RuntimeBuilder::new(data_dir.clone())
.with_web_overrides(overrides)
.build()
.await?;
let config = runtime.config();
let state = runtime.state().clone();
let bind_ips = resolve_bind_addresses(&config.web)?;
let scheme = if config.web.https_enabled {
@@ -187,500 +151,12 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Server will listen on: {}://{}", scheme, addr);
}
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
let user_store = UserStore::new(db.clone_pool());
let two_factor = TwoFactorService::new(db.clone_pool());
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
let events = Arc::new(EventBus::new());
tracing::info!("Event bus initialized");
let (video_format, video_resolution) = parse_video_config(&config);
tracing::debug!(
"Parsed video config: {} @ {}x{}",
video_format,
video_resolution.width,
video_resolution.height
);
let streamer = Streamer::new();
streamer.set_event_bus(events.clone()).await;
if let Some(ref device_path) = config.video.device {
if let Err(e) = streamer
.apply_video_config(
device_path,
video_format,
video_resolution,
config.video.fps,
)
.await
{
tracing::warn!(
"Failed to initialize video with config: {}, will auto-detect",
e
);
} else {
tracing::info!(
"Video configured: {} @ {}x{} {}",
device_path,
video_resolution.width,
video_resolution.height,
video_format
);
}
}
let webrtc_streamer = {
let webrtc_config = WebRtcStreamerConfig {
resolution: video_resolution,
input_format: video_format,
fps: config.video.fps,
bitrate_preset: config.stream.bitrate_preset,
encoder_backend: one_kvm::stream_encoder::encoder_type_to_backend(
config.stream.encoder.clone(),
),
webrtc: {
let mut stun_servers = vec![];
let mut turn_servers = vec![];
let has_custom_stun = config
.stream
.stun_server
.as_ref()
.map(|s| !s.is_empty())
.unwrap_or(false);
let has_custom_turn = config
.stream
.turn_server
.as_ref()
.map(|s| !s.is_empty())
.unwrap_or(false);
if !has_custom_stun && !has_custom_turn {
use one_kvm::webrtc::config::public_ice;
let stun = public_ice::stun_server().to_string();
tracing::info!("Using public STUN server: {}", stun);
stun_servers.push(stun);
} else {
if let Some(ref stun) = config.stream.stun_server {
if !stun.is_empty() {
stun_servers.push(stun.clone());
tracing::info!("Using custom STUN server: {}", stun);
}
}
if let Some(ref turn) = config.stream.turn_server {
if !turn.is_empty() {
let username = config.stream.turn_username.clone().unwrap_or_default();
let credential =
config.stream.turn_password.clone().unwrap_or_default();
turn_servers.push(one_kvm::webrtc::config::TurnServer::new(
turn.clone(),
username.clone(),
credential,
));
tracing::info!(
"Using custom TURN server: {} (user: {})",
turn,
username
);
}
}
}
one_kvm::webrtc::config::WebRtcConfig {
stun_servers,
turn_servers,
..Default::default()
}
},
..Default::default()
};
WebRtcStreamer::with_config(webrtc_config)
};
tracing::info!("WebRTC streamer created");
#[cfg(unix)]
let otg_service = Arc::new(OtgService::new());
#[cfg(unix)]
tracing::info!("OTG Service created");
#[cfg(unix)]
if let Err(e) = otg_service
.apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac)
.await
{
tracing::warn!("Failed to apply OTG config: {}", e);
}
let hid_backend = match config.hid.backend {
config::HidBackend::Otg => HidBackendType::Otg,
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
port: config.hid.ch9329_port.clone(),
baud_rate: config.hid.ch9329_baudrate,
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
},
config::HidBackend::None => HidBackendType::None,
};
#[cfg(unix)]
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
#[cfg(not(unix))]
let hid = Arc::new(HidController::new(hid_backend));
hid.set_event_bus(events.clone()).await;
if let Err(e) = hid.init().await {
tracing::warn!("Failed to initialize HID backend: {}", e);
}
#[cfg(unix)]
let msd = if config.msd.enabled {
let ventoy_resource_dir = data_dir.join("ventoy");
let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path());
if let Err(e) = controller.init(&ventoy_resource_dir).await {
tracing::warn!("Failed to initialize MSD controller: {}", e);
None
} else {
controller.set_event_bus(events.clone()).await;
Some(controller)
}
} else {
tracing::info!("MSD disabled in configuration");
None
};
let atx = if config.atx.enabled {
let controller_config = config.atx.to_controller_config();
let controller = AtxController::new(controller_config);
if let Err(e) = controller.init().await {
tracing::warn!("Failed to initialize ATX controller: {}", e);
None
} else {
Some(controller)
}
} else {
tracing::info!("ATX disabled in configuration");
None
};
let audio = {
let audio_config = AudioControllerConfig {
enabled: config.audio.enabled,
device: config.audio.device.clone(),
quality: match config.audio.quality.parse::<AudioQuality>() {
Ok(q) => q,
Err(e) => {
tracing::warn!(
"Invalid audio quality in config (value={:?}): {}, using balanced",
config.audio.quality,
e
);
AudioQuality::Balanced
}
},
};
let controller = AudioController::new(audio_config);
controller.set_event_bus(events.clone()).await;
if config.audio.enabled {
tracing::info!(
"Audio enabled: {}, quality={}",
config.audio.device,
config.audio.quality
);
if let Err(e) = controller.start_streaming().await {
tracing::warn!("Failed to start audio streaming: {}", e);
}
} else {
tracing::info!("Audio disabled in configuration");
}
Arc::new(controller)
};
let extensions = Arc::new(ExtensionManager::new());
tracing::info!("Extension manager initialized");
webrtc_streamer.set_hid_controller(hid.clone()).await;
webrtc_streamer.set_audio_controller(audio.clone()).await;
if config.audio.enabled {
if let Err(e) = webrtc_streamer.set_audio_enabled(true).await {
tracing::warn!("Failed to enable WebRTC audio: {}", e);
} else {
tracing::debug!("WebRTC audio enabled");
}
}
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
streamer.current_capture_config().await;
tracing::debug!(
"Initial video config: {}x{} {:?} @ {}fps",
actual_resolution.width,
actual_resolution.height,
actual_format,
actual_fps
);
webrtc_streamer
.update_video_config(actual_resolution, actual_format, actual_fps)
.await;
if let Some(device_path) = device_path {
let device_info = streamer.current_device().await;
webrtc_streamer
.set_capture_device(device_path, jpeg_quality, device_info)
.await;
tracing::debug!("WebRTC streamer configured for direct capture");
} else {
tracing::warn!("No capture device configured for WebRTC");
}
let stream_manager = VideoStreamManager::with_webrtc_streamer(
streamer.clone(),
webrtc_streamer.clone() as std::sync::Arc<dyn one_kvm::video::traits::VideoOutput>,
);
stream_manager.set_event_bus(events.clone()).await;
stream_manager.set_config_store(config_store.clone()).await;
{
let stream_manager_weak = Arc::downgrade(&stream_manager);
audio
.set_recovered_callback(Arc::new(move || {
if let Some(stream_manager) = stream_manager_weak.upgrade() {
tokio::spawn(async move {
stream_manager.reconnect_webrtc_audio_sources().await;
});
}
}))
.await;
}
let initial_mode = config.stream.mode.clone();
if let Err(e) = stream_manager.init_with_mode(initial_mode.clone()).await {
tracing::warn!(
"Failed to initialize stream manager with mode {:?}: {}",
initial_mode,
e
);
} else {
tracing::info!(
"Video stream manager initialized with mode: {:?}",
initial_mode
);
}
let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}",
e
);
false
}
};
let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() {
tracing::info!(
"Initializing RustDesk service: ID={} -> {}",
config.rustdesk.device_id,
config.rustdesk.rendezvous_addr()
);
let service = RustDeskService::new(
config.rustdesk.clone(),
stream_manager.clone(),
hid.clone(),
audio.clone(),
);
Some(Arc::new(service))
} else {
if config.rustdesk.enabled {
tracing::warn!(
"RustDesk enabled but configuration is incomplete (missing server or credentials)"
);
} else {
tracing::info!("RustDesk disabled in configuration");
}
None
};
let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
tracing::info!(
"Initializing RTSP service: rtsp://{}:{}/{}",
config.rtsp.bind,
config.rtsp.port,
config.rtsp.path
);
let service = RtspService::new(config.rtsp.clone(), stream_manager.clone());
Some(Arc::new(service))
} else {
tracing::info!("RTSP disabled in configuration");
None
};
let vnc = if third_party_codec_config_valid && config.vnc.enabled {
tracing::info!(
"Initializing VNC service: {}:{} ({:?})",
config.vnc.bind,
config.vnc.port,
config.vnc.encoding
);
Some(Arc::new(VncService::new(
config.vnc.clone(),
stream_manager.clone(),
hid.clone(),
)))
} else {
tracing::info!("VNC disabled in configuration");
None
};
let update_service = Arc::new(UpdateService::new());
let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone());
let state = AppState::new(
db.clone(),
config_store.clone(),
session_store,
user_store,
two_factor,
#[cfg(unix)]
otg_service,
stream_manager,
webrtc_streamer.clone(),
hid,
computer_use,
#[cfg(unix)]
msd,
atx,
audio,
rustdesk.clone(),
vnc.clone(),
rtsp.clone(),
extensions.clone(),
events.clone(),
update_service,
shutdown_tx.clone(),
data_dir.clone(),
);
#[cfg(unix)]
{
// Initialize UAC playback writer if UAC is enabled.
if config.uac.enabled {
let uac_cfg = one_kvm::audio::uac::UacPlaybackConfig {
sample_rate: config.uac.sample_rate,
channels: config.uac.channels as u16,
..Default::default()
};
match one_kvm::audio::uac::UacPlayback::start(uac_cfg) {
Ok(writer) => {
*state.uac_playback.write().await = Some(writer);
tracing::info!("UAC playback writer started");
}
Err(e) => {
tracing::warn!("Failed to start UAC playback writer: {}", e);
}
}
}
}
if config.watchdog.enabled {
if let Err(error) = state.watchdog.enable().await {
tracing::error!(
"Configured hardware watchdog failed to start; web service will continue: {}",
error
);
} else {
tracing::info!("Hardware watchdog started");
}
}
extensions.set_event_bus(events.clone()).await;
if let Some(ref service) = rustdesk {
if let Err(e) = service.start().await {
tracing::error!("Failed to start RustDesk service: {}", e);
} else {
if let Some(updated_config) = service.save_credentials() {
if let Err(e) = config_store
.update(|cfg| {
cfg.rustdesk.public_key = updated_config.public_key.clone();
cfg.rustdesk.private_key = updated_config.private_key.clone();
cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone();
cfg.rustdesk.signing_private_key =
updated_config.signing_private_key.clone();
cfg.rustdesk.uuid = updated_config.uuid.clone();
})
.await
{
tracing::warn!("Failed to save RustDesk credentials: {}", e);
}
}
tracing::info!("RustDesk service started");
}
}
if let Some(ref service) = vnc {
if let Err(e) = service.start().await {
tracing::error!("Failed to start VNC service: {}", e);
} else {
tracing::info!("VNC service started");
}
}
if let Some(ref service) = rtsp {
if let Err(e) = service.start().await {
tracing::error!("Failed to start RTSP service: {}", e);
} else {
tracing::info!("RTSP service started");
}
}
{
let runtime_config = state.runtime_third_party_config().await;
let constraints = StreamCodecConstraints::from_config(&runtime_config);
state
.stream_manager
.set_runtime_codec_constraints(constraints.clone())
.await;
match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await {
Ok(result) if result.changed => {
if let Some(message) = result.message {
tracing::info!("{}", message);
}
}
Ok(_) => {}
Err(e) => tracing::warn!("Failed to enforce startup codec constraints: {}", e),
}
}
{
let ext_config = config_store.get();
extensions.start_enabled(&ext_config.extensions).await;
}
{
let extensions_clone = extensions.clone();
let config_store_clone = config_store.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
interval.tick().await;
let config = config_store_clone.get();
extensions_clone.health_check(&config.extensions).await;
}
});
tracing::info!("Extension health check task started");
}
state.publish_device_info().await;
spawn_device_info_broadcaster(state.clone(), events);
let app = web::create_router(state.clone());
let app = runtime.router();
let listeners = bind_tcp_listeners(&bind_ips, bind_port)?;
let shutdown_signal = {
let shutdown_tx = state.shutdown_tx.clone();
let mut shutdown_rx = shutdown_tx.subscribe();
async move {
tokio::select! {
@@ -740,7 +216,7 @@ async fn main() -> anyhow::Result<()> {
servers.push(server);
}
run_servers_until_shutdown(servers, shutdown_signal, &state, "HTTPS").await
run_servers_until_shutdown(servers, shutdown_signal, "HTTPS").await
} else {
let servers = FuturesUnordered::new();
for listener in listeners {
@@ -752,9 +228,10 @@ async fn main() -> anyhow::Result<()> {
servers.push(async move { server.await });
}
run_servers_until_shutdown(servers, shutdown_signal, &state, "HTTP").await
run_servers_until_shutdown(servers, shutdown_signal, "HTTP").await
};
runtime.shutdown().await;
tracing::info!("Server shutdown complete");
if let ShutdownAction::Restart { exe_path } = shutdown_action {
restart_current_process(exe_path)?;
@@ -762,25 +239,16 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
fn init_logging(level: LogLevel, verbose_count: u8) {
let effective_level = match verbose_count {
0 => level,
1 => LogLevel::Verbose,
2 => LogLevel::Debug,
_ => LogLevel::Trace,
fn init_logging(level: LogLevel) {
let app_level = match level {
LogLevel::Error => "error",
LogLevel::Warn => "warn",
LogLevel::Info => "info",
LogLevel::Debug => "debug",
LogLevel::Trace => "trace",
};
let filter = match effective_level {
LogLevel::Error => "one_kvm=error,tower_http=error,webrtc_sctp=warn",
LogLevel::Warn => "one_kvm=warn,tower_http=warn,webrtc_sctp=warn",
LogLevel::Info => "one_kvm=info,tower_http=info,webrtc_sctp=warn",
LogLevel::Verbose => "one_kvm=debug,tower_http=info,webrtc_sctp=warn",
LogLevel::Debug => "one_kvm=debug,tower_http=debug,webrtc_sctp=warn",
LogLevel::Trace => "one_kvm=trace,tower_http=debug,webrtc_sctp=warn",
};
let env_filter =
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into());
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| app_level.into());
if let Err(err) = tracing_subscriber::registry()
.with(env_filter)
@@ -830,24 +298,16 @@ async fn shutdown_signal() -> anyhow::Result<()> {
Ok(())
}
async fn open_database_pool(data_dir: &Path) -> anyhow::Result<DatabasePool> {
let db_path = data_dir.join("one-kvm.db");
let db = DatabasePool::new(&db_path).await?;
db.init_schema().await?;
Ok(db)
}
async fn run_servers_until_shutdown<F, E>(
mut servers: FuturesUnordered<F>,
shutdown_signal: impl Future<Output = ShutdownAction>,
state: &Arc<AppState>,
protocol: &'static str,
) -> ShutdownAction
where
F: Future<Output = Result<(), E>> + Send,
E: std::fmt::Display,
{
let action = tokio::select! {
tokio::select! {
action = shutdown_signal => {
action
}
@@ -857,9 +317,7 @@ where
}
ShutdownAction::Exit
}
};
cleanup(state).await;
action
}
}
fn restart_current_process(exe_path: Option<PathBuf>) -> anyhow::Result<()> {
@@ -883,7 +341,6 @@ fn restart_current_process(exe_path: Option<PathBuf>) -> anyhow::Result<()> {
}
async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> {
tokio::fs::create_dir_all(&data_dir).await?;
let db = open_database_pool(&data_dir).await?;
let users = UserStore::new(db.clone_pool());
let two_factor = TwoFactorService::new(db.clone_pool());
@@ -896,64 +353,6 @@ async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Resu
}
}
async fn load_runtime_config(
data_dir: &Path,
) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> {
tokio::fs::create_dir_all(data_dir).await?;
let db = open_database_pool(data_dir).await?;
let config_store = ConfigStore::new(db.clone_pool())?;
config_store.load().await?;
let mut config = (*config_store.get()).clone();
config.apply_platform_defaults();
prepare_linux_runtime_dirs(data_dir, &config_store, &mut config).await?;
Ok((db, config_store, config))
}
#[cfg(unix)]
async fn prepare_linux_runtime_dirs(
data_dir: &Path,
config_store: &ConfigStore,
config: &mut AppConfig,
) -> anyhow::Result<()> {
let mut msd_dir_updated = false;
if config.msd.msd_dir.trim().is_empty() {
let msd_dir = data_dir.join("msd");
config.msd.msd_dir = msd_dir.to_string_lossy().to_string();
msd_dir_updated = true;
} else if !PathBuf::from(&config.msd.msd_dir).is_absolute() {
let msd_dir = data_dir.join(&config.msd.msd_dir);
tracing::warn!(
"MSD directory is relative, rebasing to {}",
msd_dir.display()
);
config.msd.msd_dir = msd_dir.to_string_lossy().to_string();
msd_dir_updated = true;
}
if msd_dir_updated {
config_store.set(config.clone()).await?;
}
let msd_dir = PathBuf::from(&config.msd.msd_dir);
if let Err(e) = tokio::fs::create_dir_all(msd_dir.join("images")).await {
tracing::warn!("Failed to create MSD images directory: {}", e);
}
if let Err(e) = tokio::fs::create_dir_all(msd_dir.join("ventoy")).await {
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
}
Ok(())
}
#[cfg(not(unix))]
async fn prepare_linux_runtime_dirs(
_data_dir: &Path,
_config_store: &ConfigStore,
_config: &mut AppConfig,
) -> anyhow::Result<()> {
Ok(())
}
async fn run_user_action(
action: UserAction,
users: &UserStore,
@@ -1065,17 +464,6 @@ fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result<Vec<std::ne
Ok(listeners)
}
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
let format = config
.video
.format
.as_ref()
.and_then(|f: &String| f.parse::<PixelFormat>().ok())
.unwrap_or(PixelFormat::Mjpeg);
let resolution = Resolution::new(config.video.width, config.video.height);
(format, resolution)
}
fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyPair>> {
use rcgen::generate_simple_self_signed;
@@ -1088,197 +476,3 @@ fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyP
let certified_key = generate_simple_self_signed(subject_alt_names)?;
Ok(certified_key)
}
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
use std::time::{Duration, Instant};
enum DeviceInfoTrigger {
Event,
Lagged { topic: &'static str, count: u64 },
}
const DEVICE_INFO_TOPICS: &[&str] = &[
"stream.state_changed",
"stream.config_applied",
"stream.mode_ready",
];
const DEBOUNCE_MS: u64 = 100;
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
for topic in DEVICE_INFO_TOPICS {
let Some(mut rx) = events.subscribe_topic(topic) else {
tracing::warn!(
"DeviceInfo broadcaster missing topic subscription: {}",
topic
);
continue;
};
let trigger_tx = trigger_tx.clone();
let topic_name = *topic;
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(_) => {
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
if trigger_tx
.send(DeviceInfoTrigger::Lagged {
topic: topic_name,
count,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
{
let mut dirty_rx = events.subscribe_device_info_dirty();
let trigger_tx = trigger_tx.clone();
tokio::spawn(async move {
loop {
match dirty_rx.recv().await {
Ok(()) => {
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
if trigger_tx
.send(DeviceInfoTrigger::Lagged {
topic: "device_info_dirty",
count,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
tokio::spawn(async move {
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
let mut pending_broadcast = false;
loop {
let recv_result = if pending_broadcast {
let remaining =
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
} else {
Ok(trigger_rx.recv().await)
};
match recv_result {
Ok(Some(DeviceInfoTrigger::Event)) => {
pending_broadcast = true;
}
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
tracing::warn!(
"DeviceInfo broadcaster lagged by {} events on topic {}",
count,
topic
);
pending_broadcast = true;
}
Ok(None) => {
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
break;
}
Err(_timeout) => {}
}
if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
state.publish_device_info().await;
tracing::trace!("Broadcasted DeviceInfo (debounced)");
last_broadcast = Instant::now();
pending_broadcast = false;
}
}
});
tracing::info!(
"DeviceInfo broadcaster task started (debounce: {}ms)",
DEBOUNCE_MS
);
}
async fn cleanup(state: &Arc<AppState>) {
state.extensions.stop_all().await;
tracing::info!("Extensions stopped");
if let Some(ref service) = *state.rustdesk.read().await {
if let Err(e) = service.stop().await {
tracing::warn!("Failed to stop RustDesk service: {}", e);
} else {
tracing::info!("RustDesk service stopped");
}
}
if let Some(ref service) = *state.vnc.read().await {
if let Err(e) = service.stop().await {
tracing::warn!("Failed to stop VNC service: {}", e);
} else {
tracing::info!("VNC service stopped");
}
}
if let Some(ref service) = *state.rtsp.read().await {
if let Err(e) = service.stop().await {
tracing::warn!("Failed to stop RTSP service: {}", e);
} else {
tracing::info!("RTSP service stopped");
}
}
if let Err(e) = state.stream_manager.stop().await {
tracing::warn!("Failed to stop streamer: {}", e);
}
if let Err(e) = state.hid.shutdown().await {
tracing::warn!("Failed to shutdown HID: {}", e);
}
#[cfg(unix)]
if let Some(msd) = state.msd.write().await.as_mut() {
if let Err(e) = msd.shutdown().await {
tracing::warn!("Failed to shutdown MSD: {}", e);
}
}
#[cfg(unix)]
if let Err(e) = state.otg_service.shutdown().await {
tracing::warn!("Failed to shutdown OTG: {}", e);
}
if let Some(atx) = state.atx.write().await.as_mut() {
if let Err(e) = atx.shutdown().await {
tracing::warn!("Failed to shutdown ATX: {}", e);
}
}
if let Err(e) = state.audio.shutdown().await {
tracing::warn!("Failed to shutdown audio: {}", e);
}
if let Err(error) = state.watchdog.disable().await {
tracing::error!(
"CRITICAL: failed to disable hardware watchdog during shutdown: {}",
error
);
}
}

View File

@@ -8,9 +8,10 @@ use tracing::{debug, info, warn};
use super::image::ImageManager;
use super::monitor::MsdHealthMonitor;
use super::types::{
DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia,
MountedMediaKind, MsdState,
DiskMode, DownloadProgress, DownloadStatus, DriveFileAccess, DriveInfo, ImageInfo,
MountedMedia, MountedMediaKind, MsdState,
};
use super::ventoy_drive::VentoyDrive;
use crate::error::{AppError, MsdErrorCode, Result};
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
@@ -62,12 +63,8 @@ impl MsdController {
),
}
if let Err(e) = std::fs::create_dir_all(&self.images_path) {
warn!("Failed to create images directory: {}", e);
}
if let Err(e) = std::fs::create_dir_all(&self.ventoy_dir) {
warn!("Failed to create ventoy directory: {}", e);
}
tokio::fs::create_dir_all(&self.images_path).await?;
tokio::fs::create_dir_all(&self.ventoy_dir).await?;
info!("Fetching MSD function from OtgService");
let msd_func = self
@@ -87,14 +84,9 @@ impl MsdController {
state.available = true;
if self.drive_path.exists() {
if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
let drive_info = DriveInfo {
size: metadata.len(),
used: 0,
free: metadata.len(),
initialized: true,
path: self.drive_path.clone(),
};
if let Ok(drive_info) =
VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown)
{
state.drive_info = Some(drive_info.clone());
debug!(
"Found existing virtual drive: {}",
@@ -203,28 +195,6 @@ impl MsdController {
self.assert_available(&state).await?;
if !self.drive_path.exists() {
self.monitor
.report_error("Virtual drive not initialized", "drive_not_found")
.await;
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let drive_info = state.drive_info.clone().or_else(|| {
std::fs::metadata(&self.drive_path)
.ok()
.map(|metadata| DriveInfo {
size: metadata.len(),
used: 0,
free: metadata.len(),
initialized: true,
path: self.drive_path.clone(),
})
});
if state.drive_info.is_none() {
state.drive_info = drive_info.clone();
}
if state
.mounted_media
.iter()
@@ -233,8 +203,22 @@ impl MsdController {
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
}
let drive_info =
drive_info.ok_or_else(|| AppError::from(MsdErrorCode::MsdDriveNotInitialized))?;
let drive_info = match self.drive_mount_info() {
Ok(info) => info,
Err(error) => {
if matches!(
&error,
AppError::Msd(msd) if msd.code() == MsdErrorCode::MsdDriveNotInitialized
) {
self.monitor
.report_error("Virtual drive not initialized", "drive_not_found")
.await;
}
return Err(error);
}
};
state.drive_info = Some(drive_info.clone());
let lun = Self::lowest_free_lun(&state)
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?;
@@ -244,6 +228,8 @@ impl MsdController {
return Err(e);
}
state.mounted_media.push(media);
state.drive_info =
Some(drive_info.with_file_access(DriveFileAccess::BlockedWhileConnected));
info!(
"Mounted virtual drive on LUN {}: {}",
@@ -258,6 +244,15 @@ impl MsdController {
Ok(())
}
fn drive_mount_info(&self) -> Result<DriveInfo> {
VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown)
}
pub async fn set_drive_info(&self, drive_info: Option<DriveInfo>) {
self.state.write().await.drive_info = drive_info;
self.mark_device_info_dirty().await;
}
async fn assert_available(&self, state: &MsdState) -> Result<()> {
if !state.available {
self.monitor
@@ -297,6 +292,16 @@ impl MsdController {
}
fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) {
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
{
state.drive_info = state
.drive_info
.take()
.map(|info| info.with_file_access(DriveFileAccess::Unknown));
}
state.disk_mode = disk_mode;
state.mounted_media.clear();
}
@@ -401,6 +406,12 @@ impl MsdController {
self.disconnect_lun(media.lun).await?;
state.mounted_media.remove(index);
if media.kind == MountedMediaKind::Drive {
state.drive_info = state
.drive_info
.take()
.map(|info| info.with_file_access(DriveFileAccess::Unknown));
}
info!("Unmounted media");
drop(state);
@@ -494,6 +505,16 @@ impl MsdController {
disconnected.push(media.clone());
}
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
{
state.drive_info = state
.drive_info
.take()
.map(|info| info.with_file_access(DriveFileAccess::Unknown));
}
state.mounted_media.clear();
info!("Disconnected all mounted media");
@@ -748,6 +769,29 @@ mod tests {
assert!(state.mounted_media.is_empty());
}
#[tokio::test]
async fn drive_mount_metadata_ignores_cached_drive_info() {
let temp_dir = TempDir::new().unwrap();
let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path());
std::fs::create_dir_all(&controller.ventoy_dir).unwrap();
std::fs::write(&controller.drive_path, vec![0u8; 128]).unwrap();
controller.state.write().await.drive_info = Some(DriveInfo::from_raw(
controller.drive_path.clone(),
64,
DriveFileAccess::Available,
));
std::fs::write(&controller.drive_path, vec![0u8; 256]).unwrap();
let info = controller.drive_mount_info().unwrap();
assert_eq!(info.size, 256);
assert_eq!(info.used, None);
assert_eq!(info.file_access, DriveFileAccess::Unknown);
let media = MountedMedia::drive(0, &info);
let config = MsdController::media_config(&media);
assert_eq!(config.file, controller.drive_path);
}
#[test]
fn single_disk_mode_only_exposes_lun_zero() {
let mut state = MsdState::default();
@@ -846,13 +890,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let drive_path = temp_dir.path().join("ventoy.img");
std::fs::write(&drive_path, b"drive").unwrap();
let drive = DriveInfo {
size: 5,
used: 0,
free: 5,
initialized: true,
path: drive_path,
};
let drive = DriveInfo::from_raw(drive_path, 5, DriveFileAccess::Unknown);
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state.mounted_media.push(MountedMedia::drive(0, &drive));
@@ -904,13 +942,11 @@ mod tests {
let image_path = temp_dir.path().join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
let drive = DriveInfo {
size: 5,
used: 0,
free: 5,
initialized: true,
path: temp_dir.path().join("ventoy.img"),
};
let drive = DriveInfo::from_raw(
temp_dir.path().join("ventoy.img"),
5,
DriveFileAccess::Unknown,
);
let mut state = MsdState::default();
state
.mounted_media

View File

@@ -8,8 +8,8 @@ pub use controller::MsdController;
pub use image::ImageManager;
pub use monitor::MsdHealthMonitor;
pub use types::{
DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo,
DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia,
DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveFileAccess,
DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia,
MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS,
};
pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB};

View File

@@ -156,26 +156,44 @@ impl DiskMode {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriveFileAccess {
Available,
Unsupported,
BlockedWhileConnected,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriveInfo {
pub size: u64,
pub used: u64,
pub free: u64,
pub used: Option<u64>,
pub free: Option<u64>,
pub initialized: bool,
pub file_access: DriveFileAccess,
#[serde(skip_serializing)]
pub path: PathBuf,
}
impl DriveInfo {
pub fn new(path: PathBuf, size: u64) -> Self {
pub fn from_raw(path: PathBuf, size: u64, file_access: DriveFileAccess) -> Self {
Self {
size,
used: 0,
free: size,
initialized: false,
used: None,
free: None,
initialized: true,
file_access,
path,
}
}
pub fn with_file_access(mut self, file_access: DriveFileAccess) -> Self {
self.used = None;
self.free = None;
self.file_access = file_access;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -267,4 +285,36 @@ mod tests {
assert!(json.get("current_image").is_none());
assert!(json.get("slots").is_none());
}
#[test]
fn drive_info_json_has_stable_nullable_space_and_file_access() {
let info = DriveInfo::from_raw(
PathBuf::from("/tmp/drive.img"),
4096,
DriveFileAccess::Unsupported,
);
let value = serde_json::to_value(info).unwrap();
assert_eq!(value["size"], 4096);
assert_eq!(value["used"], serde_json::Value::Null);
assert_eq!(value["free"], serde_json::Value::Null);
assert_eq!(value["initialized"], true);
assert_eq!(value["file_access"], "unsupported");
assert!(value.get("path").is_none());
}
#[test]
fn drive_file_access_serializes_all_public_states() {
for (access, expected) in [
(DriveFileAccess::Available, "available"),
(DriveFileAccess::Unsupported, "unsupported"),
(
DriveFileAccess::BlockedWhileConnected,
"blocked_while_connected",
),
(DriveFileAccess::Unknown, "unknown"),
] {
assert_eq!(serde_json::to_value(access).unwrap(), expected);
}
}
}

View File

@@ -5,7 +5,7 @@ use tracing::{info, warn};
use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage};
use super::types::{DriveFile, DriveInfo};
use super::types::{DriveFile, DriveFileAccess, DriveInfo};
use crate::error::{AppError, MsdErrorCode, Result};
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
@@ -35,11 +35,10 @@ impl VentoyDrive {
&self.path
}
/// Returns just the raw file size without attempting to parse the filesystem.
/// Used as a fallback when the image has been reformatted to an unsupported
/// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open.
pub fn raw_size(&self) -> Option<u64> {
std::fs::metadata(&self.path).ok().map(|m| m.len())
/// Read and validate only the backing file metadata, without parsing its
/// partition table or filesystem.
pub fn raw_info(&self, file_access: DriveFileAccess) -> Result<DriveInfo> {
raw_drive_info(&self.path, file_access)
}
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
@@ -60,9 +59,10 @@ impl VentoyDrive {
Ok::<DriveInfo, AppError>(DriveInfo {
size: metadata.len(),
used: 0,
free: metadata.len(),
used: Some(0),
free: Some(metadata.len()),
initialized: true,
file_access: DriveFileAccess::Available,
path,
})
})
@@ -74,20 +74,23 @@ impl VentoyDrive {
}
pub async fn info(&self) -> Result<DriveInfo> {
if !self.exists() {
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let path = self.path.clone();
let _lock = self.lock.read().await;
tokio::task::spawn_blocking(move || {
let metadata = std::fs::metadata(&path)
.map_err(|error| drive_io_error("read drive metadata", error))?;
let raw = raw_drive_info(&path, DriveFileAccess::Unsupported)?;
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
let image = match VentoyImage::open(&path) {
Ok(image) => image,
Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw),
Err(error) => return Err(ventoy_to_app_error(error)),
};
let files = image.list_files_recursive().map_err(ventoy_to_app_error)?;
let files = match image.list_files_recursive() {
Ok(files) => files,
Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw),
Err(error) => return Err(ventoy_to_app_error(error)),
};
let used: u64 = files
.iter()
@@ -95,14 +98,15 @@ impl VentoyDrive {
.map(|f| f.size)
.sum();
let size = metadata.len();
let size = raw.size;
let free = size.saturating_sub(used);
Ok(DriveInfo {
size,
used,
free,
used: Some(used),
free: Some(free),
initialized: true,
file_access: DriveFileAccess::Available,
path,
})
})
@@ -332,6 +336,35 @@ impl VentoyDrive {
}
}
fn raw_drive_info(path: &Path, file_access: DriveFileAccess) -> Result<DriveInfo> {
let metadata = std::fs::metadata(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
AppError::from(MsdErrorCode::MsdDriveNotInitialized)
} else {
drive_io_error("read drive metadata", error)
}
})?;
if !metadata.is_file() || metadata.len() == 0 {
return Err(MsdErrorCode::MsdDriveSizeInvalid.into());
}
Ok(DriveInfo::from_raw(
path.to_path_buf(),
metadata.len(),
file_access,
))
}
fn is_unsupported_filesystem_error(error: &VentoyError) -> bool {
matches!(
error,
VentoyError::FilesystemError(_)
| VentoyError::ImageError(_)
| VentoyError::PartitionError(_)
)
}
fn ventoy_to_app_error(err: VentoyError) -> AppError {
warn!(%err, "Virtual drive filesystem operation failed");
match err {
@@ -575,9 +608,71 @@ mod tests {
let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
assert!(info.initialized);
assert_eq!(info.file_access, DriveFileAccess::Available);
assert_eq!(info.used, Some(0));
assert!(info.free.is_some());
assert!(drive.exists());
}
#[tokio::test]
async fn raw_bytes_are_reported_as_unsupported_with_capacity() {
let temp_dir = TempDir::new().unwrap();
let drive_path = temp_dir.path().join("custom.img");
std::fs::write(&drive_path, vec![0x5a; 1024 * 1024]).unwrap();
let drive = VentoyDrive::new(drive_path);
let info = drive.info().await.unwrap();
assert_eq!(info.size, 1024 * 1024);
assert_eq!(info.used, None);
assert_eq!(info.free, None);
assert_eq!(info.file_access, DriveFileAccess::Unsupported);
assert!(matches!(
drive.list_files("/").await.unwrap_err(),
AppError::Msd(error)
if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported
));
}
#[test]
fn raw_metadata_rejects_missing_empty_and_non_file_paths() {
let temp_dir = TempDir::new().unwrap();
let missing = VentoyDrive::new(temp_dir.path().join("missing.img"));
assert!(matches!(
missing.raw_info(DriveFileAccess::Unknown).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveNotInitialized
));
let empty_path = temp_dir.path().join("empty.img");
std::fs::write(&empty_path, []).unwrap();
let empty = VentoyDrive::new(empty_path);
assert!(matches!(
empty.raw_info(DriveFileAccess::Unknown).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid
));
let directory = VentoyDrive::new(temp_dir.path().to_path_buf());
assert!(matches!(
directory.raw_info(DriveFileAccess::Unknown).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid
));
}
#[tokio::test]
async fn supported_drive_info_has_space_values() {
if !ensure_resources() {
return;
}
let temp_dir = TempDir::new().unwrap();
let drive = VentoyDrive::new(temp_dir.path().join("supported.img"));
drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
let info = drive.info().await.unwrap();
assert_eq!(info.file_access, DriveFileAccess::Available);
assert!(info.used.is_some());
assert!(info.free.is_some());
}
#[tokio::test]
async fn test_drive_mkdir() {
if !ensure_resources() {

594
src/runtime/builder.rs Normal file
View 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());
}
}

View 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
View 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;

View 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
View 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
View 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()
}

View File

@@ -1,7 +1,7 @@
//! Variable-length TCP framing (RustDesk wire format).
use bytes::{Buf, BufMut, Bytes, BytesMut};
use std::io;
use std::io::{self, IoSlice};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
const MAX_PACKET_LENGTH: usize = 0x3FFFFFFF;
@@ -53,6 +53,18 @@ fn decode_header(first_byte: u8, header_bytes: &[u8]) -> (usize, usize) {
}
pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<BytesMut> {
read_frame_with_limit(reader, MAX_PACKET_LENGTH).await
}
/// Read one framed message while enforcing a caller-selected allocation limit.
///
/// Network-facing protocol stages should use a substantially smaller limit than
/// the wire format's theoretical maximum so an untrusted peer cannot force a
/// huge allocation by sending only a length header.
pub async fn read_frame_with_limit<R: AsyncRead + Unpin>(
reader: &mut R,
max_packet_length: usize,
) -> io::Result<BytesMut> {
let mut first_byte = [0u8; 1];
reader.read_exact(&mut first_byte).await?;
@@ -65,10 +77,10 @@ pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<Byte
let (_, msg_len) = decode_header(first_byte[0], &header_rest);
if msg_len > MAX_PACKET_LENGTH {
if msg_len > max_packet_length {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Message too large",
format!("Message too large: {msg_len} bytes exceeds {max_packet_length}-byte limit"),
));
}
@@ -86,6 +98,53 @@ pub async fn write_frame<W: AsyncWrite + Unpin>(writer: &mut W, data: &[u8]) ->
Ok(())
}
/// Write a frame without copying its payload into a second contiguous buffer.
/// TCP writers normally send the small header and payload in one vectored write.
pub async fn write_frame_vectored<W: AsyncWrite + Unpin>(
writer: &mut W,
data: &[u8],
) -> io::Result<()> {
let len = data.len();
let mut header = [0u8; 4];
let header_len = if len <= 0x3F {
header[0] = (len << 2) as u8;
1
} else if len <= 0x3FFF {
header[..2].copy_from_slice(&(((len << 2) as u16) | 0x1).to_le_bytes());
2
} else if len <= 0x3FFFFF {
let value = ((len << 2) as u32) | 0x2;
header[0] = (value & 0xFF) as u8;
header[1] = ((value >> 8) & 0xFF) as u8;
header[2] = ((value >> 16) & 0xFF) as u8;
3
} else if len <= MAX_PACKET_LENGTH {
header.copy_from_slice(&(((len << 2) as u32) | 0x3).to_le_bytes());
4
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Message too large",
));
};
let slices = [IoSlice::new(&header[..header_len]), IoSlice::new(data)];
let written = writer.write_vectored(&slices).await?;
if written == 0 {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"failed to write RustDesk frame",
));
}
if written < header_len {
writer.write_all(&header[written..header_len]).await?;
writer.write_all(data).await?;
} else {
writer.write_all(&data[written - header_len..]).await?;
}
Ok(())
}
pub async fn write_frame_buffered<W: AsyncWrite + Unpin>(
writer: &mut W,
data: &[u8],
@@ -281,4 +340,32 @@ mod tests {
let decoded = codec.decode(&mut buf).unwrap().unwrap();
assert_eq!(decoded.len(), 100000);
}
#[tokio::test]
async fn read_limit_rejects_length_before_allocating_payload() {
let encoded = encode_frame(&vec![0u8; 1024]).unwrap();
let (mut writer, mut reader) = tokio::io::duplex(encoded.len());
tokio::spawn(async move {
writer.write_all(&encoded).await.unwrap();
});
let error = read_frame_with_limit(&mut reader, 128).await.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn vectored_writer_round_trips() {
let payload = vec![0x5a; 100_000];
let (mut writer, mut reader) = tokio::io::duplex(payload.len() + 4);
let expected = payload.clone();
let send = tokio::spawn(async move {
write_frame_vectored(&mut writer, &payload).await.unwrap();
});
let decoded = read_frame_with_limit(&mut reader, expected.len())
.await
.unwrap();
send.await.unwrap();
assert_eq!(decoded.as_ref(), expected.as_slice());
}
}

View File

@@ -11,12 +11,24 @@ pub enum RustDeskCodec {
H265,
}
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum RustDeskMode {
#[default]
Id,
DirectIp,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RustDeskConfig {
pub enabled: bool,
pub mode: RustDeskMode,
pub codec: RustDeskCodec,
pub direct_access_port: u16,
pub rendezvous_server: String,
pub relay_server: Option<String>,
#[typeshare(skip)]
@@ -40,7 +52,9 @@ impl Default for RustDeskConfig {
fn default() -> Self {
Self {
enabled: false,
mode: RustDeskMode::Id,
codec: RustDeskCodec::H264,
direct_access_port: 21118,
rendezvous_server: String::new(),
relay_server: None,
relay_key: None,
@@ -58,9 +72,12 @@ impl Default for RustDeskConfig {
impl RustDeskConfig {
pub fn is_valid(&self) -> bool {
self.enabled
&& !self.rendezvous_server.is_empty()
&& !self.device_id.is_empty()
&& !self.device_password.is_empty()
&& match self.mode {
RustDeskMode::Id => !self.rendezvous_server.trim().is_empty(),
RustDeskMode::DirectIp => self.direct_access_port != 0,
}
}
pub fn effective_rendezvous_server(&self) -> &str {
@@ -214,4 +231,43 @@ mod tests {
config.rendezvous_server = String::new();
assert_eq!(config.effective_rendezvous_server(), "");
}
#[test]
fn direct_ip_mode_is_valid_without_rendezvous_server() {
let config = RustDeskConfig {
enabled: true,
mode: RustDeskMode::DirectIp,
rendezvous_server: String::new(),
..Default::default()
};
assert!(config.is_valid());
}
#[test]
fn id_mode_is_invalid_without_rendezvous_server() {
let config = RustDeskConfig {
enabled: true,
mode: RustDeskMode::Id,
rendezvous_server: String::new(),
..Default::default()
};
assert!(!config.is_valid());
}
#[test]
fn legacy_config_defaults_to_id_mode() {
let config: RustDeskConfig = serde_json::from_value(serde_json::json!({
"enabled": false,
"codec": "h264",
"rendezvous_server": "",
"device_id": "123456789",
"device_password": "password"
}))
.expect("legacy RustDesk config should deserialize");
assert_eq!(config.mode, RustDeskMode::Id);
assert_eq!(config.direct_access_port, 21118);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,8 +31,6 @@ pub struct VideoFrameAdapter {
codec: VideoCodec,
seq: u32,
timestamp_base: u64,
h264_sps: Option<Bytes>,
h264_pps: Option<Bytes>,
}
impl VideoFrameAdapter {
@@ -41,8 +39,6 @@ impl VideoFrameAdapter {
codec,
seq: 0,
timestamp_base: 0,
h264_sps: None,
h264_pps: None,
}
}
@@ -56,7 +52,6 @@ impl VideoFrameAdapter {
is_keyframe: bool,
timestamp_ms: u64,
) -> Message {
let data = self.prepare_h264_frame(data, is_keyframe);
if self.seq == 0 {
self.timestamp_base = timestamp_ms;
}
@@ -86,39 +81,6 @@ impl VideoFrameAdapter {
msg
}
fn prepare_h264_frame(&mut self, data: Bytes, is_keyframe: bool) -> Bytes {
if self.codec != VideoCodec::H264 {
return data;
}
let (sps, pps) = crate::video::codec::h264_bitstream::extract_sps_pps(&data);
let mut has_sps = false;
let mut has_pps = false;
if let Some(sps) = sps {
self.h264_sps = Some(Bytes::from(sps));
has_sps = true;
}
if let Some(pps) = pps {
self.h264_pps = Some(Bytes::from(pps));
has_pps = true;
}
if is_keyframe && (!has_sps || !has_pps) {
if let (Some(sps), Some(pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) {
let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len());
out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(sps);
out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(pps);
out.extend_from_slice(&data);
return Bytes::from(out);
}
}
data
}
pub fn encode_frame(&mut self, data: &[u8], is_keyframe: bool, timestamp_ms: u64) -> Message {
self.encode_frame_from_bytes(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms)
}

View File

@@ -1,10 +1,11 @@
use super::keyboard_mapping::{self, CharacterMapping, KeyboardCodeSpace};
use super::protocol::hbb::message::key_event as ke_union;
use super::protocol::{ControlKey, KeyEvent, KeyboardMode, MouseEvent};
use crate::hid::{
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEvent as OneKvmMouseEvent, MouseEventType,
};
use protobuf::Enum;
use tracing::debug;
pub mod mouse_type {
pub const MOVE: i32 = 0;
@@ -105,14 +106,23 @@ fn button_id_to_button(button_id: i32) -> Option<MouseButton> {
}
}
/// Convert using the code space associated with One-KVM's compatibility platform.
pub fn convert_key_events(event: &KeyEvent) -> Vec<KeyboardEvent> {
convert_key_events_in_code_space(event, KeyboardCodeSpace::WindowsSet1)
}
pub(super) fn convert_key_events_in_code_space(
event: &KeyEvent,
code_space: KeyboardCodeSpace,
) -> Vec<KeyboardEvent> {
let base_modifiers = if is_modifier_control_key(event) {
KeyboardModifiers::default()
} else {
parse_modifiers(event)
};
let Some(mapping) = key_event_to_hid(event, base_modifiers) else {
let Some(mapping) = key_event_to_mapping(event, code_space, base_modifiers) else {
log_rejected_key_event(event, code_space);
return Vec::new();
};
@@ -135,13 +145,12 @@ pub fn convert_key_events(event: &KeyEvent) -> Vec<KeyboardEvent> {
},
]
} else {
let event_type = if event.down {
KeyEventType::Down
} else {
KeyEventType::Up
};
vec![KeyboardEvent {
event_type,
event_type: if event.down {
KeyEventType::Down
} else {
KeyEventType::Up
},
key: mapping.key,
modifiers: mapping.modifiers,
}]
@@ -159,534 +168,337 @@ struct KeyMapping {
added_shift: bool,
}
fn key_event_to_hid(event: &KeyEvent, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
fn key_event_to_mapping(
event: &KeyEvent,
code_space: KeyboardCodeSpace,
modifiers: KeyboardModifiers,
) -> Option<KeyMapping> {
let mode = event.mode.enum_value().ok()?;
match &event.union {
Some(ke_union::Union::ControlKey(ck)) => {
let key = CanonicalKey::from_hid_usage(control_key_to_hid(ck.value())?)?;
Some(KeyMapping {
key,
Some(ke_union::Union::ControlKey(key)) => {
plain_mapping(keyboard_mapping::control_key(key.value())?, modifiers)
}
Some(ke_union::Union::Unicode(ch)) => character_mapping(*ch, modifiers),
Some(ke_union::Union::Chr(code)) => match mode {
KeyboardMode::Map | KeyboardMode::Translate => plain_mapping(
keyboard_mapping::physical_key(code_space, *code)?,
modifiers,
added_shift: false,
})
}
Some(ke_union::Union::Chr(chr)) => {
if event.mode.value() != KeyboardMode::Map.value() {
if let Some(mapping) = shifted_printable_char_to_hid(*chr, modifiers) {
return Some(mapping);
}
}
let key = CanonicalKey::from_hid_usage(keycode_to_hid(*chr)?)?;
Some(KeyMapping {
key,
modifiers,
added_shift: false,
})
}
Some(ke_union::Union::Unicode(unicode)) => {
let mapping = printable_char_to_hid(*unicode, modifiers)?;
Some(mapping)
}
_ => None,
),
KeyboardMode::Legacy | KeyboardMode::Auto => legacy_character_mapping(*code, modifiers),
},
Some(ke_union::Union::Seq(_)) | Some(ke_union::Union::Win2winHotkey(_)) | None => None,
}
}
fn plain_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
Some(KeyMapping {
key,
modifiers,
added_shift: false,
})
}
fn character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
let CharacterMapping { key, needs_shift } = keyboard_mapping::character(ch)?;
if !needs_shift {
return plain_mapping(key, modifiers);
}
let added_shift = !modifiers.left_shift && !modifiers.right_shift;
let mut shifted_modifiers = modifiers;
shifted_modifiers.left_shift = true;
Some(KeyMapping {
key,
modifiers: shifted_modifiers,
added_shift,
})
}
fn legacy_character_mapping(ch: u32, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
let CharacterMapping { key, needs_shift } = keyboard_mapping::character(ch)?;
// Legacy Chr historically relied on the event's modifier list for uppercase
// letters, while synthesizing Shift for printable US-layout symbols.
if needs_shift && !(0x41..=0x5A).contains(&ch) {
character_mapping(ch, modifiers)
} else {
plain_mapping(key, modifiers)
}
}
fn is_modifier_control_key(event: &KeyEvent) -> bool {
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
let val = ck.value();
return val == ControlKey::Control.value()
|| val == ControlKey::Shift.value()
|| val == ControlKey::Alt.value()
|| val == ControlKey::Meta.value()
|| val == ControlKey::RControl.value()
|| val == ControlKey::RShift.value()
|| val == ControlKey::RAlt.value();
}
false
let Some(ke_union::Union::ControlKey(key)) = &event.union else {
return false;
};
matches!(
key.enum_value(),
Ok(ControlKey::Control)
| Ok(ControlKey::Shift)
| Ok(ControlKey::Alt)
| Ok(ControlKey::Meta)
| Ok(ControlKey::RControl)
| Ok(ControlKey::RShift)
| Ok(ControlKey::RAlt)
| Ok(ControlKey::RWin)
)
}
fn parse_modifiers(event: &KeyEvent) -> KeyboardModifiers {
let mut modifiers = KeyboardModifiers::default();
for modifier in &event.modifiers {
let val = modifier.value();
match val {
x if x == ControlKey::Control.value() => modifiers.left_ctrl = true,
x if x == ControlKey::Shift.value() => modifiers.left_shift = true,
x if x == ControlKey::Alt.value() => modifiers.left_alt = true,
x if x == ControlKey::Meta.value() => modifiers.left_meta = true,
x if x == ControlKey::RControl.value() => modifiers.right_ctrl = true,
x if x == ControlKey::RShift.value() => modifiers.right_shift = true,
x if x == ControlKey::RAlt.value() => modifiers.right_alt = true,
match modifier.enum_value() {
Ok(ControlKey::Control) => modifiers.left_ctrl = true,
Ok(ControlKey::Shift) => modifiers.left_shift = true,
Ok(ControlKey::Alt) => modifiers.left_alt = true,
Ok(ControlKey::Meta) => modifiers.left_meta = true,
Ok(ControlKey::RControl) => modifiers.right_ctrl = true,
Ok(ControlKey::RShift) => modifiers.right_shift = true,
Ok(ControlKey::RAlt) => modifiers.right_alt = true,
Ok(ControlKey::RWin) => modifiers.right_meta = true,
_ => {}
}
}
modifiers
}
fn with_shift(mut modifiers: KeyboardModifiers) -> KeyboardModifiers {
modifiers.left_shift = true;
modifiers
}
fn shifted_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping {
let added_shift = !modifiers.left_shift && !modifiers.right_shift;
KeyMapping {
key,
modifiers: with_shift(modifiers),
added_shift,
}
}
fn plain_mapping(key: CanonicalKey, modifiers: KeyboardModifiers) -> KeyMapping {
KeyMapping {
key,
modifiers,
added_shift: false,
}
}
fn shifted_printable_char_to_hid(ch: u32, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
match ch {
33 => Some(shifted_mapping(CanonicalKey::Digit1, modifiers)),
64 => Some(shifted_mapping(CanonicalKey::Digit2, modifiers)),
35 => Some(shifted_mapping(CanonicalKey::Digit3, modifiers)),
36 => Some(shifted_mapping(CanonicalKey::Digit4, modifiers)),
37 => Some(shifted_mapping(CanonicalKey::Digit5, modifiers)),
94 => Some(shifted_mapping(CanonicalKey::Digit6, modifiers)),
38 => Some(shifted_mapping(CanonicalKey::Digit7, modifiers)),
42 => Some(shifted_mapping(CanonicalKey::Digit8, modifiers)),
40 => Some(shifted_mapping(CanonicalKey::Digit9, modifiers)),
41 => Some(shifted_mapping(CanonicalKey::Digit0, modifiers)),
95 => Some(shifted_mapping(CanonicalKey::Minus, modifiers)),
43 => Some(shifted_mapping(CanonicalKey::Equal, modifiers)),
123 => Some(shifted_mapping(CanonicalKey::BracketLeft, modifiers)),
125 => Some(shifted_mapping(CanonicalKey::BracketRight, modifiers)),
124 => Some(shifted_mapping(CanonicalKey::Backslash, modifiers)),
58 => Some(shifted_mapping(CanonicalKey::Semicolon, modifiers)),
34 => Some(shifted_mapping(CanonicalKey::Quote, modifiers)),
126 => Some(shifted_mapping(CanonicalKey::Backquote, modifiers)),
60 => Some(shifted_mapping(CanonicalKey::Comma, modifiers)),
62 => Some(shifted_mapping(CanonicalKey::Period, modifiers)),
63 => Some(shifted_mapping(CanonicalKey::Slash, modifiers)),
_ => None,
}
}
fn printable_char_to_hid(ch: u32, modifiers: KeyboardModifiers) -> Option<KeyMapping> {
match ch {
65..=90 => Some(shifted_mapping(
CanonicalKey::from_hid_usage((ch - 65 + 0x04) as u8)?,
modifiers,
)),
97..=122 => Some(plain_mapping(
CanonicalKey::from_hid_usage((ch - 97 + 0x04) as u8)?,
modifiers,
)),
48 => Some(plain_mapping(CanonicalKey::Digit0, modifiers)),
49 => Some(plain_mapping(CanonicalKey::Digit1, modifiers)),
50 => Some(plain_mapping(CanonicalKey::Digit2, modifiers)),
51 => Some(plain_mapping(CanonicalKey::Digit3, modifiers)),
52 => Some(plain_mapping(CanonicalKey::Digit4, modifiers)),
53 => Some(plain_mapping(CanonicalKey::Digit5, modifiers)),
54 => Some(plain_mapping(CanonicalKey::Digit6, modifiers)),
55 => Some(plain_mapping(CanonicalKey::Digit7, modifiers)),
56 => Some(plain_mapping(CanonicalKey::Digit8, modifiers)),
57 => Some(plain_mapping(CanonicalKey::Digit9, modifiers)),
32 => Some(plain_mapping(CanonicalKey::Space, modifiers)),
13 | 10 => Some(plain_mapping(CanonicalKey::Enter, modifiers)),
9 => Some(plain_mapping(CanonicalKey::Tab, modifiers)),
27 => Some(plain_mapping(CanonicalKey::Escape, modifiers)),
8 => Some(plain_mapping(CanonicalKey::Backspace, modifiers)),
127 => Some(plain_mapping(CanonicalKey::Delete, modifiers)),
45 => Some(plain_mapping(CanonicalKey::Minus, modifiers)),
61 => Some(plain_mapping(CanonicalKey::Equal, modifiers)),
91 => Some(plain_mapping(CanonicalKey::BracketLeft, modifiers)),
93 => Some(plain_mapping(CanonicalKey::BracketRight, modifiers)),
92 => Some(plain_mapping(CanonicalKey::Backslash, modifiers)),
59 => Some(plain_mapping(CanonicalKey::Semicolon, modifiers)),
39 => Some(plain_mapping(CanonicalKey::Quote, modifiers)),
96 => Some(plain_mapping(CanonicalKey::Backquote, modifiers)),
44 => Some(plain_mapping(CanonicalKey::Comma, modifiers)),
46 => Some(plain_mapping(CanonicalKey::Period, modifiers)),
47 => Some(plain_mapping(CanonicalKey::Slash, modifiers)),
_ => shifted_printable_char_to_hid(ch, modifiers),
}
}
fn control_key_to_hid(key: i32) -> Option<u8> {
match key {
x if x == ControlKey::Alt as i32 => Some(0xE2), // Left Alt
x if x == ControlKey::Backspace as i32 => Some(0x2A),
x if x == ControlKey::CapsLock as i32 => Some(0x39),
x if x == ControlKey::Control as i32 => Some(0xE0), // Left Ctrl
x if x == ControlKey::Delete as i32 => Some(0x4C),
x if x == ControlKey::DownArrow as i32 => Some(0x51),
x if x == ControlKey::End as i32 => Some(0x4D),
x if x == ControlKey::Escape as i32 => Some(0x29),
x if x == ControlKey::F1 as i32 => Some(0x3A),
x if x == ControlKey::F2 as i32 => Some(0x3B),
x if x == ControlKey::F3 as i32 => Some(0x3C),
x if x == ControlKey::F4 as i32 => Some(0x3D),
x if x == ControlKey::F5 as i32 => Some(0x3E),
x if x == ControlKey::F6 as i32 => Some(0x3F),
x if x == ControlKey::F7 as i32 => Some(0x40),
x if x == ControlKey::F8 as i32 => Some(0x41),
x if x == ControlKey::F9 as i32 => Some(0x42),
x if x == ControlKey::F10 as i32 => Some(0x43),
x if x == ControlKey::F11 as i32 => Some(0x44),
x if x == ControlKey::F12 as i32 => Some(0x45),
x if x == ControlKey::Home as i32 => Some(0x4A),
x if x == ControlKey::LeftArrow as i32 => Some(0x50),
x if x == ControlKey::Meta as i32 => Some(0xE3), // Left GUI/Windows
x if x == ControlKey::PageDown as i32 => Some(0x4E),
x if x == ControlKey::PageUp as i32 => Some(0x4B),
x if x == ControlKey::Return as i32 => Some(0x28),
x if x == ControlKey::RightArrow as i32 => Some(0x4F),
x if x == ControlKey::Shift as i32 => Some(0xE1), // Left Shift
x if x == ControlKey::Space as i32 => Some(0x2C),
x if x == ControlKey::Tab as i32 => Some(0x2B),
x if x == ControlKey::UpArrow as i32 => Some(0x52),
x if x == ControlKey::Numpad0 as i32 => Some(0x62),
x if x == ControlKey::Numpad1 as i32 => Some(0x59),
x if x == ControlKey::Numpad2 as i32 => Some(0x5A),
x if x == ControlKey::Numpad3 as i32 => Some(0x5B),
x if x == ControlKey::Numpad4 as i32 => Some(0x5C),
x if x == ControlKey::Numpad5 as i32 => Some(0x5D),
x if x == ControlKey::Numpad6 as i32 => Some(0x5E),
x if x == ControlKey::Numpad7 as i32 => Some(0x5F),
x if x == ControlKey::Numpad8 as i32 => Some(0x60),
x if x == ControlKey::Numpad9 as i32 => Some(0x61),
x if x == ControlKey::Insert as i32 => Some(0x49),
x if x == ControlKey::Pause as i32 => Some(0x48),
x if x == ControlKey::Scroll as i32 => Some(0x47),
x if x == ControlKey::NumLock as i32 => Some(0x53),
x if x == ControlKey::RShift as i32 => Some(0xE5),
x if x == ControlKey::RControl as i32 => Some(0xE4),
x if x == ControlKey::RAlt as i32 => Some(0xE6),
x if x == ControlKey::Multiply as i32 => Some(0x55),
x if x == ControlKey::Add as i32 => Some(0x57),
x if x == ControlKey::Subtract as i32 => Some(0x56),
x if x == ControlKey::Decimal as i32 => Some(0x63),
x if x == ControlKey::Divide as i32 => Some(0x54),
x if x == ControlKey::NumpadEnter as i32 => Some(0x58),
_ => None,
}
}
fn keycode_to_hid(keycode: u32) -> Option<u8> {
if let Some(hid) = ascii_to_hid(keycode) {
return Some(hid);
}
if let Some(hid) = windows_vk_to_hid(keycode) {
return Some(hid);
}
x11_keycode_to_hid(keycode)
}
fn ascii_to_hid(ascii: u32) -> Option<u8> {
match ascii {
97..=122 => Some((ascii - 97 + 0x04) as u8),
65..=90 => Some((ascii - 65 + 0x04) as u8),
48 => Some(0x27), // 0
49..=57 => Some((ascii - 49 + 0x1E) as u8), // 1-9
32 => Some(0x2C), // Space
13 => Some(0x28), // Enter (CR)
10 => Some(0x28), // Enter (LF)
9 => Some(0x2B), // Tab
27 => Some(0x29), // Escape
8 => Some(0x2A), // Backspace
127 => Some(0x4C), // Delete
45 => Some(0x2D), // -
61 => Some(0x2E), // =
91 => Some(0x2F), // [
93 => Some(0x30), // ]
92 => Some(0x31), // \
59 => Some(0x33), // ;
39 => Some(0x34), // '
96 => Some(0x35), // `
44 => Some(0x36), // ,
46 => Some(0x37), // .
47 => Some(0x38), // /
_ => None,
}
}
fn windows_vk_to_hid(vk: u32) -> Option<u8> {
match vk {
0x41..=0x5A => {
let letter = (vk - 0x41) as u8;
Some(match letter {
0 => 0x04, // A
1 => 0x05, // B
2 => 0x06, // C
3 => 0x07, // D
4 => 0x08, // E
5 => 0x09, // F
6 => 0x0A, // G
7 => 0x0B, // H
8 => 0x0C, // I
9 => 0x0D, // J
10 => 0x0E, // K
11 => 0x0F, // L
12 => 0x10, // M
13 => 0x11, // N
14 => 0x12, // O
15 => 0x13, // P
16 => 0x14, // Q
17 => 0x15, // R
18 => 0x16, // S
19 => 0x17, // T
20 => 0x18, // U
21 => 0x19, // V
22 => 0x1A, // W
23 => 0x1B, // X
24 => 0x1C, // Y
25 => 0x1D, // Z
_ => return None,
})
fn log_rejected_key_event(event: &KeyEvent, code_space: KeyboardCodeSpace) {
let mode = event.mode.value();
match &event.union {
Some(ke_union::Union::ControlKey(key)) => debug!(
mode,
union = "ControlKey",
raw = format_args!("0x{:X}", key.value()),
?code_space,
"Dropping unsupported RustDesk keyboard event"
),
Some(ke_union::Union::Chr(code)) => debug!(
mode,
union = "Chr",
raw = format_args!("0x{code:X}"),
?code_space,
"Dropping unsupported RustDesk keyboard event"
),
Some(ke_union::Union::Unicode(code)) => debug!(
mode,
union = "Unicode",
raw = format_args!("0x{code:X}"),
?code_space,
"Dropping unsupported RustDesk keyboard event"
),
Some(ke_union::Union::Seq(seq)) => {
debug!(mode, union = "Seq", raw = ?seq, ?code_space, "Dropping unsupported RustDesk keyboard event")
}
0x30 => Some(0x27), // 0
0x31..=0x39 => Some((vk - 0x31 + 0x1E) as u8), // 1-9
0x60 => Some(0x62), // Numpad 0
0x61..=0x69 => Some((vk - 0x61 + 0x59) as u8), // Numpad 1-9
0x6A => Some(0x55), // Numpad *
0x6B => Some(0x57), // Numpad +
0x6D => Some(0x56), // Numpad -
0x6E => Some(0x63), // Numpad .
0x6F => Some(0x54), // Numpad /
0x70..=0x7B => Some((vk - 0x70 + 0x3A) as u8),
0x08 => Some(0x2A), // Backspace
0x09 => Some(0x2B), // Tab
0x0D => Some(0x28), // Enter
0x1B => Some(0x29), // Escape
0x20 => Some(0x2C), // Space
0x21 => Some(0x4B), // Page Up
0x22 => Some(0x4E), // Page Down
0x23 => Some(0x4D), // End
0x24 => Some(0x4A), // Home
0x25 => Some(0x50), // Left Arrow
0x26 => Some(0x52), // Up Arrow
0x27 => Some(0x4F), // Right Arrow
0x28 => Some(0x51), // Down Arrow
0x2D => Some(0x49), // Insert
0x2E => Some(0x4C), // Delete
0xBA => Some(0x33), // ; :
0xBB => Some(0x2E), // = +
0xBC => Some(0x36), // , <
0xBD => Some(0x2D), // - _
0xBE => Some(0x37), // . >
0xBF => Some(0x38), // / ?
0xC0 => Some(0x35), // ` ~
0xDB => Some(0x2F), // [ {
0xDC => Some(0x31), // \ |
0xDD => Some(0x30), // ] }
0xDE => Some(0x34), // ' "
0x14 => Some(0x39), // Caps Lock
0x90 => Some(0x53), // Num Lock
0x91 => Some(0x47), // Scroll Lock
0x2C => Some(0x46), // Print Screen
0x13 => Some(0x48), // Pause
_ => None,
}
}
fn x11_keycode_to_hid(keycode: u32) -> Option<u8> {
match keycode {
10..=18 => Some((keycode - 10 + 0x1E) as u8), // 1-9
19 => Some(0x27), // 0
20 => Some(0x2D), // -
21 => Some(0x2E), // =
34 => Some(0x2F), // [
35 => Some(0x30), // ]
24 => Some(0x14), // q
25 => Some(0x1A), // w
26 => Some(0x08), // e
27 => Some(0x15), // r
28 => Some(0x17), // t
29 => Some(0x1C), // y
30 => Some(0x18), // u
31 => Some(0x0C), // i
32 => Some(0x12), // o
33 => Some(0x13), // p
38 => Some(0x04), // a
39 => Some(0x16), // s
40 => Some(0x07), // d
41 => Some(0x09), // f
42 => Some(0x0A), // g
43 => Some(0x0B), // h
44 => Some(0x0D), // j
45 => Some(0x0E), // k
46 => Some(0x0F), // l
47 => Some(0x33), // ;
48 => Some(0x34), // '
49 => Some(0x35), // `
51 => Some(0x31), // \
52 => Some(0x1D), // z
53 => Some(0x1B), // x
54 => Some(0x06), // c
55 => Some(0x19), // v
56 => Some(0x05), // b
57 => Some(0x11), // n
58 => Some(0x10), // m
59 => Some(0x36), // ,
60 => Some(0x37), // .
61 => Some(0x38), // /
65 => Some(0x2C),
_ => None,
Some(ke_union::Union::Win2winHotkey(code)) => debug!(
mode,
union = "Win2winHotkey",
raw = format_args!("0x{code:X}"),
?code_space,
"Dropping unsupported RustDesk keyboard event"
),
None => debug!(
mode,
union = "None",
?code_space,
"Dropping unsupported RustDesk keyboard event"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use protobuf::EnumOrUnknown;
#[test]
fn test_control_key_mapping() {
assert_eq!(control_key_to_hid(ControlKey::Escape.value()), Some(0x29));
assert_eq!(control_key_to_hid(ControlKey::Return.value()), Some(0x28));
assert_eq!(control_key_to_hid(ControlKey::Space.value()), Some(0x2C));
fn key_event(mode: KeyboardMode, union: ke_union::Union, down: bool) -> KeyEvent {
let mut event = KeyEvent::new();
event.mode = EnumOrUnknown::new(mode);
event.union = Some(union);
event.down = down;
event
}
fn map_chr(code: u32, down: bool) -> KeyEvent {
key_event(KeyboardMode::Map, ke_union::Union::Chr(code), down)
}
#[test]
fn test_convert_mouse_move() {
let mut event = MouseEvent::new();
event.x = 500;
event.y = 300;
event.mask = mouse_type::MOVE; // Pure move event
let events = convert_mouse_event(&event, 1920, 1080);
assert!(!events.is_empty());
assert_eq!(events[0].event_type, MouseEventType::MoveAbs);
}
#[test]
fn test_convert_mouse_button_down() {
let mut event = MouseEvent::new();
event.x = 500;
event.y = 300;
event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN;
let events = convert_mouse_event(&event, 1920, 1080);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, MouseEventType::Down);
assert_eq!(events[0].button, Some(MouseButton::Left));
}
#[test]
fn test_convert_mouse_button_down_does_not_move() {
let mut event = MouseEvent::new();
event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN;
let events = convert_mouse_event(&event, 1920, 1080);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, MouseEventType::Down);
assert_eq!(events[0].button, Some(MouseButton::Left));
}
#[test]
fn test_convert_mouse_wheel_does_not_move() {
let mut event = MouseEvent::new();
event.x = 500;
event.y = 1;
event.mask = mouse_type::WHEEL;
let events = convert_mouse_event(&event, 1920, 1080);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, MouseEventType::Scroll);
assert_eq!(events[0].scroll, 1);
}
#[test]
fn test_convert_mouse_move_relative() {
fn mouse_events_keep_existing_semantics() {
let mut event = MouseEvent::new();
event.x = -12;
event.y = 8;
event.mask = mouse_type::MOVE_RELATIVE;
let events = convert_mouse_event(&event, 1920, 1080);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, MouseEventType::Move);
assert_eq!(events[0].x, -12);
assert_eq!(events[0].y, 8);
assert_eq!((events[0].x, events[0].y), (-12, 8));
event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN;
let events = convert_mouse_event(&event, 1920, 1080);
assert_eq!(events[0].event_type, MouseEventType::Down);
assert_eq!(events[0].button, Some(MouseButton::Left));
assert_eq!((events[0].x, events[0].y), (0, 0));
}
#[test]
fn test_convert_key_event() {
use protobuf::EnumOrUnknown;
let mut key_event = KeyEvent::new();
key_event.down = true;
key_event.press = false;
key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new(
ControlKey::Return,
)));
let result = convert_key_event(&key_event);
assert!(result.is_some());
let kb_event = result.unwrap();
assert_eq!(kb_event.event_type, KeyEventType::Down);
assert_eq!(kb_event.key, CanonicalKey::Enter);
fn map_delete_generates_only_delete_down_and_up() {
let down = convert_key_events(&map_chr(0xE053, true));
let up = convert_key_events(&map_chr(0xE053, false));
assert_eq!((down.len(), up.len()), (1, 1));
assert_eq!(
(down[0].event_type, up[0].event_type),
(KeyEventType::Down, KeyEventType::Up)
);
assert_eq!(
(down[0].key, up[0].key),
(CanonicalKey::Delete, CanonicalKey::Delete)
);
assert_eq!(
(down[0].key.to_hid_usage(), up[0].key.to_hid_usage()),
(0x4C, 0x4C)
);
}
#[test]
fn test_convert_at_press_to_shift_digit2() {
let mut key_event = KeyEvent::new();
key_event.press = true;
key_event.union = Some(ke_union::Union::Unicode('@' as u32));
fn map_scan_codes_do_not_become_ascii_digits() {
let alt = convert_key_events(&map_chr(0x38, true));
let shift = convert_key_events(&map_chr(0x36, true));
assert_eq!(alt[0].key, CanonicalKey::AltLeft);
assert_ne!(alt[0].key, CanonicalKey::Digit8);
assert_eq!(shift[0].key, CanonicalKey::ShiftRight);
assert_ne!(shift[0].key, CanonicalKey::Digit6);
}
let events = convert_key_events(&key_event);
#[test]
fn legacy_uses_character_semantics_for_same_values() {
let digit8 = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x38), true);
let digit6 = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x36), true);
assert_eq!(convert_key_events(&digit8)[0].key, CanonicalKey::Digit8);
assert_eq!(convert_key_events(&digit6)[0].key, CanonicalKey::Digit6);
let uppercase = key_event(KeyboardMode::Legacy, ke_union::Union::Chr(0x41), true);
let uppercase = convert_key_events(&uppercase);
assert_eq!(uppercase[0].key, CanonicalKey::KeyA);
assert!(!uppercase[0].modifiers.left_shift);
}
#[test]
fn translate_chr_uses_physical_fallback_semantics() {
let event = key_event(KeyboardMode::Translate, ke_union::Union::Chr(0xE053), true);
assert_eq!(convert_key_events(&event)[0].key, CanonicalKey::Delete);
}
#[test]
fn unknown_map_codes_never_fall_back() {
for code in [0, 0x59, 0x61, 0x7F, 0xE054, 0x0101] {
assert!(
convert_key_events(&map_chr(code, true)).is_empty(),
"0x{code:X}"
);
}
let ascii_a_value = convert_key_events(&map_chr(0x41, true));
assert_eq!(ascii_a_value[0].key, CanonicalKey::F7);
assert_ne!(ascii_a_value[0].key, CanonicalKey::KeyA);
}
#[test]
fn unicode_and_control_keys_stay_independent() {
let unicode = key_event(
KeyboardMode::Map,
ke_union::Union::Unicode('@' as u32),
true,
);
let control = key_event(
KeyboardMode::Translate,
ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::Delete)),
true,
);
let unicode = convert_key_events(&unicode);
assert_eq!(unicode[0].key, CanonicalKey::Digit2);
assert!(unicode[0].modifiers.left_shift);
assert_eq!(convert_key_events(&control)[0].key, CanonicalKey::Delete);
}
#[test]
fn press_and_repeated_events_preserve_state_model() {
let mut press = map_chr(0x1E, false);
press.press = true;
press
.modifiers
.push(EnumOrUnknown::new(ControlKey::Control));
let events = convert_key_events(&press);
assert_eq!(events.len(), 2);
assert_eq!(events[0].event_type, KeyEventType::Down);
assert_eq!(events[0].key, CanonicalKey::Digit2);
assert_eq!(
(events[0].event_type, events[1].event_type),
(KeyEventType::Down, KeyEventType::Up)
);
assert_eq!(
(events[0].key, events[1].key),
(CanonicalKey::KeyA, CanonicalKey::KeyA)
);
assert!(events.iter().all(|event| event.modifiers.left_ctrl));
let down = map_chr(0x1E, true);
assert_eq!(convert_key_events(&down)[0].event_type, KeyEventType::Down);
assert_eq!(convert_key_events(&down)[0].event_type, KeyEventType::Down);
}
#[test]
fn shifted_press_releases_only_synthetic_shift() {
let mut event = key_event(
KeyboardMode::Legacy,
ke_union::Union::Chr('@' as u32),
false,
);
event.press = true;
let events = convert_key_events(&event);
assert!(events[0].modifiers.left_shift);
assert_eq!(events[1].event_type, KeyEventType::Up);
assert_eq!(events[1].key, CanonicalKey::Digit2);
assert!(!events[1].modifiers.left_shift);
}
#[test]
fn test_convert_shifted_chr_to_shift_digit2() {
let mut key_event = KeyEvent::new();
key_event.down = true;
key_event.union = Some(ke_union::Union::Chr('@' as u32));
let events = convert_key_events(&key_event);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, KeyEventType::Down);
assert_eq!(events[0].key, CanonicalKey::Digit2);
assert!(events[0].modifiers.left_shift);
fn modifier_control_key_does_not_duplicate_state() {
let mut event = key_event(
KeyboardMode::Legacy,
ke_union::Union::ControlKey(EnumOrUnknown::new(ControlKey::RWin)),
true,
);
event.modifiers.push(EnumOrUnknown::new(ControlKey::RWin));
let events = convert_key_events(&event);
assert_eq!(events[0].key, CanonicalKey::MetaRight);
assert_eq!(events[0].modifiers, KeyboardModifiers::default());
}
#[test]
fn test_convert_map_mode_chr_as_physical_key() {
use protobuf::EnumOrUnknown;
let mut key_event = KeyEvent::new();
key_event.down = true;
key_event.mode = EnumOrUnknown::new(KeyboardMode::Map);
key_event.union = Some(ke_union::Union::Chr(0x41));
let events = convert_key_events(&key_event);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, KeyEventType::Down);
assert_eq!(events[0].key, CanonicalKey::KeyA);
assert!(!events[0].modifiers.left_shift);
fn legacy_delete_and_audited_controls_are_mapped() {
for (control, expected) in [
(ControlKey::Delete, CanonicalKey::Delete),
(ControlKey::Snapshot, CanonicalKey::PrintScreen),
(ControlKey::RWin, CanonicalKey::MetaRight),
(ControlKey::Apps, CanonicalKey::ContextMenu),
] {
let event = key_event(
KeyboardMode::Legacy,
ke_union::Union::ControlKey(EnumOrUnknown::new(control)),
true,
);
assert_eq!(convert_key_events(&event)[0].key, expected);
}
}
#[test]
fn test_convert_press_generates_down_and_up() {
use protobuf::EnumOrUnknown;
let mut key_event = KeyEvent::new();
key_event.press = true;
key_event.union = Some(ke_union::Union::ControlKey(EnumOrUnknown::new(
ControlKey::Return,
)));
fn rejects_unknown_modes_and_unsupported_unions() {
let mut unknown = map_chr(0x1E, true);
unknown.mode = EnumOrUnknown::from_i32(99);
assert!(convert_key_events(&unknown).is_empty());
let events = convert_key_events(&key_event);
assert_eq!(events.len(), 2);
assert_eq!(events[0].event_type, KeyEventType::Down);
assert_eq!(events[1].event_type, KeyEventType::Up);
assert_eq!(events[0].key, CanonicalKey::Enter);
assert_eq!(events[1].key, CanonicalKey::Enter);
let seq = key_event(
KeyboardMode::Translate,
ke_union::Union::Seq("a".to_string()),
true,
);
assert!(convert_key_events(&seq).is_empty());
let mut empty = KeyEvent::new();
empty.mode = EnumOrUnknown::new(KeyboardMode::Legacy);
assert!(convert_key_events(&empty).is_empty());
}
}

View 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);
}
}

View File

@@ -6,6 +6,7 @@ pub mod connection;
pub mod crypto;
pub mod frame_adapters;
pub mod hid_adapter;
mod keyboard_mapping;
pub mod protocol;
pub mod punch;
pub mod rendezvous;
@@ -17,7 +18,7 @@ use std::time::Duration;
use parking_lot::RwLock;
use protobuf::Message;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio::sync::{broadcast, Semaphore};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
@@ -26,13 +27,14 @@ use crate::hid::HidController;
use crate::utils::bind_tcp_listener;
use crate::video::stream_manager::VideoStreamManager;
use self::config::RustDeskConfig;
use self::config::{RustDeskConfig, RustDeskMode};
use self::connection::ConnectionManager;
use self::protocol::{make_local_addr, make_relay_response, make_request_relay};
use self::rendezvous::{AddrMangle, RendezvousMediator, RendezvousStatus};
const RELAY_CONNECT_TIMEOUT_MS: u64 = 10_000;
const SERVICE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
const MAX_PENDING_CONNECTION_ATTEMPTS: usize = 8;
#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
@@ -53,14 +55,13 @@ impl std::fmt::Display for ServiceStatus {
}
}
const DIRECT_LISTEN_PORT: u16 = 21118;
pub struct RustDeskService {
config: Arc<RwLock<RustDeskConfig>>,
status: Arc<RwLock<ServiceStatus>>,
rendezvous: Arc<RwLock<Option<Arc<RendezvousMediator>>>>,
rendezvous_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
tcp_listener_handle: Arc<RwLock<Option<Vec<JoinHandle<()>>>>>,
listener_start_lock: Arc<tokio::sync::Mutex<()>>,
listen_port: Arc<RwLock<u16>>,
connection_manager: Arc<ConnectionManager>,
video_manager: Arc<VideoStreamManager>,
@@ -78,6 +79,7 @@ impl RustDeskService {
) -> Self {
let (shutdown_tx, _) = broadcast::channel(1);
let connection_manager = Arc::new(ConnectionManager::new(config.clone()));
let direct_access_port = config.direct_access_port;
Self {
config: Arc::new(RwLock::new(config)),
@@ -85,7 +87,8 @@ impl RustDeskService {
rendezvous: Arc::new(RwLock::new(None)),
rendezvous_handle: Arc::new(RwLock::new(None)),
tcp_listener_handle: Arc::new(RwLock::new(None)),
listen_port: Arc::new(RwLock::new(DIRECT_LISTEN_PORT)),
listener_start_lock: Arc::new(tokio::sync::Mutex::new(())),
listen_port: Arc::new(RwLock::new(direct_access_port)),
connection_manager,
video_manager,
hid,
@@ -107,6 +110,7 @@ impl RustDeskService {
}
pub fn update_config(&self, config: RustDeskConfig) {
self.connection_manager.update_config(config.clone());
*self.config.write() = config;
}
@@ -126,7 +130,11 @@ impl RustDeskService {
self.tcp_listener_handle.read().is_some()
}
pub async fn start(&self) -> anyhow::Result<()> {
pub fn is_running(&self) -> bool {
self.status() == ServiceStatus::Running
}
pub async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
let config = self.config.read().clone();
if !config.enabled {
@@ -146,9 +154,8 @@ impl RustDeskService {
*self.status.write() = ServiceStatus::Starting;
info!(
"Starting RustDesk service with ID: {} -> {}",
config.device_id,
config.rendezvous_addr()
"Starting RustDesk service in {:?} mode with ID: {}",
config.mode, config.device_id,
);
if let Err(e) = crypto::init() {
@@ -157,6 +164,29 @@ impl RustDeskService {
return Err(e.into());
}
self.connection_manager.set_hid(self.hid.clone());
self.connection_manager.set_audio(self.audio.clone());
self.connection_manager
.set_video_manager(self.video_manager.clone());
if config.mode == RustDeskMode::DirectIp {
let listen_port = match self
.ensure_tcp_listener(config.direct_access_port, false)
.await
{
Ok(result) => result,
Err(err) => {
*self.status.write() = ServiceStatus::Error(err.to_string());
return Err(err);
}
};
*self.listen_port.write() = listen_port;
*self.status.write() = ServiceStatus::Running;
return Ok(());
}
let mediator = Arc::new(RendezvousMediator::new(config.clone()));
let keypair = mediator.ensure_keypair();
@@ -165,36 +195,27 @@ impl RustDeskService {
let signing_keypair = mediator.ensure_signing_keypair();
self.connection_manager.set_signing_keypair(signing_keypair);
self.connection_manager.set_hid(self.hid.clone());
self.connection_manager.set_audio(self.audio.clone());
self.connection_manager
.set_video_manager(self.video_manager.clone());
*self.rendezvous.write() = Some(mediator.clone());
let (tcp_handles, listen_port) = match self.start_tcp_listener_with_port().await {
Ok(result) => result,
Err(err) => {
*self.status.write() = ServiceStatus::Error(err.to_string());
return Err(err);
}
};
*self.tcp_listener_handle.write() = Some(tcp_handles);
mediator.set_listen_port(listen_port);
let connection_manager = self.connection_manager.clone();
let service_config = self.config.clone();
let connection_attempts = Arc::new(Semaphore::new(MAX_PENDING_CONNECTION_ATTEMPTS));
mediator.set_punch_callback(Arc::new({
let connection_manager = connection_manager.clone();
let service_config = service_config.clone();
let connection_attempts = connection_attempts.clone();
move |peer_addr, rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
let conn_mgr = connection_manager.clone();
let config = service_config.clone();
let attempts = connection_attempts.clone();
tokio::spawn(async move {
let Ok(_permit) = attempts.try_acquire_owned() else {
warn!(
"Dropping RustDesk punch request: too many pending connection attempts"
);
return;
};
if let Some(addr) = peer_addr {
info!("Attempting P2P direct connection to {}", addr);
match punch::try_direct_connection(addr).await {
@@ -232,10 +253,18 @@ impl RustDeskService {
mediator.set_relay_callback(Arc::new({
let connection_manager = connection_manager.clone();
let service_config = service_config.clone();
let connection_attempts = connection_attempts.clone();
move |rendezvous_addr, relay_server, uuid, socket_addr, device_id| {
let conn_mgr = connection_manager.clone();
let config = service_config.clone();
let attempts = connection_attempts.clone();
tokio::spawn(async move {
let Ok(_permit) = attempts.try_acquire_owned() else {
warn!(
"Dropping RustDesk relay request: too many pending connection attempts"
);
return;
};
let relay_key = rustdesk_relay_key(&config);
if let Err(e) = handle_relay_request(
&rendezvous_addr,
@@ -254,19 +283,39 @@ impl RustDeskService {
}
}));
let connection_manager2 = self.connection_manager.clone();
let weak_service = Arc::downgrade(self);
let intranet_attempts = connection_attempts.clone();
mediator.set_intranet_callback(Arc::new(
move |rendezvous_addr, peer_socket_addr, local_addr, relay_server, device_id| {
let conn_mgr = connection_manager2.clone();
move |rendezvous_addr, peer_socket_addr, local_ip, relay_server, device_id| {
let weak_service = weak_service.clone();
let attempts = intranet_attempts.clone();
tokio::spawn(async move {
let Ok(_permit) = attempts.try_acquire_owned() else {
warn!("Dropping RustDesk intranet request: too many pending connection attempts");
return;
};
let Some(service) = weak_service.upgrade() else {
return;
};
let preferred_port = service.config.read().direct_access_port;
let listen_port = match service
.ensure_tcp_listener(preferred_port, true)
.await
{
Ok(port) => port,
Err(error) => {
error!("Failed to start on-demand RustDesk listener: {}", error);
return;
}
};
let local_addr = SocketAddr::new(local_ip, listen_port);
if let Err(e) = handle_intranet_request(
&rendezvous_addr,
&peer_socket_addr,
local_addr,
&relay_server,
&device_id,
conn_mgr,
service.connection_manager.clone(),
)
.await
{
@@ -298,16 +347,29 @@ impl RustDeskService {
Ok(())
}
async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec<JoinHandle<()>>, u16)> {
let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) {
async fn ensure_tcp_listener(
self: &Arc<Self>,
preferred_port: u16,
allow_ephemeral_fallback: bool,
) -> anyhow::Result<u16> {
let _guard = self.listener_start_lock.lock().await;
if self.tcp_listener_handle.read().is_some() {
return Ok(*self.listen_port.read());
}
if self.status() == ServiceStatus::Stopped {
anyhow::bail!("RustDesk service stopped before listener could start");
}
let (listeners, listen_port) = match self.bind_direct_listeners(preferred_port) {
Ok(result) => result,
Err(err) => {
Err(error) if allow_ephemeral_fallback => {
warn!(
"Failed to bind RustDesk TCP on port {}: {}, falling back to random port",
DIRECT_LISTEN_PORT, err
"RustDesk port {} unavailable for on-demand listening: {}; using an ephemeral port",
preferred_port, error
);
self.bind_direct_listeners(0)?
}
Err(error) => return Err(error),
};
*self.listen_port.write() = listen_port;
@@ -328,15 +390,13 @@ impl RustDeskService {
match result {
Ok((stream, peer_addr)) => {
info!("Accepted direct connection from {}", peer_addr);
let conn_mgr = conn_mgr.clone();
tokio::spawn(async move {
if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await {
error!("Failed to handle direct connection from {}: {}", peer_addr, e);
}
});
if let Err(e) = conn_mgr.accept_listener_connection(stream, peer_addr).await {
warn!("Rejected direct connection from {}: {}", peer_addr, e);
}
}
Err(e) => {
error!("TCP accept error: {}", e);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
@@ -350,7 +410,8 @@ impl RustDeskService {
handles.push(handle);
}
Ok((handles, listen_port))
*self.tcp_listener_handle.write() = Some(handles);
Ok(listen_port)
}
fn bind_direct_listeners(&self, port: u16) -> anyhow::Result<(Vec<TcpListener>, u16)> {
@@ -384,8 +445,8 @@ impl RustDeskService {
info!("Stopping RustDesk service");
let _ = self.shutdown_tx.send(());
self.connection_manager.close_all();
let _listener_guard = self.listener_start_lock.lock().await;
*self.status.write() = ServiceStatus::Stopped;
if let Some(mediator) = self.rendezvous.read().as_ref() {
mediator.stop();
@@ -403,13 +464,15 @@ impl RustDeskService {
}
}
// No listener can admit a new session after this point.
self.connection_manager.close_all().await;
*self.rendezvous.write() = None;
*self.status.write() = ServiceStatus::Stopped;
Ok(())
}
pub async fn restart(&self, config: RustDeskConfig) -> anyhow::Result<()> {
pub async fn restart(self: &Arc<Self>, config: RustDeskConfig) -> anyhow::Result<()> {
self.stop().await?;
self.update_config(config);
self.start().await

View File

@@ -128,7 +128,7 @@ pub type RelayCallback = Arc<dyn Fn(String, String, String, Vec<u8>, String) + S
pub type PunchCallback =
Arc<dyn Fn(Option<SocketAddr>, String, String, String, Vec<u8>, String) + Send + Sync>;
pub type IntranetCallback = Arc<dyn Fn(String, Vec<u8>, SocketAddr, String, String) + Send + Sync>;
pub type IntranetCallback = Arc<dyn Fn(String, Vec<u8>, IpAddr, String, String) + Send + Sync>;
pub struct RendezvousMediator {
config: Arc<RwLock<RustDeskConfig>>,
@@ -143,7 +143,6 @@ pub struct RendezvousMediator {
relay_callback: Arc<RwLock<Option<RelayCallback>>>,
punch_callback: Arc<RwLock<Option<PunchCallback>>>,
intranet_callback: Arc<RwLock<Option<IntranetCallback>>>,
listen_port: Arc<RwLock<u16>>,
shutdown_tx: broadcast::Sender<()>,
}
@@ -166,23 +165,10 @@ impl RendezvousMediator {
relay_callback: Arc::new(RwLock::new(None)),
punch_callback: Arc::new(RwLock::new(None)),
intranet_callback: Arc::new(RwLock::new(None)),
listen_port: Arc::new(RwLock::new(21118)),
shutdown_tx,
}
}
pub fn set_listen_port(&self, port: u16) {
let old_port = *self.listen_port.read();
if old_port != port {
*self.listen_port.write() = port;
self.increment_serial();
}
}
pub fn listen_port(&self) -> u16 {
*self.listen_port.read()
}
pub fn increment_serial(&self) {
let mut serial = self.serial.write();
*serial = serial.wrapping_add(1);
@@ -430,7 +416,9 @@ impl RendezvousMediator {
) -> anyhow::Result<()> {
let id = self.device_id();
let local_addrs = get_local_addresses();
let local_addrs = tokio::task::spawn_blocking(get_local_addresses)
.await
.map_err(|error| anyhow::anyhow!("Failed to inspect local addresses: {error}"))?;
if local_addrs.is_empty() {
debug!("No local addresses available for LocalAddr response");
return Ok(());
@@ -439,21 +427,18 @@ impl RendezvousMediator {
let config = self.config.read().clone();
let rendezvous_addr = config.rendezvous_addr();
let listen_port = self.listen_port();
let local_ip = local_addrs[0];
let local_sock_addr = SocketAddr::new(local_ip, listen_port);
info!(
"FetchLocalAddr: calling intranet callback with local_addr={}, rendezvous={}",
local_sock_addr, rendezvous_addr
"FetchLocalAddr: requesting an on-demand listener for {}, rendezvous={}",
local_ip, rendezvous_addr
);
if let Some(callback) = self.intranet_callback.read().as_ref() {
callback(
rendezvous_addr,
peer_socket_addr.to_vec(),
local_sock_addr,
local_ip,
relay_server.to_string(),
id,
);

View File

@@ -19,11 +19,9 @@ use crate::hid::HidController;
use crate::msd::MsdController;
#[cfg(unix)]
use crate::otg::OtgService;
use crate::rtsp::RtspService;
use crate::rustdesk::RustDeskService;
use crate::runtime::{RemoteAccessCoordinator, UsbCoordinator};
use crate::update::UpdateService;
use crate::video::VideoStreamManager;
use crate::vnc::VncService;
use crate::watchdog::WatchdogController;
use crate::webrtc::WebRtcStreamer;
@@ -81,9 +79,8 @@ pub struct AppState {
pub audio: Arc<AudioController>,
#[cfg(unix)]
pub uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
pub usb: Arc<UsbCoordinator>,
pub remote_access: Arc<RemoteAccessCoordinator>,
pub extensions: Arc<ExtensionManager>,
pub events: Arc<EventBus>,
device_info_tx: watch::Sender<Option<SystemEvent>>,
@@ -111,9 +108,6 @@ impl AppState {
#[cfg(unix)] msd: Option<MsdController>,
atx: Option<AtxController>,
audio: Arc<AudioController>,
rustdesk: Option<Arc<RustDeskService>>,
vnc: Option<Arc<VncService>>,
rtsp: Option<Arc<RtspService>>,
extensions: Arc<ExtensionManager>,
events: Arc<EventBus>,
update: Arc<UpdateService>,
@@ -122,6 +116,28 @@ impl AppState {
) -> Arc<Self> {
let (device_info_tx, _device_info_rx) = watch::channel(None);
let remote_access = RemoteAccessCoordinator::new(
config.clone(),
stream_manager.clone(),
hid.clone(),
audio.clone(),
);
#[cfg(unix)]
let msd = Arc::new(RwLock::new(msd));
#[cfg(unix)]
let uac_playback = Arc::new(RwLock::new(None));
let usb = UsbCoordinator::new(
hid.clone(),
#[cfg(unix)]
otg_service.clone(),
#[cfg(unix)]
msd.clone(),
#[cfg(unix)]
uac_playback.clone(),
events.clone(),
data_dir.clone(),
);
Arc::new(Self {
db,
config,
@@ -135,12 +151,11 @@ impl AppState {
hid,
computer_use,
#[cfg(unix)]
msd: Arc::new(RwLock::new(msd)),
msd,
atx: Arc::new(RwLock::new(atx)),
audio,
rustdesk: Arc::new(RwLock::new(rustdesk)),
vnc: Arc::new(RwLock::new(vnc)),
rtsp: Arc::new(RwLock::new(rtsp)),
usb,
remote_access,
extensions,
events,
device_info_tx,
@@ -151,7 +166,7 @@ impl AppState {
config_apply_locks: ConfigApplyLocks::new(),
data_dir,
#[cfg(unix)]
uac_playback: Arc::new(RwLock::new(None)),
uac_playback,
})
}
@@ -159,33 +174,6 @@ impl AppState {
&self.data_dir
}
pub async fn runtime_third_party_config(&self) -> crate::config::AppConfig {
let mut config = self.config.get().as_ref().clone();
config.rustdesk.enabled = self
.rustdesk
.read()
.await
.as_ref()
.is_some_and(|service| service.is_listening());
config.vnc.enabled = match self.vnc.read().await.as_ref() {
Some(service) => matches!(
service.status().await,
crate::vnc::VncServiceStatus::Starting | crate::vnc::VncServiceStatus::Running
),
None => false,
};
config.rtsp.enabled = match self.rtsp.read().await.as_ref() {
Some(service) => matches!(
service.status().await,
crate::rtsp::RtspServiceStatus::Starting | crate::rtsp::RtspServiceStatus::Running
),
None => false,
};
config
}
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
self.device_info_tx.subscribe()
}
@@ -230,6 +218,11 @@ impl AppState {
pub async fn publish_device_info(&self) {
let device_info = self.get_device_info().await;
if let SystemEvent::DeviceInfo { video, .. } = &device_info {
if let Some((width, height)) = video.resolution {
self.hid.set_screen_resolution(width, height).await;
}
}
let _ = self.device_info_tx.send(Some(device_info));
}

View File

@@ -14,29 +14,5 @@ pub fn encoder_type_to_backend(encoder: EncoderType) -> Option<EncoderBackend> {
EncoderType::Amf => Some(EncoderBackend::Amf),
EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp),
EncoderType::V4l2m2m => Some(EncoderBackend::V4l2m2m),
EncoderType::Amlogic => Some(EncoderBackend::Amlogic),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_amlogic_config_to_backend() {
assert_eq!(
encoder_type_to_backend(EncoderType::Amlogic),
Some(EncoderBackend::Amlogic)
);
}
#[test]
fn amlogic_config_json_round_trip() {
let json = serde_json::to_string(&EncoderType::Amlogic).unwrap();
assert_eq!(json, "\"amlogic\"");
assert_eq!(
serde_json::from_str::<EncoderType>(&json).unwrap(),
EncoderType::Amlogic
);
}
}

View 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);
}
}

View File

@@ -3,6 +3,8 @@
use std::fs::File;
use std::io;
use std::os::fd::AsFd;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use std::os::fd::OwnedFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@@ -29,6 +31,10 @@ use crate::video::device::VideoControlMode;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::signal::SignalStatus;
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[path = "dmabuf_layout.rs"]
mod dmabuf_layout;
/// Metadata for a captured frame.
#[derive(Debug, Clone, Copy)]
pub struct CaptureMeta {
@@ -67,6 +73,8 @@ pub struct CaptureStream {
bridge_kind: Option<CsiBridgeKind>,
native_hdmirx_state: Option<NativeHdmirxState>,
native_hdmirx_next_state_check: Option<Instant>,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
dma_layout_bytes: Option<usize>,
}
fn open_capture_device(path: &Path) -> io::Result<File> {
@@ -319,6 +327,24 @@ impl CaptureStream {
mappings.push(plane_maps);
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
let dma_layout_bytes = PixelFormat::from_v4l2r(actual_fmt.pixelformat).and_then(|format| {
dmabuf_layout::DmaCaptureLayout {
native_hdmi: is_native_hdmirx,
driver: &caps.driver,
bus_info: &caps.bus_info,
configurable_usb: !is_source_following
&& bridge.kind.is_none()
&& !bridge.has_subdev(),
single_planar: queue == QueueType::VideoCapture,
fourcc: format.to_fourcc(),
width: actual_resolution.width,
height: actual_resolution.height,
stride,
}
.minimum_bytes()
});
let mut stream = Self {
fd,
queue,
@@ -332,6 +358,8 @@ impl CaptureStream {
bridge_kind: bridge.kind,
native_hdmirx_state,
native_hdmirx_next_state_check,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
dma_layout_bytes,
};
stream.queue_all_buffers()?;
@@ -421,10 +449,7 @@ impl CaptureStream {
}
}
pub fn next_into(
&mut self,
dst: &mut Vec<u8>,
) -> std::result::Result<CaptureMeta, CaptureReadError> {
fn dequeue_buffer(&mut self) -> std::result::Result<V4l2Buffer, CaptureReadError> {
self.wait_ready()?;
// Several vendor BSPs update G_FMT/DV timings without making the
@@ -455,6 +480,143 @@ impl CaptureStream {
};
CaptureReadError::Io(error)
})?;
Ok(dqbuf)
}
/// Native HDMI NV12/BGR24 and single-planar USB UVC YUYV/NV12/RGB24/MJPEG.
/// Actual EXPBUF/import support is probed separately; failure retains copy.
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub(crate) fn supports_rkmpp_dmabuf(&self) -> bool {
self.dma_layout_bytes.is_some_and(|minimum| {
(2..=16).contains(&self.mappings.len())
&& self
.mappings
.iter()
.all(|planes| planes.len() == 1 && planes[0].len() >= minimum)
})
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub(crate) fn export_dmabufs(&self) -> io::Result<Vec<(OwnedFd, usize)>> {
if !self.supports_rkmpp_dmabuf() {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"Unsupported RKMPP DMA capture layout",
));
}
self.mappings
.iter()
.enumerate()
.map(|(index, planes)| {
let fd = ioctl::expbuf(&self.fd, self.queue, index, 0, ioctl::ExpbufFlags::CLOEXEC)
.map_err(|error| io::Error::other(error.to_string()))?;
Ok((fd, planes[0].len()))
})
.collect()
}
/// Run a synchronous consumer while a buffer is dequeued. QBUF occurs only
/// after the callback returns, including its error path. Consumers must end
/// hardware access before returning; see hwcodec::rkmpp_dmabuf::encode.
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub(crate) fn with_next_dmabuf<T>(
&mut self,
consume: impl FnOnce(usize, usize, Option<OwnedFd>) -> T,
) -> std::result::Result<(CaptureMeta, T), CaptureReadError> {
let buffer = self.dequeue_buffer()?;
let index = buffer.as_v4l2_buffer().index as usize;
let sequence = buffer.as_v4l2_buffer().sequence as u64;
if index >= self.mappings.len() {
return Err(
io::Error::new(io::ErrorKind::InvalidData, "Invalid capture buffer index").into(),
);
}
let expected = self.expected_capture_bytes();
let mapped_size = self.mappings[index][0].len();
let native_hdmi = self.native_hdmirx_state.is_some();
let compressed = self.format == PixelFormat::Mjpeg;
let lease = BufferReturn(Some(|| {
self.queue_buffer(index as u32)
.map_err(|e| io::Error::other(e.to_string()))
}));
if buffer.as_v4l2_buffer().flags & v4l2r::bindings::V4L2_BUF_FLAG_ERROR != 0 {
// A corrupt UVC frame is not a source change or a DMA failure.
// Return it without ever letting the encoder read its payload.
lease.finish()?;
return Err(io::Error::from(io::ErrorKind::WouldBlock).into());
}
if !native_hdmi
&& buffer.as_v4l2_buffer().field != v4l2r::bindings::v4l2_field_V4L2_FIELD_NONE
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Interlaced USB DMA frames are not supported",
)
.into());
}
let mut planes = buffer.planes_iter();
let plane = planes
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Missing DMA plane"))?;
if planes.next().is_some() || plane.data_offset.copied().unwrap_or(0) != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Unsupported DMA plane offset/layout",
)
.into());
}
let bytes_used = *plane.bytesused as usize;
if !dmabuf_layout::valid_payload(compressed, bytes_used, mapped_size, expected) {
if !native_hdmi {
// An unexpected UVC payload is not evidence of a source mode
// change. Disable DMA instead of reopening it indefinitely.
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Unexpected USB DMA payload length",
)
.into());
}
return Err(CaptureReadError::SourceChanged);
}
// UVC commonly fills vmalloc memory on the CPU. Older BSP exporters
// cache DMA attachments without usable per-frame CPU-access sync hooks.
// A fresh export object forces a fresh device mapping of this completed
// frame. Reuse the actual capture allocation, not a stale attachment.
let fresh_fd = if !native_hdmi {
Some(
ioctl::expbuf(
&self.fd,
self.queue,
index,
0,
ioctl::ExpbufFlags::CLOEXEC | ioctl::ExpbufFlags::RDWR,
)
.map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("USB DMA re-export failed: {error}"),
)
})?,
)
} else {
None
};
let output = consume(index, bytes_used, fresh_fd);
lease.finish()?;
Ok((
CaptureMeta {
bytes_used,
sequence,
},
output,
))
}
pub fn next_into(
&mut self,
dst: &mut Vec<u8>,
) -> std::result::Result<CaptureMeta, CaptureReadError> {
let dqbuf = self.dequeue_buffer()?;
let index = dqbuf.as_v4l2_buffer().index as usize;
let sequence = dqbuf.as_v4l2_buffer().sequence as u64;
@@ -664,7 +826,7 @@ impl CaptureStream {
Ok(())
}
fn queue_buffer(&mut self, index: u32) -> Result<()> {
fn queue_buffer(&self, index: u32) -> Result<()> {
let handle = MmapHandle;
let planes = self.mappings[index as usize]
.iter()
@@ -682,6 +844,64 @@ impl CaptureStream {
}
}
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
struct BufferReturn<F: FnOnce() -> io::Result<()>>(Option<F>);
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
impl<F: FnOnce() -> io::Result<()>> BufferReturn<F> {
fn finish(mut self) -> io::Result<()> {
self.0.take().expect("capture lease already returned")()
}
}
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
impl<F: FnOnce() -> io::Result<()>> Drop for BufferReturn<F> {
fn drop(&mut self) {
if let Some(return_buffer) = self.0.take() {
if let Err(error) = return_buffer() {
warn!("Failed to return leased capture buffer: {}", error);
}
}
}
}
#[cfg(test)]
mod dma_lease_tests {
use super::*;
use std::cell::RefCell;
#[test]
fn returns_buffer_once_after_consumer_and_does_not_retry_failed_qbuf() {
let operations = RefCell::new(Vec::new());
let lease = BufferReturn(Some(|| {
operations.borrow_mut().push("qbuf");
Err(io::Error::other("device lost"))
}));
operations.borrow_mut().push("encode completed");
assert!(lease.finish().is_err());
assert_eq!(*operations.borrow(), ["encode completed", "qbuf"]);
}
#[test]
fn returns_buffer_on_validation_error_or_unwind() {
let returns = std::cell::Cell::new(0);
{
let _lease = BufferReturn(Some(|| {
returns.set(returns.get() + 1);
Ok(())
}));
}
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _lease = BufferReturn(Some(|| {
returns.set(returns.get() + 1);
Ok(())
}));
panic!("consumer panic");
}));
assert_eq!(returns.get(), 2);
}
}
impl Drop for CaptureStream {
fn drop(&mut self) {
// Release ordering matters on rkcif: a subsequent open()/S_FMT from a

View File

@@ -2,8 +2,32 @@
use std::io;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
use crate::video::device::VideoControlMode;
use crate::video::signal::SignalStatus;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
pub(crate) fn capture_recovery_status(
control_mode: VideoControlMode,
error: &io::Error,
) -> SignalStatus {
if control_mode == VideoControlMode::Configurable && error.kind() == io::ErrorKind::TimedOut {
return SignalStatus::UvcCaptureStall;
}
match classify_capture_io_error(error) {
CaptureIoErrorKind::TransientSignal {
status: Some(status),
} => status,
_ => SignalStatus::NoSignal,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureIoErrorKind {
DeviceLost,
@@ -52,6 +76,33 @@ pub fn capture_error_log_key(err: &io::Error) -> String {
mod tests {
use super::*;
#[test]
fn recovery_distinguishes_uvc_stalls_from_hdmi_signal_loss() {
let timeout = io::Error::from(io::ErrorKind::TimedOut);
assert_eq!(
capture_recovery_status(VideoControlMode::Configurable, &timeout),
SignalStatus::UvcCaptureStall
);
assert_eq!(
capture_recovery_status(VideoControlMode::SourceFollowing, &timeout),
SignalStatus::NoSignal
);
assert_eq!(
capture_recovery_status(
VideoControlMode::Configurable,
&io::Error::from_raw_os_error(71)
),
SignalStatus::UvcUsbError
);
assert_eq!(
capture_recovery_status(
VideoControlMode::SourceFollowing,
&io::Error::from_raw_os_error(5)
),
SignalStatus::NoSignal
);
}
#[test]
fn maps_known_signal_status_strings() {
assert_eq!(

View File

@@ -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"));
}
}

View File

@@ -48,8 +48,6 @@ pub enum H264EncoderType {
Rkmpp,
/// V4L2 M2M (ARM generic) - requires hwcodec extension
V4l2M2m,
/// Amlogic S912/GXM AMLENC
Amlogic,
/// Software encoding (libx264/openh264)
Software,
/// No encoder available
@@ -66,7 +64,6 @@ impl std::fmt::Display for H264EncoderType {
H264EncoderType::Vaapi => write!(f, "VAAPI"),
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
H264EncoderType::Amlogic => write!(f, "AMLENC"),
H264EncoderType::Software => write!(f, "Software"),
H264EncoderType::None => write!(f, "None"),
}
@@ -83,7 +80,6 @@ impl From<EncoderBackend> for H264EncoderType {
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
EncoderBackend::Amlogic => H264EncoderType::Amlogic,
EncoderBackend::Software => H264EncoderType::Software,
}
}
@@ -202,7 +198,7 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
align: 1,
fps: 30,
gop: 30,
rc: RateControl::RC_CBR,
rc: RateControl::RC_VBR,
quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (ultrafast)
kbs: 2000,
q: 23,
@@ -301,7 +297,7 @@ impl H264Encoder {
align: 1,
fps: config.fps as i32,
gop: config.gop_size as i32,
rc: RateControl::RC_CBR,
rc: RateControl::RC_VBR,
quality: Quality::Quality_Low, // Use low quality preset for fastest encoding (lowest latency)
kbs: config.bitrate_kbps as i32,
q: 23,

View File

@@ -252,13 +252,37 @@ pub fn avcc_to_annex_b(data: &[u8]) -> Option<Vec<u8>> {
}
}
pub fn normalize_for_webrtc(data: &[u8]) -> Vec<u8> {
if is_annex_b(data) {
return strip_aud_nal_units(data);
/// Normalize a length-prefixed H.264 access unit to Annex-B when necessary.
///
/// FFmpeg normally exposes elementary H.264 from hardware encoders as
/// Annex-B, but some V4L2 M2M drivers return AVCC-style packets. Consumers
/// such as RustDesk do not receive codec extradata from our protocol adapter,
/// so passing those packets through unchanged leaves the decoder unable to
/// find NAL unit boundaries.
pub fn normalize_annex_b(data: bytes::Bytes) -> bytes::Bytes {
// A four-byte start code is unambiguous for real encoder output. A
// three-byte prefix is not: an AVCC NAL of 256..511 bytes also begins
// with 00 00 01. Validate AVCC before accepting that shorter prefix.
if data.starts_with(&[0, 0, 0, 1]) {
return data;
}
if let Some(annex_b) = avcc_to_annex_b(data) {
return strip_aud_nal_units(&annex_b);
if let Some(annex_b) = avcc_to_annex_b(data.as_ref()) {
return bytes::Bytes::from(annex_b);
}
data
}
pub fn normalize_for_webrtc(data: &[u8]) -> Vec<u8> {
if !data.starts_with(&[0, 0, 0, 1]) {
if let Some(annex_b) = avcc_to_annex_b(data) {
return strip_aud_nal_units(&annex_b);
}
}
if is_annex_b(data) {
return strip_aud_nal_units(data);
}
data.to_vec()
@@ -296,4 +320,36 @@ mod tests {
Some("42402a".to_string())
);
}
#[test]
fn converts_avcc_access_unit_to_annex_b() {
let avcc = [
0, 0, 0, 4, 0x67, 0x42, 0x40, 0x1f, // SPS
0, 0, 0, 2, 0x68, 0xce, // PPS
0, 0, 0, 3, 0x65, 0x88, 0x84, // IDR
];
let annex_b = normalize_annex_b(bytes::Bytes::copy_from_slice(&avcc));
assert!(is_annex_b(&annex_b));
assert!(has_sps_pps(&annex_b));
assert!(is_keyframe(&annex_b));
}
#[test]
fn leaves_annex_b_packet_unchanged() {
let annex_b = bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x84]);
let normalized = normalize_annex_b(annex_b.clone());
assert_eq!(normalized, annex_b);
}
#[test]
fn recognizes_avcc_length_that_looks_like_three_byte_start_code() {
let mut avcc = vec![0, 0, 1, 0];
avcc.push(0x65);
avcc.resize(4 + 256, 0x88);
let annex_b = normalize_annex_b(bytes::Bytes::from(avcc));
assert_eq!(&annex_b[..5], &[0, 0, 0, 1, 0x65]);
assert!(is_keyframe(&annex_b));
}
}

View File

@@ -45,8 +45,6 @@ pub enum H265EncoderType {
Rkmpp,
/// V4L2 M2M (ARM generic)
V4l2M2m,
/// Amlogic S912/GXM AMLENC
Amlogic,
/// Software encoder (libx265)
Software,
/// No encoder available
@@ -63,7 +61,6 @@ impl std::fmt::Display for H265EncoderType {
H265EncoderType::Vaapi => write!(f, "VAAPI"),
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
H265EncoderType::Amlogic => write!(f, "AMLENC"),
H265EncoderType::Software => write!(f, "Software"),
H265EncoderType::None => write!(f, "None"),
}
@@ -79,7 +76,6 @@ impl From<EncoderBackend> for H265EncoderType {
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
EncoderBackend::Amlogic => H265EncoderType::Amlogic,
EncoderBackend::Software => H265EncoderType::Software,
}
}

View 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));
}
}

View File

@@ -3,12 +3,12 @@
use hwcodec::common::DataFormat;
use hwcodec::ffmpeg_ram::CodecInfo;
pub mod amlenc;
pub mod convert;
pub mod h264;
pub mod h264_bitstream;
pub mod h265;
pub mod h265_bitstream;
pub mod jpeg;
pub mod registry;
pub mod self_check;
@@ -20,7 +20,6 @@ pub mod vp9;
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
pub mod mjpeg_rkmpp;
pub use amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder};
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};

View File

@@ -10,17 +10,11 @@ use std::sync::OnceLock;
use std::time::Duration;
use tracing::{debug, info, warn};
use super::amlenc::{self, AmlencCodec, AMLENC_H264_CODEC_NAME, AMLENC_H265_CODEC_NAME};
use hwcodec::common::{DataFormat, Quality, RateControl};
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
use hwcodec::ffmpeg_ram::CodecInfo;
// Keep native AMLENC behind the highest-priority desktop GPU backends while
// ensuring it is selected before hwcodec's software priority (3).
const AMLENC_PRIORITY: i32 = 2;
/// Video encoder format type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum VideoEncoderType {
@@ -102,8 +96,6 @@ pub enum EncoderBackend {
Rkmpp,
/// V4L2 Memory-to-Memory (ARM)
V4l2m2m,
/// Amlogic S912/GXM vendor AMLENC
Amlogic,
/// Software encoding (libx264, libx265, libvpx)
Software,
}
@@ -123,8 +115,6 @@ impl EncoderBackend {
EncoderBackend::Rkmpp
} else if name.contains("v4l2m2m") {
EncoderBackend::V4l2m2m
} else if name.contains("amlenc") {
EncoderBackend::Amlogic
} else {
EncoderBackend::Software
}
@@ -144,7 +134,6 @@ impl EncoderBackend {
EncoderBackend::Amf => "AMF",
EncoderBackend::Rkmpp => "RKMPP",
EncoderBackend::V4l2m2m => "V4L2 M2M",
EncoderBackend::Amlogic => "AMLENC",
EncoderBackend::Software => "Software",
}
}
@@ -159,7 +148,6 @@ impl EncoderBackend {
"amf" => Some(EncoderBackend::Amf),
"rkmpp" => Some(EncoderBackend::Rkmpp),
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
"amlogic" | "amlenc" => Some(EncoderBackend::Amlogic),
"software" | "cpu" => Some(EncoderBackend::Software),
_ => None,
}
@@ -286,79 +274,6 @@ impl EncoderRegistry {
}
}
fn detect_amlenc(&mut self) {
match amlenc::system_is_s912_gxm() {
Ok(true) => {}
Ok(false) => {
debug!("AMLENC skipped: host is not Linux/aarch64 S912/GXM");
return;
}
Err(error) => {
warn!("AMLENC skipped: {}", error);
return;
}
}
self.detect_amlenc_candidates(
true,
|codec| std::path::Path::new(codec.device_node()).exists(),
amlenc::smoke_test,
);
}
fn detect_amlenc_candidates<NodeExists, SmokeTest>(
&mut self,
compatible: bool,
mut node_exists: NodeExists,
mut smoke_test: SmokeTest,
) where
NodeExists: FnMut(AmlencCodec) -> bool,
SmokeTest: FnMut(AmlencCodec) -> crate::error::Result<()>,
{
if !compatible {
return;
}
for (codec, format, codec_name) in [
(
AmlencCodec::H264,
VideoEncoderType::H264,
AMLENC_H264_CODEC_NAME,
),
(
AmlencCodec::H265,
VideoEncoderType::H265,
AMLENC_H265_CODEC_NAME,
),
] {
let node = codec.device_node();
if !node_exists(codec) {
warn!(
"AMLENC {} unavailable: device node {} is missing",
format, node
);
continue;
}
match smoke_test(codec) {
Ok(()) => {
self.encoders
.entry(format)
.or_default()
.push(AvailableEncoder {
format,
codec_name: codec_name.to_string(),
backend: EncoderBackend::Amlogic,
priority: AMLENC_PRIORITY,
is_hardware: true,
});
info!("Registered native AMLENC encoder: {}", codec_name);
}
Err(error) => warn!("AMLENC {} unavailable ({}): {}", format, node, error),
}
}
}
/// Get the global registry instance
///
/// The registry is initialized lazily on first access with 1280x720 detection.
@@ -426,8 +341,6 @@ impl EncoderRegistry {
}
}
self.detect_amlenc();
// Sort encoders by priority (lower is better)
for encoders in self.encoders.values_mut() {
encoders.sort_by_key(|e| e.priority);
@@ -624,14 +537,6 @@ mod tests {
EncoderBackend::from_codec_name("libx264"),
EncoderBackend::Software
);
assert_eq!(
EncoderBackend::from_codec_name("h264_amlenc"),
EncoderBackend::Amlogic
);
assert_eq!(
EncoderBackend::from_str("amlogic"),
Some(EncoderBackend::Amlogic)
);
}
#[test]
@@ -656,65 +561,4 @@ mod tests {
println!("Available formats: {:?}", registry.available_formats(false));
println!("Selectable formats: {:?}", registry.selectable_formats());
}
#[test]
fn test_amlenc_registration_prerequisite_matrix() {
let ok = |_codec| Ok(());
let mut incompatible = EncoderRegistry::new();
incompatible.detect_amlenc_candidates(false, |_| true, ok);
assert!(incompatible.encoders.is_empty());
let mut no_nodes = EncoderRegistry::new();
no_nodes.detect_amlenc_candidates(true, |_| false, ok);
assert!(no_nodes.encoders.is_empty());
for reason in ["library missing", "ABI marker missing"] {
let mut rejected = EncoderRegistry::new();
rejected.detect_amlenc_candidates(
true,
|_| true,
|_| Err(crate::error::AppError::VideoError(reason.to_string())),
);
assert!(rejected.encoders.is_empty());
}
let mut h264_only = EncoderRegistry::new();
h264_only.detect_amlenc_candidates(true, |codec| codec == AmlencCodec::H264, ok);
assert!(h264_only
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic)
.is_some());
assert!(h264_only
.encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic)
.is_none());
let mut both = EncoderRegistry::new();
both.detect_amlenc_candidates(true, |_| true, ok);
assert!(both
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic)
.is_some());
assert!(both
.encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic)
.is_some());
both.encoders
.entry(VideoEncoderType::H264)
.or_default()
.push(AvailableEncoder {
format: VideoEncoderType::H264,
codec_name: "libx264".to_string(),
backend: EncoderBackend::Software,
priority: 3,
is_hardware: false,
});
both.encoders
.get_mut(&VideoEncoderType::H264)
.unwrap()
.sort_by_key(|encoder| encoder.priority);
assert_eq!(
both.best_available_encoder(VideoEncoderType::H264)
.map(|encoder| encoder.backend),
Some(EncoderBackend::Amlogic)
);
}
}

View File

@@ -3,8 +3,8 @@ use std::sync::mpsc;
use std::time::{Duration, Instant};
use super::{
AmlencCodec, AmlencConfig, AmlencEncoder, EncoderRegistry, H264Config, H264Encoder, H265Config,
H265Encoder, VP8Config, VP8Encoder, VP9Config, VP9Encoder, VideoEncoderType,
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
VP9Config, VP9Encoder, VideoEncoderType,
};
use crate::error::{AppError, Result};
use crate::video::format::{PixelFormat, Resolution};
@@ -226,9 +226,6 @@ fn run_smoke_test(
resolution: Resolution,
codec_name_ffmpeg: &str,
) -> Result<()> {
if codec_name_ffmpeg.contains("amlenc") {
return run_amlenc_smoke_test(codec, resolution);
}
match codec {
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg),
@@ -237,37 +234,6 @@ fn run_smoke_test(
}
}
fn run_amlenc_smoke_test(codec: VideoEncoderType, resolution: Resolution) -> Result<()> {
let amlenc_codec = match codec {
VideoEncoderType::H264 => AmlencCodec::H264,
VideoEncoderType::H265 => AmlencCodec::H265,
_ => {
return Err(AppError::VideoError(
"AMLENC only supports H.264 and H.265".to_string(),
))
}
};
let mut encoder = AmlencEncoder::new(AmlencConfig {
codec: amlenc_codec,
resolution,
fps: 30,
bitrate_kbps: bitrate_kbps_for_resolution(resolution),
gop: 30,
})?;
let frame_len = PixelFormat::Nv12.frame_size(resolution).ok_or_else(|| {
AppError::VideoError("Cannot calculate AMLENC NV12 self-check size".to_string())
})?;
let frame = build_nv12_test_frame(resolution, frame_len);
for _ in 0..SELF_CHECK_FRAME_ATTEMPTS {
if encoder.encode_raw(&frame)?.is_some() {
return Ok(());
}
}
Err(AppError::VideoError(
"AMLENC produced no output after multiple frames".to_string(),
))
}
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
let mut encoder = H264Encoder::with_codec(
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),

View File

@@ -1,9 +1,10 @@
//! CSI/HDMI bridge helpers: subdev discovery, DV probe, RK628 "fake VGA" filter (must run before `S_FMT` / `STREAMON` on capture — see RK628 driver).
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::File;
use std::io;
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
@@ -97,43 +98,391 @@ impl std::fmt::Debug for DvTimingsMode {
}
}
/// Heuristic: scan `/sys/class/video4linux/v4l-subdev*` names for rk628 / hdmirx / tc358743.
pub fn discover_subdev_for_video(video_path: &Path) -> Option<(PathBuf, CsiBridgeKind)> {
let sysfs_base = Path::new("/sys/class/video4linux");
let entries = std::fs::read_dir(sysfs_base).ok()?;
const SYSFS_VIDEO4LINUX: &str = "/sys/class/video4linux";
const DEV_ROOT: &str = "/dev";
const MEDIA_ENT_ID_FLAG_NEXT: u32 = 1 << 31;
const MEDIA_LNK_FL_ENABLED: u32 = 1 << 0;
const MEDIA_LNK_FL_LINK_TYPE: u32 = 0xf << 28;
const MEDIA_LNK_FL_DATA_LINK: u32 = 0 << 28;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("v4l-subdev") {
continue;
}
let Some(kind) = read_sysfs_name(&entry.path())
.as_deref()
.and_then(CsiBridgeKind::from_subdev_name)
else {
continue;
};
let dev_path = PathBuf::from("/dev").join(&*name_str);
if dev_path.exists() {
#[repr(C)]
#[derive(Clone, Copy)]
struct MediaEntityDesc {
id: u32,
name: [u8; 32],
type_: u32,
revision: u32,
flags: u32,
group_id: u32,
pads: u16,
links: u16,
reserved: [u32; 4],
info: MediaEntityInfo,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct MediaDeviceNode {
major: u32,
minor: u32,
}
#[repr(C)]
#[derive(Clone, Copy)]
union MediaEntityInfo {
dev: MediaDeviceNode,
_raw: [u8; 184],
}
impl Default for MediaEntityDesc {
fn default() -> Self {
// This mirrors the zero-initialization required by the media UAPI.
unsafe { std::mem::zeroed() }
}
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct MediaPadDesc {
entity: u32,
index: u16,
flags: u32,
reserved: [u32; 2],
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct MediaLinkDesc {
source: MediaPadDesc,
sink: MediaPadDesc,
flags: u32,
reserved: [u32; 2],
}
#[repr(C)]
struct MediaLinksEnum {
entity: u32,
pads: *mut MediaPadDesc,
links: *mut MediaLinkDesc,
reserved: [u32; 4],
}
nix::ioctl_readwrite!(media_ioc_enum_entities, b'|', 0x01, MediaEntityDesc);
nix::ioctl_readwrite!(media_ioc_enum_links, b'|', 0x02, MediaLinksEnum);
#[derive(Debug, Clone)]
struct MediaGraphEntity {
id: u32,
name: String,
major: u32,
minor: u32,
pads: u16,
links: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MediaGraphLink {
source: u32,
sink: u32,
}
#[derive(Debug)]
struct MediaGraph {
path: PathBuf,
entities: Vec<MediaGraphEntity>,
links: Vec<MediaGraphLink>,
}
/// Find the CSI/HDMI bridge that is connected to `video_path` in the same
/// media-controller graph. Name-only global scans are deliberately avoided:
/// boards can expose RK628, native HDMI RX and USB capture at the same time.
pub fn discover_subdev_for_video(video_path: &Path) -> Option<(PathBuf, CsiBridgeKind)> {
match discover_subdev_for_video_inner(video_path) {
Ok(Some((path, kind, media_path))) => {
info!(
"Discovered CSI bridge subdev for {:?}: {:?} ({:?})",
video_path, dev_path, kind
"Discovered CSI bridge subdev for {:?}: {:?} ({:?}) via {:?}",
video_path, path, kind, media_path
);
return Some((dev_path, kind));
Some((path, kind))
}
Ok(None) => {
debug!(
"No connected CSI bridge subdev found in media topology for {:?}",
video_path
);
None
}
Err(error) => {
warn!(
"Failed to inspect media topology for {:?}: {}",
video_path, error
);
None
}
}
}
fn discover_subdev_for_video_inner(
video_path: &Path,
) -> io::Result<Option<(PathBuf, CsiBridgeKind, PathBuf)>> {
let video_device = device_numbers(video_path)?;
let mut graphs = Vec::new();
let mut first_error = None;
// A physical device may expose more than one media controller. Inspect
// every controller and use the graph that actually contains video_path;
// choosing the first mediaN node merely moves the old global-scan bug.
for media_path in media_device_paths()? {
let graph = File::open(&media_path).and_then(|media| {
read_media_graph(&media).map(|(entities, links)| MediaGraph {
path: media_path.clone(),
entities,
links,
})
});
match graph {
Ok(graph) => graphs.push(graph),
Err(error) => {
debug!(
"Failed to inspect media controller {:?}: {}",
media_path, error
);
first_error.get_or_insert(error);
}
}
}
let graph_contains_video = graphs.iter().any(|graph| {
graph
.entities
.iter()
.any(|entity| (entity.major, entity.minor) == video_device)
});
let Some((media_path, entity, kind)) = connected_bridge_in_media_graphs(&graphs, video_device)
else {
if !graph_contains_video {
if let Some(error) = first_error {
return Err(error);
}
}
return Ok(None);
};
let Some(subdev_path) = video4linux_devnode((entity.major, entity.minor))? else {
return Ok(None);
};
if !subdev_path
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with("v4l-subdev"))
{
return Ok(None);
}
Ok(Some((subdev_path, kind, media_path.to_path_buf())))
}
fn device_numbers(path: &Path) -> io::Result<(u32, u32)> {
let rdev = std::fs::metadata(path)?.rdev();
let major = nix::sys::stat::major(rdev);
let minor = nix::sys::stat::minor(rdev);
Ok((major as u32, minor as u32))
}
fn parse_device_numbers(value: &str) -> Option<(u32, u32)> {
let (major, minor) = value.trim().split_once(':')?;
Some((major.parse().ok()?, minor.parse().ok()?))
}
fn video4linux_class_entry(device: (u32, u32)) -> io::Result<Option<PathBuf>> {
for entry in std::fs::read_dir(SYSFS_VIDEO4LINUX)? {
let entry = entry?;
let dev = match std::fs::read_to_string(entry.path().join("dev")) {
Ok(value) => value,
Err(_) => continue,
};
if parse_device_numbers(&dev) == Some(device) {
return Ok(Some(entry.path()));
}
}
Ok(None)
}
fn video4linux_devnode(device: (u32, u32)) -> io::Result<Option<PathBuf>> {
let Some(class_entry) = video4linux_class_entry(device)? else {
return Ok(None);
};
let Some(name) = class_entry.file_name() else {
return Ok(None);
};
let path = Path::new(DEV_ROOT).join(name);
Ok(path.exists().then_some(path))
}
fn media_device_paths() -> io::Result<Vec<PathBuf>> {
let mut media_nodes = std::fs::read_dir(DEV_ROOT)?
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name();
let name = name.to_str()?;
let index = media_device_index(name)?;
Some((index, entry.path()))
})
.collect::<Vec<_>>();
media_nodes.sort_by(|(left_index, left_path), (right_index, right_path)| {
left_index
.cmp(right_index)
.then_with(|| left_path.cmp(right_path))
});
Ok(media_nodes.into_iter().map(|(_, path)| path).collect())
}
fn media_device_index(name: &str) -> Option<u32> {
let suffix = name.strip_prefix("media")?;
if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
suffix.parse().ok()
}
fn read_media_graph(media: &File) -> io::Result<(Vec<MediaGraphEntity>, Vec<MediaGraphLink>)> {
let mut entities = Vec::new();
let mut previous_id = 0u32;
loop {
let mut desc = MediaEntityDesc {
id: previous_id | MEDIA_ENT_ID_FLAG_NEXT,
..Default::default()
};
// SAFETY: `desc` has the exact media_entity_desc UAPI layout and is
// writable for the duration of the ioctl.
match unsafe { media_ioc_enum_entities(media.as_raw_fd(), &mut desc) } {
Ok(_) => {}
Err(Errno::EINVAL) => break,
Err(error) => return Err(io::Error::from_raw_os_error(error as i32)),
}
if desc.id == previous_id || entities.len() >= 4096 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"media entity enumeration did not advance",
));
}
previous_id = desc.id;
let nul = desc
.name
.iter()
.position(|byte| *byte == 0)
.unwrap_or(desc.name.len());
let name = String::from_utf8_lossy(&desc.name[..nul]).into_owned();
// SAFETY: the kernel filled the `dev` member of the media UAPI union
// for V4L2 devnode entities. Non-devnode entities report zeroes.
let device = unsafe { desc.info.dev };
entities.push(MediaGraphEntity {
id: desc.id,
name,
major: device.major,
minor: device.minor,
pads: desc.pads,
links: desc.links,
});
}
let mut graph_links = HashSet::new();
for entity in &entities {
let mut pads = vec![MediaPadDesc::default(); entity.pads as usize];
let mut links = vec![MediaLinkDesc::default(); entity.links as usize];
let mut request = MediaLinksEnum {
entity: entity.id,
pads: if pads.is_empty() {
std::ptr::null_mut()
} else {
pads.as_mut_ptr()
},
links: if links.is_empty() {
std::ptr::null_mut()
} else {
links.as_mut_ptr()
},
reserved: [0; 4],
};
// SAFETY: the vectors provide the number of entries reported by the
// entity descriptor and remain alive while the kernel fills them.
unsafe { media_ioc_enum_links(media.as_raw_fd(), &mut request) }
.map_err(|error| io::Error::from_raw_os_error(error as i32))?;
for link in links {
if link.flags & MEDIA_LNK_FL_ENABLED == 0
|| link.flags & MEDIA_LNK_FL_LINK_TYPE != MEDIA_LNK_FL_DATA_LINK
{
continue;
}
graph_links.insert((link.source.entity, link.sink.entity));
}
}
let mut links = graph_links
.into_iter()
.map(|(source, sink)| MediaGraphLink { source, sink })
.collect::<Vec<_>>();
links.sort_by_key(|link| (link.source, link.sink));
Ok((entities, links))
}
fn connected_bridge_entity<'a>(
entities: &'a [MediaGraphEntity],
links: &[MediaGraphLink],
video_device: (u32, u32),
) -> Option<(&'a MediaGraphEntity, CsiBridgeKind)> {
let start = entities
.iter()
.find(|entity| (entity.major, entity.minor) == video_device)?;
let by_id = entities
.iter()
.map(|entity| (entity.id, entity))
.collect::<HashMap<_, _>>();
let mut upstream = HashMap::<u32, Vec<u32>>::new();
for link in links {
// Media data flows source -> sink. Starting at a capture video node,
// only sink -> source traversal can lead to its real input bridge.
upstream.entry(link.sink).or_default().push(link.source);
}
for neighbors in upstream.values_mut() {
neighbors.sort_unstable();
neighbors.dedup();
}
let mut visited = HashSet::from([start.id]);
let mut queue = VecDeque::from([start.id]);
while let Some(id) = queue.pop_front() {
if id != start.id {
if let Some(entity) = by_id.get(&id) {
if entity.major != 0 || entity.minor != 0 {
if let Some(kind) = CsiBridgeKind::from_subdev_name(&entity.name) {
return Some((entity, kind));
}
}
}
}
if let Some(neighbors) = upstream.get(&id) {
for neighbor in neighbors {
if visited.insert(*neighbor) {
queue.push_back(*neighbor);
}
}
}
}
debug!(
"No CSI bridge subdev found in /sys/class/video4linux for {:?}",
video_path
);
None
}
fn read_sysfs_name(subdev_sysfs: &Path) -> Option<String> {
std::fs::read_to_string(subdev_sysfs.join("name"))
.ok()
.map(|s| s.trim().to_string())
fn connected_bridge_in_media_graphs(
graphs: &[MediaGraph],
video_device: (u32, u32),
) -> Option<(&Path, &MediaGraphEntity, CsiBridgeKind)> {
graphs.iter().find_map(|graph| {
connected_bridge_entity(&graph.entities, &graph.links, video_device)
.map(|(entity, kind)| (graph.path.as_path(), entity, kind))
})
}
pub fn open_subdev(path: &Path) -> io::Result<File> {
@@ -339,6 +688,53 @@ pub fn wait_source_change(subdev_fd: &File, timeout: Duration) -> io::Result<boo
mod tests {
use super::*;
fn media_graph(
path: &str,
entities: Vec<MediaGraphEntity>,
links: Vec<MediaGraphLink>,
) -> MediaGraph {
MediaGraph {
path: PathBuf::from(path),
entities,
links,
}
}
fn graph_entity(id: u32, name: &str, device: (u32, u32)) -> MediaGraphEntity {
MediaGraphEntity {
id,
name: name.to_string(),
major: device.0,
minor: device.1,
pads: 0,
links: 0,
}
}
fn graph_link(source: u32, sink: u32) -> MediaGraphLink {
MediaGraphLink { source, sink }
}
#[test]
fn media_uapi_layout_matches_linux_legacy_api() {
assert_eq!(std::mem::size_of::<MediaEntityDesc>(), 256);
assert_eq!(std::mem::size_of::<MediaPadDesc>(), 20);
assert_eq!(std::mem::size_of::<MediaLinkDesc>(), 52);
#[cfg(target_pointer_width = "64")]
assert_eq!(std::mem::size_of::<MediaLinksEnum>(), 40);
#[cfg(target_pointer_width = "32")]
assert_eq!(std::mem::size_of::<MediaLinksEnum>(), 28);
}
#[test]
fn media_device_names_require_a_numeric_suffix() {
assert_eq!(media_device_index("media0"), Some(0));
assert_eq!(media_device_index("media12"), Some(12));
assert_eq!(media_device_index("media"), None);
assert_eq!(media_device_index("media-controller"), None);
assert_eq!(media_device_index("video0"), None);
}
#[test]
fn subdevice_handles_are_non_blocking() {
let file = tempfile::NamedTempFile::new().unwrap();
@@ -348,6 +744,88 @@ mod tests {
assert_ne!(flags & libc::O_NONBLOCK, 0);
}
#[test]
fn media_graph_finds_only_the_connected_rk628() {
// Captured shape of the RK3588 RKCIF graph:
// video0 <- mipi-csi2 <- dphy <- RK628. A second RK628 entity is
// present in the same synthetic topology but is not connected.
let entities = vec![
graph_entity(1, "stream_cif_mipi_id0", (81, 0)),
graph_entity(45, "rockchip-mipi-csi2", (0, 0)),
graph_entity(58, "rockchip-csi2-dphy0", (0, 0)),
graph_entity(63, "m00_b_rk628-csi 3-0050", (81, 16)),
graph_entity(90, "other-rk628-csi 7-0050", (81, 19)),
];
let links = vec![graph_link(63, 58), graph_link(58, 45), graph_link(45, 1)];
let (entity, kind) = connected_bridge_entity(&entities, &links, (81, 0)).unwrap();
assert_eq!(entity.id, 63);
assert_eq!(kind, CsiBridgeKind::Rk628);
}
#[test]
fn media_graph_search_only_walks_towards_link_sources() {
let entities = vec![
graph_entity(1, "stream_cif_mipi_id0", (81, 0)),
graph_entity(20, "rockchip-mipi-csi2", (0, 0)),
graph_entity(63, "unrelated-rk628-csi 7-0050", (81, 19)),
];
// Entity 20 is upstream of the capture node. Entity 63 is downstream
// of 20 and must not be reached while tracing the capture input.
let links = vec![graph_link(20, 1), graph_link(20, 63)];
assert!(connected_bridge_entity(&entities, &links, (81, 0)).is_none());
}
#[test]
fn media_graphs_select_the_controller_containing_the_video_node() {
let graphs = vec![
media_graph(
"/dev/media0",
vec![
graph_entity(1, "other-video", (81, 4)),
graph_entity(63, "wrong-rk628-csi", (81, 16)),
],
vec![graph_link(63, 1)],
),
media_graph(
"/dev/media1",
vec![
graph_entity(10, "stream_cif_mipi_id0", (81, 0)),
graph_entity(75, "tc358743 2-000f", (81, 20)),
],
vec![graph_link(75, 10)],
),
];
let (path, entity, kind) = connected_bridge_in_media_graphs(&graphs, (81, 0)).unwrap();
assert_eq!(path, Path::new("/dev/media1"));
assert_eq!(entity.id, 75);
assert_eq!(kind, CsiBridgeKind::Tc358743);
}
#[test]
fn media_graph_does_not_attach_an_unrelated_rk628_to_native_hdmirx() {
let entities = vec![
graph_entity(1, "rk_hdmirx", (81, 11)),
graph_entity(63, "m00_b_rk628-csi 3-0050", (81, 16)),
];
assert!(connected_bridge_entity(&entities, &[], (81, 11)).is_none());
}
#[test]
fn media_graph_leaves_usb_capture_without_a_csi_bridge() {
let entities = vec![
graph_entity(1, "USB Video: USB Video", (81, 12)),
graph_entity(8, "Processing 2", (0, 0)),
graph_entity(11, "Input 1", (0, 0)),
];
let links = vec![graph_link(11, 8), graph_link(8, 1)];
assert!(connected_bridge_entity(&entities, &links, (81, 12)).is_none());
}
#[test]
fn rk628_fingerprint_matches_vga() {
let mut bt: v4l2_bt_timings = unsafe { std::mem::zeroed() };

View File

@@ -125,6 +125,12 @@ pub fn resolve_video_input_config(
requested_resolution: Resolution,
requested_fps: u32,
) -> ResolvedVideoInputConfig {
let mut resolved = ResolvedVideoInputConfig {
format: requested_format,
resolution: requested_resolution,
fps: requested_fps,
};
if device.control_mode == VideoControlMode::SourceFollowing {
if let VideoInputStatus {
state: VideoInputState::Locked,
@@ -135,26 +141,39 @@ pub fn resolve_video_input_config(
} = &device.input_status
{
if let Ok(format) = format.parse::<PixelFormat>() {
return ResolvedVideoInputConfig {
resolved = ResolvedVideoInputConfig {
format,
resolution: Resolution::new(*width, *height),
fps: fps.round().clamp(1.0, 120.0) as u32,
};
}
}
// Source-following devices do not allow One-KVM to choose the HDMI
// resolution or frame rate, but their pixel format still has to be one
// of the formats enumerated by the capture node. In particular, rkcif
// commonly exposes NV12 but One-KVM's default is MJPEG. Passing that
// unsupported default to S_FMT leaves the pipeline in an invalid state.
if !device.formats.is_empty()
&& !device
.formats
.iter()
.any(|format| format.format == resolved.format)
{
resolved.format = device.formats[0].format;
}
}
ResolvedVideoInputConfig {
format: requested_format,
resolution: requested_resolution,
fps: requested_fps,
}
resolved
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use super::linux::FormatInfo;
#[cfg(unix)]
fn device(control_mode: VideoControlMode, input_status: VideoInputStatus) -> VideoDeviceInfo {
VideoDeviceInfo {
@@ -175,6 +194,15 @@ mod tests {
}
}
#[cfg(unix)]
fn format(format: PixelFormat) -> FormatInfo {
FormatInfo {
format,
resolutions: Vec::new(),
description: format.to_string(),
}
}
#[test]
fn recognizes_vendor_and_upstream_native_hdmirx_names() {
assert!(is_rk_hdmirx_driver("rk_hdmirx", "rk_hdmirx"));
@@ -246,6 +274,48 @@ mod tests {
}
}
#[cfg(unix)]
#[test]
fn source_following_replaces_unenumerated_default_format_without_signal() {
let mut device = device(
VideoControlMode::SourceFollowing,
VideoInputStatus::no_signal(),
);
device.formats = vec![format(PixelFormat::Nv12), format(PixelFormat::Yuyv)];
let resolved = resolve_video_input_config(
&device,
PixelFormat::Mjpeg,
Resolution::new(1920, 1080),
30,
);
assert_eq!(resolved.format, PixelFormat::Nv12);
assert_eq!(resolved.resolution, Resolution::new(1920, 1080));
assert_eq!(resolved.fps, 30);
}
#[cfg(unix)]
#[test]
fn source_following_replaces_stale_active_format_but_keeps_input_mode() {
let mut device = device(
VideoControlMode::SourceFollowing,
VideoInputStatus::locked(PixelFormat::Mjpeg, 1280, 720, 59.94),
);
device.formats = vec![format(PixelFormat::Nv12), format(PixelFormat::Yuyv)];
let resolved = resolve_video_input_config(
&device,
PixelFormat::Mjpeg,
Resolution::new(1920, 1080),
30,
);
assert_eq!(resolved.format, PixelFormat::Nv12);
assert_eq!(resolved.resolution, Resolution::new(1280, 720));
assert_eq!(resolved.fps, 60);
}
#[test]
fn no_signal_and_unavailable_never_expose_stale_mode_fields() {
for status in [

View 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(())
}

View File

@@ -1,5 +1,4 @@
use crate::error::{AppError, Result};
use crate::video::codec::amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder};
use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter};
use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat};
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
@@ -117,47 +116,6 @@ impl VideoEncoderTrait for H265EncoderWrapper {
}
}
struct AmlencEncoderWrapper(AmlencEncoder);
impl VideoEncoderTrait for AmlencEncoderWrapper {
fn encode_raw(&mut self, data: &[u8], _pts_ms: i64) -> Result<Vec<EncodedFrame>> {
Ok(match self.0.encode_raw(data)? {
Some((data, keyframe)) => vec![EncodedFrame {
data,
key: i32::from(keyframe),
}],
None => Vec::new(),
})
}
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
self.0.set_bitrate(bitrate_kbps)
}
fn codec_name(&self) -> &str {
self.0.codec_name()
}
fn request_keyframe(&mut self) {
self.0.request_keyframe()
}
}
fn create_amlenc_encoder(
config: &SharedVideoPipelineConfig,
codec: AmlencCodec,
) -> Result<Box<dyn VideoEncoderTrait + Send>> {
let encoder = AmlencEncoder::new(AmlencConfig {
codec,
resolution: config.resolution,
fps: config.fps,
bitrate_kbps: config.bitrate_kbps(),
gop: config.gop_size(),
})?;
info!("Created native AMLENC encoder: {}", encoder.codec_name());
Ok(Box::new(AmlencEncoderWrapper(encoder)))
}
struct VP8EncoderWrapper(VP8Encoder);
impl VideoEncoderTrait for VP8EncoderWrapper {
@@ -231,9 +189,9 @@ fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, Pix
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
}
/// AMLENC and libjpeg-turbo use independent CPU/hardware resources. Decode
/// MJPEG in the capture worker so encoding the previous NV12 frame can overlap
/// with decoding the next frame.
/// V4L2 M2M hardware encoding and libjpeg-turbo use independent CPU/hardware
/// resources. Decode MJPEG outside the encoder worker so encoding the previous
/// NV12 frame can overlap with decoding subsequent frames.
pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -> bool {
if !config.input_format.is_compressed()
|| !matches!(
@@ -248,7 +206,7 @@ pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -
Some(backend) => registry.encoder_with_backend(config.output_codec, backend),
None => registry.best_available_encoder(config.output_codec),
};
selected.is_some_and(|encoder| encoder.backend == EncoderBackend::Amlogic)
selected.is_some_and(|encoder| encoder.backend == EncoderBackend::V4l2m2m)
}
pub(super) fn build_encoder_state(
@@ -415,80 +373,70 @@ pub(super) fn build_encoder_state(
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
VideoEncoderType::H264 => {
let codec_name = selected_codec_name.clone();
if codec_name == crate::video::codec::amlenc::AMLENC_H264_CODEC_NAME {
create_amlenc_encoder(config, AmlencCodec::H264)?
} else {
let direct_input_format =
h264_direct_input_format(&codec_name, pipeline_input_format);
let input_format = direct_input_format.unwrap_or_else(|| {
if codec_name.contains("libx264") {
H264InputFormat::Yuv420p
} else {
H264InputFormat::Nv12
}
});
if use_rkmpp_direct {
info!(
"Creating H264 encoder with RKMPP backend for {} direct input (codec: {})",
config.input_format, codec_name
);
} else if let Some(ref backend) = config.encoder_backend {
info!(
"Creating H264 encoder with backend {:?} (codec: {})",
backend, codec_name
);
let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format);
let input_format = direct_input_format.unwrap_or_else(|| {
if codec_name.contains("libx264") {
H264InputFormat::Yuv420p
} else {
H264InputFormat::Nv12
}
});
create_h264_encoder(config, input_format, &codec_name)?
if use_rkmpp_direct {
info!(
"Creating H264 encoder with RKMPP backend for {} direct input (codec: {})",
config.input_format, codec_name
);
} else if let Some(ref backend) = config.encoder_backend {
info!(
"Creating H264 encoder with backend {:?} (codec: {})",
backend, codec_name
);
}
create_h264_encoder(config, input_format, &codec_name)?
}
VideoEncoderType::H265 => {
let codec_name = selected_codec_name.clone();
if codec_name == crate::video::codec::amlenc::AMLENC_H265_CODEC_NAME {
create_amlenc_encoder(config, AmlencCodec::H265)?
} else {
let direct_input_format =
h265_direct_input_format(&codec_name, pipeline_input_format);
let input_format = direct_input_format.unwrap_or_else(|| {
if codec_name.contains("libx265") {
H265InputFormat::Yuv420p
} else {
H265InputFormat::Nv12
}
});
if use_rkmpp_direct {
info!(
"Creating H265 encoder with RKMPP backend for {} direct input (codec: {})",
config.input_format, codec_name
);
} else if let Some(ref backend) = config.encoder_backend {
info!(
"Creating H265 encoder with backend {:?} (codec: {})",
backend, codec_name
);
let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format);
let input_format = direct_input_format.unwrap_or_else(|| {
if codec_name.contains("libx265") {
H265InputFormat::Yuv420p
} else {
H265InputFormat::Nv12
}
});
let encoder = H265Encoder::with_codec(
H265Config {
base: EncoderConfig {
resolution: config.resolution,
input_format: config.input_format,
quality: config.bitrate_kbps(),
fps: config.fps,
gop_size: config.gop_size(),
},
bitrate_kbps: config.bitrate_kbps(),
gop_size: config.gop_size(),
fps: config.fps,
input_format,
},
&codec_name,
)?;
info!("Created H265 encoder: {}", encoder.codec_name());
Box::new(H265EncoderWrapper(encoder))
if use_rkmpp_direct {
info!(
"Creating H265 encoder with RKMPP backend for {} direct input (codec: {})",
config.input_format, codec_name
);
} else if let Some(ref backend) = config.encoder_backend {
info!(
"Creating H265 encoder with backend {:?} (codec: {})",
backend, codec_name
);
}
let encoder = H265Encoder::with_codec(
H265Config {
base: EncoderConfig {
resolution: config.resolution,
input_format: config.input_format,
quality: config.bitrate_kbps(),
fps: config.fps,
gop_size: config.gop_size(),
},
bitrate_kbps: config.bitrate_kbps(),
gop_size: config.gop_size(),
fps: config.fps,
input_format,
},
&codec_name,
)?;
info!("Created H265 encoder: {}", encoder.codec_name());
Box::new(H265EncoderWrapper(encoder))
}
VideoEncoderType::VP8 => {
let codec_name = selected_codec_name.clone();
@@ -523,9 +471,7 @@ pub(super) fn build_encoder_state(
};
let codec_name = encoder.codec_name();
let use_direct_input = if codec_name.contains("amlenc") {
pipeline_input_format == PixelFormat::Nv12
} else if codec_name.contains("rkmpp") {
let use_direct_input = if codec_name.contains("rkmpp") {
matches!(
pipeline_input_format,
PixelFormat::Yuyv

View File

@@ -21,6 +21,7 @@ use parking_lot::Mutex as ParkingMutex;
use parking_lot::RwLock as ParkingRwLock;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, Mutex, RwLock};
@@ -28,9 +29,98 @@ use tracing::{debug, error, info, trace, warn};
use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, EncoderThreadState};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
#[path = "dmabuf.rs"]
mod dmabuf;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
fn rkmpp_dma_eligible(
backend: Option<EncoderBackend>,
codec: VideoEncoderType,
format: PixelFormat,
) -> bool {
backend == Some(EncoderBackend::Rkmpp)
&& matches!(codec, VideoEncoderType::H264 | VideoEncoderType::H265)
&& matches!(
format,
PixelFormat::Bgr24
| PixelFormat::Nv12
| PixelFormat::Yuyv
| PixelFormat::Rgb24
| PixelFormat::Mjpeg
)
}
#[cfg(test)]
mod dma_selection_tests {
use super::*;
#[test]
fn only_selected_rkmpp_uses_dma() {
for backend in [
EncoderBackend::Software,
EncoderBackend::Vaapi,
EncoderBackend::Nvenc,
EncoderBackend::Qsv,
EncoderBackend::Amf,
EncoderBackend::V4l2m2m,
] {
for codec in [VideoEncoderType::H264, VideoEncoderType::H265] {
for format in [
PixelFormat::Bgr24,
PixelFormat::Nv12,
PixelFormat::Yuyv,
PixelFormat::Rgb24,
PixelFormat::Mjpeg,
] {
assert!(!rkmpp_dma_eligible(Some(backend), codec, format));
}
}
}
assert!(!rkmpp_dma_eligible(
None,
VideoEncoderType::H264,
PixelFormat::Nv12
));
for codec in [VideoEncoderType::H264, VideoEncoderType::H265] {
for format in [
PixelFormat::Bgr24,
PixelFormat::Nv12,
PixelFormat::Yuyv,
PixelFormat::Rgb24,
PixelFormat::Mjpeg,
] {
assert!(rkmpp_dma_eligible(
Some(EncoderBackend::Rkmpp),
codec,
format
));
}
}
for format in [
PixelFormat::Nv16,
PixelFormat::Nv21,
PixelFormat::Nv24,
PixelFormat::Yuv420,
] {
assert!(!rkmpp_dma_eligible(
Some(EncoderBackend::Rkmpp),
VideoEncoderType::H264,
format
));
}
assert!(!rkmpp_dma_eligible(
Some(EncoderBackend::Rkmpp),
VideoEncoderType::VP9,
PixelFormat::Nv12
));
}
}
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
const AMLENC_MAX_FPS: u32 = 60;
/// After this many consecutive timeouts, log a prominent warning.
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
const CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD: u32 = 3;
@@ -49,21 +139,104 @@ use crate::video::capture::status::{
signal_status_from_capture_kind, CaptureIoErrorKind,
};
use crate::video::capture::{BridgeContext, CaptureReadError, CaptureStream};
use crate::video::codec::h264_bitstream;
use crate::video::codec::registry::{EncoderBackend, VideoEncoderType};
use crate::video::codec::MjpegToNv12Decoder;
use crate::video::codec::{h264_bitstream, h265_bitstream};
use crate::video::device::parse_bridge_kind;
use crate::video::device::VideoControlMode;
use crate::video::format::{PixelFormat, Resolution};
fn amlenc_supported_fps(requested_fps: u32) -> u32 {
requested_fps.min(AMLENC_MAX_FPS)
}
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy};
use crate::video::signal::SignalStatus;
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
struct MjpegDecodeJob {
data: Vec<u8>,
sequence: u64,
}
fn mjpeg_decode_worker_count(available_parallelism: usize) -> usize {
available_parallelism.max(1)
}
fn spawn_mjpeg_decode_workers(
pipeline: &Arc<SharedVideoPipeline>,
latest_frame: &Arc<ParkingRwLock<Option<Arc<VideoFrame>>>>,
frame_seq_tx: &watch::Sender<u64>,
buffer_pool: &Arc<FrameBufferPool>,
resolution: Resolution,
) -> Vec<SyncSender<MjpegDecodeJob>> {
let available = std::thread::available_parallelism()
.map(|count| count.get())
.unwrap_or(1);
let worker_count = mjpeg_decode_worker_count(available);
let mut senders = Vec::with_capacity(worker_count);
for worker_id in 0..worker_count {
// A rendezvous channel deliberately has no queue. If every decoder is
// busy, capture drops the new compressed frame instead of building up
// latency behind stale frames.
let (tx, rx) = sync_channel::<MjpegDecodeJob>(0);
let worker_pipeline = pipeline.clone();
let worker_latest_frame = latest_frame.clone();
let worker_frame_seq_tx = frame_seq_tx.clone();
let worker_buffer_pool = buffer_pool.clone();
let thread_name = format!("mjpeg-decoder-{worker_id}");
let spawn_result = std::thread::Builder::new()
.name(thread_name)
.spawn(move || {
let mut decoder = MjpegToNv12Decoder::new(resolution);
while let Ok(job) = rx.recv() {
let nv12_size = resolution.width as usize * resolution.height as usize * 3 / 2;
let mut nv12 = worker_buffer_pool.take(nv12_size);
let decode_result = decoder.decode_into(&job.data, &mut nv12);
worker_buffer_pool.put(job.data);
if let Err(error) = decode_result {
worker_buffer_pool.put(nv12);
warn!("Dropping undecodable MJPEG frame: {}", error);
continue;
}
if !worker_pipeline.running_flag.load(Ordering::Acquire) {
worker_buffer_pool.put(nv12);
break;
}
let frame = Arc::new(VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(nv12, Some(worker_buffer_pool.clone()))),
resolution,
PixelFormat::Nv12,
resolution.width,
job.sequence,
));
let published = {
let mut latest = worker_latest_frame.write();
if latest
.as_ref()
.is_some_and(|current| current.sequence >= job.sequence)
{
false
} else {
*latest = Some(frame);
true
}
};
if published {
let _ = worker_frame_seq_tx.send(job.sequence.wrapping_add(1));
}
}
});
match spawn_result {
Ok(_) => senders.push(tx),
Err(error) => error!("Failed to start MJPEG decoder worker: {}", error),
}
}
info!("Started {} parallel MJPEG decoder worker(s)", senders.len());
senders
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
@@ -75,7 +248,10 @@ pub struct EncodedVideoFrame {
pub data: Bytes,
/// Presentation timestamp in milliseconds
pub pts_ms: i64,
/// Whether this is a keyframe
/// Whether this frame can initialize a decoder without earlier frames.
///
/// For H.264/H.265 this is stricter than the encoder packet flag: the
/// payload must be IDR/IRAP and include all required parameter sets.
pub is_keyframe: bool,
/// Frame sequence number
pub sequence: u64,
@@ -86,7 +262,9 @@ pub struct EncodedVideoFrame {
}
enum PipelineCmd {
SetBitrate { bitrate_kbps: u32, gop: u32 },
SetBitrate {
preset: crate::video::codec::BitratePreset,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -169,6 +347,15 @@ impl Default for SharedVideoPipelineConfig {
}
impl SharedVideoPipelineConfig {
/// Keep encoder timing aligned with the negotiated HDMI source on every open.
fn align_source_fps(&mut self, source_fps: Option<f64>) {
if self.control_mode == VideoControlMode::SourceFollowing {
if let Some(fps) = source_fps {
self.fps = fps.round().clamp(1.0, 120.0) as u32;
}
}
}
/// Get effective bitrate in kbps
pub fn bitrate_kbps(&self) -> u32 {
self.bitrate_preset.bitrate_kbps()
@@ -280,6 +467,15 @@ pub struct SharedVideoPipelineStats {
pub current_fps: f32,
}
#[derive(Default)]
struct CachedH26xParameterSets {
h264_sps: Option<Vec<u8>>,
h264_pps: Option<Vec<u8>>,
h265_vps: Option<Vec<u8>>,
h265_sps: Option<Vec<u8>>,
h265_pps: Option<Vec<u8>>,
}
/// Universal shared video pipeline
pub struct SharedVideoPipeline {
config: RwLock<SharedVideoPipelineConfig>,
@@ -287,9 +483,8 @@ pub struct SharedVideoPipeline {
stats: Mutex<SharedVideoPipelineStats>,
running: watch::Sender<bool>,
running_rx: watch::Receiver<bool>,
/// Becomes true only after the synchronous encoder worker has dropped its
/// vendor handles. Capture teardown alone is not sufficient for AMLENC:
/// a blocked dequeue/encode can otherwise overlap the next pipeline.
/// Becomes true only after the synchronous encoder worker has exited and
/// dropped its encoder handles.
encoder_done: watch::Sender<bool>,
encoder_done_rx: watch::Receiver<bool>,
h264_profile_level_id: watch::Sender<Option<String>>,
@@ -301,6 +496,9 @@ pub struct SharedVideoPipeline {
sequence: AtomicU64,
/// Atomic flag for keyframe request (avoids lock contention)
keyframe_requested: AtomicBool,
parameter_sets: ParkingMutex<CachedH26xParameterSets>,
/// Most recent random-access frame with all decoder parameter sets.
bootstrap_frame: ParkingRwLock<Option<Arc<EncodedVideoFrame>>>,
/// Pipeline start time for monotonic PTS calculation (microseconds from process start).
/// Uses AtomicI64 instead of Mutex for lock-free access.
pipeline_start_time_us: AtomicI64,
@@ -340,6 +538,8 @@ impl SharedVideoPipeline {
running_flag: AtomicBool::new(false),
sequence: AtomicU64::new(0),
keyframe_requested: AtomicBool::new(false),
parameter_sets: ParkingMutex::new(CachedH26xParameterSets::default()),
bootstrap_frame: ParkingRwLock::new(None),
pipeline_start_time_us: AtomicI64::new(0),
pending_sync_geometry: ParkingMutex::new(None),
device_lost_reason: ParkingMutex::new(None),
@@ -398,6 +598,9 @@ impl SharedVideoPipeline {
// Keep at most one pending frame so a slow WebRTC writer cannot make
// the encoder wait or accumulate seconds of latency.
let (tx, rx) = mpsc::channel(1);
if let Some(frame) = self.bootstrap_frame.read().clone() {
let _ = tx.try_send(frame);
}
self.subscribers.write().push(tx);
rx
}
@@ -436,14 +639,13 @@ impl SharedVideoPipeline {
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
match cmd {
PipelineCmd::SetBitrate { bitrate_kbps, gop } => {
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
let _ = gop;
PipelineCmd::SetBitrate { preset } => {
let bitrate_kbps = preset.bitrate_kbps();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if state.ffmpeg_hw_enabled {
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
pipeline
.reconfigure(bitrate_kbps as i32, gop as i32)
.reconfigure(bitrate_kbps as i32, preset.gop_size(state.fps) as i32)
.map_err(|e| {
let detail = if e.is_empty() {
ffmpeg_hw_last_error()
@@ -512,7 +714,105 @@ impl SharedVideoPipeline {
let _ = self.h264_profile_level_id.send(Some(profile_level_id));
}
fn inspect_and_parameterize_packet(
&self,
codec: VideoEncoderType,
data: Bytes,
ffmpeg_keyframe: bool,
) -> (Bytes, bool) {
match codec {
VideoEncoderType::H264 => {
let was_annex_b = h264_bitstream::is_annex_b(data.as_ref());
let data = h264_bitstream::normalize_annex_b(data);
if !was_annex_b && h264_bitstream::is_annex_b(data.as_ref()) {
debug!("[Pipeline] Converted length-prefixed H264 packet to Annex-B");
}
let (sps, pps) = h264_bitstream::extract_sps_pps(data.as_ref());
// Require metadata and payload to agree before advertising a
// decoder bootstrap frame.
let is_idr = ffmpeg_keyframe && h264_bitstream::is_keyframe(data.as_ref());
let mut cache = self.parameter_sets.lock();
if let Some(sps) = sps.as_ref() {
cache.h264_sps = Some(sps.clone());
}
if let Some(pps) = pps.as_ref() {
cache.h264_pps = Some(pps.clone());
}
if !is_idr {
return (data, false);
}
if sps.is_some() && pps.is_some() {
return (data, true);
}
match (&cache.h264_sps, &cache.h264_pps) {
(Some(cached_sps), Some(cached_pps)) => {
let mut output = Vec::with_capacity(
data.len() + cached_sps.len() + cached_pps.len() + 8,
);
output.extend_from_slice(&[0, 0, 0, 1]);
output.extend_from_slice(cached_sps);
output.extend_from_slice(&[0, 0, 0, 1]);
output.extend_from_slice(cached_pps);
output.extend_from_slice(data.as_ref());
debug!("[Pipeline] Prepended cached SPS/PPS to H264 IDR");
(Bytes::from(output), true)
}
// An IDR without SPS/PPS is not a decoder bootstrap frame.
_ => (data, false),
}
}
VideoEncoderType::H265 => {
let (vps, sps, pps) = h265_bitstream::extract_vps_sps_pps(data.as_ref());
let is_irap = ffmpeg_keyframe && h265_bitstream::is_keyframe(data.as_ref());
let mut cache = self.parameter_sets.lock();
if let Some(vps) = vps.as_ref() {
cache.h265_vps = Some(vps.clone());
}
if let Some(sps) = sps.as_ref() {
cache.h265_sps = Some(sps.clone());
}
if let Some(pps) = pps.as_ref() {
cache.h265_pps = Some(pps.clone());
}
if !is_irap {
return (data, false);
}
if vps.is_some() && sps.is_some() && pps.is_some() {
return (data, true);
}
match (&cache.h265_vps, &cache.h265_sps, &cache.h265_pps) {
(Some(cached_vps), Some(cached_sps), Some(cached_pps)) => {
let mut output = Vec::with_capacity(
data.len()
+ cached_vps.len()
+ cached_sps.len()
+ cached_pps.len()
+ 12,
);
for parameter_set in [cached_vps, cached_sps, cached_pps] {
output.extend_from_slice(&[0, 0, 0, 1]);
output.extend_from_slice(parameter_set);
}
output.extend_from_slice(data.as_ref());
debug!("[Pipeline] Prepended cached VPS/SPS/PPS to H265 IRAP");
(Bytes::from(output), true)
}
_ => (data, false),
}
}
_ => (data, ffmpeg_keyframe),
}
}
fn broadcast_encoded(&self, frame: Arc<EncodedVideoFrame>) {
if frame.is_keyframe {
*self.bootstrap_frame.write() = Some(frame.clone());
}
let subscribers = {
let guard = self.subscribers.read();
if guard.is_empty() {
@@ -552,19 +852,11 @@ impl SharedVideoPipeline {
return Ok(());
}
*self.parameter_sets.lock() = CachedH26xParameterSets::default();
*self.bootstrap_frame.write() = None;
let mut config = self.config.read().await.clone();
let parallel_mjpeg_decode = should_parallel_decode_mjpeg(&config);
if parallel_mjpeg_decode {
let stable_fps = amlenc_supported_fps(config.fps);
if stable_fps != config.fps {
warn!(
"Limiting S912 AMLENC capture at {}x{} from {} to {} fps (hardware limit)",
config.resolution.width, config.resolution.height, config.fps, stable_fps
);
config.fps = stable_fps;
*self.config.write().await = config.clone();
}
}
{
let mut last = self.last_state_notification.lock();
*last = None;
@@ -575,7 +867,8 @@ impl SharedVideoPipeline {
subdev_path.clone(),
parse_bridge_kind(bridge_kind.as_deref()),
);
let preopened: Option<CaptureStream> = match open_capture_stream(
#[allow(unused_mut)]
let mut preopened: Option<CaptureStream> = match open_capture_stream(
&device_path,
config.resolution,
config.input_format,
@@ -589,16 +882,9 @@ impl SharedVideoPipeline {
let negotiated_res = s.resolution();
let negotiated_fmt = s.format();
let previous = (config.resolution, config.input_format, config.fps);
if config.control_mode == VideoControlMode::SourceFollowing {
if let Some(source_fps) = s.source_fps() {
config.fps = source_fps.round().clamp(1.0, 120.0) as u32;
}
}
config.align_source_fps(s.source_fps());
config.resolution = negotiated_res;
config.input_format = negotiated_fmt;
if parallel_mjpeg_decode {
config.fps = amlenc_supported_fps(config.fps);
}
if previous != (config.resolution, config.input_format, config.fps) {
info!(
"Negotiated capture {}x{} {:?} @ {} fps (configured {}x{} {:?} @ {} fps) — aligning encoder to source",
@@ -633,10 +919,36 @@ impl SharedVideoPipeline {
Err(e) => return Err(e),
};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
if dmabuf::eligible(&config) {
if let Some(stream) = preopened.as_ref().filter(|s| s.supports_rkmpp_dmabuf()) {
match dmabuf::prepare(stream, &config) {
Ok(encoder) => {
return dmabuf::start(
self.clone(),
preopened.take().expect("preopened DMA capture"),
encoder,
config,
device_path,
buffer_count,
BridgeContext::from_parts(
subdev_path,
parse_bridge_kind(bridge_kind.as_deref()),
),
);
}
Err(error) => warn!(
"RKMPP DMA unavailable; using existing copy pipeline: {}",
error
),
}
}
}
let mut encoder_config = config.clone();
if parallel_mjpeg_decode {
encoder_config.input_format = PixelFormat::Nv12;
info!("Using capture-thread libyuv MJPEG decode with parallel AMLENC encoding");
info!("Using parallel libyuv MJPEG decode with hardware encoding");
}
let mut encoder_state = build_encoder_state(&encoder_config)?;
let _ = self.running.send(true);
@@ -741,8 +1053,7 @@ impl SharedVideoPipeline {
}
pipeline.clear_cmd_tx();
// Dropping encoder_state here releases AMLENC before a caller
// is allowed to construct a replacement pipeline.
// Release encoder resources before allowing a replacement pipeline.
drop(encoder_state);
let _ = pipeline.encoder_done.send(true);
});
@@ -763,8 +1074,25 @@ impl SharedVideoPipeline {
let mut pixel_format = config.input_format;
let mut active_fps = config.fps;
let mut stride: u32 = 0;
let mut mjpeg_decoder =
parallel_mjpeg_decode.then(|| MjpegToNv12Decoder::new(config.resolution));
let mut mjpeg_decode_senders = parallel_mjpeg_decode
.then(|| {
spawn_mjpeg_decode_workers(
&pipeline,
&latest_frame,
&frame_seq_tx,
&buffer_pool,
config.resolution,
)
})
.filter(|senders| !senders.is_empty());
let mut next_mjpeg_decoder = 0usize;
let mut mjpeg_decoder = parallel_mjpeg_decode
.then(|| MjpegToNv12Decoder::new(config.resolution))
.filter(|_| {
mjpeg_decode_senders
.as_ref()
.is_none_or(|senders| senders.is_empty())
});
if let Some(s) = preopened {
resolution = s.resolution();
@@ -1109,6 +1437,32 @@ impl SharedVideoPipeline {
pixel_format,
active_fps,
));
if let Some(senders) = mjpeg_decode_senders.as_mut() {
let mut pending = Some(MjpegDecodeJob {
data: owned,
sequence: meta.sequence,
});
for offset in 0..senders.len() {
let index = (next_mjpeg_decoder + offset) % senders.len();
let job = pending.take().expect("pending MJPEG decode job");
match senders[index].try_send(job) {
Ok(()) => {
next_mjpeg_decoder = (index + 1) % senders.len();
break;
}
Err(TrySendError::Full(job))
| Err(TrySendError::Disconnected(job)) => {
pending = Some(job);
}
}
}
if let Some(job) = pending {
buffer_pool.put(job.data);
}
continue;
}
let (frame_data, frame_format, frame_stride) =
if let Some(decoder) = mjpeg_decoder.as_mut() {
let nv12_size =
@@ -1169,23 +1523,7 @@ impl SharedVideoPipeline {
let input_format = state.input_format;
let raw_frame = frame.data();
let process_start = PROCESS_START.get_or_init(Instant::now);
let current_ts_us = process_start.elapsed().as_micros() as i64;
let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire);
let pts_ms = if start_ts_us == 0 {
let start_ts_us = match self.pipeline_start_time_us.compare_exchange(
0,
current_ts_us,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => current_ts_us,
Err(existing) => existing,
};
current_ts_us.saturating_sub(start_ts_us) / 1000
} else {
current_ts_us.saturating_sub(start_ts_us) / 1000
};
let pts_ms = self.pts_ms();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if state.ffmpeg_hw_enabled {
@@ -1213,9 +1551,11 @@ impl SharedVideoPipeline {
})?;
if let Some((data, is_keyframe)) = packet {
let (data, is_keyframe) =
self.inspect_and_parameterize_packet(codec, Bytes::from(data), is_keyframe);
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
return Ok(vec![EncodedVideoFrame {
data: Bytes::from(data),
data,
pts_ms,
is_keyframe,
sequence,
@@ -1295,14 +1635,15 @@ impl SharedVideoPipeline {
let mut encoded_frames = Vec::with_capacity(frames.len());
for encoded in frames {
let is_keyframe = encoded.key == 1;
let (data, is_keyframe) =
self.inspect_and_parameterize_packet(codec, encoded.data, encoded.key == 1);
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
if codec == VideoEncoderType::H264 {
self.update_h264_profile_level_id(&encoded.data);
self.update_h264_profile_level_id(&data);
}
encoded_frames.push(EncodedVideoFrame {
data: encoded.data,
data,
pts_ms,
is_keyframe,
sequence,
@@ -1317,6 +1658,28 @@ impl SharedVideoPipeline {
}
}
fn pts_ms(&self) -> i64 {
let current_ts_us = PROCESS_START
.get_or_init(Instant::now)
.elapsed()
.as_micros() as i64;
let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire);
let start_ts_us = if start_ts_us == 0 {
match self.pipeline_start_time_us.compare_exchange(
0,
current_ts_us,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => current_ts_us,
Err(existing) => existing,
}
} else {
start_ts_us
};
current_ts_us.saturating_sub(start_ts_us) / 1000
}
/// Stop the pipeline (non-blocking, does not wait for capture thread to exit)
pub fn stop(&self) {
if self.running_flag.swap(false, Ordering::AcqRel) {
@@ -1396,13 +1759,11 @@ impl SharedVideoPipeline {
&self,
preset: crate::video::codec::BitratePreset,
) -> Result<()> {
let bitrate_kbps = preset.bitrate_kbps();
let gop = {
{
let mut config = self.config.write().await;
config.bitrate_preset = preset;
config.gop_size()
};
self.send_cmd(PipelineCmd::SetBitrate { bitrate_kbps, gop });
}
self.send_cmd(PipelineCmd::SetBitrate { preset });
Ok(())
}
@@ -1597,6 +1958,60 @@ mod tests {
use super::*;
use crate::video::codec::BitratePreset;
#[tokio::test]
async fn bitrate_commands_preserve_custom_values_and_gop_policy() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::default()).unwrap();
let (tx, mut rx) = mpsc::unbounded_channel();
*pipeline.cmd_tx.write() = Some(tx);
for preset in [
BitratePreset::Custom(2500),
BitratePreset::Custom(1000),
BitratePreset::Speed,
BitratePreset::Quality,
] {
pipeline.set_bitrate_preset(preset).await.unwrap();
let PipelineCmd::SetBitrate { preset: received } = rx.try_recv().unwrap();
assert_eq!(received, preset);
assert_eq!(pipeline.config().await.bitrate_preset, preset);
// Rebuilt encoders must retain the preset's policy at the new FPS.
let restored = SharedVideoPipelineConfig {
bitrate_preset: received,
fps: 60,
..Default::default()
};
assert_eq!(restored.bitrate_kbps(), preset.bitrate_kbps());
assert_eq!(restored.gop_size(), preset.gop_size(60));
}
}
#[test]
fn source_reopen_updates_fps_and_gop_without_changing_geometry_or_bitrate() {
let mut config = SharedVideoPipelineConfig {
control_mode: VideoControlMode::SourceFollowing,
resolution: Resolution::HD1080,
fps: 60,
bitrate_preset: BitratePreset::Quality,
..Default::default()
};
config.align_source_fps(Some(29.97));
assert_eq!(config.fps, 30);
assert_eq!(config.gop_size(), 60);
assert_eq!(config.resolution, Resolution::HD1080);
assert_eq!(config.bitrate_kbps(), 8000);
config.align_source_fps(None);
assert_eq!(config.fps, 30);
config.align_source_fps(Some(59.94));
assert_eq!(config.fps, 60);
assert_eq!(config.gop_size(), 120);
}
#[test]
fn configurable_capture_keeps_requested_fps() {
let mut config = SharedVideoPipelineConfig::default();
config.align_source_fps(Some(60.0));
assert_eq!(config.fps, 30);
}
#[test]
fn test_pipeline_config() {
let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, BitratePreset::Balanced);
@@ -1604,11 +2019,136 @@ mod tests {
let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, BitratePreset::Speed);
assert_eq!(h265.output_codec, VideoEncoderType::H265);
}
assert_eq!(amlenc_supported_fps(30), 30);
assert_eq!(amlenc_supported_fps(50), 50);
assert_eq!(amlenc_supported_fps(60), 60);
assert_eq!(amlenc_supported_fps(120), 60);
#[test]
fn h264_keyframe_requires_idr_and_parameter_sets() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let predicted = Bytes::from_static(&[0, 0, 0, 1, 0x41, 0xc0]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, predicted, true);
assert!(
!key,
"a driver flag must not turn a P-frame into a keyframe"
);
let parameter_sets =
Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, parameter_sets, false);
assert!(!key, "parameter sets alone are not a keyframe");
let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]);
let (_, key) = pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, false);
assert!(!key, "an IDR without a driver key flag is not trusted");
let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]);
let (bootstrap, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, true);
assert!(
key,
"matching driver metadata and IDR payload should bootstrap"
);
assert!(h264_bitstream::has_sps_pps(bootstrap.as_ref()));
assert!(h264_bitstream::is_keyframe(bootstrap.as_ref()));
}
#[test]
fn h265_keyframe_requires_irap_and_parameter_sets() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h265(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let trail = Bytes::from_static(&[0, 0, 0, 1, 1 << 1, 1, 0xaa]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, trail, true);
assert!(
!key,
"a driver flag must not turn a trailing frame into a keyframe"
);
let parameter_sets = Bytes::from_static(&[
0,
0,
0,
1,
32 << 1,
1,
0xaa,
0,
0,
0,
1,
33 << 1,
1,
0xbb,
0,
0,
0,
1,
34 << 1,
1,
0xcc,
]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, parameter_sets, false);
assert!(!key, "parameter sets alone are not a keyframe");
let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, false);
assert!(!key, "an IRAP without a driver key flag is not trusted");
let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]);
let (bootstrap, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, true);
assert!(
key,
"matching driver metadata and IRAP payload should bootstrap"
);
assert!(h265_bitstream::has_vps_sps_pps(bootstrap.as_ref()));
assert!(h265_bitstream::is_keyframe(bootstrap.as_ref()));
}
#[test]
fn new_subscriber_receives_cached_bootstrap_frame() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let bootstrap = Arc::new(EncodedVideoFrame {
data: Bytes::from_static(&[
0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce, 0, 0, 0, 1, 0x65, 0x88,
]),
pts_ms: 0,
is_keyframe: true,
sequence: 1,
duration: Duration::from_millis(33),
codec: VideoEncoderType::H264,
});
pipeline.broadcast_encoded(bootstrap.clone());
let mut subscriber = pipeline.subscribe();
let received = subscriber
.try_recv()
.expect("cached bootstrap frame should seed the subscriber queue");
assert!(Arc::ptr_eq(&received, &bootstrap));
}
#[test]
fn mjpeg_workers_match_available_cpu_count() {
assert_eq!(mjpeg_decode_worker_count(1), 1);
assert_eq!(mjpeg_decode_worker_count(2), 2);
assert_eq!(mjpeg_decode_worker_count(4), 4);
assert_eq!(mjpeg_decode_worker_count(64), 64);
}
#[test]

View File

@@ -195,25 +195,15 @@ impl VideoStreamManager {
info!("Initializing video stream manager with mode: {:?}", mode);
*self.mode.write().await = mode.clone();
// Check if streamer is already initialized (capturer exists)
let needs_init = self.streamer.state().await == StreamerState::Uninitialized;
// A failed fixed-device configuration can leave the streamer in a transient
// state without a capture device. Treat that the same as an uninitialized
// streamer so the advertised auto-detection fallback actually runs.
let state = self.streamer.state().await;
let (device_path, _, _, _, _) = self.streamer.current_capture_config().await;
let needs_init = state == StreamerState::Uninitialized || device_path.is_none();
if needs_init {
match mode {
StreamMode::Mjpeg => {
// Initialize MJPEG streamer
if let Err(e) = self.streamer.init_auto().await {
warn!("Failed to auto-initialize MJPEG streamer: {}", e);
}
}
StreamMode::WebRTC => {
// WebRTC is initialized on-demand when clients connect
// But we still need to initialize the video capture
if let Err(e) = self.streamer.init_auto().await {
warn!("Failed to auto-initialize video capture for WebRTC: {}", e);
}
}
}
self.streamer.init_auto().await?;
}
self.sync_webrtc_capture_source("after init").await;

View File

@@ -171,7 +171,7 @@ pub async fn atx_wol(
// Send WOL packet
crate::atx::send_wol(&mac_address, interface)?;
if let Err(error) = crate::atx::record_wol_history(state.db.pool(), &mac_address).await {
if let Err(error) = state.db.wol_history().record(&mac_address).await {
warn!("Failed to persist WOL history: {}", error);
}
@@ -191,7 +191,7 @@ pub async fn atx_wol_history(
.unwrap_or(WOL_HISTORY_DEFAULT_LIMIT)
.clamp(1, WOL_HISTORY_MAX_LIMIT);
let rows = crate::atx::list_wol_history(state.db.pool(), limit).await?;
let rows = state.db.wol_history().list(limit).await?;
let history = rows
.into_iter()

View File

@@ -2,96 +2,9 @@ use std::sync::Arc;
use crate::config::*;
use crate::error::{AppError, Result};
use crate::rtsp::RtspService;
pub use crate::runtime::{try_apply_lock, ConfigApplyOptions};
use crate::state::AppState;
use crate::stream_encoder::encoder_type_to_backend;
use crate::video::codec_constraints::{
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
StreamCodecConstraints,
};
use tokio::sync::{Mutex, OwnedMutexGuard};
#[derive(Debug, Clone, Copy, Default)]
pub struct ConfigApplyOptions {
pub force: bool,
pub preserve_service_state: bool,
pub runtime_only: bool,
}
impl ConfigApplyOptions {
pub const fn forced() -> Self {
Self {
force: true,
preserve_service_state: false,
runtime_only: false,
}
}
pub const fn preserving_service_state() -> Self {
Self {
force: false,
preserve_service_state: true,
runtime_only: false,
}
}
pub const fn runtime_only() -> Self {
Self {
force: false,
preserve_service_state: false,
runtime_only: true,
}
}
}
pub fn try_apply_lock(lock: &Arc<Mutex<()>>, domain: &str) -> Result<OwnedMutexGuard<()>> {
lock.clone().try_lock_owned().map_err(|_| {
AppError::ServiceUnavailable(format!("{domain} configuration is already applying"))
})
}
fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType {
match config.backend {
HidBackend::Otg => crate::hid::HidBackendType::Otg,
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
port: config.ch9329_port.clone(),
baud_rate: config.ch9329_baudrate,
hybrid_mouse: config.ch9329_hybrid_mouse,
},
HidBackend::None => crate::hid::HidBackendType::None,
}
}
fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool {
old_config.backend == HidBackend::Otg
|| new_config.backend == HidBackend::Otg
|| old_config.otg_udc != new_config.otg_udc
|| old_config.otg_descriptor != new_config.otg_descriptor
|| old_config.constrained_otg_functions() != new_config.constrained_otg_functions()
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds()
}
async fn reconcile_otg_config(
state: &Arc<AppState>,
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &UacConfig,
) -> Result<()> {
#[cfg(not(unix))]
{
let _ = (state, hid, msd, network, uac);
Ok(())
}
#[cfg(unix)]
{
state
.otg_service
.apply_config(hid, msd, network, uac)
.await
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
}
}
pub async fn apply_video_config(
state: &Arc<AppState>,
@@ -189,305 +102,6 @@ pub async fn apply_stream_config(
Ok(())
}
pub async fn apply_hid_config(
state: &Arc<AppState>,
old_config: &HidConfig,
new_config: &HidConfig,
msd_config: &MsdConfig,
network_config: &OtgNetworkConfig,
uac_config: &UacConfig,
options: ConfigApplyOptions,
) -> Result<()> {
new_config.validate_otg_functions()?;
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let old_hid_functions = old_config.constrained_otg_functions();
let new_hid_functions = new_config.constrained_otg_functions();
let hid_functions_changed = old_hid_functions != new_hid_functions;
let keyboard_leds_changed =
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse;
if old_config.backend == new_config.backend
&& old_config.ch9329_port == new_config.ch9329_port
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
&& !ch9329_runtime_changed
&& old_config.otg_udc == new_config.otg_udc
&& !descriptor_changed
&& !hid_functions_changed
&& !keyboard_leds_changed
&& !options.force
{
tracing::info!("HID config unchanged, skipping reload");
return Ok(());
}
tracing::info!("Applying HID config changes...");
let new_hid_backend = hid_backend_type(new_config);
let transitioning_away_from_otg =
old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg;
let otg_config_changed = hid_otg_config_changed(old_config, new_config);
if transitioning_away_from_otg {
state
.hid
.reload(new_hid_backend.clone())
.await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
}
if otg_config_changed {
reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?;
}
if !transitioning_away_from_otg {
state
.hid
.reload(new_hid_backend)
.await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
}
tracing::info!(
"HID backend reloaded successfully: {:?}",
new_config.backend
);
Ok(())
}
#[cfg(unix)]
pub async fn apply_msd_config(
state: &Arc<AppState>,
old_config: &MsdConfig,
new_config: &MsdConfig,
hid_config: &HidConfig,
network_config: &OtgNetworkConfig,
uac_config: &UacConfig,
options: ConfigApplyOptions,
) -> Result<()> {
let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
let effective_new_msd_enabled = new_config.enabled && hid_backend_is_otg;
tracing::info!("MSD config sent, checking if reload needed...");
tracing::debug!("Old MSD config: {:?}", old_config);
tracing::debug!("New MSD config: {:?}", new_config);
let old_msd_enabled = old_config.enabled;
let new_msd_enabled = effective_new_msd_enabled;
let msd_dir_changed = old_config.msd_dir != new_config.msd_dir;
let inquiry_strings_changed = old_config.flash_inquiry_string
!= new_config.flash_inquiry_string
|| old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string;
tracing::info!(
"MSD enabled: old={}, new={}",
old_msd_enabled,
new_msd_enabled
);
if msd_dir_changed {
tracing::info!("MSD directory changed: {}", new_config.msd_dir);
}
if inquiry_strings_changed {
tracing::info!("MSD inquiry strings changed");
}
let msd_dir = new_config.msd_dir_path();
if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) {
tracing::warn!("Failed to create MSD images directory: {}", e);
}
if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) {
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
}
let needs_reload = options.force
|| old_msd_enabled != new_msd_enabled
|| msd_dir_changed
|| inquiry_strings_changed;
if !needs_reload {
tracing::info!("MSD configuration unchanged, no reload needed");
return Ok(());
}
if new_msd_enabled {
tracing::info!("(Re)initializing MSD...");
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() {
msd.shutdown()
.await
.map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?;
}
*msd_guard = None;
drop(msd_guard);
let msd =
crate::msd::MsdController::new(state.otg_service.clone(), new_config.msd_dir_path());
let ventoy_resource_dir = state.data_dir().join("ventoy");
msd.init(&ventoy_resource_dir)
.await
.map_err(|e| AppError::Config(format!("MSD initialization failed: {}", e)))?;
let events = state.events.clone();
msd.set_event_bus(events).await;
*state.msd.write().await = Some(msd);
tracing::info!("MSD initialized successfully");
} else {
tracing::info!("MSD disabled in config, shutting down...");
let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() {
msd.shutdown()
.await
.map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?;
}
*msd_guard = None;
tracing::info!("MSD shutdown complete");
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
}
if hid_config.backend == HidBackend::Otg
&& (options.force || old_msd_enabled != new_msd_enabled)
{
state
.hid
.reload(crate::hid::HidBackendType::Otg)
.await
.map_err(|e| AppError::Config(format!("OTG HID reload failed: {}", e)))?;
}
Ok(())
}
pub async fn apply_usb_config(
state: &Arc<AppState>,
old_config: &AppConfig,
new_config: &AppConfig,
) -> Result<()> {
#[cfg(unix)]
{
let transitioning_away_from_otg =
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
let hid_unchanged = old_config.hid == new_config.hid;
let otg_gadget_rebuilt = old_config.msd != new_config.msd
|| old_config.otg_network != new_config.otg_network
|| old_config.uac != new_config.uac
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|| old_config.hid.backend != new_config.hid.backend
|| old_config.hid.constrained_otg_functions()
!= new_config.hid.constrained_otg_functions()
|| old_config.hid.effective_otg_keyboard_leds()
!= new_config.hid.effective_otg_keyboard_leds();
let restart_uac_playback =
old_config.uac != new_config.uac || (new_config.uac.enabled && otg_gadget_rebuilt);
// A bound ALSA handle refers to the old configfs function. Stop it
// before any gadget teardown so the worker cannot write through a
// disappearing PCM node. It is restarted only after every reconcile.
if restart_uac_playback {
let playback = state.uac_playback.write().await.take();
if let Some(playback) = playback {
playback.stop();
tracing::info!("UAC playback writer stopped before OTG reconcile");
}
}
if transitioning_away_from_otg {
apply_hid_config(
state,
&old_config.hid,
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await?;
} else {
reconcile_otg_config(
state,
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
)
.await?;
apply_hid_config(
state,
&old_config.hid,
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await?;
}
// When the OTG gadget was rebuilt due to MSD or network config changes
// while HID config stayed the same, the /dev/hidg* devices are new and
// the HID backend must be reloaded to reopen them.
if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg {
tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices");
let hid_backend = hid_backend_type(&new_config.hid);
state.hid.reload(hid_backend).await.map_err(|e| {
AppError::Config(format!("HID reload after gadget rebuild failed: {}", e))
})?;
}
apply_msd_config(
state,
&old_config.msd,
&new_config.msd,
&new_config.hid,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await?;
// apply_msd_config may perform a second gadget reconcile. Resolve the
// new ALSA card only after that final rebuild, then publish the worker.
if restart_uac_playback && new_config.uac.enabled {
let config = crate::audio::uac::UacPlaybackConfig {
sample_rate: new_config.uac.sample_rate,
channels: new_config.uac.channels as u16,
..Default::default()
};
let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| {
AppError::Config(format!("Failed to start UAC playback: {error}"))
})?;
*state.uac_playback.write().await = Some(writer);
tracing::info!("UAC playback writer started after OTG reconcile");
} else if restart_uac_playback {
tracing::info!("UAC playback remains disabled");
}
Ok(())
}
#[cfg(not(unix))]
{
apply_hid_config(
state,
&old_config.hid,
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await
}
}
pub async fn apply_atx_config(
state: &Arc<AppState>,
_old_config: &AtxConfig,
@@ -554,336 +168,3 @@ pub async fn apply_audio_config(
Ok(())
}
pub async fn enforce_stream_codec_constraints(state: &Arc<AppState>) -> Result<Option<String>> {
let config = state.runtime_third_party_config().await;
let constraints = StreamCodecConstraints::from_config(&config);
state
.stream_manager
.set_runtime_codec_constraints(constraints.clone())
.await;
let enforcement =
enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await?;
Ok(enforcement.message)
}
async fn validate_runtime_candidate<T>(
state: &Arc<AppState>,
apply: impl FnOnce(&mut crate::config::AppConfig, T),
config: T,
) -> Result<()> {
let mut candidate = state.runtime_third_party_config().await;
apply(&mut candidate, config);
validate_third_party_codec_compatibility(&candidate)
}
fn validate_rustdesk_candidate(
state: &Arc<AppState>,
new_config: &crate::rustdesk::config::RustDeskConfig,
) -> Result<()> {
let mut candidate = state.config.get().as_ref().clone();
candidate.rustdesk = new_config.clone();
validate_third_party_codec_compatibility(&candidate)
}
fn validate_vnc_candidate(state: &Arc<AppState>, new_config: &VncConfig) -> Result<()> {
let mut candidate = state.config.get().as_ref().clone();
candidate.vnc = new_config.clone();
validate_third_party_codec_compatibility(&candidate)
}
fn validate_rtsp_candidate(state: &Arc<AppState>, new_config: &RtspConfig) -> Result<()> {
let mut candidate = state.config.get().as_ref().clone();
candidate.rtsp = new_config.clone();
validate_third_party_codec_compatibility(&candidate)
}
pub async fn apply_rustdesk_config(
state: &Arc<AppState>,
old_config: &crate::rustdesk::config::RustDeskConfig,
new_config: &crate::rustdesk::config::RustDeskConfig,
options: ConfigApplyOptions,
) -> Result<()> {
tracing::info!("Applying RustDesk config changes...");
if options.runtime_only {
validate_runtime_candidate(
state,
|candidate, config| candidate.rustdesk = config,
new_config.clone(),
)
.await?;
} else {
validate_rustdesk_candidate(state, new_config)?;
}
let mut rustdesk_guard = state.rustdesk.write().await;
let mut credentials_to_save = None;
let need_restart = options.force
|| old_config.codec != new_config.codec
|| old_config.rendezvous_server != new_config.rendezvous_server
|| old_config.device_id != new_config.device_id
|| old_config.device_password != new_config.device_password;
if !options.preserve_service_state && !new_config.enabled {
if let Some(ref service) = *rustdesk_guard {
service
.stop()
.await
.map_err(|e| AppError::Config(format!("Failed to stop RustDesk service: {}", e)))?;
tracing::info!("RustDesk service stopped");
}
*rustdesk_guard = None;
}
if !options.preserve_service_state && new_config.enabled {
if rustdesk_guard.is_none() {
tracing::info!("Initializing RustDesk service...");
let service = std::sync::Arc::new(crate::rustdesk::RustDeskService::new(
new_config.clone(),
state.stream_manager.clone(),
state.hid.clone(),
state.audio.clone(),
));
*rustdesk_guard = Some(service.clone());
service.start().await.map_err(|e| {
AppError::Config(format!("Failed to start RustDesk service: {}", e))
})?;
tracing::info!("RustDesk service started with ID: {}", new_config.device_id);
credentials_to_save = service.save_credentials();
} else {
if let Some(ref service) = *rustdesk_guard {
if service.is_listening() {
if need_restart {
service.restart(new_config.clone()).await.map_err(|e| {
AppError::Config(format!("Failed to restart RustDesk service: {}", e))
})?;
tracing::info!(
"RustDesk service restarted with ID: {}",
new_config.device_id
);
}
} else {
service.update_config(new_config.clone());
service.start().await.map_err(|e| {
AppError::Config(format!("Failed to start RustDesk service: {}", e))
})?;
}
credentials_to_save = service.save_credentials();
}
}
} else if options.preserve_service_state && need_restart {
if let Some(ref service) = *rustdesk_guard {
let mut runtime_config = new_config.clone();
runtime_config.enabled = true;
service.restart(runtime_config).await.map_err(|e| {
AppError::Config(format!("Failed to restart RustDesk service: {}", e))
})?;
credentials_to_save = service.save_credentials();
}
}
drop(rustdesk_guard);
if let Some(updated_config) = credentials_to_save {
tracing::info!("Saving RustDesk credentials to config store...");
state
.config
.update(|cfg| {
cfg.rustdesk.public_key = updated_config.public_key.clone();
cfg.rustdesk.private_key = updated_config.private_key.clone();
cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone();
cfg.rustdesk.signing_private_key = updated_config.signing_private_key.clone();
cfg.rustdesk.uuid = updated_config.uuid.clone();
})
.await?;
tracing::info!("RustDesk credentials saved successfully");
}
if let Some(message) = enforce_stream_codec_constraints(state).await? {
tracing::info!("{}", message);
}
Ok(())
}
pub async fn apply_vnc_config(
state: &Arc<AppState>,
old_config: &VncConfig,
new_config: &VncConfig,
options: ConfigApplyOptions,
) -> Result<()> {
tracing::info!("Applying VNC config changes...");
if options.runtime_only {
validate_runtime_candidate(
state,
|candidate, config| candidate.vnc = config,
new_config.clone(),
)
.await?;
} else {
validate_vnc_candidate(state, new_config)?;
}
let runtime_config = state.runtime_third_party_config().await;
let will_run = if options.preserve_service_state {
runtime_config.vnc.enabled
} else {
new_config.enabled
};
if will_run {
let mut candidate = runtime_config;
candidate.vnc = new_config.clone();
candidate.vnc.enabled = true;
let constraints = StreamCodecConstraints::from_config(&candidate);
match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await {
Ok(result) if result.changed => {
if let Some(message) = result.message {
tracing::info!("{}", message);
}
}
Ok(_) => {}
Err(e) => tracing::warn!(
"Failed to enforce VNC stream constraints before start: {}",
e
),
}
}
let mut vnc_guard = state.vnc.write().await;
let need_restart = options.force
|| old_config.bind != new_config.bind
|| old_config.port != new_config.port
|| old_config.encoding != new_config.encoding
|| old_config.password != new_config.password
|| old_config.allow_one_client != new_config.allow_one_client;
if !options.preserve_service_state && !new_config.enabled {
if let Some(ref service) = *vnc_guard {
service.stop().await?;
}
*vnc_guard = None;
}
if !options.preserve_service_state && new_config.enabled {
if vnc_guard.is_none() {
let service = Arc::new(crate::vnc::VncService::new(
new_config.clone(),
state.stream_manager.clone(),
state.hid.clone(),
));
*vnc_guard = Some(service.clone());
service.start().await?;
tracing::info!("VNC service started");
} else {
if let Some(ref service) = *vnc_guard {
if matches!(
service.status().await,
crate::vnc::VncServiceStatus::Running
) {
if need_restart {
service.restart(new_config.clone()).await?;
tracing::info!("VNC service restarted");
}
} else {
service.update_config(new_config.clone()).await;
service.start().await?;
}
}
}
} else if options.preserve_service_state && need_restart {
if let Some(ref service) = *vnc_guard {
let mut runtime_config = new_config.clone();
runtime_config.enabled = true;
service.restart(runtime_config).await?;
}
}
drop(vnc_guard);
if let Some(message) = enforce_stream_codec_constraints(state).await? {
tracing::info!("{}", message);
}
Ok(())
}
pub async fn apply_rtsp_config(
state: &Arc<AppState>,
old_config: &RtspConfig,
new_config: &RtspConfig,
options: ConfigApplyOptions,
) -> Result<()> {
tracing::info!("Applying RTSP config changes...");
if options.runtime_only {
validate_runtime_candidate(
state,
|candidate, config| candidate.rtsp = config,
new_config.clone(),
)
.await?;
} else {
validate_rtsp_candidate(state, new_config)?;
}
let mut rtsp_guard = state.rtsp.write().await;
let need_restart = options.force
|| old_config.bind != new_config.bind
|| old_config.port != new_config.port
|| old_config.path != new_config.path
|| old_config.codec != new_config.codec
|| old_config.username != new_config.username
|| old_config.password != new_config.password
|| old_config.allow_one_client != new_config.allow_one_client;
if !options.preserve_service_state && !new_config.enabled {
if let Some(ref service) = *rtsp_guard {
service
.stop()
.await
.map_err(|e| AppError::Config(format!("Failed to stop RTSP service: {}", e)))?;
}
*rtsp_guard = None;
}
if !options.preserve_service_state && new_config.enabled {
if rtsp_guard.is_none() {
let service = Arc::new(RtspService::new(
new_config.clone(),
state.stream_manager.clone(),
));
*rtsp_guard = Some(service.clone());
service.start().await?;
tracing::info!("RTSP service started");
} else {
if let Some(ref service) = *rtsp_guard {
if matches!(
service.status().await,
crate::rtsp::RtspServiceStatus::Running
) {
if need_restart {
service.restart(new_config.clone()).await?;
tracing::info!("RTSP service restarted");
}
} else {
service.update_config(new_config.clone()).await;
service.start().await?;
}
}
}
} else if options.preserve_service_state && need_restart {
if let Some(ref service) = *rtsp_guard {
let mut runtime_config = new_config.clone();
runtime_config.enabled = true;
service.restart(runtime_config).await?;
}
}
drop(rtsp_guard);
if let Some(message) = enforce_stream_codec_constraints(state).await? {
tracing::info!("{}", message);
}
Ok(())
}

View File

@@ -1,22 +1,22 @@
use axum::{extract::State, Json};
use std::sync::Arc;
use crate::config::HidConfig;
use crate::error::Result;
use crate::state::AppState;
use crate::web::state::UsbApiState;
use super::types::HidConfigUpdate;
use super::usb_update::{stage_hid_config_update, update_usb_config};
use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset};
pub async fn get_hid_config(State(state): State<Arc<AppState>>) -> Json<HidConfig> {
pub async fn get_hid_config(State(state): State<UsbApiState>) -> Json<HidConfig> {
Json(state.config.get().hid.clone())
}
pub async fn update_hid_config(
State(state): State<Arc<AppState>>,
State(state): State<UsbApiState>,
Json(req): Json<HidConfigUpdate>,
) -> Result<Json<HidConfig>> {
let config = update_usb_config(&state, move |staged| {
let reset = req.bluetooth_reset_pairing.unwrap_or(false);
let config = update_usb_config_with_reset(&state, reset, move |staged| {
stage_hid_config_update(&mut staged.hid, &req)
})
.await?;

View File

@@ -1,19 +1,18 @@
use axum::{extract::State, Json};
use std::sync::Arc;
use crate::config::MsdConfig;
use crate::error::Result;
use crate::state::AppState;
use crate::web::state::UsbApiState;
use super::otg::update_otg_config_inner;
use super::types::{MsdConfigUpdate, OtgConfigUpdate};
pub async fn get_msd_config(State(state): State<Arc<AppState>>) -> Json<MsdConfig> {
pub async fn get_msd_config(State(state): State<UsbApiState>) -> Json<MsdConfig> {
Json(state.config.get().msd.clone())
}
pub async fn update_msd_config(
State(state): State<Arc<AppState>>,
State(state): State<UsbApiState>,
Json(req): Json<MsdConfigUpdate>,
) -> Result<Json<MsdConfig>> {
let response = update_otg_config_inner(

View File

@@ -1,5 +1,3 @@
use std::sync::Arc;
use axum::{extract::State, Json};
use serde::Serialize;
use typeshare::typeshare;
@@ -7,10 +5,10 @@ use typeshare::typeshare;
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig};
use crate::error::Result;
use crate::otg::OtgNetworkStatus;
use crate::state::AppState;
use crate::web::state::UsbApiState;
use super::types::OtgConfigUpdate;
use super::usb_update::{stage_hid_config_update, update_usb_config};
use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset};
#[typeshare]
#[derive(Debug, Serialize)]
@@ -22,17 +20,22 @@ pub struct OtgConfigResponse {
}
pub async fn update_otg_config(
State(state): State<Arc<AppState>>,
State(state): State<UsbApiState>,
Json(request): Json<OtgConfigUpdate>,
) -> Result<Json<OtgConfigResponse>> {
update_otg_config_inner(&state, request).await.map(Json)
}
pub(super) async fn update_otg_config_inner(
state: &Arc<AppState>,
state: &UsbApiState,
request: OtgConfigUpdate,
) -> Result<OtgConfigResponse> {
let staged_config = update_usb_config(state, move |staged| {
let reset = request
.hid
.as_ref()
.and_then(|h| h.bluetooth_reset_pairing)
.unwrap_or(false);
let staged_config = update_usb_config_with_reset(state, reset, move |staged| {
let requested_ch9329_descriptor = match request.hid.as_ref() {
Some(update) => stage_hid_config_update(&mut staged.hid, update)?,
None => None,
@@ -54,6 +57,6 @@ pub(super) async fn update_otg_config_inner(
hid: staged_config.hid,
msd: staged_config.msd,
network: staged_config.otg_network,
status: state.otg_service.network_status().await,
status: state.otg.network_status().await,
})
}

View File

@@ -1,21 +1,19 @@
use std::sync::Arc;
use axum::{extract::State, Json};
use crate::config::OtgNetworkConfig;
use crate::error::Result;
use crate::otg::OtgNetworkStatus;
use crate::state::AppState;
use crate::web::state::UsbApiState;
use super::otg::update_otg_config_inner;
use super::types::{OtgConfigUpdate, OtgNetworkConfigUpdate};
pub async fn get_otg_network_config(State(state): State<Arc<AppState>>) -> Json<OtgNetworkConfig> {
pub async fn get_otg_network_config(State(state): State<UsbApiState>) -> Json<OtgNetworkConfig> {
Json(state.config.get().otg_network.clone())
}
pub async fn update_otg_network_config(
State(state): State<Arc<AppState>>,
State(state): State<UsbApiState>,
Json(request): Json<OtgNetworkConfigUpdate>,
) -> Result<Json<OtgNetworkConfig>> {
let response = update_otg_config_inner(
@@ -29,6 +27,6 @@ pub async fn update_otg_network_config(
Ok(Json(response.network))
}
pub async fn get_otg_network_status(State(state): State<Arc<AppState>>) -> Json<OtgNetworkStatus> {
Json(state.otg_service.network_status().await)
pub async fn get_otg_network_status(State(state): State<UsbApiState>) -> Json<OtgNetworkStatus> {
Json(state.otg.network_status().await)
}

View File

@@ -1,20 +1,22 @@
use axum::{extract::State, Json};
use std::sync::Arc;
use crate::error::Result;
use crate::state::AppState;
use crate::web::state::RemoteAccessApiState;
use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions};
use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse};
use crate::runtime::{try_apply_lock, ConfigApplyOptions};
fn validate_candidate(state: &Arc<AppState>, config: &crate::config::RtspConfig) -> Result<()> {
fn validate_candidate(
state: &RemoteAccessApiState,
config: &crate::config::RtspConfig,
) -> Result<()> {
let mut candidate = state.config.get().as_ref().clone();
candidate.rtsp = config.clone();
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
}
async fn persist_and_apply(
state: &Arc<AppState>,
state: &RemoteAccessApiState,
old_config: crate::config::RtspConfig,
new_config: crate::config::RtspConfig,
) -> Result<crate::config::RtspConfig> {
@@ -26,31 +28,31 @@ async fn persist_and_apply(
})
.await?;
let stored_config = state.config.get().rtsp.clone();
apply_rtsp_config(
state,
&old_config,
&stored_config,
ConfigApplyOptions::preserving_service_state(),
)
.await?;
state
.coordinator
.apply_rtsp(
&old_config,
&stored_config,
ConfigApplyOptions::preserving_service_state(),
)
.await?;
Ok(stored_config)
}
async fn current_status(state: &Arc<AppState>) -> crate::rtsp::RtspServiceStatus {
let guard = state.rtsp.read().await;
if let Some(ref service) = *guard {
service.status().await
} else {
crate::rtsp::RtspServiceStatus::Stopped
}
async fn current_status(state: &RemoteAccessApiState) -> crate::rtsp::RtspServiceStatus {
state.coordinator.rtsp_status().await
}
pub async fn get_rtsp_config(State(state): State<Arc<AppState>>) -> Json<RtspConfigResponse> {
pub async fn get_rtsp_config(
State(state): State<RemoteAccessApiState>,
) -> Json<RtspConfigResponse> {
let config = state.config.get();
Json(RtspConfigResponse::from(&config.rtsp))
}
pub async fn get_rtsp_status(State(state): State<Arc<AppState>>) -> Json<RtspStatusResponse> {
pub async fn get_rtsp_status(
State(state): State<RemoteAccessApiState>,
) -> Json<RtspStatusResponse> {
let config = state.config.get().rtsp.clone();
let status = current_status(&state).await;
@@ -58,12 +60,12 @@ pub async fn get_rtsp_status(State(state): State<Arc<AppState>>) -> Json<RtspSta
}
pub async fn update_rtsp_config(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
Json(req): Json<RtspConfigUpdate>,
) -> Result<Json<RtspConfigResponse>> {
req.validate()?;
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?;
let old_config = state.config.get().rtsp.clone();
let mut merged_config = old_config.clone();
req.apply_to(&mut merged_config);
@@ -73,40 +75,42 @@ pub async fn update_rtsp_config(
}
pub async fn start_rtsp_service(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Result<Json<RtspStatusResponse>> {
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?;
let stored_config = state.config.get().rtsp.clone();
let runtime_config = state.runtime_third_party_config().await.rtsp;
let runtime_config = state.coordinator.runtime_config().await.rtsp;
let mut start_config = stored_config.clone();
start_config.enabled = true;
apply_rtsp_config(
&state,
&runtime_config,
&start_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
state
.coordinator
.apply_rtsp(
&runtime_config,
&start_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
let status = current_status(&state).await;
Ok(Json(RtspStatusResponse::new(&stored_config, status)))
}
pub async fn stop_rtsp_service(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Result<Json<RtspStatusResponse>> {
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
let _apply_guard = try_apply_lock(&state.rtsp_apply_lock, "rtsp")?;
let stored_config = state.config.get().rtsp.clone();
let runtime_config = state.runtime_third_party_config().await.rtsp;
let runtime_config = state.coordinator.runtime_config().await.rtsp;
let mut stop_config = stored_config.clone();
stop_config.enabled = false;
apply_rtsp_config(
&state,
&runtime_config,
&stop_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
state
.coordinator
.apply_rtsp(
&runtime_config,
&stop_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
let status = current_status(&state).await;
Ok(Json(RtspStatusResponse::new(&stored_config, status)))

View File

@@ -1,21 +1,20 @@
use axum::{extract::State, Json};
use std::sync::Arc;
use crate::error::Result;
use crate::rustdesk::config::RustDeskConfig;
use crate::state::AppState;
use crate::web::state::RemoteAccessApiState;
use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions};
use super::types::RustDeskConfigUpdate;
use crate::runtime::{try_apply_lock, ConfigApplyOptions};
fn validate_candidate(state: &Arc<AppState>, config: &RustDeskConfig) -> Result<()> {
fn validate_candidate(state: &RemoteAccessApiState, config: &RustDeskConfig) -> Result<()> {
let mut candidate = state.config.get().as_ref().clone();
candidate.rustdesk = config.clone();
crate::video::codec_constraints::validate_third_party_codec_compatibility(&candidate)
}
async fn persist_and_apply(
state: &Arc<AppState>,
state: &RemoteAccessApiState,
old_config: RustDeskConfig,
new_config: RustDeskConfig,
) -> Result<RustDeskConfig> {
@@ -27,44 +26,43 @@ async fn persist_and_apply(
})
.await?;
let stored_config = state.config.get().rustdesk.clone();
apply_rustdesk_config(
state,
&old_config,
&stored_config,
ConfigApplyOptions::preserving_service_state(),
)
.await?;
state
.coordinator
.apply_rustdesk(
&old_config,
&stored_config,
ConfigApplyOptions::preserving_service_state(),
)
.await?;
Ok(stored_config)
}
async fn current_status(state: &Arc<AppState>, config: RustDeskConfig) -> RustDeskStatusResponse {
let (service_status, rendezvous_status) = {
let guard = state.rustdesk.read().await;
if let Some(ref service) = *guard {
let status = format!("{}", service.status());
let rv_status = service.rendezvous_status().map(|s| format!("{}", s));
(status, rv_status)
} else {
("not_initialized".to_string(), None)
}
};
async fn current_status(
state: &RemoteAccessApiState,
config: RustDeskConfig,
) -> RustDeskStatusResponse {
let runtime = state.coordinator.rustdesk_status().await;
RustDeskStatusResponse {
config: RustDeskConfigResponse::from(&config),
service_status,
rendezvous_status,
service_status: runtime.service_status,
rendezvous_status: runtime.rendezvous_status,
connection_count: runtime.connection_count,
listening: runtime.listening,
listen_port: runtime.listen_port,
}
}
#[derive(Debug, serde::Serialize)]
pub struct RustDeskConfigResponse {
pub enabled: bool,
pub mode: crate::rustdesk::config::RustDeskMode,
pub codec: crate::rustdesk::config::RustDeskCodec,
pub direct_access_port: u16,
pub rendezvous_server: String,
pub relay_server: Option<String>,
pub device_id: String,
pub has_password: bool,
pub has_keypair: bool,
pub relay_key: Option<String>,
}
@@ -72,12 +70,13 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse {
fn from(config: &RustDeskConfig) -> Self {
Self {
enabled: config.enabled,
mode: config.mode,
codec: config.codec,
direct_access_port: config.direct_access_port,
rendezvous_server: config.rendezvous_server.clone(),
relay_server: config.relay_server.clone(),
device_id: config.device_id.clone(),
has_password: !config.device_password.is_empty(),
has_keypair: config.public_key.is_some() && config.private_key.is_some(),
relay_key: config.relay_key.clone(),
}
}
@@ -88,28 +87,31 @@ pub struct RustDeskStatusResponse {
pub config: RustDeskConfigResponse,
pub service_status: String,
pub rendezvous_status: Option<String>,
pub connection_count: usize,
pub listening: bool,
pub listen_port: Option<u16>,
}
pub async fn get_rustdesk_config(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Json<RustDeskConfigResponse> {
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
}
pub async fn get_rustdesk_status(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Json<RustDeskStatusResponse> {
let config = state.config.get().rustdesk.clone();
Json(current_status(&state, config).await)
}
pub async fn update_rustdesk_config(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
Json(req): Json<RustDeskConfigUpdate>,
) -> Result<Json<RustDeskConfigResponse>> {
req.validate()?;
let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?;
let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?;
let old_config = state.config.get().rustdesk.clone();
let mut merged_config = old_config.clone();
req.apply_to(&mut merged_config);
@@ -117,46 +119,34 @@ pub async fn update_rustdesk_config(
let new_config = persist_and_apply(&state, old_config, merged_config).await?;
let constraints = state.stream_manager.codec_constraints().await;
if constraints.rustdesk_enabled || constraints.rtsp_enabled {
tracing::info!(
"Stream codec constraints active after RustDesk update: {}",
constraints.reason
);
}
Ok(Json(RustDeskConfigResponse::from(&new_config)))
}
pub async fn regenerate_device_id(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Result<Json<RustDeskConfigResponse>> {
state
.config
.update(|config| {
config.rustdesk.device_id = RustDeskConfig::generate_device_id();
})
.await?;
let new_config = state.config.get().rustdesk.clone();
let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?;
let old_config = state.config.get().rustdesk.clone();
let mut regenerated = old_config.clone();
regenerated.device_id = RustDeskConfig::generate_device_id();
let new_config = persist_and_apply(&state, old_config, regenerated).await?;
Ok(Json(RustDeskConfigResponse::from(&new_config)))
}
pub async fn regenerate_device_password(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Result<Json<RustDeskConfigResponse>> {
state
.config
.update(|config| {
config.rustdesk.device_password = RustDeskConfig::generate_password();
})
.await?;
let new_config = state.config.get().rustdesk.clone();
let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?;
let old_config = state.config.get().rustdesk.clone();
let mut regenerated = old_config.clone();
regenerated.device_password = RustDeskConfig::generate_password();
let new_config = persist_and_apply(&state, old_config, regenerated).await?;
Ok(Json(RustDeskConfigResponse::from(&new_config)))
}
pub async fn get_device_password(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
pub async fn get_device_password(
State(state): State<RemoteAccessApiState>,
) -> Json<serde_json::Value> {
let config = state.config.get().rustdesk.clone();
Json(serde_json::json!({
"device_id": config.device_id,
@@ -165,38 +155,40 @@ pub async fn get_device_password(State(state): State<Arc<AppState>>) -> Json<ser
}
pub async fn start_rustdesk_service(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Result<Json<RustDeskStatusResponse>> {
let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?;
let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?;
let stored_config = state.config.get().rustdesk.clone();
let runtime_config = state.runtime_third_party_config().await.rustdesk;
let runtime_config = state.coordinator.runtime_config().await.rustdesk;
let mut start_config = stored_config.clone();
start_config.enabled = true;
apply_rustdesk_config(
&state,
&runtime_config,
&start_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
state
.coordinator
.apply_rustdesk(
&runtime_config,
&start_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
let stored_config = state.config.get().rustdesk.clone();
Ok(Json(current_status(&state, stored_config).await))
}
pub async fn stop_rustdesk_service(
State(state): State<Arc<AppState>>,
State(state): State<RemoteAccessApiState>,
) -> Result<Json<RustDeskStatusResponse>> {
let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?;
let _apply_guard = try_apply_lock(&state.rustdesk_apply_lock, "rustdesk")?;
let stored_config = state.config.get().rustdesk.clone();
let runtime_config = state.runtime_third_party_config().await.rustdesk;
let runtime_config = state.coordinator.runtime_config().await.rustdesk;
let mut stop_config = stored_config.clone();
stop_config.enabled = false;
apply_rustdesk_config(
&state,
&runtime_config,
&stop_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
state
.coordinator
.apply_rustdesk(
&runtime_config,
&stop_config,
ConfigApplyOptions::runtime_only(),
)
.await?;
Ok(Json(current_status(&state, stored_config).await))
}

Some files were not shown because too many files have changed in this diff Show More