Compare commits

..

63 Commits

Author SHA1 Message Date
mofeng-git
51d7d8b8be chore: bump version to v0.1.7 2026-03-28 22:45:39 +08:00
mofeng-git
f95714d9f0 删除无用测试 2026-03-28 22:41:35 +08:00
mofeng-git
7d52b2e2ea fix(otg): 优化运行时状态监测与未枚举提示 2026-03-28 22:06:53 +08:00
mofeng-git
a2a8b3802d feat(web): 优化视频格式与虚拟键盘显示 2026-03-28 21:34:22 +08:00
mofeng-git
f4283f45a4 refactor(otg): 简化运行时与设置逻辑 2026-03-28 21:09:10 +08:00
mofeng-git
4784cb75e4 调整登录页文案并改为点击切换语言 2026-03-28 20:47:29 +08:00
mofeng-git
abc6bd1677 feat(otg): 增加 libcomposite 自动加载兜底 2026-03-28 17:06:41 +08:00
mofeng-git
1c5288d783 优化控制台与设置页切换时的 WebRTC 会话保活与恢复逻辑 2026-03-27 11:29:27 +08:00
mofeng-git
6bcb54bd22 feat(web): 改为通过 WebSocket 推送 ttyd 状态并清理轮询与冗余接口 2026-03-27 10:49:04 +08:00
mofeng-git
e20136a5ab fix(web): 修复 WebRTC 首帧状态与视频状态判定 2026-03-27 10:44:59 +08:00
mofeng-git
c8fd3648ad refactor(video): 删除废弃视频流水线并收敛 MJPEG/WebRTC 编排与死代码 2026-03-27 08:21:14 +08:00
mofeng-git
6ef2d394d9 fix(video): 启动时丢弃前三帧无效 MJPEG 2026-03-26 23:27:42 +08:00
mofeng-git
762a3b037d chore(hid): 删除 CH9329 收发 trace 日志 2026-03-26 23:05:49 +08:00
mofeng-git
e09a906f93 refactor(hid): 统一 HID 键盘 CanonicalKey 语义并清理前端布局与输入链路冗余代码 2026-03-26 22:51:29 +08:00
mofeng-git
95bf1a852e feat(web): 登录页改为引导卡片样式并增加语言切换按钮 2026-03-26 22:45:28 +08:00
mofeng-git
200f947b5d fix(video): 修复 FFmpeg 硬编码 EAGAIN 刷屏并为编码错误增加日志节流 2026-03-26 22:30:53 +08:00
mofeng-git
46ae0c81e2 refactor(events): 将设备状态广播降级为快照同步并按需订阅 WebSocket 事件,顺带修复相关测试 2026-03-26 22:01:50 +08:00
mofeng-git
779aa180ad refactor: 重构部分事件检查逻辑,修复 ch9329 hid 状态显示异常 2026-03-26 12:33:24 +08:00
mofeng-git
ae26e3c863 feat: 支持自动检测因特尔 GPU 驱动类型 2026-03-25 20:27:26 +08:00
mofeng-git
eeb41159b7 build: 增加硬件编码所需驱动依赖 2026-03-22 20:19:30 +08:00
mofeng-git
24a10aa222 feat: 支持硬件编码能力测试,otg 自检修改为需要手动执行 2026-03-22 20:19:30 +08:00
mofeng-git
c119db4908 perf: 编码器探测测试分辨率由 1080p 调整为 720p 2026-03-22 20:19:30 +08:00
mofeng-git
0db287bf55 refactor: 重构 ffmpeg 编码器探测模块 2026-03-22 20:19:30 +08:00
mofeng-git
e229f35777 fix(web): 修复控制台全屏视频时鼠标定位偏移问题 2026-03-22 20:19:30 +08:00
SilentWind
df647b45cd Merge pull request #230 from a15355447898a/main
修复树莓派4B上 V4L2 编码时 WebRTC 无画面的问题
2026-03-02 19:05:32 +08:00
a15355447898a
b74659dcd4 refactor(video): restore v4l2r and remove temporary debug logs 2026-03-01 01:40:28 +08:00
a15355447898a
4f2fb534a4 fix(video): v4l path + webrtc h264 startup diagnostics 2026-03-01 01:24:26 +08:00
mofeng-git
bd17f8d0f8 chore: 更新版本号到 v0.1.6 2026-02-22 23:03:24 +08:00
mofeng-git
cee43795f8 fix: 添加前端电源状态显示 #226 2026-02-22 22:55:56 +08:00
mofeng-git
79d90ea703 docs: 更新版本号到 v0.1.5,更新 readme,删除落后的文档 2026-02-20 21:51:17 +08:00
mofeng-git
486db7b4aa feat(hid): 增加 HID 后端健康检查与错误码上报,完善前端掉线恢复状态同步及错误提示展示 2026-02-20 20:30:12 +08:00
mofeng-git
016c0d5dbb fix(atx): 完善串口继电器配置校验与前端防冲突 2026-02-20 15:36:08 +08:00
mofeng-git
6e2c6dea1c revert: remove non-ATX changes from #223 merge 2026-02-20 14:24:38 +08:00
SilentWind
078c4e4ea1 Merge pull request #223 from Fridayssheep/main
尝试添加了对串口协议的USB继电器的支持
2026-02-20 14:19:51 +08:00
SilentWind
6daa348c63 Merge branch 'main' into main 2026-02-20 14:19:38 +08:00
SilentWind
3341d8b5cd Merge pull request #225 from mofeng-git/pr-223-atx-only
feat(atx): merge serial relay support from #223
2026-02-20 14:14:30 +08:00
mofeng-git
251a1e00c4 feat(atx): merge serial relay support from #223 2026-02-20 14:11:00 +08:00
mofeng-git
ce622e4492 fix: 优化 WebRTC 建连流程、修复平台信息、修复虚拟键盘键值映射
- WebRTC:默认 mDNS 调整为 QueryOnly,Answer 阶段改为等待 ICE gathering complete(2.5s 超时),提升首次建连成功率与候选完整性
- WebRTC:前端建连流程增加阶段化状态与串行保护(connectInFlight/ready gate),优化配置变更后的重连时机与失败处理,减少竞态和无效重试
- Device:平台信息补充 `/proc/device-tree/model` 回退并统一展示为“处理器/平台”
- HID:键盘输入链路统一为 HID usage + modifier bitmask,修复虚拟键盘/宏/粘贴键值映射错误
2026-02-20 13:34:49 +08:00
mofeng-git
5f03971579 feat(web): 新增 HID OTG 自检接口与设置页环境诊断面板,并优化在线升级状态文案本地化及重启后自动刷新体验 2026-02-20 09:44:02 +08:00
mofeng-git
ba1b5224ff fix(web): 调整控制台HID状态卡片内容与弹层对齐 2026-02-11 22:16:54 +08:00
mofeng-git
ccaf4d1205 fix(rtsp): 修复清空用户名后仍触发认证导致 401 的问题 2026-02-11 20:55:20 +08:00
mofeng-git
6ed1cf5bef feat: HID串口优先ttyUSB并在视频设备名称后显示路径 2026-02-11 20:45:13 +08:00
mofeng-git
74411d354c fix: 修复卡片字体大小不一致 2026-02-11 20:00:49 +08:00
mofeng-git
3133db9c86 fix: 修复 rtsp 服务连接错误 2026-02-11 20:00:33 +08:00
mofeng-git
934dc48208 feat: 支持在线升级功能 2026-02-11 19:41:19 +08:00
mofeng-git
60b294e0ab feat: 增加WOL服务端历史记录并支持跨浏览器同步 2026-02-11 17:04:40 +08:00
mofeng-git
fb975875f1 feat: 新增 RTSP 设置菜单与配置面板 2026-02-11 17:01:25 +08:00
mofeng-git
f912c977d0 refactor: 移除 ttyd 扩展的“用户凭据”功能 2026-02-11 16:50:49 +08:00
mofeng-git
24c4002ef2 fix: 修复 RustDesk 中继选择逻辑,使“用户本地配置的 relay_server(非空)”在运行时优先生效 2026-02-11 16:29:47 +08:00
mofeng-git
a7143287d9 fix: 修复构建错误,构建时安装 rsync 2026-02-11 16:17:27 +08:00
mofeng-git
3824e57fc5 feat: 支持 rtsp 功能 2026-02-11 16:06:06 +08:00
Fridayssheep
ba6ec56cee fix: 修复了确认对话框操作的变量在点击时被重置的bug 2026-02-11 15:16:42 +08:00
Fridayssheep
21bea797e4 feat: 添加了对串口协议的继电器的支持 2026-02-11 13:06:05 +08:00
mofeng-git
261deb1303 refactor: 收敛单用户模型并优化可访问性与响应式体验
- 后端移除 is_admin 权限字段与相关逻辑,统一为单用户系统模型
- 修复会话过期清理的时间比较方式(改为 RFC3339 参数比较)
- /api/config 聚合配置增加敏感字段脱敏,避免暴露 TURN/RustDesk 密钥与密码
- 配置更新日志改为摘要,避免打印完整配置内容
- 前端修复可点击卡片语义与键盘可达,补齐图标按钮可访问名称
- 调整弹窗与抽屉的响应式尺寸,优化多端显示与交互
2026-02-10 22:30:52 +08:00
mofeng-git
394baca938 fix: 补齐 ATX 控制器缺失接口并完成全项目 clippy -D warnings 修复 2026-02-10 21:37:33 +08:00
SilentWind
7baf6fcf44 Merge pull request #214 from a15355447898a/main
支持v4l2编码,arm机器原生构建,允许初始化时禁用HID,修改README
2026-02-10 14:31:46 +08:00
SilentWind
04a2ff9724 Merge branch 'main' into main 2026-02-10 14:31:17 +08:00
mofeng-git
72eb2c450d feat: 迁移视频采集到 v4l2r,支持多平面设备并完善构建头文件
- 将 V4L2 采集依赖从 v4l 切换到 v4l2r

- 新增基于 v4l2r 的 mmap 采集实现,优先使用 VIDEO_CAPTURE_MPLANE

- 更新像素格式转换与设备枚举逻辑,探测阶段改为只读打开

- 增加采集错误日志节流,避免 dqbuf EINVAL 日志风暴

- 交叉编译镜像安装更新的 Linux 内核头文件供 bindgen 使用
2026-02-10 13:52:52 +08:00
mofeng
f8a031c90c feat: 支持树莓派 v4l2m2m 编码器探测 2026-02-09 14:54:46 +08:00
ayaya
176e71c79e 修改readme 2026-01-23 21:24:22 +08:00
a15355447898a
d78c6ed047 回滚docker构建脚本 2026-01-23 21:20:48 +08:00
a15355447898a
89072ad58d 支持v4l2编码,arm机器原生构建,docker镜像换archlinux,允许初始化时禁用HID 2026-01-23 17:11:19 +08:00
mofeng
e7d8c93bff docs: 新明 2026-01-20 19:53:15 +08:00
180 changed files with 14458 additions and 26508 deletions

1
.gitignore vendored
View File

@@ -40,3 +40,4 @@ CLAUDE.md
# Secrets (compile-time configuration)
secrets.toml
.env
/docs/

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.1.4"
version = "0.1.7"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"
@@ -28,7 +28,8 @@ serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"] }
tracing-log = "0.2"
# Error handling
thiserror = "2"
@@ -46,7 +47,7 @@ nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] }
# HTTP client (for URL downloads)
# Use rustls by default, but allow native-tls for systems with older GLIBC
reqwest = { version = "0.13", features = ["stream", "rustls"], default-features = false }
reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false }
urlencoding = "2"
# Static file embedding
@@ -65,7 +66,7 @@ clap = { version = "4", features = ["derive"] }
time = "0.3"
# Video capture (V4L2)
v4l = "0.14"
v4l2r = "0.0.7"
# JPEG encoding (libjpeg-turbo, SIMD accelerated)
turbojpeg = "1.3"
@@ -91,6 +92,8 @@ arc-swap = "1.8"
# WebRTC
webrtc = "0.14"
rtp = "0.14"
rtsp-types = "0.1"
sdp-types = "0.1"
# Audio (ALSA capture + Opus encoding)
# Note: audiopus links to libopus.so (unavoidable for audio support)
@@ -115,7 +118,6 @@ hwcodec = { path = "libs/hwcodec" }
protobuf = { version = "3.7", features = ["with-bytes"] }
sodiumoxide = "0.2"
sha2 = "0.10"
# High-performance pixel format conversion (libyuv)
libyuv = { path = "res/vcpkg/libyuv" }

View File

@@ -7,13 +7,6 @@
[![GitHub stars](https://img.shields.io/github/stars/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/network/members)
[![GitHub issues](https://img.shields.io/github/issues/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/issues)
<p>
<a href="docs/README.md">📖 技术文档</a>
<a href="#快速开始">⚡ 快速开始</a>
<a href="#功能介绍">📊 功能介绍</a>
<a href="#迁移说明">🔁 迁移说明</a>
</p>
</div>
---
@@ -26,7 +19,7 @@
- **开放**:不绑定特定硬件配置,尽量适配常见 Linux 设备
- **轻量**:单二进制分发,部署过程更简单
- **易用**:网页界面完成设备与参数配置,尽量减少手动改配置文件
- **易用**:网页界面完成设备与参数配置,无需手动改配置文件
> **注意:** One-KVM Rust 目前仍处于开发早期阶段,功能与细节会快速迭代,欢迎体验与反馈。
@@ -35,7 +28,7 @@
开发重心正在从 **One-KVM Python** 逐步转向 **One-KVM Rust**
- 如果你在使用 **One-KVM Python基于 PiKVM**,请查看 [One-KVM Python 文档](https://docs.one-kvm.cn/python/)
- One-KVM Rust 相较于 One-KVM Python**尚未适配 CSI HDMI 采集卡**、**不支持 VNC 访问**,仍处于开发早期阶段
- One-KVM Rust 相较于 One-KVM Python**尚未完全适配 CSI HDMI 采集卡**、**不支持 VNC 访问**,仍处于开发早期阶段
## 📊 功能介绍
@@ -55,13 +48,13 @@
- **VAAPI**Intel/AMD GPU
- **RKMPP**Rockchip SoC
- **V4L2 M2M**:通用硬件编码器(尚未实现)
- **V4L2 M2M**:通用硬件编码器
- **软件编码**CPU 编码
### 扩展能力
- Web UI 配置,多语言支持(中文/英文)
- 内置 Web 终端ttyd内网穿透支持gostc、P2P 组网支持EasyTier、RustDesk 协议集成(用于跨平台远程访问能力扩展)
- 内置 Web 终端ttyd内网穿透支持gostc、P2P 组网支持EasyTier、RustDesk 协议集成(用于跨平台远程访问能力扩展)和 RTSP 视频流(用于视频推流)
## ⚡ 安装使用
@@ -195,6 +188,10 @@
- 爱发电用户_JT6c
- MaxZ
- 爱发电用户_d3d9c
- ......
</details>

View File

@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
# Install runtime dependencies in a single layer
# All codec libraries (libx264, libx265, libopus) are now statically linked
# Only hardware acceleration drivers and core system libraries remain dynamic
RUN apt-get update && \
RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends \
# Core runtime (all platforms) - no codec libs needed
ca-certificates \
@@ -24,7 +25,8 @@ RUN apt-get update && \
# Platform-specific hardware acceleration
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
apt-get install -y --no-install-recommends \
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
apt-get install -y --no-install-recommends \
libdrm2 libva2; \

View File

@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
# Install runtime dependencies in a single layer
# All codec libraries (libx264, libx265, libopus) are now statically linked
# Only hardware acceleration drivers and core system libraries remain dynamic
RUN apt-get update && \
RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends \
# Core runtime (all platforms) - no codec libs needed
ca-certificates \
@@ -24,7 +25,8 @@ RUN apt-get update && \
# Platform-specific hardware acceleration
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
apt-get install -y --no-install-recommends \
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
apt-get install -y --no-install-recommends \
libdrm2 libva2; \

View File

@@ -3,9 +3,13 @@
FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
# Set Rustup mirrors (Aliyun)
ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -31,7 +35,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
meson \
ninja-build \
wget \
xz-utils \
file \
rsync \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libc6-dev-arm64-cross \
@@ -47,10 +53,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libv4l-dev:arm64 \
libudev-dev:arm64 \
zlib1g-dev:arm64 \
linux-libc-dev:arm64 \
# Note: libjpeg-turbo, libyuv, libvpx, libx264, libx265, libopus are built from source below for static linking
libdrm-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
# Install newer V4L2 headers for v4l2r bindgen
RUN mkdir -p /opt/v4l2-headers \
&& wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \
&& if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \
&& tar -xf /tmp/linux-headers.tar.xz -C /tmp \
&& cd /tmp/linux-${LINUX_HEADERS_VERSION} \
&& make ARCH=arm64 headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \
&& rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz
ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include
# Build static libjpeg-turbo from source (cross-compile for ARM64)
RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \
&& cd /tmp/libjpeg-turbo \

View File

@@ -3,9 +3,13 @@
FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
# Set Rustup mirrors (Aliyun)
ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -31,7 +35,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
meson \
ninja-build \
wget \
xz-utils \
file \
rsync \
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf \
libc6-dev-armhf-cross \
@@ -46,10 +52,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2-dev:armhf \
libv4l-dev:armhf \
libudev-dev:armhf \
linux-libc-dev:armhf \
zlib1g-dev:armhf \
libdrm-dev:armhf \
&& rm -rf /var/lib/apt/lists/*
# Install newer V4L2 headers for v4l2r bindgen
RUN mkdir -p /opt/v4l2-headers \
&& wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \
&& if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \
&& tar -xf /tmp/linux-headers.tar.xz -C /tmp \
&& cd /tmp/linux-${LINUX_HEADERS_VERSION} \
&& make ARCH=arm headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \
&& rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz
ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include
# Build static libjpeg-turbo from source (cross-compile for ARMv7)
RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \
&& cd /tmp/libjpeg-turbo \

View File

@@ -3,9 +3,13 @@
FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
# Set Rustup mirrors (Aliyun)
ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -29,6 +33,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libclang-dev \
llvm \
wget \
xz-utils \
rsync \
# Autotools for libopus (requires autoreconf)
autoconf \
automake \
@@ -37,6 +43,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2-dev \
libv4l-dev \
libudev-dev \
linux-libc-dev \
zlib1g-dev \
# Note: libjpeg-turbo, libx264, libx265, libopus are built from source below for static linking
libva-dev \
@@ -49,6 +56,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxdmcp-dev \
&& rm -rf /var/lib/apt/lists/*
# Install newer V4L2 headers for v4l2r bindgen
RUN mkdir -p /opt/v4l2-headers \
&& wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \
&& if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \
&& tar -xf /tmp/linux-headers.tar.xz -C /tmp \
&& cd /tmp/linux-${LINUX_HEADERS_VERSION} \
&& make ARCH=x86 headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \
&& rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz
ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include
# Build static libjpeg-turbo from source (needed by libyuv)
RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \
&& cd /tmp/libjpeg-turbo \

View File

@@ -4,6 +4,68 @@
set -e
detect_intel_libva_driver() {
if [ -n "${LIBVA_DRIVER_NAME:-}" ]; then
echo "[INFO] Using preconfigured LIBVA_DRIVER_NAME=$LIBVA_DRIVER_NAME"
return
fi
if [ "$(uname -m)" != "x86_64" ]; then
return
fi
local devices=()
if [ -n "${LIBVA_DEVICE:-}" ]; then
devices=("$LIBVA_DEVICE")
else
shopt -s nullglob
devices=(/dev/dri/renderD*)
shopt -u nullglob
fi
if [ ${#devices[@]} -eq 0 ]; then
return
fi
local device=""
local node=""
local vendor=""
local driver=""
for device in "${devices[@]}"; do
if [ ! -e "$device" ]; then
continue
fi
node="$(basename "$device")"
vendor=""
if [ -r "/sys/class/drm/$node/device/vendor" ]; then
vendor="$(cat "/sys/class/drm/$node/device/vendor")"
fi
if [ -n "$vendor" ] && [ "$vendor" != "0x8086" ]; then
echo "[INFO] Skipping VA-API probe for $device (vendor=$vendor)"
continue
fi
for driver in iHD i965; do
if LIBVA_DRIVER_NAME="$driver" vainfo --display drm --device "$device" >/dev/null 2>&1; then
export LIBVA_DRIVER_NAME="$driver"
if [ -n "$vendor" ]; then
echo "[INFO] Detected Intel VA-API driver '$driver' on $device (vendor=$vendor)"
else
echo "[INFO] Detected Intel VA-API driver '$driver' on $device"
fi
return
fi
done
done
echo "[WARN] Unable to auto-detect an Intel VA-API driver; leaving LIBVA_DRIVER_NAME unset"
}
detect_intel_libva_driver
# Start one-kvm with default options.
# Additional options can be passed via environment variables.

View File

@@ -7,6 +7,8 @@ Wants=network-online.target
[Service]
Type=simple
User=root
# Example for older Intel GPUs:
# Environment=LIBVA_DRIVER_NAME=i965
ExecStart=/usr/bin/one-kvm
Restart=on-failure
RestartSec=5

View File

@@ -126,7 +126,7 @@ EOF
# Create control file
BASE_DEPS="libc6 (>= 2.31), libgcc-s1, libstdc++6, libasound2 (>= 1.1), libdrm2 (>= 2.4)"
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14)"
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14), i965-va-driver-shaders (>= 2.4), intel-media-va-driver-non-free (>= 21.1)"
DEPS="$BASE_DEPS"
if [ "$DEB_ARCH" = "amd64" ]; then
DEPS="$DEPS, $AMD64_DEPS"

View File

@@ -1,130 +0,0 @@
# One-KVM 技术文档
本目录包含 One-KVM 项目的完整技术文档。
## 文档结构
```
docs/
├── README.md # 本文件 - 文档索引
├── system-architecture.md # 系统架构文档
├── tech-stack.md # 技术栈文档
└── modules/ # 模块文档
├── video.md # 视频模块
├── hid.md # HID 模块
├── otg.md # OTG 模块
├── msd.md # MSD 模块
├── atx.md # ATX 模块
├── audio.md # 音频模块
├── webrtc.md # WebRTC 模块
├── rustdesk.md # RustDesk 模块
├── auth.md # 认证模块
├── config.md # 配置模块
├── events.md # 事件模块
└── web.md # Web 模块
```
## 快速导航
### 核心文档
| 文档 | 描述 |
|------|------|
| [系统架构](./system-architecture.md) | 整体架构设计、数据流、模块依赖 |
| [技术栈](./tech-stack.md) | 使用的技术、库和开发规范 |
### 功能模块
| 模块 | 描述 | 关键文件 |
|------|------|---------|
| [Video](./modules/video.md) | 视频采集和编码 | `src/video/` |
| [HID](./modules/hid.md) | 键盘鼠标控制 | `src/hid/` |
| [OTG](./modules/otg.md) | USB Gadget 管理 | `src/otg/` |
| [MSD](./modules/msd.md) | 虚拟存储设备 | `src/msd/` |
| [ATX](./modules/atx.md) | 电源控制 | `src/atx/` |
| [Audio](./modules/audio.md) | 音频采集编码 | `src/audio/` |
| [WebRTC](./modules/webrtc.md) | WebRTC 流媒体 | `src/webrtc/` |
| [RustDesk](./modules/rustdesk.md) | RustDesk 协议集成 | `src/rustdesk/` |
### 基础设施
| 模块 | 描述 | 关键文件 |
|------|------|---------|
| [Auth](./modules/auth.md) | 认证和会话 | `src/auth/` |
| [Config](./modules/config.md) | 配置管理 | `src/config/` |
| [Events](./modules/events.md) | 事件系统 | `src/events/` |
| [Web](./modules/web.md) | HTTP API | `src/web/` |
## 架构概览
```
┌─────────────────────────────────────────────────────────────────┐
│ One-KVM System │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Web Frontend (Vue3) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Axum Web Server │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AppState │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Video │ │ HID │ │ MSD │ │ ATX │ │ │
│ │ │ Module │ │ Module │ │ Module │ │ Module │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Audio │ │ WebRTC │ │RustDesk│ │ Events │ │ │
│ │ │ Module │ │ Module │ │ Module │ │ Bus │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Hardware Layer │ │
│ │ V4L2 │ USB OTG │ GPIO │ ALSA │ Network │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 关键特性
- **单一二进制**: Web UI + 后端一体化部署
- **双流模式**: WebRTC (H264/H265/VP8/VP9) + MJPEG
- **USB OTG**: 虚拟键鼠、虚拟存储
- **硬件加速**: VAAPI/RKMPP/V4L2 M2M
- **RustDesk**: 跨平台远程访问
- **无配置文件**: SQLite 配置存储
## 目标平台
| 平台 | 架构 | 用途 |
|------|------|------|
| aarch64-unknown-linux-gnu | ARM64 | 主要目标 |
| armv7-unknown-linux-gnueabihf | ARMv7 | 备选 |
| x86_64-unknown-linux-gnu | x86-64 | 开发/测试 |
## 快速开始
```bash
# 构建前端
cd web && npm install && npm run build && cd ..
# 构建后端
cargo build --release
# 运行
./target/release/one-kvm --enable-https
```
## 相关链接
- [项目仓库](https://github.com/mofeng-git/One-KVM)
- [开发计划](./DEVELOPMENT_PLAN.md)
- [项目目标](./PROJECT_GOALS.md)

View File

@@ -1,484 +0,0 @@
# ATX 模块文档
## 1. 模块概述
ATX 模块提供电源控制功能,通过 GPIO 或 USB 继电器控制目标计算机的电源和重置按钮。
### 1.1 主要功能
- 电源按钮控制
- 重置按钮控制
- 电源 LED 状态监视
- Wake-on-LAN 支持
- 多后端支持 (GPIO/USB 继电器)
### 1.2 文件结构
```
src/atx/
├── mod.rs # 模块导出
├── controller.rs # AtxController (11KB)
├── executor.rs # 动作执行器 (10KB)
├── types.rs # 类型定义 (7KB)
├── led.rs # LED 监视 (5KB)
└── wol.rs # Wake-on-LAN (5KB)
```
---
## 2. 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ ATX Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
Web API
┌─────────────────┐
│ AtxController │
│ (controller.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ Power │ │ Reset │ │ LED │
│Executor│ │Executor│ │Monitor │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ GPIO │ │ GPIO │ │ GPIO │
│ or USB │ │ or USB │ │ Input │
│ Relay │ │ Relay │ │ │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
└───────────┼─────────────┘
┌───────────────┐
│ Target PC │
│ (ATX Header) │
└───────────────┘
```
---
## 3. 核心组件
### 3.1 AtxController (controller.rs)
```rust
pub struct AtxController {
/// 电源按钮配置
power: Arc<AtxButton>,
/// 重置按钮配置
reset: Arc<AtxButton>,
/// LED 监视器
led_monitor: Arc<RwLock<Option<LedMonitor>>>,
/// WoL 控制器
wol: Arc<RwLock<Option<WolController>>>,
/// 当前状态
state: Arc<RwLock<AtxState>>,
/// 事件总线
events: Arc<EventBus>,
}
impl AtxController {
/// 创建控制器
pub fn new(config: &AtxConfig, events: Arc<EventBus>) -> Result<Self>;
/// 短按电源按钮 (开机/正常关机)
pub async fn power_short_press(&self) -> Result<()>;
/// 长按电源按钮 (强制关机)
pub async fn power_long_press(&self) -> Result<()>;
/// 按重置按钮
pub async fn reset_press(&self) -> Result<()>;
/// 获取电源状态
pub fn power_state(&self) -> PowerState;
/// 发送 WoL 魔术包
pub async fn wake_on_lan(&self, mac: &str) -> Result<()>;
/// 获取状态
pub fn state(&self) -> AtxState;
/// 重新加载配置
pub async fn reload(&self, config: &AtxConfig) -> Result<()>;
}
pub struct AtxState {
/// 是否可用
pub available: bool,
/// 电源是否开启
pub power_on: bool,
/// 最后操作时间
pub last_action: Option<DateTime<Utc>>,
/// 错误信息
pub error: Option<String>,
}
pub enum PowerState {
On,
Off,
Unknown,
}
```
### 3.2 AtxButton (executor.rs)
```rust
pub struct AtxButton {
/// 按钮名称
name: String,
/// 驱动类型
driver: AtxDriverType,
/// GPIO 句柄
gpio: Option<LineHandle>,
/// USB 继电器句柄
relay: Option<UsbRelay>,
/// 配置
config: AtxKeyConfig,
}
impl AtxButton {
/// 创建按钮
pub fn new(name: &str, config: &AtxKeyConfig) -> Result<Self>;
/// 短按 (100ms)
pub async fn short_press(&self) -> Result<()>;
/// 长按 (3000ms)
pub async fn long_press(&self) -> Result<()>;
/// 自定义按压时间
pub async fn press(&self, duration: Duration) -> Result<()>;
/// 设置输出状态
fn set_output(&self, high: bool) -> Result<()>;
}
pub enum AtxDriverType {
/// GPIO 直连
Gpio,
/// USB 继电器
UsbRelay,
/// 禁用
None,
}
```
### 3.3 LedMonitor (led.rs)
```rust
pub struct LedMonitor {
/// GPIO 引脚
pin: u32,
/// GPIO 句柄
line: LineHandle,
/// 当前状态
state: Arc<AtomicBool>,
/// 监视任务
monitor_task: Option<JoinHandle<()>>,
}
impl LedMonitor {
/// 创建监视器
pub fn new(config: &AtxLedConfig) -> Result<Self>;
/// 启动监视
pub fn start(&mut self, events: Arc<EventBus>) -> Result<()>;
/// 停止监视
pub fn stop(&mut self);
/// 获取当前状态
pub fn state(&self) -> bool;
}
```
### 3.4 WolController (wol.rs)
```rust
pub struct WolController {
/// 网络接口
interface: String,
/// 广播地址
broadcast_addr: SocketAddr,
}
impl WolController {
/// 创建控制器
pub fn new(interface: Option<&str>) -> Result<Self>;
/// 发送 WoL 魔术包
pub async fn wake(&self, mac: &str) -> Result<()>;
/// 构建魔术包
fn build_magic_packet(mac: &[u8; 6]) -> [u8; 102];
/// 解析 MAC 地址
fn parse_mac(mac: &str) -> Result<[u8; 6]>;
}
```
---
## 4. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AtxConfig {
/// 是否启用
pub enabled: bool,
/// 电源按钮配置
pub power: AtxKeyConfig,
/// 重置按钮配置
pub reset: AtxKeyConfig,
/// LED 监视配置
pub led: AtxLedConfig,
/// WoL 配置
pub wol: WolConfig,
}
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AtxKeyConfig {
/// 驱动类型
pub driver: AtxDriverType,
/// GPIO 芯片 (如 /dev/gpiochip0)
pub gpio_chip: Option<String>,
/// GPIO 引脚号
pub gpio_pin: Option<u32>,
/// USB 继电器设备
pub relay_device: Option<String>,
/// 继电器通道
pub relay_channel: Option<u8>,
/// 激活电平
pub active_level: ActiveLevel,
}
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AtxLedConfig {
/// 是否启用
pub enabled: bool,
/// GPIO 芯片
pub gpio_chip: Option<String>,
/// GPIO 引脚号
pub gpio_pin: Option<u32>,
/// 激活电平
pub active_level: ActiveLevel,
}
pub enum ActiveLevel {
High,
Low,
}
impl Default for AtxConfig {
fn default() -> Self {
Self {
enabled: false,
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
wol: WolConfig::default(),
}
}
}
```
---
## 5. API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/api/atx/status` | GET | 获取 ATX 状态 |
| `/api/atx/power/short` | POST | 短按电源 |
| `/api/atx/power/long` | POST | 长按电源 |
| `/api/atx/reset` | POST | 按重置 |
| `/api/atx/wol` | POST | 发送 WoL |
### 响应格式
```json
// GET /api/atx/status
{
"available": true,
"power_on": true,
"last_action": "2024-01-15T10:30:00Z",
"error": null
}
// POST /api/atx/wol
// Request: { "mac": "00:11:22:33:44:55" }
{
"success": true
}
```
---
## 6. 硬件连接
### 6.1 GPIO 直连
```
One-KVM Device Target PC
┌─────────────┐ ┌─────────────┐
│ GPIO Pin │───────────────│ Power SW │
│ (Output) │ │ │
└─────────────┘ └─────────────┘
接线说明:
- GPIO 引脚连接到 ATX 电源按钮
- 使用光耦或继电器隔离 (推荐)
- 注意电平匹配
```
### 6.2 USB 继电器
```
One-KVM Device USB Relay Target PC
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ USB │───────────────│ Relay │──────────│ Power SW │
│ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
优点:
- 完全隔离
- 无需担心电平问题
- 更安全
```
---
## 7. 事件
```rust
pub enum SystemEvent {
AtxStateChanged {
power_on: bool,
last_action: Option<String>,
error: Option<String>,
},
AtxActionPerformed {
action: String, // "power_short" | "power_long" | "reset" | "wol"
success: bool,
},
}
```
---
## 8. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum AtxError {
#[error("ATX not available")]
NotAvailable,
#[error("GPIO error: {0}")]
GpioError(String),
#[error("Relay error: {0}")]
RelayError(String),
#[error("WoL error: {0}")]
WolError(String),
#[error("Invalid MAC address: {0}")]
InvalidMac(String),
#[error("Operation in progress")]
Busy,
}
```
---
## 9. 使用示例
```rust
let atx = AtxController::new(&config, events)?;
// 开机
atx.power_short_press().await?;
// 检查状态
tokio::time::sleep(Duration::from_secs(5)).await;
if atx.power_state() == PowerState::On {
println!("PC is now on");
}
// 强制关机
atx.power_long_press().await?;
// 重置
atx.reset_press().await?;
// Wake-on-LAN
atx.wake_on_lan("00:11:22:33:44:55").await?;
```
---
## 10. 常见问题
### Q: GPIO 无法控制?
1. 检查引脚配置
2. 检查权限 (`/dev/gpiochip*`)
3. 检查接线
### Q: LED 状态不正确?
1. 检查 active_level 配置
2. 检查 GPIO 输入模式
### Q: WoL 不工作?
1. 检查目标 PC BIOS 设置
2. 检查网卡支持
3. 检查网络广播

View File

@@ -1,463 +0,0 @@
# Audio 模块文档
## 1. 模块概述
Audio 模块负责音频采集和编码,支持 ALSA 采集和 Opus 编码。
### 1.1 主要功能
- ALSA 音频采集
- Opus 编码
- 多质量配置
- WebSocket/WebRTC 传输
### 1.2 文件结构
```
src/audio/
├── mod.rs # 模块导出
├── controller.rs # AudioController (15KB)
├── capture.rs # ALSA 采集 (12KB)
├── encoder.rs # Opus 编码 (8KB)
├── shared_pipeline.rs # 共享管道 (15KB)
├── monitor.rs # 健康监视 (11KB)
└── device.rs # 设备枚举 (8KB)
```
---
## 2. 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Audio Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
ALSA Device (hw:0,0)
│ PCM 48kHz/16bit/Stereo
┌─────────────────┐
│ AudioCapturer │
│ (capture.rs) │
└────────┬────────┘
┌─────────────────────────────────────────┐
│ SharedAudioPipeline │
│ ┌─────────────────────────────────┐ │
│ │ Opus Encoder │ │
│ │ 48kHz → 24-96 kbps │ │
│ └─────────────────────────────────┘ │
└────────────────┬────────────────────────┘
┌─────────┴─────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ WebSocket │ │ WebRTC │
│ Stream │ │ Audio Track │
└─────────────┘ └─────────────┘
```
---
## 3. 核心组件
### 3.1 AudioController (controller.rs)
```rust
pub struct AudioController {
/// 采集器
capturer: Arc<RwLock<Option<AudioCapturer>>>,
/// 共享管道
pipeline: Arc<SharedAudioPipeline>,
/// 配置
config: Arc<RwLock<AudioConfig>>,
/// 状态
state: Arc<RwLock<AudioState>>,
/// 事件总线
events: Arc<EventBus>,
}
impl AudioController {
/// 创建控制器
pub fn new(config: &AudioConfig, events: Arc<EventBus>) -> Result<Self>;
/// 启动音频
pub async fn start(&self) -> Result<()>;
/// 停止音频
pub async fn stop(&self) -> Result<()>;
/// 订阅音频帧
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame>;
/// 获取状态
pub fn status(&self) -> AudioStatus;
/// 设置质量
pub fn set_quality(&self, quality: AudioQuality) -> Result<()>;
/// 列出设备
pub fn list_devices(&self) -> Vec<AudioDeviceInfo>;
/// 重新加载配置
pub async fn reload(&self, config: &AudioConfig) -> Result<()>;
}
pub struct AudioStatus {
pub enabled: bool,
pub streaming: bool,
pub device: Option<String>,
pub sample_rate: u32,
pub channels: u16,
pub bitrate: u32,
pub error: Option<String>,
}
```
### 3.2 AudioCapturer (capture.rs)
```rust
pub struct AudioCapturer {
/// PCM 句柄
pcm: PCM,
/// 设备名
device: String,
/// 采样率
sample_rate: u32,
/// 通道数
channels: u16,
/// 帧大小
frame_size: usize,
/// 运行状态
running: AtomicBool,
}
impl AudioCapturer {
/// 打开设备
pub fn open(device: &str, config: &CaptureConfig) -> Result<Self>;
/// 读取音频帧
pub fn read_frame(&self) -> Result<Vec<i16>>;
/// 启动采集
pub fn start(&self) -> Result<()>;
/// 停止采集
pub fn stop(&self);
/// 是否运行中
pub fn is_running(&self) -> bool;
}
pub struct CaptureConfig {
pub sample_rate: u32, // 48000
pub channels: u16, // 2
pub frame_size: usize, // 960 (20ms)
pub buffer_size: usize, // 4800
}
```
### 3.3 OpusEncoder (encoder.rs)
```rust
pub struct OpusEncoder {
/// Opus 编码器
encoder: audiopus::Encoder,
/// 采样率
sample_rate: u32,
/// 通道数
channels: u16,
/// 帧大小
frame_size: usize,
/// 码率
bitrate: u32,
}
impl OpusEncoder {
/// 创建编码器
pub fn new(quality: AudioQuality) -> Result<Self>;
/// 编码 PCM 数据
pub fn encode(&mut self, pcm: &[i16]) -> Result<Vec<u8>>;
/// 设置码率
pub fn set_bitrate(&mut self, bitrate: u32) -> Result<()>;
/// 获取码率
pub fn bitrate(&self) -> u32;
/// 重置编码器
pub fn reset(&mut self) -> Result<()>;
}
```
### 3.4 SharedAudioPipeline (shared_pipeline.rs)
```rust
pub struct SharedAudioPipeline {
/// 采集器
capturer: Arc<RwLock<Option<AudioCapturer>>>,
/// 编码器
encoder: Arc<Mutex<OpusEncoder>>,
/// 广播通道
tx: broadcast::Sender<AudioFrame>,
/// 采集任务
capture_task: Arc<RwLock<Option<JoinHandle<()>>>>,
/// 配置
config: Arc<RwLock<AudioConfig>>,
}
impl SharedAudioPipeline {
/// 创建管道
pub fn new(config: &AudioConfig) -> Result<Self>;
/// 启动管道
pub async fn start(&self) -> Result<()>;
/// 停止管道
pub async fn stop(&self) -> Result<()>;
/// 订阅音频帧
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame>;
/// 获取统计
pub fn stats(&self) -> PipelineStats;
}
pub struct AudioFrame {
/// Opus 数据
pub data: Bytes,
/// 时间戳
pub timestamp: u64,
/// 帧序号
pub sequence: u64,
}
```
---
## 4. 音频质量
```rust
pub enum AudioQuality {
/// 24 kbps - 最低带宽
VeryLow,
/// 48 kbps - 低带宽
Low,
/// 64 kbps - 中等
Medium,
/// 96 kbps - 高质量
High,
}
impl AudioQuality {
pub fn bitrate(&self) -> u32 {
match self {
Self::VeryLow => 24000,
Self::Low => 48000,
Self::Medium => 64000,
Self::High => 96000,
}
}
}
```
---
## 5. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AudioConfig {
/// 是否启用
pub enabled: bool,
/// 设备名
pub device: Option<String>,
/// 音频质量
pub quality: AudioQuality,
/// 自动启动
pub auto_start: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
enabled: true,
device: None, // 使用默认设备
quality: AudioQuality::Medium,
auto_start: false,
}
}
}
```
---
## 6. API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/api/audio/status` | GET | 获取音频状态 |
| `/api/audio/start` | POST | 启动音频 |
| `/api/audio/stop` | POST | 停止音频 |
| `/api/audio/devices` | GET | 列出设备 |
| `/api/audio/quality` | GET | 获取质量 |
| `/api/audio/quality` | POST | 设置质量 |
| `/api/ws/audio` | WS | 音频流 |
### 响应格式
```json
// GET /api/audio/status
{
"enabled": true,
"streaming": true,
"device": "hw:0,0",
"sample_rate": 48000,
"channels": 2,
"bitrate": 64000,
"error": null
}
// GET /api/audio/devices
{
"devices": [
{
"name": "hw:0,0",
"description": "USB Audio Device",
"is_default": true
}
]
}
```
---
## 7. WebSocket 音频流
```javascript
// 连接 WebSocket
const ws = new WebSocket('/api/ws/audio');
ws.binaryType = 'arraybuffer';
// 初始化 Opus 解码器
const decoder = new OpusDecoder();
// 接收音频帧
ws.onmessage = (event) => {
const frame = new Uint8Array(event.data);
const pcm = decoder.decode(frame);
audioContext.play(pcm);
};
```
---
## 8. 事件
```rust
pub enum SystemEvent {
AudioStateChanged {
enabled: bool,
streaming: bool,
device: Option<String>,
error: Option<String>,
},
}
```
---
## 9. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum AudioError {
#[error("Device not found: {0}")]
DeviceNotFound(String),
#[error("Device busy: {0}")]
DeviceBusy(String),
#[error("ALSA error: {0}")]
AlsaError(String),
#[error("Encoder error: {0}")]
EncoderError(String),
#[error("Not streaming")]
NotStreaming,
}
```
---
## 10. 使用示例
```rust
let controller = AudioController::new(&config, events)?;
// 启动音频
controller.start().await?;
// 订阅音频帧
let mut rx = controller.subscribe();
while let Ok(frame) = rx.recv().await {
// 处理 Opus 数据
send_to_client(frame.data);
}
// 停止
controller.stop().await?;
```
---
## 11. 常见问题
### Q: 找不到音频设备?
1. 检查 ALSA 配置
2. 运行 `arecord -l`
3. 检查权限
### Q: 音频延迟高?
1. 减小帧大小
2. 降低质量
3. 检查网络
### Q: 音频断断续续?
1. 增大缓冲区
2. 检查 CPU 负载
3. 使用更低质量

View File

@@ -1,340 +0,0 @@
# Auth 模块文档
## 1. 模块概述
Auth 模块提供用户认证和会话管理功能。
### 1.1 主要功能
- 用户管理
- 密码哈希 (Argon2)
- 会话管理
- 认证中间件
- 权限控制
### 1.2 文件结构
```
src/auth/
├── mod.rs # 模块导出
├── user.rs # 用户管理 (5KB)
├── session.rs # 会话管理 (4KB)
├── password.rs # 密码哈希 (1KB)
└── middleware.rs # 中间件 (4KB)
```
---
## 2. 核心组件
### 2.1 UserStore (user.rs)
```rust
pub struct UserStore {
db: Pool<Sqlite>,
}
impl UserStore {
/// 创建存储
pub async fn new(db: Pool<Sqlite>) -> Result<Self>;
/// 创建用户
pub async fn create_user(&self, user: &CreateUser) -> Result<User>;
/// 获取用户
pub async fn get_user(&self, id: &str) -> Result<Option<User>>;
/// 按用户名获取
pub async fn get_by_username(&self, username: &str) -> Result<Option<User>>;
/// 更新用户
pub async fn update_user(&self, id: &str, update: &UpdateUser) -> Result<()>;
/// 删除用户
pub async fn delete_user(&self, id: &str) -> Result<()>;
/// 列出用户
pub async fn list_users(&self) -> Result<Vec<User>>;
/// 验证密码
pub async fn verify_password(&self, username: &str, password: &str) -> Result<Option<User>>;
/// 更新密码
pub async fn update_password(&self, id: &str, new_password: &str) -> Result<()>;
/// 检查是否需要初始化
pub async fn needs_setup(&self) -> Result<bool>;
}
pub struct User {
pub id: String,
pub username: String,
pub role: UserRole,
pub created_at: DateTime<Utc>,
}
pub enum UserRole {
Admin,
User,
}
pub struct CreateUser {
pub username: String,
pub password: String,
pub role: UserRole,
}
```
### 2.2 SessionStore (session.rs)
```rust
pub struct SessionStore {
/// 会话映射
sessions: RwLock<HashMap<String, Session>>,
/// 会话超时
timeout: Duration,
}
impl SessionStore {
/// 创建存储
pub fn new(timeout: Duration) -> Self;
/// 创建会话
pub fn create_session(&self, user: &User) -> String;
/// 获取会话
pub fn get_session(&self, token: &str) -> Option<Session>;
/// 删除会话
pub fn delete_session(&self, token: &str);
/// 清理过期会话
pub fn cleanup_expired(&self);
/// 刷新会话
pub fn refresh_session(&self, token: &str) -> bool;
}
pub struct Session {
pub token: String,
pub user_id: String,
pub username: String,
pub role: UserRole,
pub created_at: Instant,
pub last_active: Instant,
}
```
### 2.3 密码哈希 (password.rs)
```rust
/// 哈希密码
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(hash)
}
/// 验证密码
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
```
### 2.4 认证中间件 (middleware.rs)
```rust
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
cookies: Cookies,
request: Request,
next: Next,
) -> Response {
// 获取 session token
let token = cookies
.get("session_id")
.map(|c| c.value().to_string());
// 验证会话
let session = token
.and_then(|t| state.sessions.get_session(&t));
if let Some(session) = session {
// 将用户信息注入请求
let mut request = request;
request.extensions_mut().insert(session);
next.run(request).await
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
pub async fn admin_middleware(
session: Extension<Session>,
request: Request,
next: Next,
) -> Response {
if session.role == UserRole::Admin {
next.run(request).await
} else {
StatusCode::FORBIDDEN.into_response()
}
}
```
---
## 3. API 端点
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/auth/login` | POST | Public | 登录 |
| `/api/auth/logout` | POST | User | 登出 |
| `/api/auth/check` | GET | User | 检查认证 |
| `/api/auth/password` | POST | User | 修改密码 |
| `/api/users` | GET | Admin | 列出用户 |
| `/api/users` | POST | Admin | 创建用户 |
| `/api/users/:id` | DELETE | Admin | 删除用户 |
| `/api/setup/init` | POST | Public | 初始化设置 |
### 请求/响应格式
```json
// POST /api/auth/login
// Request:
{
"username": "admin",
"password": "password123"
}
// Response:
{
"user": {
"id": "uuid",
"username": "admin",
"role": "admin"
}
}
// GET /api/auth/check
{
"authenticated": true,
"user": {
"id": "uuid",
"username": "admin",
"role": "admin"
}
}
```
---
## 4. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AuthConfig {
/// 会话超时 (秒)
pub session_timeout_secs: u64,
/// 是否启用认证
pub enabled: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
session_timeout_secs: 86400, // 24 小时
enabled: true,
}
}
}
```
---
## 5. 安全特性
### 5.1 密码存储
- Argon2id 哈希
- 随机盐值
- 不可逆
### 5.2 会话安全
- 随机 token (UUID v4)
- HTTPOnly Cookie
- 会话超时
- 自动清理
### 5.3 权限控制
- 两级权限: Admin / User
- 中间件检查
- 敏感操作需 Admin
---
## 6. 使用示例
```rust
// 创建用户
let user = users.create_user(&CreateUser {
username: "admin".to_string(),
password: "password123".to_string(),
role: UserRole::Admin,
}).await?;
// 验证密码
if let Some(user) = users.verify_password("admin", "password123").await? {
// 创建会话
let token = sessions.create_session(&user);
// 设置 Cookie
cookies.add(Cookie::build("session_id", token)
.http_only(true)
.path("/")
.finish());
}
// 获取会话
if let Some(session) = sessions.get_session(&token) {
println!("User: {}", session.username);
}
```
---
## 7. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("User not found")]
UserNotFound,
#[error("User already exists")]
UserExists,
#[error("Session expired")]
SessionExpired,
#[error("Permission denied")]
PermissionDenied,
#[error("Setup required")]
SetupRequired,
}
```

View File

@@ -1,755 +0,0 @@
# Config 模块文档
## 1. 模块概述
Config 模块提供配置管理功能,所有配置存储在 SQLite 数据库中,使用 ArcSwap 实现无锁读取,提供高性能配置访问。
### 1.1 主要功能
- SQLite 配置存储(持久化)
- 无锁配置读取ArcSwap
- 类型安全的配置结构
- 配置变更通知broadcast channel
- TypeScript 类型生成typeshare
- RESTful API按功能域分离
### 1.2 文件结构
```
src/config/
├── mod.rs # 模块导出
├── schema.rs # 配置结构定义(包含所有子配置)
└── store.rs # SQLite 存储与无锁缓存
```
---
## 2. 核心组件
### 2.1 ConfigStore (store.rs)
配置存储使用 **ArcSwap** 实现无锁读取,提供接近零成本的配置访问性能:
```rust
pub struct ConfigStore {
pool: Pool<Sqlite>,
/// 无锁缓存,使用 ArcSwap 实现零成本读取
cache: Arc<ArcSwap<AppConfig>>,
/// 配置变更通知通道
change_tx: broadcast::Sender<ConfigChange>,
}
impl ConfigStore {
/// 创建存储
pub async fn new(db_path: &Path) -> Result<Self>;
/// 获取当前配置(无锁,零拷贝)
///
/// 返回 Arc<AppConfig>,高效共享无需克隆
/// 这是一个无锁操作,开销极小
pub fn get(&self) -> Arc<AppConfig>;
/// 设置完整配置
pub async fn set(&self, config: AppConfig) -> Result<()>;
/// 使用闭包更新配置
///
/// 读-修改-写模式。并发更新时,最后的写入获胜。
/// 对于不频繁的用户触发配置更改来说是可接受的。
pub async fn update<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut AppConfig);
/// 订阅配置变更事件
pub fn subscribe(&self) -> broadcast::Receiver<ConfigChange>;
/// 检查系统是否已初始化(无锁)
pub fn is_initialized(&self) -> bool;
/// 获取数据库连接池(用于会话管理)
pub fn pool(&self) -> &Pool<Sqlite>;
}
```
**性能特点**
- `get()` 是无锁读取操作,返回 `Arc<AppConfig>`,无需克隆
- 配置读取频率远高于写入ArcSwap 优化了读取路径
- 写入操作先持久化到数据库,再原子性更新内存缓存
- 使用 broadcast channel 通知配置变更,支持多订阅者
**数据库连接池配置**
```rust
SqlitePoolOptions::new()
.max_connections(2) // SQLite 单写模式2 个连接足够
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(300))
```
### 2.2 AppConfig (schema.rs)
主应用配置结构,包含所有子系统的配置:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AppConfig {
/// 初始设置是否完成
pub initialized: bool,
/// 认证配置
pub auth: AuthConfig,
/// 视频采集配置
pub video: VideoConfig,
/// HID键盘/鼠标)配置
pub hid: HidConfig,
/// MSD大容量存储配置
pub msd: MsdConfig,
/// ATX 电源控制配置
pub atx: AtxConfig,
/// 音频配置
pub audio: AudioConfig,
/// 流媒体配置
pub stream: StreamConfig,
/// Web 服务器配置
pub web: WebConfig,
/// 扩展配置ttyd, gostc, easytier
pub extensions: ExtensionsConfig,
/// RustDesk 远程访问配置
pub rustdesk: RustDeskConfig,
}
```
### 2.3 主要子配置结构
#### AuthConfig - 认证配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AuthConfig {
/// 会话超时时间(秒)
pub session_timeout_secs: u32, // 默认 8640024小时
/// 启用双因素认证
pub totp_enabled: bool,
/// TOTP 密钥(加密存储)
pub totp_secret: Option<String>,
}
```
#### VideoConfig - 视频采集配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
#[typeshare]
pub struct VideoConfig {
/// 视频设备路径(如 /dev/video0
pub device: Option<String>,
/// 像素格式(如 "MJPEG", "YUYV", "NV12"
pub format: Option<String>,
/// 分辨率宽度
pub width: u32, // 默认 1920
/// 分辨率高度
pub height: u32, // 默认 1080
/// 帧率
pub fps: u32, // 默认 30
/// JPEG 质量1-100
pub quality: u32, // 默认 80
}
```
#### HidConfig - HID 配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
#[typeshare]
pub struct HidConfig {
/// HID 后端类型
pub backend: HidBackend, // Otg | Ch9329 | None
/// OTG 键盘设备路径
pub otg_keyboard: String, // 默认 "/dev/hidg0"
/// OTG 鼠标设备路径
pub otg_mouse: String, // 默认 "/dev/hidg1"
/// OTG UDCUSB 设备控制器)名称
pub otg_udc: Option<String>,
/// OTG USB 设备描述符配置
pub otg_descriptor: OtgDescriptorConfig,
/// CH9329 串口路径
pub ch9329_port: String, // 默认 "/dev/ttyUSB0"
/// CH9329 波特率
pub ch9329_baudrate: u32, // 默认 9600
/// 鼠标模式:绝对定位或相对定位
pub mouse_absolute: bool, // 默认 true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[typeshare]
pub struct OtgDescriptorConfig {
pub vendor_id: u16, // 默认 0x1d6bLinux Foundation
pub product_id: u16, // 默认 0x0104
pub manufacturer: String, // 默认 "One-KVM"
pub product: String, // 默认 "One-KVM USB Device"
pub serial_number: Option<String>,
}
```
#### StreamConfig - 流媒体配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct StreamConfig {
/// 流模式WebRTC | Mjpeg
pub mode: StreamMode,
/// 编码器类型
pub encoder: EncoderType, // Auto | Software | Vaapi | Nvenc | Qsv | Amf | Rkmpp | V4l2m2m
/// 码率预设Speed | Balanced | Quality
pub bitrate_preset: BitratePreset,
/// 自定义 STUN 服务器
pub stun_server: Option<String>, // 默认 "stun:stun.l.google.com:19302"
/// 自定义 TURN 服务器
pub turn_server: Option<String>,
/// TURN 用户名
pub turn_username: Option<String>,
/// TURN 密码(加密存储,不通过 API 暴露)
pub turn_password: Option<String>,
/// 无客户端时自动暂停
#[typeshare(skip)]
pub auto_pause_enabled: bool,
/// 自动暂停延迟(秒)
#[typeshare(skip)]
pub auto_pause_delay_secs: u64,
/// 客户端超时清理(秒)
#[typeshare(skip)]
pub client_timeout_secs: u64,
}
```
#### MsdConfig - 大容量存储配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct MsdConfig {
/// 启用 MSD 功能
pub enabled: bool, // 默认 true
/// ISO/IMG 镜像存储路径
pub images_path: String, // 默认 "./data/msd/images"
/// Ventoy 启动驱动器文件路径
pub drive_path: String, // 默认 "./data/msd/ventoy.img"
/// 虚拟驱动器大小MB最小 1024
pub virtual_drive_size_mb: u32, // 默认 1638416GB
}
```
#### AtxConfig - ATX 电源控制配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AtxConfig {
/// 启用 ATX 功能
pub enabled: bool,
/// 电源按钮配置(短按和长按共用)
pub power: AtxKeyConfig,
/// 重置按钮配置
pub reset: AtxKeyConfig,
/// LED 检测配置(可选)
pub led: AtxLedConfig,
/// WOL 数据包使用的网络接口(空字符串 = 自动)
pub wol_interface: String,
}
```
#### AudioConfig - 音频配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AudioConfig {
/// 启用音频采集
pub enabled: bool, // 默认 false
/// ALSA 设备名称
pub device: String, // 默认 "default"
/// 音频质量预设:"voice" | "balanced" | "high"
pub quality: String, // 默认 "balanced"
}
```
**注意**:采样率固定为 48000Hz声道固定为 2立体声这是 Opus 编码和 WebRTC 的最佳配置。
#### WebConfig - Web 服务器配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct WebConfig {
/// HTTP 端口
pub http_port: u16, // 默认 8080
/// HTTPS 端口
pub https_port: u16, // 默认 8443
/// 绑定地址
pub bind_address: String, // 默认 "0.0.0.0"
/// 启用 HTTPS
pub https_enabled: bool, // 默认 false
/// 自定义 SSL 证书路径
pub ssl_cert_path: Option<String>,
/// 自定义 SSL 密钥路径
pub ssl_key_path: Option<String>,
}
```
---
## 3. TypeScript 类型生成
使用 `#[typeshare]` 属性自动生成 TypeScript 类型:
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct VideoConfig {
pub device: Option<String>,
pub width: u32,
pub height: u32,
}
```
生成的 TypeScript
```typescript
export interface VideoConfig {
device?: string;
width: number;
height: number;
}
```
生成命令:
```bash
./scripts/generate-types.sh
# 或
typeshare src --lang=typescript --output-file=web/src/types/generated.ts
```
---
## 4. API 端点
所有配置端点均需要 **Admin** 权限,采用 RESTful 设计,按功能域分离。
### 4.1 全局配置
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/config` | GET | Admin | 获取完整配置(敏感信息已过滤) |
| `/api/config` | POST | Admin | 更新完整配置(已废弃,请使用按域 PATCH |
### 4.2 分域配置端点
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/config/video` | GET | Admin | 获取视频配置 |
| `/api/config/video` | PATCH | Admin | 更新视频配置(部分更新) |
| `/api/config/stream` | GET | Admin | 获取流配置 |
| `/api/config/stream` | PATCH | Admin | 更新流配置(部分更新) |
| `/api/config/hid` | GET | Admin | 获取 HID 配置 |
| `/api/config/hid` | PATCH | Admin | 更新 HID 配置(部分更新) |
| `/api/config/msd` | GET | Admin | 获取 MSD 配置 |
| `/api/config/msd` | PATCH | Admin | 更新 MSD 配置(部分更新) |
| `/api/config/atx` | GET | Admin | 获取 ATX 配置 |
| `/api/config/atx` | PATCH | Admin | 更新 ATX 配置(部分更新) |
| `/api/config/audio` | GET | Admin | 获取音频配置 |
| `/api/config/audio` | PATCH | Admin | 更新音频配置(部分更新) |
| `/api/config/web` | GET | Admin | 获取 Web 服务器配置 |
| `/api/config/web` | PATCH | Admin | 更新 Web 服务器配置(部分更新) |
### 4.3 RustDesk 配置端点
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/config/rustdesk` | GET | Admin | 获取 RustDesk 配置 |
| `/api/config/rustdesk` | PATCH | Admin | 更新 RustDesk 配置 |
| `/api/config/rustdesk/status` | GET | Admin | 获取 RustDesk 服务状态 |
| `/api/config/rustdesk/password` | GET | Admin | 获取设备密码 |
| `/api/config/rustdesk/regenerate-id` | POST | Admin | 重新生成设备 ID |
| `/api/config/rustdesk/regenerate-password` | POST | Admin | 重新生成设备密码 |
### 4.4 请求/响应示例
#### 获取视频配置
```bash
GET /api/config/video
```
响应:
```json
{
"device": "/dev/video0",
"format": "MJPEG",
"width": 1920,
"height": 1080,
"fps": 30,
"quality": 80
}
```
#### 部分更新视频配置
```bash
PATCH /api/config/video
Content-Type: application/json
{
"width": 1280,
"height": 720,
"fps": 60
}
```
响应:更新后的完整 VideoConfig
```json
{
"device": "/dev/video0",
"format": "MJPEG",
"width": 1280,
"height": 720,
"fps": 60,
"quality": 80
}
```
**注意**
- 所有 PATCH 请求都支持部分更新,只需要提供要修改的字段
- 未提供的字段保持原有值不变
- 更新后返回完整的配置对象
- 配置变更会自动触发相关组件重载
---
## 5. 配置变更通知
ConfigStore 提供 broadcast channel 用于配置变更通知:
```rust
/// 配置变更事件
#[derive(Debug, Clone)]
pub struct ConfigChange {
pub key: String,
}
// 订阅配置变更
let mut rx = config_store.subscribe();
// 监听变更事件
while let Ok(change) = rx.recv().await {
println!("配置 {} 已更新", change.key);
// 重载相关组件
}
```
**工作流程**
1. 调用 `config_store.set()``config_store.update()`
2. 配置写入数据库(持久化)
3. 原子性更新内存缓存ArcSwap
4. 发送 `ConfigChange` 事件到 broadcast channel
5. 各组件的订阅者接收事件并执行重载逻辑
**组件重载示例**
```rust
// VideoStreamManager 监听配置变更
let mut config_rx = config_store.subscribe();
tokio::spawn(async move {
while let Ok(change) = config_rx.recv().await {
if change.key == "app_config" {
video_manager.reload().await;
}
}
});
```
---
## 6. 数据库结构
ConfigStore 使用 SQLite 存储配置和其他系统数据:
### 6.1 配置表
```sql
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
配置以 JSON 格式存储:
```sql
-- 应用配置
key: 'app_config'
value: '{"initialized": true, "video": {...}, "hid": {...}, ...}'
```
### 6.2 用户表
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### 6.3 会话表
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
data TEXT
);
```
### 6.4 API 令牌表
```sql
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
);
```
**存储特点**
- 所有配置存储在单个 JSON 文本中(`app_config` key
- 每次配置更新都更新整个 JSON简化事务处理
- 使用 `ON CONFLICT` 实现 upsert 操作
- 连接池大小为 21 读 + 1 写),适合嵌入式环境
---
## 7. 使用示例
### 7.1 基本用法
```rust
use crate::config::ConfigStore;
use std::path::Path;
// 创建配置存储
let config_store = ConfigStore::new(Path::new("./data/config.db")).await?;
// 获取配置(无锁,零拷贝)
let config = config_store.get();
println!("视频设备: {:?}", config.video.device);
println!("是否已初始化: {}", config.initialized);
// 检查是否已初始化
if !config_store.is_initialized() {
println!("系统尚未初始化,请完成初始设置");
}
```
### 7.2 更新配置
```rust
// 方式 1: 使用闭包更新(推荐)
config_store.update(|config| {
config.video.width = 1280;
config.video.height = 720;
config.video.fps = 60;
}).await?;
// 方式 2: 整体替换
let mut new_config = (*config_store.get()).clone();
new_config.stream.mode = StreamMode::WebRTC;
new_config.stream.encoder = EncoderType::Rkmpp;
config_store.set(new_config).await?;
```
### 7.3 订阅配置变更
```rust
// 在组件中监听配置变更
let config_store = state.config.clone();
let mut rx = config_store.subscribe();
tokio::spawn(async move {
while let Ok(change) = rx.recv().await {
tracing::info!("配置 {} 已变更", change.key);
// 重新加载配置
let config = config_store.get();
// 执行重载逻辑
if let Err(e) = reload_component(&config).await {
tracing::error!("重载组件失败: {}", e);
}
}
});
```
### 7.4 在 Handler 中使用
```rust
use axum::{extract::State, Json};
use std::sync::Arc;
use crate::config::VideoConfig;
use crate::state::AppState;
// 获取视频配置
pub async fn get_video_config(
State(state): State<Arc<AppState>>
) -> Json<VideoConfig> {
let config = state.config.get();
Json(config.video.clone())
}
// 更新视频配置
pub async fn update_video_config(
State(state): State<Arc<AppState>>,
Json(update): Json<VideoConfig>,
) -> Result<Json<VideoConfig>> {
// 更新配置
state.config.update(|config| {
config.video = update;
}).await?;
// 返回更新后的配置
let config = state.config.get();
Ok(Json(config.video.clone()))
}
```
### 7.5 访问数据库连接池
```rust
// ConfigStore 还提供数据库连接池访问
// 用于用户管理、会话管理等功能
let pool = config_store.pool();
// 查询用户
let user: Option<User> = sqlx::query_as(
"SELECT * FROM users WHERE username = ?"
)
.bind(username)
.fetch_optional(pool)
.await?;
```
---
## 8. 默认配置
系统首次运行时会自动创建默认配置:
```rust
impl Default for AppConfig {
fn default() -> Self {
Self {
initialized: false, // 需要通过初始设置向导完成
auth: AuthConfig {
session_timeout_secs: 86400, // 24小时
totp_enabled: false,
totp_secret: None,
},
video: VideoConfig {
device: None, // 自动检测
format: None, // 自动检测或使用 MJPEG
width: 1920,
height: 1080,
fps: 30,
quality: 80,
},
stream: StreamConfig {
mode: StreamMode::Mjpeg,
encoder: EncoderType::Auto,
bitrate_preset: BitratePreset::Balanced,
stun_server: Some("stun:stun.l.google.com:19302".to_string()),
turn_server: None,
turn_username: None,
turn_password: None,
auto_pause_enabled: false,
auto_pause_delay_secs: 10,
client_timeout_secs: 30,
},
hid: HidConfig {
backend: HidBackend::None, // 需要用户手动启用
otg_keyboard: "/dev/hidg0".to_string(),
otg_mouse: "/dev/hidg1".to_string(),
otg_udc: None, // 自动检测
otg_descriptor: OtgDescriptorConfig::default(),
ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600,
mouse_absolute: true,
},
msd: MsdConfig {
enabled: true,
images_path: "./data/msd/images".to_string(),
drive_path: "./data/msd/ventoy.img".to_string(),
virtual_drive_size_mb: 16384, // 16GB
},
atx: AtxConfig {
enabled: false, // 需要用户配置硬件绑定
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
wol_interface: String::new(), // 自动检测
},
audio: AudioConfig {
enabled: false,
device: "default".to_string(),
quality: "balanced".to_string(),
},
web: WebConfig {
http_port: 8080,
https_port: 8443,
bind_address: "0.0.0.0".to_string(),
https_enabled: false,
ssl_cert_path: None,
ssl_key_path: None,
},
extensions: ExtensionsConfig::default(),
rustdesk: RustDeskConfig::default(),
}
}
}
```
**配置初始化流程**
1. 用户首次访问 Web UI系统检测到 `initialized = false`
2. 重定向到初始设置向导(`/setup`
3. 用户设置管理员账户、选择视频设备等
4. 完成设置后,`initialized` 设为 `true`
5. 后续可通过设置页面(`/settings`)修改各项配置

View File

@@ -1,353 +0,0 @@
# Events 模块文档
## 1. 模块概述
Events 模块提供事件总线功能,用于模块间通信和状态广播。
### 1.1 主要功能
- 事件发布/订阅
- 多订阅者广播
- WebSocket 事件推送
- 状态变更通知
### 1.2 文件结构
```
src/events/
└── mod.rs # EventBus 实现
```
---
## 2. 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event System │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Video │ │ HID │ │ Audio │
│ Module │ │ Module │ │ Module │
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
│ publish() │ publish() │ publish()
└──────────────────┼──────────────────┘
┌─────────────────────┐
│ EventBus │
│ (broadcast channel) │
└──────────┬──────────┘
┌─────────────────┼─────────────────┐
│ │ │
│ subscribe() │ subscribe() │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ WebSocket │ │ DeviceInfo │ │ Internal │
│ Handler │ │ Broadcaster │ │ Tasks │
└────────────────┘ └────────────────┘ └────────────────┘
```
---
## 3. 核心组件
### 3.1 EventBus
```rust
pub struct EventBus {
/// 广播发送器
tx: broadcast::Sender<SystemEvent>,
}
impl EventBus {
/// 创建事件总线
pub fn new() -> Self {
let (tx, _) = broadcast::channel(1024);
Self { tx }
}
/// 发布事件
pub fn publish(&self, event: SystemEvent) {
let _ = self.tx.send(event);
}
/// 订阅事件
pub fn subscribe(&self) -> broadcast::Receiver<SystemEvent> {
self.tx.subscribe()
}
}
```
### 3.2 SystemEvent
```rust
#[derive(Clone, Debug, Serialize)]
pub enum SystemEvent {
// 视频事件
StreamStateChanged {
state: String,
device: Option<String>,
resolution: Option<Resolution>,
fps: Option<f32>,
},
VideoDeviceChanged {
added: Vec<String>,
removed: Vec<String>,
},
// HID 事件
HidStateChanged {
backend: String,
initialized: bool,
keyboard_connected: bool,
mouse_connected: bool,
mouse_mode: String,
error: Option<String>,
},
// MSD 事件
MsdStateChanged {
mode: String,
connected: bool,
image: Option<String>,
error: Option<String>,
},
MsdDownloadProgress {
download_id: String,
downloaded: u64,
total: u64,
speed: u64,
},
// ATX 事件
AtxStateChanged {
power_on: bool,
last_action: Option<String>,
error: Option<String>,
},
// 音频事件
AudioStateChanged {
enabled: bool,
streaming: bool,
device: Option<String>,
error: Option<String>,
},
// 配置事件
ConfigChanged {
section: String,
},
// 设备信息汇总
DeviceInfo {
video: VideoInfo,
hid: HidInfo,
msd: MsdInfo,
atx: AtxInfo,
audio: AudioInfo,
},
// 系统错误
SystemError {
module: String,
severity: String,
message: String,
},
// RustDesk 事件
RustDeskStatusChanged {
status: String,
device_id: Option<String>,
error: Option<String>,
},
RustDeskConnectionOpened {
connection_id: String,
peer_id: String,
},
RustDeskConnectionClosed {
connection_id: String,
peer_id: String,
reason: String,
},
}
```
---
## 4. 设备信息广播器
`main.rs` 中启动的后台任务:
```rust
pub fn spawn_device_info_broadcaster(
state: Arc<AppState>,
events: Arc<EventBus>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut rx = events.subscribe();
let mut debounce = tokio::time::interval(Duration::from_millis(100));
let mut pending = false;
loop {
tokio::select! {
// 收到事件
result = rx.recv() => {
if result.is_ok() {
pending = true;
}
}
// 防抖定时器
_ = debounce.tick() => {
if pending {
pending = false;
// 收集设备信息
let device_info = state.get_device_info().await;
// 广播
events.publish(SystemEvent::DeviceInfo(device_info));
}
}
}
}
})
}
```
---
## 5. WebSocket 事件推送
```rust
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_ws(socket, state))
}
async fn handle_ws(mut socket: WebSocket, state: Arc<AppState>) {
let mut rx = state.events.subscribe();
loop {
tokio::select! {
// 发送事件给客户端
result = rx.recv() => {
if let Ok(event) = result {
let json = serde_json::to_string(&event).unwrap();
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
}
// 接收客户端消息
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
}
```
---
## 6. 使用示例
### 6.1 发布事件
```rust
// 视频模块发布状态变更
events.publish(SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
resolution: Some(Resolution { width: 1920, height: 1080 }),
fps: Some(30.0),
});
// HID 模块发布状态变更
events.publish(SystemEvent::HidStateChanged {
backend: "otg".to_string(),
initialized: true,
keyboard_connected: true,
mouse_connected: true,
mouse_mode: "absolute".to_string(),
error: None,
});
```
### 6.2 订阅事件
```rust
let mut rx = events.subscribe();
loop {
match rx.recv().await {
Ok(SystemEvent::StreamStateChanged { state, .. }) => {
println!("Stream state: {}", state);
}
Ok(SystemEvent::HidStateChanged { backend, .. }) => {
println!("HID backend: {}", backend);
}
Err(_) => break,
}
}
```
---
## 7. 前端事件处理
```typescript
// 连接 WebSocket
const ws = new WebSocket('/api/ws');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'StreamStateChanged':
updateStreamStatus(data);
break;
case 'HidStateChanged':
updateHidStatus(data);
break;
case 'MsdStateChanged':
updateMsdStatus(data);
break;
case 'DeviceInfo':
updateAllDevices(data);
break;
}
};
```
---
## 8. 最佳实践
### 8.1 事件粒度
- 使用细粒度事件便于精确更新
- DeviceInfo 用于初始化和定期同步
### 8.2 防抖
- 使用 100ms 防抖避免事件风暴
- 合并多个快速变更
### 8.3 错误处理
- 发布失败静默忽略 (fire-and-forget)
- 订阅者断开自动清理

File diff suppressed because it is too large Load Diff

View File

@@ -1,617 +0,0 @@
# MSD 模块文档
## 1. 模块概述
MSD (Mass Storage Device) 模块提供虚拟存储设备功能,允许将 ISO/IMG 镜像作为 USB 存储设备挂载到目标计算机。
### 1.1 主要功能
- ISO/IMG 镜像挂载
- 镜像下载管理
- Ventoy 多 ISO 启动盘
- 热插拔支持
- 下载进度追踪
### 1.2 文件结构
```
src/msd/
├── mod.rs # 模块导出
├── controller.rs # MsdController (20KB)
├── image.rs # 镜像管理 (21KB)
├── ventoy_drive.rs # Ventoy 驱动 (24KB)
├── monitor.rs # 健康监视 (9KB)
└── types.rs # 类型定义 (6KB)
```
---
## 2. 架构设计
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ MSD Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
Web API
┌─────────────────┐
│ MsdController │
│ (controller.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────┐ ┌───────────┐
│ Image │ │ Ventoy │ │ OTG │
│ Manager │ │ Drive │ │ Service │
│ (image.rs) │ │(ventoy.rs)│ │ │
└──────┬──────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────┐ ┌───────────┐
│ /data/ │ │ exFAT │ │ MSD │
│ images/ │ │ Drive │ │ Function │
└─────────────┘ └───────────┘ └───────────┘
┌───────────────┐
│ Target PC │
│ (USB Drive) │
└───────────────┘
```
### 2.2 MSD 模式
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ MSD Modes │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Image Mode │
│ ┌───────────┐ │
│ │ ISO/IMG │ ──► MSD LUN ──► Target PC sees single drive │
│ │ File │ │
│ └───────────┘ │
│ 特点: │
│ - 单个镜像文件 │
│ - 直接挂载 │
│ - 适合系统安装 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Ventoy Mode │
│ ┌───────────┐ │
│ │ ISO 1 │ │
│ ├───────────┤ ┌───────────┐ │
│ │ ISO 2 │ ──► │ Ventoy │ ──► Target PC sees bootable drive │
│ ├───────────┤ │ Drive │ with ISO selection menu │
│ │ ISO 3 │ └───────────┘ │
│ └───────────┘ │
│ 特点: │
│ - 多个 ISO 文件 │
│ - exFAT 文件系统 │
│ - 启动菜单选择 │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 3. 核心组件
### 3.1 MsdController (controller.rs)
MSD 控制器主类。
```rust
pub struct MsdController {
/// 当前状态
state: Arc<RwLock<MsdState>>,
/// 镜像管理器
image_manager: Arc<ImageManager>,
/// Ventoy 驱动器
ventoy_drive: Arc<RwLock<Option<VentoyDrive>>>,
/// OTG 服务
otg_service: Arc<OtgService>,
/// MSD 函数句柄
msd_function: Arc<RwLock<Option<MsdFunction>>>,
/// 事件总线
events: Arc<EventBus>,
/// 数据目录
data_dir: PathBuf,
}
impl MsdController {
/// 创建控制器
pub async fn new(
otg_service: Arc<OtgService>,
data_dir: PathBuf,
events: Arc<EventBus>,
) -> Result<Arc<Self>>;
/// 获取状态
pub fn state(&self) -> MsdState;
/// 连接 MSD
pub async fn connect(&self) -> Result<()>;
/// 断开 MSD
pub async fn disconnect(&self) -> Result<()>;
/// 切换到镜像模式
pub async fn set_image(&self, image_id: &str) -> Result<()>;
/// 切换到 Ventoy 模式
pub async fn set_ventoy(&self) -> Result<()>;
/// 清除当前挂载
pub async fn clear(&self) -> Result<()>;
/// 列出镜像
pub fn list_images(&self) -> Vec<ImageInfo>;
/// 上传镜像
pub async fn upload_image(&self, name: &str, data: Bytes) -> Result<ImageInfo>;
/// 从 URL 下载镜像
pub async fn download_image(&self, url: &str) -> Result<String>;
/// 删除镜像
pub async fn delete_image(&self, image_id: &str) -> Result<()>;
/// 获取下载进度
pub fn get_download_progress(&self, download_id: &str) -> Option<DownloadProgress>;
}
pub struct MsdState {
/// 是否可用
pub available: bool,
/// 当前模式
pub mode: MsdMode,
/// 是否已连接
pub connected: bool,
/// 当前镜像信息
pub current_image: Option<ImageInfo>,
/// 驱动器信息
pub drive_info: Option<DriveInfo>,
/// 错误信息
pub error: Option<String>,
}
pub enum MsdMode {
/// 未激活
None,
/// 单镜像模式
Image,
/// Ventoy 模式
Drive,
}
```
### 3.2 ImageManager (image.rs)
镜像文件管理器。
```rust
pub struct ImageManager {
/// 镜像目录
images_dir: PathBuf,
/// 镜像列表缓存
images: RwLock<HashMap<String, ImageInfo>>,
/// 下载任务
downloads: RwLock<HashMap<String, DownloadTask>>,
/// HTTP 客户端
http_client: reqwest::Client,
}
impl ImageManager {
/// 创建管理器
pub fn new(images_dir: PathBuf) -> Result<Self>;
/// 扫描镜像目录
pub fn scan_images(&self) -> Result<Vec<ImageInfo>>;
/// 获取镜像信息
pub fn get_image(&self, id: &str) -> Option<ImageInfo>;
/// 添加镜像
pub async fn add_image(&self, name: &str, data: Bytes) -> Result<ImageInfo>;
/// 删除镜像
pub fn delete_image(&self, id: &str) -> Result<()>;
/// 开始下载
pub async fn start_download(&self, url: &str) -> Result<String>;
/// 取消下载
pub fn cancel_download(&self, download_id: &str) -> Result<()>;
/// 获取下载进度
pub fn get_download_progress(&self, download_id: &str) -> Option<DownloadProgress>;
/// 验证镜像文件
fn validate_image(path: &Path) -> Result<ImageFormat>;
}
pub struct ImageInfo {
/// 唯一 ID
pub id: String,
/// 文件名
pub name: String,
/// 文件大小
pub size: u64,
/// 格式
pub format: ImageFormat,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 下载状态
pub download_status: Option<DownloadStatus>,
}
pub enum ImageFormat {
/// ISO 光盘镜像
Iso,
/// 原始磁盘镜像
Img,
/// 未知格式
Unknown,
}
pub struct DownloadProgress {
/// 已下载字节
pub downloaded: u64,
/// 总字节数
pub total: u64,
/// 下载速度 (bytes/sec)
pub speed: u64,
/// 预计剩余时间
pub eta_secs: u64,
/// 状态
pub status: DownloadStatus,
}
pub enum DownloadStatus {
Pending,
Downloading,
Completed,
Failed(String),
Cancelled,
}
```
### 3.3 VentoyDrive (ventoy_drive.rs)
Ventoy 可启动驱动器管理。
```rust
pub struct VentoyDrive {
/// 驱动器路径
drive_path: PathBuf,
/// 镜像路径
images: Vec<PathBuf>,
/// 容量
capacity: u64,
/// 已用空间
used: u64,
}
impl VentoyDrive {
/// 创建 Ventoy 驱动器
pub fn create(drive_path: PathBuf, capacity: u64) -> Result<Self>;
/// 添加 ISO
pub fn add_iso(&mut self, iso_path: &Path) -> Result<()>;
/// 移除 ISO
pub fn remove_iso(&mut self, name: &str) -> Result<()>;
/// 列出 ISO
pub fn list_isos(&self) -> Vec<String>;
/// 获取驱动器信息
pub fn info(&self) -> DriveInfo;
/// 获取驱动器路径
pub fn path(&self) -> &Path;
}
pub struct DriveInfo {
/// 容量
pub capacity: u64,
/// 已用空间
pub used: u64,
/// 可用空间
pub available: u64,
/// ISO 列表
pub isos: Vec<String>,
}
```
---
## 4. 类型定义
### 4.1 MSD 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct MsdConfig {
/// 是否启用 MSD
pub enabled: bool,
/// 镜像目录
pub images_dir: Option<String>,
/// 默认模式
pub default_mode: MsdMode,
/// Ventoy 容量 (MB)
pub ventoy_capacity_mb: u32,
}
impl Default for MsdConfig {
fn default() -> Self {
Self {
enabled: true,
images_dir: None,
default_mode: MsdMode::None,
ventoy_capacity_mb: 4096, // 4GB
}
}
}
```
---
## 5. API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/api/msd/status` | GET | 获取 MSD 状态 |
| `/api/msd/connect` | POST | 连接 MSD |
| `/api/msd/disconnect` | POST | 断开 MSD |
| `/api/msd/images` | GET | 列出镜像 |
| `/api/msd/images` | POST | 上传镜像 |
| `/api/msd/images/:id` | DELETE | 删除镜像 |
| `/api/msd/images/download` | POST | 从 URL 下载 |
| `/api/msd/images/download/:id` | GET | 获取下载进度 |
| `/api/msd/images/download/:id` | DELETE | 取消下载 |
| `/api/msd/set-image` | POST | 设置当前镜像 |
| `/api/msd/set-ventoy` | POST | 设置 Ventoy 模式 |
| `/api/msd/clear` | POST | 清除挂载 |
### 响应格式
```json
// GET /api/msd/status
{
"available": true,
"mode": "image",
"connected": true,
"current_image": {
"id": "abc123",
"name": "ubuntu-22.04.iso",
"size": 4700000000,
"format": "iso"
},
"drive_info": null,
"error": null
}
// GET /api/msd/images
{
"images": [
{
"id": "abc123",
"name": "ubuntu-22.04.iso",
"size": 4700000000,
"format": "iso",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
// POST /api/msd/images/download
// Request: { "url": "https://example.com/image.iso" }
// Response: { "download_id": "xyz789" }
// GET /api/msd/images/download/xyz789
{
"downloaded": 1234567890,
"total": 4700000000,
"speed": 12345678,
"eta_secs": 280,
"status": "downloading"
}
```
---
## 6. 事件
```rust
pub enum SystemEvent {
MsdStateChanged {
mode: MsdMode,
connected: bool,
image: Option<String>,
error: Option<String>,
},
MsdDownloadProgress {
download_id: String,
progress: DownloadProgress,
},
MsdDownloadComplete {
download_id: String,
image_id: String,
success: bool,
error: Option<String>,
},
}
```
---
## 7. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum MsdError {
#[error("MSD not available")]
NotAvailable,
#[error("Already connected")]
AlreadyConnected,
#[error("Not connected")]
NotConnected,
#[error("Image not found: {0}")]
ImageNotFound(String),
#[error("Invalid image format: {0}")]
InvalidFormat(String),
#[error("Download failed: {0}")]
DownloadFailed(String),
#[error("Storage full")]
StorageFull,
#[error("OTG error: {0}")]
OtgError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
```
---
## 8. 使用示例
### 8.1 挂载 ISO 镜像
```rust
let msd = MsdController::new(otg_service, data_dir, events).await?;
// 列出镜像
let images = msd.list_images();
println!("Available images: {:?}", images);
// 设置镜像
msd.set_image("abc123").await?;
// 连接到目标 PC
msd.connect().await?;
// 目标 PC 现在可以看到 USB 驱动器...
// 断开连接
msd.disconnect().await?;
```
### 8.2 从 URL 下载
```rust
// 开始下载
let download_id = msd.download_image("https://example.com/ubuntu.iso").await?;
// 监控进度
loop {
if let Some(progress) = msd.get_download_progress(&download_id) {
println!("Progress: {}%", progress.downloaded * 100 / progress.total);
if matches!(progress.status, DownloadStatus::Completed) {
break;
}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
```
### 8.3 使用 Ventoy 模式
```rust
// 切换到 Ventoy 模式
msd.set_ventoy().await?;
// 获取驱动器信息
let state = msd.state();
if let Some(drive_info) = state.drive_info {
println!("Capacity: {} MB", drive_info.capacity / 1024 / 1024);
println!("ISOs: {:?}", drive_info.isos);
}
// 连接
msd.connect().await?;
```
---
## 9. 常见问题
### Q: 镜像无法挂载?
1. 检查镜像文件完整性
2. 确认文件格式正确
3. 检查存储空间
### Q: 目标 PC 不识别?
1. 检查 USB 连接
2. 尝试重新连接
3. 查看目标 PC 的设备管理器
### Q: 下载速度慢?
1. 检查网络连接
2. 使用更近的镜像源
3. 检查磁盘 I/O
### Q: Ventoy 启动失败?
1. 检查目标 PC BIOS 设置
2. 尝试不同的启动模式
3. 确认 ISO 文件支持 Ventoy

View File

@@ -1,667 +0,0 @@
# OTG 模块文档
## 1. 模块概述
OTG (On-The-Go) 模块负责管理 Linux USB Gadget为 HID 和 MSD 功能提供统一的 USB 设备管理。
### 1.1 主要功能
- USB Gadget 生命周期管理
- HID 函数配置 (键盘、鼠标)
- MSD 函数配置 (虚拟存储)
- ConfigFS 操作
- UDC 绑定/解绑
### 1.2 文件结构
```
src/otg/
├── mod.rs # 模块导出
├── service.rs # OtgService (17KB)
├── manager.rs # OtgGadgetManager (12KB)
├── hid.rs # HID Function (7KB)
├── msd.rs # MSD Function (14KB)
├── configfs.rs # ConfigFS 操作 (4KB)
├── endpoint.rs # 端点分配 (2KB)
└── report_desc.rs # HID 报告描述符 (6KB)
```
---
## 2. 架构设计
### 2.1 设计目标
解决 HID 和 MSD 共享同一个 USB Gadget 的所有权问题:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ OTG Ownership Model │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ OtgService │ ◄── 唯一所有者
│ (service.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
enable_hid() enable_msd() 状态查询
│ │
└──────┬──────┘
┌─────────────────┐
│OtgGadgetManager │
│ (manager.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐
│ HID │ │ MSD │ │ UDC │
│ Func │ │ Func │ │ Bind │
└───────┘ └───────┘ └───────┘
```
### 2.2 ConfigFS 结构
```
/sys/kernel/config/usb_gadget/one-kvm/
├── idVendor # 0x05ac (Apple)
├── idProduct # 0x0001
├── bcdDevice # 0x0100
├── bcdUSB # 0x0200
├── bMaxPacketSize0 # 64
├── strings/
│ └── 0x409/ # English
│ ├── manufacturer # "One-KVM"
│ ├── product # "KVM Device"
│ └── serialnumber # UUID
├── configs/
│ └── c.1/
│ ├── MaxPower # 500
│ ├── strings/
│ │ └── 0x409/
│ │ └── configuration # "Config 1"
│ └── (function symlinks)
├── functions/
│ ├── hid.usb0/ # 键盘
│ │ ├── protocol # 1 (keyboard)
│ │ ├── subclass # 1 (boot)
│ │ ├── report_length # 8
│ │ └── report_desc # (binary)
│ │
│ ├── hid.usb1/ # 相对鼠标
│ │ ├── protocol # 2 (mouse)
│ │ ├── subclass # 1 (boot)
│ │ ├── report_length # 4
│ │ └── report_desc # (binary)
│ │
│ ├── hid.usb2/ # 绝对鼠标
│ │ ├── protocol # 2 (mouse)
│ │ ├── subclass # 0 (none)
│ │ ├── report_length # 6
│ │ └── report_desc # (binary)
│ │
│ └── mass_storage.usb0/ # 虚拟存储
│ ├── stall # 1
│ └── lun.0/
│ ├── cdrom # 1 (ISO mode)
│ ├── ro # 1 (read-only)
│ ├── removable # 1
│ ├── nofua # 1
│ └── file # /path/to/image.iso
└── UDC # UDC 设备名
```
---
## 3. 核心组件
### 3.1 OtgService (service.rs)
OTG 服务主类,提供统一的 USB Gadget 管理接口。
```rust
pub struct OtgService {
/// Gadget 管理器
manager: Arc<Mutex<OtgGadgetManager>>,
/// 当前状态
state: Arc<RwLock<OtgServiceState>>,
/// HID 函数句柄
hid_function: Arc<RwLock<Option<HidFunction>>>,
/// MSD 函数句柄
msd_function: Arc<RwLock<Option<MsdFunction>>>,
/// 请求计数器 (lock-free)
pending_requests: AtomicU8,
}
impl OtgService {
/// 创建服务
pub fn new() -> Result<Self>;
/// 启用 HID 功能
pub async fn enable_hid(&self) -> Result<HidDevicePaths>;
/// 禁用 HID 功能
pub async fn disable_hid(&self) -> Result<()>;
/// 启用 MSD 功能
pub async fn enable_msd(&self) -> Result<MsdFunction>;
/// 禁用 MSD 功能
pub async fn disable_msd(&self) -> Result<()>;
/// 获取状态
pub fn state(&self) -> OtgServiceState;
/// 检查 HID 是否启用
pub fn is_hid_enabled(&self) -> bool;
/// 检查 MSD 是否启用
pub fn is_msd_enabled(&self) -> bool;
}
pub struct OtgServiceState {
/// Gadget 是否激活
pub gadget_active: bool,
/// HID 是否启用
pub hid_enabled: bool,
/// MSD 是否启用
pub msd_enabled: bool,
/// HID 设备路径
pub hid_paths: Option<HidDevicePaths>,
/// 错误信息
pub error: Option<String>,
}
pub struct HidDevicePaths {
pub keyboard: PathBuf, // /dev/hidg0
pub mouse_relative: PathBuf, // /dev/hidg1
pub mouse_absolute: PathBuf, // /dev/hidg2
}
```
### 3.2 OtgGadgetManager (manager.rs)
Gadget 生命周期管理器。
```rust
pub struct OtgGadgetManager {
/// Gadget 路径
gadget_path: PathBuf,
/// UDC 设备名
udc_name: Option<String>,
/// 是否已创建
created: bool,
/// 是否已绑定
bound: bool,
/// 端点分配器
endpoint_allocator: EndpointAllocator,
}
impl OtgGadgetManager {
/// 创建管理器
pub fn new() -> Result<Self>;
/// 创建 Gadget
pub fn create_gadget(&mut self, config: &GadgetConfig) -> Result<()>;
/// 销毁 Gadget
pub fn destroy_gadget(&mut self) -> Result<()>;
/// 绑定 UDC
pub fn bind_udc(&mut self) -> Result<()>;
/// 解绑 UDC
pub fn unbind_udc(&mut self) -> Result<()>;
/// 添加函数
pub fn add_function(&mut self, func: &dyn GadgetFunction) -> Result<()>;
/// 移除函数
pub fn remove_function(&mut self, func: &dyn GadgetFunction) -> Result<()>;
/// 链接函数到配置
pub fn link_function(&self, func: &dyn GadgetFunction) -> Result<()>;
/// 取消链接函数
pub fn unlink_function(&self, func: &dyn GadgetFunction) -> Result<()>;
/// 检测可用 UDC
fn detect_udc() -> Result<String>;
}
pub struct GadgetConfig {
pub name: String, // "one-kvm"
pub vendor_id: u16, // 0x05ac
pub product_id: u16, // 0x0001
pub manufacturer: String, // "One-KVM"
pub product: String, // "KVM Device"
pub serial: String, // UUID
}
```
### 3.3 HID Function (hid.rs)
```rust
pub struct HidFunction {
/// 键盘函数
keyboard: HidFunctionConfig,
/// 相对鼠标函数
mouse_relative: HidFunctionConfig,
/// 绝对鼠标函数
mouse_absolute: HidFunctionConfig,
}
pub struct HidFunctionConfig {
/// 函数名
pub name: String, // "hid.usb0"
/// 协议
pub protocol: u8, // 1=keyboard, 2=mouse
/// 子类
pub subclass: u8, // 1=boot, 0=none
/// 报告长度
pub report_length: u8,
/// 报告描述符
pub report_desc: Vec<u8>,
}
impl HidFunction {
/// 创建 HID 函数
pub fn new() -> Self;
/// 获取键盘报告描述符
pub fn keyboard_report_desc() -> Vec<u8>;
/// 获取相对鼠标报告描述符
pub fn mouse_relative_report_desc() -> Vec<u8>;
/// 获取绝对鼠标报告描述符
pub fn mouse_absolute_report_desc() -> Vec<u8>;
}
impl GadgetFunction for HidFunction {
fn name(&self) -> &str;
fn function_type(&self) -> &str; // "hid"
fn configure(&self, path: &Path) -> Result<()>;
}
```
### 3.4 MSD Function (msd.rs)
```rust
pub struct MsdFunction {
/// 函数名
name: String,
/// LUN 配置
luns: Vec<MsdLun>,
}
pub struct MsdLun {
/// LUN 编号
pub lun_id: u8,
/// 镜像文件路径
pub file: Option<PathBuf>,
/// 是否 CD-ROM 模式
pub cdrom: bool,
/// 是否只读
pub readonly: bool,
/// 是否可移除
pub removable: bool,
}
impl MsdFunction {
/// 创建 MSD 函数
pub fn new() -> Self;
/// 设置镜像文件
pub fn set_image(&mut self, path: &Path, cdrom: bool) -> Result<()>;
/// 清除镜像
pub fn clear_image(&mut self) -> Result<()>;
/// 弹出介质
pub fn eject(&mut self) -> Result<()>;
}
impl GadgetFunction for MsdFunction {
fn name(&self) -> &str;
fn function_type(&self) -> &str; // "mass_storage"
fn configure(&self, path: &Path) -> Result<()>;
}
```
### 3.5 ConfigFS 操作 (configfs.rs)
```rust
pub struct ConfigFs;
impl ConfigFs {
/// ConfigFS 根路径
const ROOT: &'static str = "/sys/kernel/config/usb_gadget";
/// 创建目录
pub fn mkdir(path: &Path) -> Result<()>;
/// 删除目录
pub fn rmdir(path: &Path) -> Result<()>;
/// 写入文件
pub fn write_file(path: &Path, content: &str) -> Result<()>;
/// 写入二进制文件
pub fn write_binary(path: &Path, data: &[u8]) -> Result<()>;
/// 读取文件
pub fn read_file(path: &Path) -> Result<String>;
/// 创建符号链接
pub fn symlink(target: &Path, link: &Path) -> Result<()>;
/// 删除符号链接
pub fn unlink(path: &Path) -> Result<()>;
/// 列出目录
pub fn list_dir(path: &Path) -> Result<Vec<String>>;
}
```
### 3.6 端点分配 (endpoint.rs)
```rust
pub struct EndpointAllocator {
/// 已使用的端点
used_endpoints: HashSet<u8>,
/// 最大端点数
max_endpoints: u8,
}
impl EndpointAllocator {
/// 创建分配器
pub fn new(max_endpoints: u8) -> Self;
/// 分配端点
pub fn allocate(&mut self, count: u8) -> Result<Vec<u8>>;
/// 释放端点
pub fn release(&mut self, endpoints: &[u8]);
/// 检查可用端点数
pub fn available(&self) -> u8;
}
```
### 3.7 报告描述符 (report_desc.rs)
```rust
pub struct ReportDescriptor;
impl ReportDescriptor {
/// 标准键盘报告描述符
pub fn keyboard() -> Vec<u8> {
vec![
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224)
0x29, 0xE7, // Usage Maximum (231)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute)
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant)
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x81, 0x00, // Input (Data, Array)
0xC0, // End Collection
]
}
/// 相对鼠标报告描述符
pub fn mouse_relative() -> Vec<u8>;
/// 绝对鼠标报告描述符
pub fn mouse_absolute() -> Vec<u8>;
}
```
---
## 4. 生命周期管理
### 4.1 初始化流程
```
OtgService::new()
├── 检测 UDC 设备
│ └── 读取 /sys/class/udc/
├── 创建 OtgGadgetManager
└── 初始化状态
enable_hid()
├── 检查 Gadget 是否存在
│ └── 如不存在,创建 Gadget
├── 创建 HID 函数
│ ├── hid.usb0 (键盘)
│ ├── hid.usb1 (相对鼠标)
│ └── hid.usb2 (绝对鼠标)
├── 配置函数
│ └── 写入报告描述符
├── 链接函数到配置
├── 绑定 UDC (如未绑定)
└── 等待设备节点出现
└── /dev/hidg0, hidg1, hidg2
```
### 4.2 清理流程
```
disable_hid()
├── 检查是否有其他函数使用
├── 如果只有 HID解绑 UDC
├── 取消链接 HID 函数
└── 删除 HID 函数目录
disable_msd()
├── 同上...
└── 如果没有任何函数,销毁 Gadget
```
---
## 5. 配置
### 5.1 OTG 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct OtgConfig {
/// 是否启用 OTG
pub enabled: bool,
/// 厂商 ID
pub vendor_id: u16,
/// 产品 ID
pub product_id: u16,
/// 厂商名称
pub manufacturer: String,
/// 产品名称
pub product: String,
}
impl Default for OtgConfig {
fn default() -> Self {
Self {
enabled: true,
vendor_id: 0x05ac, // Apple
product_id: 0x0001,
manufacturer: "One-KVM".to_string(),
product: "KVM Device".to_string(),
}
}
}
```
---
## 6. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum OtgError {
#[error("No UDC device found")]
NoUdcDevice,
#[error("Gadget already exists")]
GadgetExists,
#[error("Gadget not found")]
GadgetNotFound,
#[error("Function already exists: {0}")]
FunctionExists(String),
#[error("UDC busy")]
UdcBusy,
#[error("ConfigFS error: {0}")]
ConfigFsError(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Device node not found: {0}")]
DeviceNodeNotFound(String),
}
```
---
## 7. 使用示例
### 7.1 启用 HID
```rust
let otg = OtgService::new()?;
// 启用 HID
let paths = otg.enable_hid().await?;
println!("Keyboard: {:?}", paths.keyboard);
println!("Mouse relative: {:?}", paths.mouse_relative);
println!("Mouse absolute: {:?}", paths.mouse_absolute);
// 使用设备...
// 禁用 HID
otg.disable_hid().await?;
```
### 7.2 启用 MSD
```rust
let otg = OtgService::new()?;
// 启用 MSD
let mut msd = otg.enable_msd().await?;
// 挂载 ISO
msd.set_image(Path::new("/data/ubuntu.iso"), true)?;
// 弹出
msd.eject()?;
// 禁用 MSD
otg.disable_msd().await?;
```
---
## 8. 常见问题
### Q: 找不到 UDC 设备?
1. 检查内核是否支持 USB Gadget
2. 加载必要的内核模块:
```bash
modprobe libcomposite
modprobe usb_f_hid
modprobe usb_f_mass_storage
```
3. 检查 `/sys/class/udc/` 目录
### Q: 权限错误?
1. 以 root 运行
2. 或配置 udev 规则
### Q: 设备节点不出现?
1. 检查 UDC 是否正确绑定
2. 查看 `dmesg` 日志
3. 检查 ConfigFS 配置
### Q: 目标 PC 不识别?
1. 检查 USB 线缆
2. 检查报告描述符
3. 使用 `lsusb` 确认设备

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,428 +0,0 @@
# Web 模块文档
## 1. 模块概述
Web 模块提供 HTTP API 和静态文件服务。
### 1.1 主要功能
- REST API
- WebSocket
- 静态文件服务
- 认证中间件
- CORS 支持
### 1.2 文件结构
```
src/web/
├── mod.rs # 模块导出
├── routes.rs # 路由定义 (9KB)
├── ws.rs # WebSocket (8KB)
├── audio_ws.rs # 音频 WebSocket (8KB)
├── static_files.rs # 静态文件 (6KB)
└── handlers/ # API 处理器
├── mod.rs
└── config/
├── mod.rs
├── apply.rs
├── types.rs
└── rustdesk.rs
```
---
## 2. 路由结构
### 2.1 公共路由 (无认证)
| 路由 | 方法 | 描述 |
|------|------|------|
| `/health` | GET | 健康检查 |
| `/auth/login` | POST | 登录 |
| `/setup` | GET | 获取设置状态 |
| `/setup/init` | POST | 初始化设置 |
### 2.2 用户路由 (需认证)
| 路由 | 方法 | 描述 |
|------|------|------|
| `/info` | GET | 系统信息 |
| `/devices` | GET | 设备列表 |
| `/stream/*` | * | 流控制 |
| `/webrtc/*` | * | WebRTC 信令 |
| `/hid/*` | * | HID 控制 |
| `/audio/*` | * | 音频控制 |
| `/ws` | WS | 事件 WebSocket |
| `/ws/audio` | WS | 音频 WebSocket |
### 2.3 管理员路由 (需 Admin)
| 路由 | 方法 | 描述 |
|------|------|------|
| `/config/*` | * | 配置管理 |
| `/msd/*` | * | MSD 操作 |
| `/atx/*` | * | ATX 控制 |
| `/extensions/*` | * | 扩展管理 |
| `/rustdesk/*` | * | RustDesk |
| `/users/*` | * | 用户管理 |
---
## 3. 路由定义
```rust
pub fn create_router(state: Arc<AppState>) -> Router {
Router::new()
// 公共路由
.route("/health", get(handlers::health))
.route("/auth/login", post(handlers::login))
.route("/setup", get(handlers::setup_status))
.route("/setup/init", post(handlers::setup_init))
// 用户路由
.nest("/api", user_routes())
// 管理员路由
.nest("/api/admin", admin_routes())
// 静态文件
.fallback(static_files::serve)
// 中间件
.layer(CorsLayer::permissive())
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
// 状态
.with_state(state)
}
fn user_routes() -> Router {
Router::new()
.route("/info", get(handlers::system_info))
.route("/devices", get(handlers::list_devices))
// 流控制
.route("/stream/status", get(handlers::stream_status))
.route("/stream/start", post(handlers::stream_start))
.route("/stream/stop", post(handlers::stream_stop))
.route("/stream/mjpeg", get(handlers::mjpeg_stream))
// WebRTC
.route("/webrtc/session", post(handlers::webrtc_create_session))
.route("/webrtc/offer", post(handlers::webrtc_offer))
.route("/webrtc/ice", post(handlers::webrtc_ice))
.route("/webrtc/close", post(handlers::webrtc_close))
// HID
.route("/hid/status", get(handlers::hid_status))
.route("/hid/reset", post(handlers::hid_reset))
// WebSocket
.route("/ws", get(handlers::ws_handler))
.route("/ws/audio", get(handlers::audio_ws_handler))
// 认证中间件
.layer(middleware::from_fn(auth_middleware))
}
fn admin_routes() -> Router {
Router::new()
// 配置
.route("/config", get(handlers::config::get_config))
.route("/config", patch(handlers::config::update_config))
// MSD
.route("/msd/status", get(handlers::msd_status))
.route("/msd/connect", post(handlers::msd_connect))
// ATX
.route("/atx/status", get(handlers::atx_status))
.route("/atx/power/short", post(handlers::atx_power_short))
// 认证中间件
.layer(middleware::from_fn(auth_middleware))
.layer(middleware::from_fn(admin_middleware))
}
```
---
## 4. 静态文件服务
```rust
#[derive(RustEmbed)]
#[folder = "web/dist"]
#[include = "*.html"]
#[include = "*.js"]
#[include = "*.css"]
#[include = "assets/*"]
struct Assets;
pub async fn serve(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
// 尝试获取文件
if let Some(content) = Assets::get(path) {
let mime = mime_guess::from_path(path)
.first_or_octet_stream();
return (
[(header::CONTENT_TYPE, mime.as_ref())],
content.data.into_owned(),
).into_response();
}
// SPA 回退到 index.html
if let Some(content) = Assets::get("index.html") {
return (
[(header::CONTENT_TYPE, "text/html")],
content.data.into_owned(),
).into_response();
}
StatusCode::NOT_FOUND.into_response()
}
```
---
## 5. WebSocket 处理
### 5.1 事件 WebSocket (ws.rs)
```rust
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_ws(socket, state))
}
async fn handle_ws(mut socket: WebSocket, state: Arc<AppState>) {
// 发送初始设备信息
let device_info = state.get_device_info().await;
let json = serde_json::to_string(&device_info).unwrap();
let _ = socket.send(Message::Text(json)).await;
// 订阅事件
let mut rx = state.events.subscribe();
loop {
tokio::select! {
// 发送事件
result = rx.recv() => {
if let Ok(event) = result {
let json = serde_json::to_string(&event).unwrap();
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
}
// 接收消息 (心跳/关闭)
msg = socket.recv() => {
match msg {
Some(Ok(Message::Ping(data))) => {
let _ = socket.send(Message::Pong(data)).await;
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
}
```
### 5.2 音频 WebSocket (audio_ws.rs)
```rust
pub async fn audio_ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_audio_ws(socket, state))
}
async fn handle_audio_ws(mut socket: WebSocket, state: Arc<AppState>) {
// 订阅音频帧
let mut rx = state.audio.subscribe();
loop {
tokio::select! {
// 发送音频帧
result = rx.recv() => {
if let Ok(frame) = result {
if socket.send(Message::Binary(frame.data.to_vec())).await.is_err() {
break;
}
}
}
// 处理关闭
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
}
```
---
## 6. MJPEG 流
```rust
pub async fn mjpeg_stream(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let boundary = "frame";
// 订阅视频帧
let rx = state.stream_manager.subscribe_mjpeg();
// 创建流
let stream = async_stream::stream! {
let mut rx = rx;
while let Ok(frame) = rx.recv().await {
let header = format!(
"--{}\r\nContent-Type: image/jpeg\r\nContent-Length: {}\r\n\r\n",
boundary,
frame.data.len()
);
yield Ok::<_, std::io::Error>(Bytes::from(header));
yield Ok(frame.data.clone());
yield Ok(Bytes::from("\r\n"));
}
};
(
[(
header::CONTENT_TYPE,
format!("multipart/x-mixed-replace; boundary={}", boundary),
)],
Body::from_stream(stream),
)
}
```
---
## 7. 错误处理
```rust
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::AuthError => (StatusCode::UNAUTHORIZED, "Authentication failed"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.as_str()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
AppError::Internal(err) => {
tracing::error!("Internal error: {:?}", err);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
}
// ...
};
(status, Json(json!({ "error": message }))).into_response()
}
}
```
---
## 8. 请求提取器
```rust
// 从 Cookie 获取会话
pub struct AuthUser(pub Session);
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let cookies = Cookies::from_request_parts(parts, state).await?;
let token = cookies
.get("session_id")
.map(|c| c.value().to_string())
.ok_or(AppError::Unauthorized)?;
let state = parts.extensions.get::<Arc<AppState>>().unwrap();
let session = state.sessions
.get_session(&token)
.ok_or(AppError::Unauthorized)?;
Ok(AuthUser(session))
}
}
```
---
## 9. 中间件
### 9.1 认证中间件
```rust
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
cookies: Cookies,
mut request: Request,
next: Next,
) -> Response {
let token = cookies
.get("session_id")
.map(|c| c.value().to_string());
if let Some(session) = token.and_then(|t| state.sessions.get_session(&t)) {
request.extensions_mut().insert(session);
next.run(request).await
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
```
### 9.2 Admin 中间件
```rust
pub async fn admin_middleware(
Extension(session): Extension<Session>,
request: Request,
next: Next,
) -> Response {
if session.role == UserRole::Admin {
next.run(request).await
} else {
StatusCode::FORBIDDEN.into_response()
}
}
```
---
## 10. HTTPS 支持
```rust
// 使用 axum-server 提供 TLS
let tls_config = RustlsConfig::from_pem_file(cert_path, key_path).await?;
axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service())
.await?;
// 或自动生成自签名证书
let (cert, key) = generate_self_signed_cert()?;
let tls_config = RustlsConfig::from_pem(cert, key).await?;
```

View File

@@ -1,789 +0,0 @@
# WebRTC 模块文档
## 1. 模块概述
WebRTC 模块提供低延迟的实时音视频流传输,支持多种视频编码格式和 DataChannel HID 控制。
### 1.1 主要功能
- WebRTC 会话管理
- 多编码器支持 (H264/H265/VP8/VP9)
- 音频轨道 (Opus)
- DataChannel HID
- ICE/STUN/TURN 支持
### 1.2 文件结构
```
src/webrtc/
├── mod.rs # 模块导出
├── webrtc_streamer.rs # 统一管理器 (35KB)
├── universal_session.rs # 会话管理 (32KB)
├── unified_video_track.rs # 统一视频轨道 (15KB)
├── video_track.rs # 视频轨道 (19KB)
├── rtp.rs # RTP 打包 (24KB)
├── h265_payloader.rs # H265 RTP (15KB)
├── peer.rs # PeerConnection (17KB)
├── config.rs # 配置 (3KB)
├── signaling.rs # 信令 (5KB)
├── session.rs # 会话基类 (8KB)
└── track.rs # 轨道基类 (11KB)
```
---
## 2. 架构设计
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ WebRTC Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
Browser
│ HTTP Signaling
┌─────────────────┐
│ WebRtcStreamer │
│(webrtc_streamer)│
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Session │ │Session │ │Session │
│ 1 │ │ 2 │ │ N │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
├───────────┼─────────────┤
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────┐
│ SharedVideoPipeline │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │H264 │ │H265 │ │VP8 │ │VP9 │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
└─────────────────────────────────────┘
┌────────────────┐
│ VideoCapturer │
└────────────────┘
```
### 2.2 会话生命周期
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Session Lifecycle │
└─────────────────────────────────────────────────────────────────────────────┘
1. 创建会话
POST /webrtc/session
┌─────────────────┐
│ Create Session │
│ Generate ID │
└────────┬────────┘
{ session_id: "..." }
2. 发送 Offer
POST /webrtc/offer
{ session_id, codec, offer_sdp }
┌─────────────────┐
│ Process Offer │
│ Create Answer │
│ Setup Tracks │
└────────┬────────┘
{ answer_sdp, ice_candidates }
3. ICE 候选
POST /webrtc/ice
{ session_id, candidate }
┌─────────────────┐
│ Add ICE │
│ Candidate │
└─────────────────┘
4. 连接建立
┌─────────────────┐
│ DTLS Handshake │
│ SRTP Setup │
│ DataChannel │
└────────┬────────┘
开始传输视频/音频
5. 关闭会话
POST /webrtc/close
{ session_id }
┌─────────────────┐
│ Cleanup │
│ Release │
└─────────────────┘
```
---
## 3. 核心组件
### 3.1 WebRtcStreamer (webrtc_streamer.rs)
WebRTC 服务主类。
```rust
pub struct WebRtcStreamer {
/// 会话映射
sessions: Arc<RwLock<HashMap<String, Arc<UniversalSession>>>>,
/// 共享视频管道
video_pipeline: Arc<SharedVideoPipeline>,
/// 共享音频管道
audio_pipeline: Arc<SharedAudioPipeline>,
/// HID 控制器
hid: Arc<HidController>,
/// 配置
config: WebRtcConfig,
/// 事件总线
events: Arc<EventBus>,
}
impl WebRtcStreamer {
/// 创建流服务
pub async fn new(
video_pipeline: Arc<SharedVideoPipeline>,
audio_pipeline: Arc<SharedAudioPipeline>,
hid: Arc<HidController>,
config: WebRtcConfig,
events: Arc<EventBus>,
) -> Result<Self>;
/// 创建会话
pub async fn create_session(&self) -> Result<String>;
/// 处理 Offer
pub async fn process_offer(
&self,
session_id: &str,
offer: &str,
codec: VideoCodec,
) -> Result<OfferResponse>;
/// 添加 ICE 候选
pub async fn add_ice_candidate(
&self,
session_id: &str,
candidate: &str,
) -> Result<()>;
/// 关闭会话
pub async fn close_session(&self, session_id: &str) -> Result<()>;
/// 获取会话列表
pub fn list_sessions(&self) -> Vec<SessionInfo>;
/// 获取统计信息
pub fn get_stats(&self) -> WebRtcStats;
}
pub struct OfferResponse {
pub answer_sdp: String,
pub ice_candidates: Vec<String>,
}
pub struct WebRtcStats {
pub active_sessions: usize,
pub total_bytes_sent: u64,
pub avg_bitrate: u32,
}
```
### 3.2 UniversalSession (universal_session.rs)
单个 WebRTC 会话。
```rust
pub struct UniversalSession {
/// 会话 ID
id: String,
/// PeerConnection
peer: Arc<RTCPeerConnection>,
/// 视频轨道
video_track: Arc<UniversalVideoTrack>,
/// 音频轨道
audio_track: Option<Arc<dyn TrackLocal>>,
/// HID DataChannel
hid_channel: Arc<RwLock<Option<Arc<RTCDataChannel>>>>,
/// HID 处理器
hid_handler: Arc<HidDataChannelHandler>,
/// 状态
state: Arc<RwLock<SessionState>>,
/// 编码器类型
codec: VideoCodec,
}
impl UniversalSession {
/// 创建会话
pub async fn new(
id: String,
config: &WebRtcConfig,
video_pipeline: Arc<SharedVideoPipeline>,
audio_pipeline: Arc<SharedAudioPipeline>,
hid_handler: Arc<HidDataChannelHandler>,
codec: VideoCodec,
) -> Result<Self>;
/// 处理 Offer SDP
pub async fn handle_offer(&self, offer_sdp: &str) -> Result<String>;
/// 添加 ICE 候选
pub async fn add_ice_candidate(&self, candidate: &str) -> Result<()>;
/// 获取 ICE 候选
pub fn get_ice_candidates(&self) -> Vec<String>;
/// 关闭会话
pub async fn close(&self) -> Result<()>;
/// 获取状态
pub fn state(&self) -> SessionState;
/// 获取统计
pub fn stats(&self) -> SessionStats;
}
pub enum SessionState {
New,
Connecting,
Connected,
Disconnected,
Failed,
Closed,
}
pub struct SessionStats {
pub bytes_sent: u64,
pub packets_sent: u64,
pub bitrate: u32,
pub frame_rate: f32,
pub round_trip_time: Duration,
}
```
### 3.3 VideoTrack (video_track.rs)
视频轨道封装。
```rust
pub struct UniversalVideoTrack {
/// 轨道 ID
id: String,
/// 编码类型
codec: VideoCodec,
/// RTP 发送器
rtp_sender: Arc<RtpSender>,
/// 帧计数
frame_count: AtomicU64,
/// 统计
stats: Arc<RwLock<TrackStats>>,
}
impl UniversalVideoTrack {
/// 创建轨道
pub fn new(id: &str, codec: VideoCodec) -> Result<Self>;
/// 发送编码帧
pub async fn send_frame(&self, frame: &EncodedFrame) -> Result<()>;
/// 获取 RTP 参数
pub fn rtp_params(&self) -> RtpParameters;
/// 获取统计
pub fn stats(&self) -> TrackStats;
}
pub struct TrackStats {
pub frames_sent: u64,
pub bytes_sent: u64,
pub packets_sent: u64,
pub packet_loss: f32,
}
```
### 3.4 RTP 打包 (rtp.rs)
RTP 协议实现。
```rust
pub struct RtpPacketizer {
/// SSRC
ssrc: u32,
/// 序列号
sequence: u16,
/// 时间戳
timestamp: u32,
/// 负载类型
payload_type: u8,
/// 时钟频率
clock_rate: u32,
}
impl RtpPacketizer {
/// 创建打包器
pub fn new(codec: VideoCodec) -> Self;
/// 打包 H264 帧
pub fn packetize_h264(&mut self, frame: &[u8], keyframe: bool) -> Vec<Vec<u8>>;
/// 打包 VP8 帧
pub fn packetize_vp8(&mut self, frame: &[u8], keyframe: bool) -> Vec<Vec<u8>>;
/// 打包 VP9 帧
pub fn packetize_vp9(&mut self, frame: &[u8], keyframe: bool) -> Vec<Vec<u8>>;
/// 打包 Opus 帧
pub fn packetize_opus(&mut self, frame: &[u8]) -> Vec<u8>;
}
/// H264 NAL 单元分片
pub struct H264Fragmenter;
impl H264Fragmenter {
/// 分片大于 MTU 的 NAL
pub fn fragment(nal: &[u8], mtu: usize) -> Vec<Vec<u8>>;
/// 创建 STAP-A 聚合
pub fn aggregate(nals: &[&[u8]]) -> Vec<u8>;
}
```
### 3.5 H265 打包器 (h265_payloader.rs)
H265/HEVC RTP 打包。
```rust
pub struct H265Payloader {
/// MTU 大小
mtu: usize,
}
impl H265Payloader {
/// 创建打包器
pub fn new(mtu: usize) -> Self;
/// 打包 H265 帧
pub fn packetize(&self, frame: &[u8]) -> Vec<Vec<u8>>;
/// 分析 NAL 单元类型
fn get_nal_type(nal: &[u8]) -> u8;
/// 是否需要分片
fn needs_fragmentation(&self, nal: &[u8]) -> bool;
}
```
---
## 4. 信令协议
### 4.1 创建会话
```
POST /api/webrtc/session
Content-Type: application/json
{}
Response:
{
"session_id": "abc123-def456"
}
```
### 4.2 发送 Offer
```
POST /api/webrtc/offer
Content-Type: application/json
{
"session_id": "abc123-def456",
"video_codec": "h264",
"enable_audio": true,
"offer_sdp": "v=0\r\no=- ..."
}
Response:
{
"answer_sdp": "v=0\r\no=- ...",
"ice_candidates": [
"candidate:1 1 UDP ...",
"candidate:2 1 TCP ..."
]
}
```
### 4.3 ICE 候选
```
POST /api/webrtc/ice
Content-Type: application/json
{
"session_id": "abc123-def456",
"candidate": "candidate:1 1 UDP ..."
}
Response:
{
"success": true
}
```
### 4.4 关闭会话
```
POST /api/webrtc/close
Content-Type: application/json
{
"session_id": "abc123-def456"
}
Response:
{
"success": true
}
```
---
## 5. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct WebRtcConfig {
/// STUN 服务器
pub stun_servers: Vec<String>,
/// TURN 服务器
pub turn_servers: Vec<TurnServer>,
/// 默认编码器
pub default_codec: VideoCodec,
/// 码率 (kbps)
pub bitrate_kbps: u32,
/// GOP 大小
pub gop_size: u32,
/// 启用音频
pub enable_audio: bool,
/// 启用 DataChannel HID
pub enable_datachannel_hid: bool,
}
pub struct TurnServer {
pub url: String,
pub username: String,
pub password: String,
}
impl Default for WebRtcConfig {
fn default() -> Self {
Self {
stun_servers: vec!["stun:stun.l.google.com:19302".to_string()],
turn_servers: vec![],
default_codec: VideoCodec::H264,
bitrate_kbps: 2000,
gop_size: 60,
enable_audio: true,
enable_datachannel_hid: true,
}
}
}
```
---
## 6. DataChannel HID
### 6.1 消息格式
```javascript
// 键盘事件
{
"type": "keyboard",
"keys": ["KeyA", "KeyB"],
"modifiers": {
"ctrl": false,
"shift": true,
"alt": false,
"meta": false
}
}
// 鼠标事件
{
"type": "mouse",
"x": 16384,
"y": 16384,
"button": "left",
"event": "press"
}
// 鼠标模式
{
"type": "mouse_mode",
"mode": "absolute"
}
```
### 6.2 处理流程
```
DataChannel Message
┌─────────────────┐
│Parse JSON Event │
└────────┬────────┘
┌─────────────────┐
│HidDataChannel │
│ Handler │
└────────┬────────┘
┌─────────────────┐
│ HidController │
└────────┬────────┘
USB/Serial
```
---
## 7. 支持的编码器
| 编码器 | RTP 负载类型 | 时钟频率 | 硬件加速 |
|--------|-------------|---------|---------|
| H264 | 96 (动态) | 90000 | VAAPI/RKMPP/V4L2 |
| H265 | 97 (动态) | 90000 | VAAPI |
| VP8 | 98 (动态) | 90000 | VAAPI |
| VP9 | 99 (动态) | 90000 | VAAPI |
| Opus | 111 (动态) | 48000 | 无 (软件) |
---
## 8. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum WebRtcError {
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("Session already exists")]
SessionExists,
#[error("Invalid SDP: {0}")]
InvalidSdp(String),
#[error("Codec not supported: {0}")]
CodecNotSupported(String),
#[error("ICE failed")]
IceFailed,
#[error("DTLS failed")]
DtlsFailed,
#[error("Track error: {0}")]
TrackError(String),
#[error("Connection closed")]
ConnectionClosed,
}
```
---
## 9. 使用示例
### 9.1 创建会话
```rust
let streamer = WebRtcStreamer::new(
video_pipeline,
audio_pipeline,
hid,
WebRtcConfig::default(),
events,
).await?;
// 创建会话
let session_id = streamer.create_session().await?;
// 处理 Offer
let response = streamer.process_offer(
&session_id,
&offer_sdp,
VideoCodec::H264,
).await?;
println!("Answer: {}", response.answer_sdp);
```
### 9.2 前端连接
```javascript
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 创建 DataChannel
const hidChannel = pc.createDataChannel('hid');
// 创建 Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 发送到服务器
const response = await fetch('/api/webrtc/offer', {
method: 'POST',
body: JSON.stringify({
session_id,
video_codec: 'h264',
offer_sdp: offer.sdp
})
});
const { answer_sdp, ice_candidates } = await response.json();
// 设置 Answer
await pc.setRemoteDescription({ type: 'answer', sdp: answer_sdp });
// 添加 ICE 候选
for (const candidate of ice_candidates) {
await pc.addIceCandidate({ candidate });
}
```
---
## 10. 管道重启机制
当码率或编码器配置变更时视频管道需要重启。WebRTC 模块实现了自动重连机制:
### 10.1 重启流程
```
用户修改码率/编码器
┌─────────────────────┐
│ set_bitrate_preset │
│ 1. 保存 frame_tx │ ← 关键:在停止前保存
│ 2. 停止旧管道 │
│ 3. 等待清理 │
│ 4. 恢复 frame_tx │
│ 5. 创建新管道 │
│ 6. 重连所有会话 │
└─────────────────────┘
所有 WebRTC 会话自动恢复
```
### 10.2 关键代码
```rust
pub async fn set_bitrate_preset(self: &Arc<Self>, preset: BitratePreset) -> Result<()> {
// 保存 frame_tx (监控任务会在管道停止后清除它)
let saved_frame_tx = self.video_frame_tx.read().await.clone();
// 停止管道
pipeline.stop();
tokio::time::sleep(Duration::from_millis(100)).await;
// 恢复 frame_tx 并重建管道
if let Some(tx) = saved_frame_tx {
*self.video_frame_tx.write().await = Some(tx.clone());
let pipeline = self.ensure_video_pipeline(tx).await?;
// 重连所有会话
for session in sessions {
session.start_from_video_pipeline(pipeline.subscribe(), ...).await;
}
}
}
```
---
## 11. 常见问题
### Q: 连接超时?
1. 检查 STUN/TURN 配置
2. 检查防火墙设置
3. 尝试使用 TURN 中继
### Q: 视频卡顿?
1. 降低分辨率/码率
2. 检查网络带宽
3. 使用硬件编码
### Q: 音频不同步?
1. 检查时间戳同步
2. 调整缓冲区大小
3. 使用 NTP 同步
### Q: 切换码率后视频静止?
1. 检查管道重启逻辑是否正确保存了 `video_frame_tx`
2. 确认会话重连成功
3. 查看日志中是否有 "Reconnecting session" 信息

View File

@@ -1,477 +0,0 @@
# hwcodec 技术架构报告
## 1. 项目概述
hwcodec 是一个基于 FFmpeg 的硬件视频编解码库,来源于 RustDesk 项目并针对 One-KVM 进行了深度定制优化。该库专注于 IP-KVM 场景,提供 Windows 和 Linux 平台的 GPU 加速视频编码能力。
### 1.1 项目位置
```
libs/hwcodec/
├── src/ # Rust 源代码
└── cpp/ # C++ 源代码
```
### 1.2 核心特性
- **多编解码格式支持**: H.264, H.265 (HEVC), VP8, VP9, MJPEG
- **硬件加速**: NVENC, AMF, Intel QSV (Windows), VAAPI, RKMPP, V4L2 M2M (Linux)
- **跨平台**: Windows, Linux (x86_64, ARM64, ARMv7)
- **低延迟优化**: 专为实时流媒体场景设计
- **Rust/C++ 混合架构**: Rust 提供安全的上层 APIC++ 实现底层编解码逻辑
- **IP-KVM 专用**: 解码仅支持 MJPEG采集卡输出格式编码支持多种硬件加速
## 2. 架构设计
### 2.1 整体架构图
```
┌─────────────────────────────────────────────────────────────┐
│ Rust API Layer │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ffmpeg_ram module ││
│ │ (encode.rs + decode.rs) ││
│ └──────────────────────────┬──────────────────────────────┘│
├─────────────────────────────┼───────────────────────────────┤
│ │ │
│ FFI Bindings (bindgen) │
│ ▼ │
├─────────────────────────────────────────────────────────────┤
│ C++ Core Layer │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ffmpeg_ram (encode/decode) ││
│ └──────────────────────────┬──────────────────────────────┘│
├─────────────────────────────┼───────────────────────────────┤
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ FFmpeg Libraries │ │
│ │ libavcodec │ libavutil │ libavformat │ libswscale │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
├─────────────────────────────┼───────────────────────────────┤
│ Hardware Acceleration Backends │
│ ┌────────┐ ┌─────┐ ┌─────┐ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ NVENC │ │ AMF │ │ QSV │ │ VAAPI │ │ RKMPP │ │V4L2M2M│ │
│ └────────┘ └─────┘ └─────┘ └───────┘ └───────┘ └───────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 模块职责
| 模块 | 职责 | 关键文件 |
|------|------|----------|
| `ffmpeg_ram` | 基于 RAM 的软件/硬件编解码 | `src/ffmpeg_ram/` |
| `common` | 公共定义和 GPU 检测 | `src/common.rs` |
| `ffmpeg` | FFmpeg 日志和初始化 | `src/ffmpeg.rs` |
## 3. 模块详细分析
### 3.1 库入口 (lib.rs)
```rust
// libs/hwcodec/src/lib.rs
pub mod common;
pub mod ffmpeg;
pub mod ffmpeg_ram;
```
**功能**:
- 导出所有子模块
- 提供 C 日志回调函数
### 3.2 公共模块 (common.rs)
**核心类型**:
```rust
pub enum Driver {
NV, // NVIDIA
AMF, // AMD
MFX, // Intel
FFMPEG, // 软件编码
}
```
**GPU 检测函数**:
| 平台 | 检测函数 | 检测方式 |
|------|----------|----------|
| Linux | `linux_support_nv()` | 加载 libcuda.so + libnvidia-encode.so |
| Linux | `linux_support_amd()` | 检查 `libamfrt64.so.1` |
| Linux | `linux_support_intel()` | 检查 `libvpl.so`/`libmfx.so` |
| Linux | `linux_support_rkmpp()` | 检查 `/dev/mpp_service` |
| Linux | `linux_support_v4l2m2m()` | 检查 `/dev/video*` 设备 |
### 3.3 FFmpeg RAM 编码模块
#### 3.3.1 Rust 层 (src/ffmpeg_ram/)
**CodecInfo 结构体**:
```rust
pub struct CodecInfo {
pub name: String, // 编码器名称如 "h264_nvenc"
pub mc_name: Option<String>, // MediaCodec 名称 (Android)
pub format: DataFormat, // H264/H265/VP8/VP9/MJPEG
pub priority: i32, // 优先级 (Best=0, Good=1, Normal=2, Soft=3, Bad=4)
pub hwdevice: AVHWDeviceType, // 硬件设备类型
}
```
**EncodeContext 结构体**:
```rust
pub struct EncodeContext {
pub name: String, // 编码器名称
pub width: i32, // 视频宽度
pub height: i32, // 视频高度
pub pixfmt: AVPixelFormat, // 像素格式 (NV12/YUV420P)
pub align: i32, // 内存对齐
pub fps: i32, // 帧率
pub gop: i32, // GOP 大小
pub rc: RateControl, // 码率控制模式
pub quality: Quality, // 质量级别
pub kbs: i32, // 目标码率 (kbps)
pub q: i32, // 量化参数
pub thread_count: i32, // 线程数
}
```
**Encoder 类**:
```rust
pub struct Encoder {
codec: *mut c_void, // C++ 编码器指针
frames: *mut Vec<EncodeFrame>, // 编码输出帧
pub ctx: EncodeContext,
pub linesize: Vec<i32>, // 行大小
pub offset: Vec<i32>, // 平面偏移
pub length: i32, // 总数据长度
}
```
**核心方法**:
| 方法 | 功能 |
|------|------|
| `Encoder::new()` | 创建编码器实例 |
| `Encoder::encode()` | 编码一帧 YUV 数据 |
| `Encoder::set_bitrate()` | 动态调整码率 |
| `Encoder::request_keyframe()` | 请求下一帧为关键帧 |
| `Encoder::available_encoders()` | 检测系统可用编码器 |
#### 3.3.2 C++ 层 (cpp/ffmpeg_ram/)
**FFmpegRamEncoder 类** (ffmpeg_ram_encode.cpp):
```cpp
class FFmpegRamEncoder {
AVCodecContext *c_ = NULL; // FFmpeg 编码上下文
AVFrame *frame_ = NULL; // 输入帧
AVPacket *pkt_ = NULL; // 编码输出包
AVBufferRef *hw_device_ctx_; // 硬件设备上下文
AVFrame *hw_frame_ = NULL; // 硬件帧
bool force_keyframe_ = false; // 强制关键帧标志
// 主要方法
bool init(int *linesize, int *offset, int *length);
int encode(const uint8_t *data, int length, const void *obj, uint64_t ms);
int do_encode(AVFrame *frame, const void *obj, int64_t ms);
int set_hwframe_ctx(); // 设置硬件帧上下文
};
```
**编码流程**:
```
输入 YUV 数据
fill_frame() - 填充 AVFrame 数据指针
├──▶ (软件编码) 直接使用 frame_
└──▶ (硬件编码) av_hwframe_transfer_data() 传输到 GPU
使用 hw_frame_
avcodec_send_frame() - 发送帧到编码器
avcodec_receive_packet() - 获取编码数据
callback() - 回调输出
```
### 3.4 FFmpeg RAM 解码模块
**IP-KVM 专用设计**: 解码器仅支持 MJPEG 软件解码,因为 IP-KVM 场景中视频采集卡输出的是 MJPEG 格式。
**Decoder 类**:
```rust
pub struct Decoder {
codec: *mut c_void,
frames: *mut Vec<DecodeFrame>,
pub ctx: DecodeContext,
}
pub struct DecodeFrame {
pub pixfmt: AVPixelFormat,
pub width: i32,
pub height: i32,
pub data: Vec<Vec<u8>>, // Y, U, V 平面数据
pub linesize: Vec<i32>,
pub key: bool,
}
```
**available_decoders()**: 仅返回 MJPEG 软件解码器
```rust
pub fn available_decoders() -> Vec<CodecInfo> {
vec![CodecInfo {
name: "mjpeg".to_owned(),
format: MJPEG,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Best as _,
..Default::default()
}]
}
```
**C++ 实现** (ffmpeg_ram_decode.cpp):
```cpp
class FFmpegRamDecoder {
AVCodecContext *c_ = NULL;
AVFrame *frame_ = NULL; // 解码输出帧
AVPacket *pkt_ = NULL;
int do_decode(const void *obj);
};
```
**解码流程**:
```
输入 MJPEG 数据
avcodec_send_packet() - 发送数据到解码器
avcodec_receive_frame() - 获取解码帧 (YUV420P)
callback() - 回调输出
```
## 4. 硬件加速支持
### 4.1 支持的硬件加速后端
| 后端 | 厂商 | 平台 | 编码器名称 |
|------|------|------|-----------|
| NVENC | NVIDIA | Windows/Linux | h264_nvenc, hevc_nvenc |
| AMF | AMD | Windows/Linux | h264_amf, hevc_amf |
| QSV | Intel | Windows | h264_qsv, hevc_qsv |
| VAAPI | 通用 | Linux | h264_vaapi, hevc_vaapi, vp8_vaapi, vp9_vaapi |
| RKMPP | Rockchip | Linux | h264_rkmpp, hevc_rkmpp |
| V4L2 M2M | ARM SoC | Linux | h264_v4l2m2m, hevc_v4l2m2m |
### 4.2 硬件检测逻辑 (Linux)
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
// NVIDIA 检测 - 简化的动态库检测
int linux_support_nv() {
void *handle = dlopen("libcuda.so.1", RTLD_LAZY);
if (!handle) handle = dlopen("libcuda.so", RTLD_LAZY);
if (!handle) return -1;
dlclose(handle);
handle = dlopen("libnvidia-encode.so.1", RTLD_LAZY);
if (!handle) handle = dlopen("libnvidia-encode.so", RTLD_LAZY);
if (!handle) return -1;
dlclose(handle);
return 0;
}
// AMD 检测 - 检查 AMF 运行时库
int linux_support_amd() {
void *handle = dlopen("libamfrt64.so.1", RTLD_LAZY);
if (!handle) return -1;
dlclose(handle);
return 0;
}
// Intel 检测 - 检查 VPL/MFX 库
int linux_support_intel() {
const char *libs[] = {"libvpl.so", "libmfx.so", ...};
// 任一成功加载则返回 0
}
// Rockchip MPP 检测 - 检查设备节点
int linux_support_rkmpp() {
if (access("/dev/mpp_service", F_OK) == 0) return 0;
if (access("/dev/rga", F_OK) == 0) return 0;
return -1;
}
// V4L2 M2M 检测 - 检查视频设备
int linux_support_v4l2m2m() {
const char *devices[] = {"/dev/video10", "/dev/video11", ...};
// 任一设备可打开则返回 0
}
```
### 4.3 编码器优先级系统
```rust
pub enum Priority {
Best = 0, // 最高优先级 (硬件加速)
Good = 1, // 良好 (VAAPI, 部分硬件)
Normal = 2, // 普通
Soft = 3, // 软件编码
Bad = 4, // 最低优先级
}
```
**优先级分配**:
| 编码器 | 优先级 |
|--------|--------|
| h264_nvenc, hevc_nvenc | Best (0) |
| h264_amf, hevc_amf | Best (0) |
| h264_qsv, hevc_qsv | Best (0) |
| h264_rkmpp, hevc_rkmpp | Best (0) |
| h264_vaapi, hevc_vaapi | Good (1) |
| h264_v4l2m2m, hevc_v4l2m2m | Good (1) |
| h264 (x264), hevc (x265) | Soft (3) |
### 4.4 低延迟优化配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
bool set_lantency_free(void *priv_data, const std::string &name) {
// NVENC: 禁用延迟缓冲
if (name.find("nvenc") != std::string::npos) {
av_opt_set(priv_data, "delay", "0", 0);
}
// AMF: 设置查询超时
if (name.find("amf") != std::string::npos) {
av_opt_set(priv_data, "query_timeout", "1000", 0);
}
// QSV/VAAPI: 设置异步深度为 1
if (name.find("qsv") != std::string::npos ||
name.find("vaapi") != std::string::npos) {
av_opt_set(priv_data, "async_depth", "1", 0);
}
// libvpx: 实时模式
if (name.find("libvpx") != std::string::npos) {
av_opt_set(priv_data, "deadline", "realtime", 0);
av_opt_set_int(priv_data, "cpu-used", 6, 0);
av_opt_set_int(priv_data, "lag-in-frames", 0, 0);
}
return true;
}
```
## 5. 构建系统
### 5.1 Cargo.toml 配置
```toml
[package]
name = "hwcodec"
version = "0.8.0"
edition = "2021"
description = "Hardware video codec for IP-KVM (Windows/Linux)"
[features]
default = []
[dependencies]
log = "0.4"
serde_derive = "1.0"
serde = "1.0"
serde_json = "1.0"
[build-dependencies]
cc = "1.0" # C++ 编译
bindgen = "0.59" # FFI 绑定生成
```
### 5.2 构建流程 (build.rs)
```
build.rs
├── build_common()
│ ├── 生成 common_ffi.rs (bindgen)
│ ├── 编译平台相关 C++ 代码
│ └── 链接系统库 (stdc++)
└── ffmpeg::build_ffmpeg()
├── 生成 ffmpeg_ffi.rs
├── 链接 FFmpeg 库 (VCPKG 或 pkg-config)
└── build_ffmpeg_ram()
└── 编译 ffmpeg_ram_encode.cpp, ffmpeg_ram_decode.cpp
```
### 5.3 FFmpeg 链接方式
| 方式 | 平台 | 条件 |
|------|------|------|
| VCPKG 静态链接 | 跨平台 | 设置 `VCPKG_ROOT` 环境变量 |
| pkg-config 动态链接 | Linux | 默认方式 |
## 6. 与原版 hwcodec 的区别
针对 One-KVM IP-KVM 场景,对原版 RustDesk hwcodec 进行了以下简化:
### 6.1 移除的功能
| 移除项 | 原因 |
|--------|------|
| VRAM 模块 | IP-KVM 不需要 GPU 显存直接编解码 |
| Mux 模块 | IP-KVM 不需要录制到文件 |
| macOS 支持 | IP-KVM 目标平台不包含 macOS |
| Android 支持 | IP-KVM 目标平台不包含 Android |
| 外部 SDK | 简化构建,减少依赖 |
| 多格式解码 | IP-KVM 仅需 MJPEG 解码 |
### 6.2 保留的功能
| 保留项 | 用途 |
|--------|------|
| FFmpeg RAM 编码 | WebRTC 视频编码 |
| FFmpeg RAM 解码 | MJPEG 采集卡解码 |
| 硬件加速编码 | 低延迟高效编码 |
| 软件编码后备 | 无硬件加速时的兜底方案 |
### 6.3 代码量对比
| 指标 | 原版 | 简化版 | 减少 |
|------|------|--------|------|
| 外部 SDK | ~9MB | 0 | 100% |
| C++ 文件 | ~30 | ~10 | ~67% |
| Rust 模块 | 6 | 3 | 50% |
## 7. 总结
hwcodec 库通过 Rust/C++ 混合架构,在保证内存安全的同时实现了高性能的视频编解码。针对 One-KVM IP-KVM 场景的优化设计特点包括:
1. **精简的编解码器 API**: 解码仅支持 MJPEG编码支持多种硬件加速
2. **自动硬件检测**: 运行时自动检测并选择最优的硬件加速后端
3. **优先级系统**: 基于质量和性能为不同编码器分配优先级
4. **低延迟优化**: 针对实时流媒体场景进行了专门优化
5. **简化的构建系统**: 无需外部 SDK仅依赖系统 FFmpeg
6. **Windows/Linux 跨平台**: 支持 x86_64、ARM64、ARMv7 架构

View File

@@ -1,481 +0,0 @@
# hwcodec 编解码器 API 详解
## 1. 编码器 API
### 1.1 编码器初始化
#### EncodeContext 参数
```rust
pub struct EncodeContext {
pub name: String, // 编码器名称
pub mc_name: Option<String>, // MediaCodec 名称 (保留字段)
pub width: i32, // 视频宽度 (必须为偶数)
pub height: i32, // 视频高度 (必须为偶数)
pub pixfmt: AVPixelFormat, // 像素格式
pub align: i32, // 内存对齐 (通常为 0 或 32)
pub fps: i32, // 帧率
pub gop: i32, // GOP 大小 (关键帧间隔)
pub rc: RateControl, // 码率控制模式
pub quality: Quality, // 编码质量
pub kbs: i32, // 目标码率 (kbps)
pub q: i32, // 量化参数 (CQ 模式)
pub thread_count: i32, // 编码线程数
}
```
#### 参数说明
| 参数 | 类型 | 说明 | 推荐值 |
|------|------|------|--------|
| `name` | String | FFmpeg 编码器名称 | 见下表 |
| `width` | i32 | 视频宽度 | 1920 |
| `height` | i32 | 视频高度 | 1080 |
| `pixfmt` | AVPixelFormat | 像素格式 | NV12 / YUV420P |
| `align` | i32 | 内存对齐 | 0 (自动) |
| `fps` | i32 | 帧率 | 30 |
| `gop` | i32 | GOP 大小 | 30 (1秒) |
| `rc` | RateControl | 码率控制 | CBR / VBR |
| `quality` | Quality | 质量级别 | Medium |
| `kbs` | i32 | 码率 (kbps) | 2000-8000 |
| `thread_count` | i32 | 线程数 | 4 |
#### 编码器名称对照表
| 名称 | 格式 | 加速 | 平台 |
|------|------|------|------|
| `h264_nvenc` | H.264 | NVIDIA GPU | Windows/Linux |
| `hevc_nvenc` | H.265 | NVIDIA GPU | Windows/Linux |
| `h264_amf` | H.264 | AMD GPU | Windows/Linux |
| `hevc_amf` | H.265 | AMD GPU | Windows/Linux |
| `h264_qsv` | H.264 | Intel QSV | Windows |
| `hevc_qsv` | H.265 | Intel QSV | Windows |
| `h264_vaapi` | H.264 | VAAPI | Linux |
| `hevc_vaapi` | H.265 | VAAPI | Linux |
| `vp8_vaapi` | VP8 | VAAPI | Linux |
| `vp9_vaapi` | VP9 | VAAPI | Linux |
| `h264_rkmpp` | H.264 | Rockchip MPP | Linux |
| `hevc_rkmpp` | H.265 | Rockchip MPP | Linux |
| `h264_v4l2m2m` | H.264 | V4L2 M2M | Linux |
| `hevc_v4l2m2m` | H.265 | V4L2 M2M | Linux |
| `h264` | H.264 | 软件 (x264) | 全平台 |
| `hevc` | H.265 | 软件 (x265) | 全平台 |
| `libvpx` | VP8 | 软件 | 全平台 |
| `libvpx-vp9` | VP9 | 软件 | 全平台 |
| `mjpeg` | MJPEG | 软件 | 全平台 |
### 1.2 创建编码器
```rust
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg::{AVPixelFormat};
use hwcodec::common::{RateControl, Quality};
let ctx = EncodeContext {
name: "h264_vaapi".to_string(),
mc_name: None,
width: 1920,
height: 1080,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
align: 0,
fps: 30,
gop: 30,
rc: RateControl::RC_CBR,
quality: Quality::Quality_Medium,
kbs: 4000,
q: 0,
thread_count: 4,
};
let encoder = Encoder::new(ctx)?;
println!("Linesize: {:?}", encoder.linesize);
println!("Offset: {:?}", encoder.offset);
println!("Buffer length: {}", encoder.length);
```
### 1.3 编码帧
```rust
// 准备 YUV 数据
let yuv_data: Vec<u8> = prepare_yuv_frame();
// 编码
let pts_ms: i64 = 0; // 时间戳 (毫秒)
match encoder.encode(&yuv_data, pts_ms) {
Ok(frames) => {
for frame in frames.iter() {
println!("Encoded: {} bytes, pts={}, key={}",
frame.data.len(), frame.pts, frame.key);
// 发送 frame.data
}
}
Err(code) => {
eprintln!("Encode error: {}", code);
}
}
```
### 1.4 动态调整码率
```rust
// 动态调整到 6000 kbps
encoder.set_bitrate(6000)?;
```
### 1.5 请求关键帧
```rust
// 下一帧强制编码为 IDR 帧
encoder.request_keyframe();
```
### 1.6 检测可用编码器
```rust
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg_ram::CodecInfo;
let ctx = EncodeContext {
name: String::new(),
mc_name: None,
width: 1920,
height: 1080,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
align: 0,
fps: 30,
gop: 30,
rc: RateControl::RC_DEFAULT,
quality: Quality::Quality_Default,
kbs: 4000,
q: 0,
thread_count: 4,
};
let available_encoders = Encoder::available_encoders(ctx, None);
for encoder in available_encoders {
println!("Available: {} (format: {:?}, priority: {})",
encoder.name, encoder.format, encoder.priority);
}
```
## 2. 解码器 API
### 2.1 IP-KVM 专用设计
在 One-KVM IP-KVM 场景中,解码器仅支持 MJPEG 软件解码。这是因为视频采集卡输出的格式是 MJPEG不需要其他格式的硬件解码支持。
### 2.2 解码器初始化
#### DecodeContext 参数
```rust
pub struct DecodeContext {
pub name: String, // 解码器名称 ("mjpeg")
pub device_type: AVHWDeviceType, // 硬件设备类型 (NONE)
pub thread_count: i32, // 解码线程数
}
```
### 2.3 创建解码器
```rust
use hwcodec::ffmpeg_ram::decode::{Decoder, DecodeContext};
use hwcodec::ffmpeg::AVHWDeviceType;
let ctx = DecodeContext {
name: "mjpeg".to_string(),
device_type: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
thread_count: 4,
};
let decoder = Decoder::new(ctx)?;
```
### 2.4 解码帧
```rust
// 输入 MJPEG 编码数据
let mjpeg_data: Vec<u8> = receive_mjpeg_frame();
match decoder.decode(&mjpeg_data) {
Ok(frames) => {
for frame in frames.iter() {
println!("Decoded: {}x{}, format={:?}, key={}",
frame.width, frame.height, frame.pixfmt, frame.key);
// 访问 YUV 数据
let y_plane = &frame.data[0];
let u_plane = &frame.data[1];
let v_plane = &frame.data[2];
}
}
Err(code) => {
eprintln!("Decode error: {}", code);
}
}
```
### 2.5 DecodeFrame 结构体
```rust
pub struct DecodeFrame {
pub pixfmt: AVPixelFormat, // 输出像素格式
pub width: i32, // 帧宽度
pub height: i32, // 帧高度
pub data: Vec<Vec<u8>>, // 平面数据 [Y, U, V] 或 [Y, UV]
pub linesize: Vec<i32>, // 每个平面的行字节数
pub key: bool, // 是否为关键帧
}
```
#### 像素格式与平面布局
| 像素格式 | 平面数 | data[0] | data[1] | data[2] |
|----------|--------|---------|---------|---------|
| `YUV420P` | 3 | Y | U | V |
| `YUVJ420P` | 3 | Y | U | V |
| `YUV422P` | 3 | Y | U | V |
| `NV12` | 2 | Y | UV (交错) | - |
| `NV21` | 2 | Y | VU (交错) | - |
### 2.6 获取可用解码器
```rust
use hwcodec::ffmpeg_ram::decode::Decoder;
let available_decoders = Decoder::available_decoders();
for decoder in available_decoders {
println!("Available: {} (format: {:?}, hwdevice: {:?})",
decoder.name, decoder.format, decoder.hwdevice);
}
// 输出:
// Available: mjpeg (format: MJPEG, hwdevice: AV_HWDEVICE_TYPE_NONE)
```
## 3. 码率控制模式
### 3.1 RateControl 枚举
```rust
pub enum RateControl {
RC_DEFAULT, // 使用编码器默认
RC_CBR, // 恒定码率
RC_VBR, // 可变码率
RC_CQ, // 恒定质量 (需设置 q 参数)
}
```
### 3.2 模式说明
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| `RC_CBR` | 码率恒定,质量随场景变化 | 网络带宽受限 |
| `RC_VBR` | 质量优先,码率波动 | 本地存储 |
| `RC_CQ` | 恒定质量,码率波动大 | 质量敏感场景 |
### 3.3 各编码器支持情况
| 编码器 | CBR | VBR | CQ |
|--------|-----|-----|-----|
| nvenc | ✓ | ✓ | ✓ |
| amf | ✓ | ✓ (低延迟) | ✗ |
| qsv | ✓ | ✓ | ✗ |
| vaapi | ✓ | ✓ | ✗ |
## 4. 质量等级
### 4.1 Quality 枚举
```rust
pub enum Quality {
Quality_Default, // 使用编码器默认
Quality_High, // 高质量 (慢速)
Quality_Medium, // 中等质量 (平衡)
Quality_Low, // 低质量 (快速)
}
```
### 4.2 编码器预设映射
| 质量 | nvenc | amf | qsv |
|------|-------|-----|-----|
| High | - | quality | veryslow |
| Medium | p4 | balanced | medium |
| Low | p1 | speed | veryfast |
## 5. 错误处理
### 5.1 错误码
| 错误码 | 常量 | 说明 |
|--------|------|------|
| 0 | `HWCODEC_SUCCESS` | 成功 |
| -1 | `HWCODEC_ERR_COMMON` | 通用错误 |
| -2 | `HWCODEC_ERR_HEVC_COULD_NOT_FIND_POC` | HEVC 解码参考帧丢失 |
### 5.2 常见错误处理
```rust
match encoder.encode(&yuv_data, pts) {
Ok(frames) => {
// 处理编码帧
}
Err(-1) => {
eprintln!("编码失败,可能是输入数据格式错误");
}
Err(code) => {
eprintln!("未知错误: {}", code);
}
}
```
## 6. 最佳实践
### 6.1 编码器选择策略
```rust
fn select_best_encoder(
width: i32,
height: i32,
format: DataFormat
) -> Option<String> {
let ctx = EncodeContext {
width,
height,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
// ... 其他参数
};
let encoders = Encoder::available_encoders(ctx, None);
// 按优先级排序,选择最佳
encoders.into_iter()
.filter(|e| e.format == format)
.min_by_key(|e| e.priority)
.map(|e| e.name)
}
```
### 6.2 帧内存布局
```rust
// 获取 NV12 帧布局信息
let (linesize, offset, length) = ffmpeg_linesize_offset_length(
AVPixelFormat::AV_PIX_FMT_NV12,
1920,
1080,
0, // align
)?;
// 分配缓冲区
let mut buffer = vec![0u8; length as usize];
// 填充 Y 平面: buffer[0..offset[0]]
// 填充 UV 平面: buffer[offset[0]..length]
```
### 6.3 关键帧控制
```rust
let mut frame_count = 0;
loop {
// 每 30 帧强制一个关键帧
if frame_count % 30 == 0 {
encoder.request_keyframe();
}
encoder.encode(&yuv_data, pts)?;
frame_count += 1;
}
```
### 6.4 线程安全
```rust
// Decoder 实现了 Send + Sync
unsafe impl Send for Decoder {}
unsafe impl Sync for Decoder {}
// 可以安全地在多线程间传递
let decoder = Arc::new(Mutex::new(Decoder::new(ctx)?));
```
## 7. IP-KVM 典型使用场景
### 7.1 视频采集和转码流程
```
USB 采集卡 (MJPEG)
┌─────────────────┐
│ MJPEG Decoder │ ◄── Decoder::new("mjpeg")
│ (软件解码) │
└────────┬────────┘
│ YUV420P
┌─────────────────┐
│ H264 Encoder │ ◄── Encoder::new("h264_vaapi")
│ (硬件加速) │
└────────┬────────┘
│ H264 NAL
WebRTC 传输
```
### 7.2 完整示例
```rust
use hwcodec::ffmpeg_ram::decode::{Decoder, DecodeContext};
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg::AVHWDeviceType;
// 创建 MJPEG 解码器
let decode_ctx = DecodeContext {
name: "mjpeg".to_string(),
device_type: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
thread_count: 4,
};
let mut decoder = Decoder::new(decode_ctx)?;
// 检测并选择最佳编码器
let encode_ctx = EncodeContext {
name: String::new(),
width: 1920,
height: 1080,
// ...
};
let available = Encoder::available_encoders(encode_ctx.clone(), None);
let best_h264 = available.iter()
.filter(|e| e.format == DataFormat::H264)
.min_by_key(|e| e.priority)
.expect("No H264 encoder available");
// 使用最佳编码器创建实例
let encode_ctx = EncodeContext {
name: best_h264.name.clone(),
..encode_ctx
};
let mut encoder = Encoder::new(encode_ctx)?;
// 处理循环
loop {
let mjpeg_frame = capture_frame();
// 解码 MJPEG -> YUV
let decoded = decoder.decode(&mjpeg_frame)?;
// 编码 YUV -> H264
for frame in decoded {
let yuv_data = frame.data.concat();
let encoded = encoder.encode(&yuv_data, pts)?;
// 发送编码数据
for packet in encoded {
send_to_webrtc(packet.data);
}
}
}
```

View File

@@ -1,561 +0,0 @@
# hwcodec 硬件加速详解
## 1. 硬件加速架构
### 1.1 整体流程
```
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (Rust) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Encoder::available_encoders() → 自动检测可用硬件编码器 ││
│ └─────────────────────────────────────────────────────────┘│
└────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 硬件检测层 (C++) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐│
│ │linux_ │ │linux_ │ │linux_ │ │linux_support_ ││
│ │support_nv│ │support_ │ │support_ │ │rkmpp/v4l2m2m ││
│ └────┬─────┘ │amd │ │intel │ └─────────┬────────┘│
│ │ └────┬─────┘ └────┬─────┘ │ │
└───────┼────────────┼────────────┼─────────────────┼─────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────────┐
│ CUDA/ │ │ AMF │ │ VPL/MFX │ │ 设备节点检测 │
│ NVENC │ │ Runtime │ │ Library │ │ /dev/mpp_service │
│ 动态库 │ │ 动态库 │ │ 动态库 │ │ /dev/video* │
└───────────┘ └───────────┘ └───────────┘ └───────────────────┘
```
### 1.2 编码器测试验证
每个检测到的硬件编码器都会进行实际编码测试:
```rust
// libs/hwcodec/src/ffmpeg_ram/encode.rs
// 生成测试用 YUV 数据
let yuv = Encoder::dummy_yuv(ctx.clone())?;
// 尝试创建编码器并编码测试帧
match Encoder::new(c) {
Ok(mut encoder) => {
let start = std::time::Instant::now();
match encoder.encode(&yuv, 0) {
Ok(frames) => {
let elapsed = start.elapsed().as_millis();
// 验证: 必须产生 1 帧且为关键帧,且在超时时间内完成
if frames.len() == 1 && frames[0].key == 1
&& elapsed < TEST_TIMEOUT_MS {
res.push(codec);
}
}
Err(_) => { /* 编码失败,跳过 */ }
}
}
Err(_) => { /* 创建失败,跳过 */ }
}
```
## 2. NVIDIA NVENC/NVDEC
### 2.1 检测机制 (Linux)
使用简化的动态库检测方法,无需 CUDA SDK 依赖:
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_nv() {
// 检测 CUDA 运行时库
void *handle = dlopen("libcuda.so.1", RTLD_LAZY);
if (!handle) {
handle = dlopen("libcuda.so", RTLD_LAZY);
}
if (!handle) {
LOG_TRACE("NVIDIA: libcuda.so not found");
return -1;
}
dlclose(handle);
// 检测 NVENC 编码库
handle = dlopen("libnvidia-encode.so.1", RTLD_LAZY);
if (!handle) {
handle = dlopen("libnvidia-encode.so", RTLD_LAZY);
}
if (!handle) {
LOG_TRACE("NVIDIA: libnvidia-encode.so not found");
return -1;
}
dlclose(handle);
LOG_TRACE("NVIDIA: driver support detected");
return 0;
}
```
### 2.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// NVENC 低延迟配置
if (name.find("nvenc") != std::string::npos) {
// 禁用编码延迟
av_opt_set(priv_data, "delay", "0", 0);
}
// GPU 选择
if (name.find("nvenc") != std::string::npos) {
av_opt_set_int(priv_data, "gpu", gpu_index, 0);
}
// 质量预设
switch (quality) {
case Quality_Medium:
av_opt_set(priv_data, "preset", "p4", 0);
break;
case Quality_Low:
av_opt_set(priv_data, "preset", "p1", 0);
break;
}
// 码率控制
av_opt_set(priv_data, "rc", "cbr", 0); // 或 "vbr"
```
### 2.3 环境变量
| 变量 | 说明 |
|------|------|
| `RUSTDESK_HWCODEC_NVENC_GPU` | 指定使用的 GPU 索引 (-1 = 自动) |
### 2.4 依赖库
- `libcuda.so` / `libcuda.so.1` - CUDA 运行时
- `libnvidia-encode.so` / `libnvidia-encode.so.1` - NVENC 编码器
## 3. AMD AMF
### 3.1 检测机制 (Linux)
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_amd() {
#if defined(__x86_64__) || defined(__aarch64__)
#define AMF_DLL_NAMEA "libamfrt64.so.1"
#else
#define AMF_DLL_NAMEA "libamfrt32.so.1"
#endif
void *handle = dlopen(AMF_DLL_NAMEA, RTLD_LAZY);
if (!handle) {
return -1; // AMF 不可用
}
dlclose(handle);
return 0; // AMF 可用
}
```
### 3.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// AMF 低延迟配置
if (name.find("amf") != std::string::npos) {
av_opt_set(priv_data, "query_timeout", "1000", 0);
}
// 质量预设
switch (quality) {
case Quality_High:
av_opt_set(priv_data, "quality", "quality", 0);
break;
case Quality_Medium:
av_opt_set(priv_data, "quality", "balanced", 0);
break;
case Quality_Low:
av_opt_set(priv_data, "quality", "speed", 0);
break;
}
// 码率控制
av_opt_set(priv_data, "rc", "cbr", 0); // 恒定码率
av_opt_set(priv_data, "rc", "vbr_latency", 0); // 低延迟 VBR
```
### 3.3 依赖库
- `libamfrt64.so.1` (64位) 或 `libamfrt32.so.1` (32位)
## 4. Intel QSV/MFX
### 4.1 检测机制 (Linux)
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_intel() {
const char *libs[] = {
"libvpl.so", // oneVPL (新版)
"libmfx.so", // Media SDK
"libmfx-gen.so.1.2", // 新驱动
"libmfxhw64.so.1" // 旧版驱动
};
for (size_t i = 0; i < sizeof(libs) / sizeof(libs[0]); i++) {
void *handle = dlopen(libs[i], RTLD_LAZY);
if (handle) {
dlclose(handle);
return 0; // 找到可用库
}
}
return -1; // Intel MFX 不可用
}
```
### 4.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// QSV 低延迟配置
if (name.find("qsv") != std::string::npos) {
av_opt_set(priv_data, "async_depth", "1", 0);
}
// QSV 特殊码率配置
if (name.find("qsv") != std::string::npos) {
c->rc_max_rate = c->bit_rate;
c->bit_rate--; // 实现 CBR 效果
}
// 质量预设
switch (quality) {
case Quality_High:
av_opt_set(priv_data, "preset", "veryslow", 0);
break;
case Quality_Medium:
av_opt_set(priv_data, "preset", "medium", 0);
break;
case Quality_Low:
av_opt_set(priv_data, "preset", "veryfast", 0);
break;
}
// 严格标准兼容性 (用于某些特殊设置)
c->strict_std_compliance = FF_COMPLIANCE_UNOFFICIAL;
```
### 4.3 限制
- QSV 不支持 `YUV420P` 像素格式,必须使用 `NV12`
- 在 One-KVM 简化版中仅 Windows 平台完全支持
## 5. VAAPI (Linux)
### 5.1 工作原理
VAAPI (Video Acceleration API) 是 Linux 上的通用硬件视频加速接口:
```
┌─────────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────────┤
│ FFmpeg libavcodec │
├─────────────────────────────────────────────────────────────┤
│ VAAPI (libva) │
├──────────────┬──────────────┬──────────────┬────────────────┤
│ Intel i965 │ Intel iHD │ AMD radeonsi │ NVIDIA VDPAU │
│ (Gen8-) │ (Gen9+) │ │ (via wrapper) │
├──────────────┴──────────────┴──────────────┴────────────────┤
│ Kernel DRM Driver │
├──────────────┬──────────────┬──────────────┬────────────────┤
│ i915 │ amdgpu │ nvidia │ ... │
└──────────────┴──────────────┴──────────────┴────────────────┘
```
### 5.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// VAAPI 低延迟配置
if (name.find("vaapi") != std::string::npos) {
av_opt_set(priv_data, "async_depth", "1", 0);
}
```
### 5.3 硬件上下文初始化
```cpp
// libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp
// 检测 VAAPI 编码器
if (name_.find("vaapi") != std::string::npos) {
hw_device_type_ = AV_HWDEVICE_TYPE_VAAPI;
hw_pixfmt_ = AV_PIX_FMT_VAAPI;
}
// 创建硬件设备上下文
ret = av_hwdevice_ctx_create(&hw_device_ctx_, hw_device_type_,
NULL, // 使用默认设备
NULL, 0);
// 设置硬件帧上下文
set_hwframe_ctx();
// 分配硬件帧
hw_frame_ = av_frame_alloc();
av_hwframe_get_buffer(c_->hw_frames_ctx, hw_frame_, 0);
```
### 5.4 编码流程
```
输入 YUV (CPU 内存)
av_hwframe_transfer_data(hw_frame_, frame_, 0) // CPU → GPU
avcodec_send_frame(c_, hw_frame_) // 发送 GPU 帧
avcodec_receive_packet(c_, pkt_) // 获取编码数据
编码数据 (CPU 内存)
```
### 5.5 依赖库
- `libva.so` - VAAPI 核心库
- `libva-drm.so` - DRM 后端
- `libva-x11.so` - X11 后端 (可选)
## 6. Rockchip MPP
### 6.1 检测机制
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_rkmpp() {
// 检测 MPP 服务设备
if (access("/dev/mpp_service", F_OK) == 0) {
LOG_TRACE("RKMPP: Found /dev/mpp_service");
return 0; // MPP 可用
}
// 备用: 检测 RGA 设备
if (access("/dev/rga", F_OK) == 0) {
LOG_TRACE("RKMPP: Found /dev/rga");
return 0; // MPP 可能可用
}
LOG_TRACE("RKMPP: No Rockchip MPP device found");
return -1; // MPP 不可用
}
```
### 6.2 支持的编码器
| 编码器 | 优先级 | 说明 |
|--------|--------|------|
| `h264_rkmpp` | Best (0) | H.264 硬件编码 |
| `hevc_rkmpp` | Best (0) | H.265 硬件编码 |
### 6.3 适用设备
- Rockchip RK3328 (Onecloud, Chainedbox)
- Rockchip RK3399/RK3588 系列
- 其他 Rockchip SoC
## 7. V4L2 M2M
### 7.1 检测机制
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_v4l2m2m() {
const char *m2m_devices[] = {
"/dev/video10", // 常见 M2M 编码设备
"/dev/video11", // 常见 M2M 解码设备
"/dev/video0", // 某些 SoC 使用
};
for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {
if (access(m2m_devices[i], F_OK) == 0) {
int fd = open(m2m_devices[i], O_RDWR | O_NONBLOCK);
if (fd >= 0) {
close(fd);
LOG_TRACE("V4L2 M2M: Found device " + m2m_devices[i]);
return 0; // V4L2 M2M 可用
}
}
}
LOG_TRACE("V4L2 M2M: No M2M device found");
return -1;
}
```
### 7.2 支持的编码器
| 编码器 | 优先级 | 说明 |
|--------|--------|------|
| `h264_v4l2m2m` | Good (1) | H.264 V4L2 编码 |
| `hevc_v4l2m2m` | Good (1) | H.265 V4L2 编码 |
### 7.3 适用设备
- 通用 ARM SoC (Allwinner, Amlogic 等)
- 支持 V4L2 M2M API 的设备
## 8. 硬件加速优先级
### 8.1 优先级定义
```rust
pub enum Priority {
Best = 0, // 专用硬件编码器
Good = 1, // 通用硬件加速
Normal = 2, // 基本硬件支持
Soft = 3, // 软件编码
Bad = 4, // 最低优先级
}
```
### 8.2 各编码器优先级
| 优先级 | 编码器 |
|--------|--------|
| Best (0) | nvenc, amf, qsv, rkmpp |
| Good (1) | vaapi, v4l2m2m |
| Soft (3) | x264, x265, libvpx |
### 8.3 选择策略
```rust
// libs/hwcodec/src/ffmpeg_ram/mod.rs
pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos {
// 对于每种格式,选择优先级最高的编码器
for coder in coders {
match coder.format {
DataFormat::H264 => {
if h264.is_none() || h264.priority > coder.priority {
h264 = Some(coder);
}
}
// ... 其他格式类似
}
}
}
```
## 9. 故障排除
### 9.1 NVIDIA
```bash
# 检查 NVIDIA 驱动
nvidia-smi
# 检查 NVENC 支持
ls /dev/nvidia*
# 检查 CUDA 库
ldconfig -p | grep cuda
ldconfig -p | grep nvidia-encode
```
### 9.2 AMD
```bash
# 检查 AMD 驱动
lspci | grep AMD
# 检查 AMF 库
ldconfig -p | grep amf
```
### 9.3 Intel
```bash
# 检查 Intel 驱动
vainfo
# 检查 MFX 库
ldconfig -p | grep mfx
ldconfig -p | grep vpl
```
### 9.4 VAAPI
```bash
# 安装 vainfo
sudo apt install vainfo
# 检查 VAAPI 支持
vainfo
# 输出示例:
# libva info: VA-API version 1.14.0
# libva info: Trying to open /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so
# vainfo: Driver version: Intel iHD driver for Intel(R) Gen Graphics
# vainfo: Supported profile and entrypoints
# VAProfileH264Main : VAEntrypointVLD
# VAProfileH264Main : VAEntrypointEncSlice
# ...
```
### 9.5 Rockchip MPP
```bash
# 检查 MPP 设备
ls -la /dev/mpp_service
ls -la /dev/rga
# 检查 MPP 库
ldconfig -p | grep rockchip_mpp
```
### 9.6 V4L2 M2M
```bash
# 列出 V4L2 设备
v4l2-ctl --list-devices
# 检查设备能力
v4l2-ctl -d /dev/video10 --all
```
## 10. 性能优化建议
### 10.1 编码器选择
1. **优先使用硬件编码**: NVENC > AMF > QSV > VAAPI > V4L2 M2M > 软件
2. **ARM 设备**: 优先检测 RKMPP其次 V4L2 M2M
3. **x86 设备**: 根据 GPU 厂商自动选择
### 10.2 低延迟配置
所有硬件编码器都启用了低延迟优化:
| 编码器 | 配置 |
|--------|------|
| NVENC | `delay=0` |
| AMF | `query_timeout=1000` |
| QSV | `async_depth=1` |
| VAAPI | `async_depth=1` |
| libvpx | `deadline=realtime`, `cpu-used=6` |
### 10.3 码率控制
- **实时流**: 推荐 CBR 模式,保证稳定码率
- **GOP 大小**: 建议 30-60 帧 (1-2秒),平衡延迟和压缩效率

View File

@@ -1,477 +0,0 @@
# hwcodec 构建系统与集成指南
## 1. 项目结构
```
libs/hwcodec/
├── Cargo.toml # 包配置
├── Cargo.lock # 依赖锁定
├── build.rs # 构建脚本
└── src/ # Rust 源码
├── lib.rs # 库入口
├── common.rs # 公共定义
├── ffmpeg.rs # FFmpeg 集成
└── ffmpeg_ram/ # RAM 编解码
├── mod.rs
├── encode.rs
└── decode.rs
└── cpp/ # C++ 源码
├── common/ # 公共代码
│ ├── log.cpp
│ ├── log.h
│ ├── util.cpp
│ ├── util.h
│ ├── callback.h
│ ├── common.h
│ └── platform/
│ ├── linux/
│ │ ├── linux.cpp
│ │ └── linux.h
│ └── win/
│ ├── win.cpp
│ └── win.h
├── ffmpeg_ram/ # FFmpeg RAM 实现
│ ├── ffmpeg_ram_encode.cpp
│ ├── ffmpeg_ram_decode.cpp
│ └── ffmpeg_ram_ffi.h
└── yuv/ # YUV 处理
└── yuv.cpp
```
## 2. Cargo 配置
### 2.1 Cargo.toml
```toml
[package]
name = "hwcodec"
version = "0.8.0"
edition = "2021"
description = "Hardware video codec for IP-KVM (Windows/Linux)"
[features]
default = []
[dependencies]
log = "0.4" # 日志
serde_derive = "1.0" # 序列化派生宏
serde = "1.0" # 序列化
serde_json = "1.0" # JSON 序列化
[build-dependencies]
cc = "1.0" # C++ 编译
bindgen = "0.59" # FFI 绑定生成
[dev-dependencies]
env_logger = "0.10" # 日志输出
```
### 2.2 与原版的区别
| 特性 | 原版 (RustDesk) | 简化版 (One-KVM) |
|------|-----------------|------------------|
| `vram` feature | ✓ | ✗ (已移除) |
| 外部 SDK | 需要 | 不需要 |
| 版本号 | 0.7.1 | 0.8.0 |
| 目标平台 | Windows/Linux/macOS/Android | Windows/Linux |
### 2.3 使用方式
```toml
# 在 One-KVM 项目中使用
[dependencies]
hwcodec = { path = "libs/hwcodec" }
```
## 3. 构建脚本详解 (build.rs)
### 3.1 主入口
```rust
fn main() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut builder = Build::new();
// 1. 构建公共模块
build_common(&mut builder);
// 2. 构建 FFmpeg 相关模块
ffmpeg::build_ffmpeg(&mut builder);
// 3. 编译生成静态库
builder.static_crt(true).compile("hwcodec");
}
```
### 3.2 公共模块构建
```rust
fn build_common(builder: &mut Build) {
let common_dir = manifest_dir.join("cpp").join("common");
// 生成 FFI 绑定
bindgen::builder()
.header(common_dir.join("common.h"))
.header(common_dir.join("callback.h"))
.rustified_enum("*")
.generate()
.write_to_file(OUT_DIR.join("common_ffi.rs"));
// 平台相关代码
#[cfg(windows)]
builder.file(common_dir.join("platform/win/win.cpp"));
#[cfg(target_os = "linux")]
builder.file(common_dir.join("platform/linux/linux.cpp"));
// 工具代码
builder.files([
common_dir.join("log.cpp"),
common_dir.join("util.cpp"),
]);
}
```
### 3.3 FFmpeg 模块构建
```rust
mod ffmpeg {
pub fn build_ffmpeg(builder: &mut Build) {
// 生成 FFmpeg FFI 绑定
ffmpeg_ffi();
// 链接 FFmpeg 库
if let Ok(vcpkg_root) = std::env::var("VCPKG_ROOT") {
link_vcpkg(builder, vcpkg_root.into());
} else {
link_system_ffmpeg(builder); // pkg-config
}
// 链接系统库
link_os();
// 构建 FFmpeg RAM 模块
build_ffmpeg_ram(builder);
}
}
```
### 3.4 FFmpeg 链接方式
#### VCPKG (跨平台静态链接)
```rust
fn link_vcpkg(builder: &mut Build, path: PathBuf) -> PathBuf {
// 目标平台识别
let target = match (target_os, target_arch) {
("windows", "x86_64") => "x64-windows-static",
("linux", arch) => format!("{}-linux", arch),
_ => panic!("unsupported platform"),
};
let lib_path = path.join("installed").join(target).join("lib");
// 链接 FFmpeg 静态库
println!("cargo:rustc-link-search=native={}", lib_path);
["avcodec", "avutil", "avformat"].iter()
.for_each(|lib| println!("cargo:rustc-link-lib=static={}", lib));
}
```
#### pkg-config (Linux 动态链接)
```rust
fn link_system_ffmpeg(builder: &mut Build) {
let libs = ["libavcodec", "libavutil", "libavformat", "libswscale"];
for lib in &libs {
// 获取编译标志
let cflags = Command::new("pkg-config")
.args(["--cflags", lib])
.output()?;
// 获取链接标志
let libs = Command::new("pkg-config")
.args(["--libs", lib])
.output()?;
// 解析并应用
for flag in libs.split_whitespace() {
if flag.starts_with("-L") {
println!("cargo:rustc-link-search=native={}", &flag[2..]);
} else if flag.starts_with("-l") {
println!("cargo:rustc-link-lib={}", &flag[2..]);
}
}
}
}
```
### 3.5 系统库链接
```rust
fn link_os() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let libs: Vec<&str> = match target_os.as_str() {
"windows" => vec!["User32", "bcrypt", "ole32", "advapi32"],
"linux" => vec!["drm", "X11", "stdc++", "z"],
_ => panic!("unsupported os"),
};
for lib in libs {
println!("cargo:rustc-link-lib={}", lib);
}
}
```
## 4. FFI 绑定生成
### 4.1 bindgen 配置
```rust
bindgen::builder()
.header("path/to/header.h")
.rustified_enum("*") // 生成 Rust 枚举
.parse_callbacks(Box::new(Callbacks)) // 自定义回调
.generate()
.write_to_file(OUT_DIR.join("ffi.rs"));
```
### 4.2 自定义派生
```rust
#[derive(Debug)]
struct CommonCallbacks;
impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
fn add_derives(&self, name: &str) -> Vec<String> {
// 为特定类型添加序列化支持
match name {
"DataFormat" | "SurfaceFormat" | "API" => {
vec!["Serialize".to_string(), "Deserialize".to_string()]
}
_ => vec![],
}
}
}
```
### 4.3 生成的文件
| 文件 | 来源 | 内容 |
|------|------|------|
| `common_ffi.rs` | `common.h`, `callback.h` | 枚举、常量、回调类型 |
| `ffmpeg_ffi.rs` | `ffmpeg_ffi.h` | FFmpeg 日志级别、函数 |
| `ffmpeg_ram_ffi.rs` | `ffmpeg_ram_ffi.h` | 编解码器函数 |
## 5. 平台构建指南
### 5.1 Linux 构建
```bash
# 安装 FFmpeg 开发库
sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
# 安装其他依赖
sudo apt install libdrm-dev libx11-dev pkg-config
# 安装 clang (bindgen 需要)
sudo apt install clang libclang-dev
# 构建
cargo build --release -p hwcodec
```
### 5.2 Windows 构建 (VCPKG)
```powershell
# 安装 VCPKG
git clone https://github.com/microsoft/vcpkg
cd vcpkg
./bootstrap-vcpkg.bat
# 安装 FFmpeg
./vcpkg install ffmpeg:x64-windows-static
# 设置环境变量
$env:VCPKG_ROOT = "C:\path\to\vcpkg"
# 构建
cargo build --release -p hwcodec
```
### 5.3 交叉编译
```bash
# 安装 cross
cargo install cross --git https://github.com/cross-rs/cross
# ARM64 Linux
cross build --release -p hwcodec --target aarch64-unknown-linux-gnu
# ARMv7 Linux
cross build --release -p hwcodec --target armv7-unknown-linux-gnueabihf
```
## 6. 集成到 One-KVM
### 6.1 依赖配置
```toml
# Cargo.toml
[dependencies]
hwcodec = { path = "libs/hwcodec" }
```
### 6.2 使用示例
```rust
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg_ram::decode::{Decoder, DecodeContext};
use hwcodec::ffmpeg::{AVPixelFormat, AVHWDeviceType};
// 检测可用编码器
let encoders = Encoder::available_encoders(ctx, None);
// 创建编码器
let encoder = Encoder::new(EncodeContext {
name: "h264_vaapi".to_string(),
width: 1920,
height: 1080,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
fps: 30,
gop: 30,
kbs: 4000,
// ...
})?;
// 编码
let frames = encoder.encode(&yuv_data, pts_ms)?;
// 创建 MJPEG 解码器 (IP-KVM 专用)
let decoder = Decoder::new(DecodeContext {
name: "mjpeg".to_string(),
device_type: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
thread_count: 4,
})?;
// 解码
let frames = decoder.decode(&mjpeg_data)?;
```
### 6.3 日志集成
```rust
// hwcodec 使用 log crate与 One-KVM 日志系统兼容
use log::{debug, info, warn, error};
// C++ 层日志通过回调传递到 Rust
#[no_mangle]
pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const c_char) {
// 转发到 Rust log 系统
match level {
AV_LOG_ERROR => error!("{}", message),
AV_LOG_WARNING => warn!("{}", message),
AV_LOG_INFO => info!("{}", message),
AV_LOG_DEBUG => debug!("{}", message),
_ => {}
}
}
```
## 7. 故障排除
### 7.1 编译错误
**FFmpeg 未找到**:
```
error: pkg-config failed for libavcodec
```
解决: 安装 FFmpeg 开发库
```bash
sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
```
**bindgen 错误**:
```
error: failed to run custom build command for `hwcodec`
```
解决: 安装 clang
```bash
sudo apt install clang libclang-dev
```
### 7.2 链接错误
**符号未定义**:
```
undefined reference to `av_log_set_level'
```
解决: 检查 FFmpeg 库链接顺序,确保 pkg-config 正确配置
**动态库未找到**:
```
error while loading shared libraries: libavcodec.so.59
```
解决:
```bash
sudo ldconfig
# 或设置 LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
```
### 7.3 运行时错误
**硬件编码器不可用**:
```
Encoder h264_vaapi test failed
```
检查:
1. 驱动是否正确安装: `vainfo`
2. 权限是否足够: `ls -la /dev/dri/`
3. 用户是否在 video 组: `groups`
**解码失败**:
```
avcodec_receive_frame failed, ret = -11
```
解决: 这通常表示需要更多输入数据 (EAGAIN),是正常行为
## 8. 与原版 RustDesk hwcodec 的构建差异
### 8.1 移除的构建步骤
| 步骤 | 原因 |
|------|------|
| `build_mux()` | 移除了 Mux 模块 |
| `build_ffmpeg_vram()` | 移除了 VRAM 模块 |
| `sdk::build_sdk()` | 移除了外部 SDK 依赖 |
| macOS 框架链接 | 移除了 macOS 支持 |
| Android NDK 链接 | 移除了 Android 支持 |
### 8.2 简化的构建流程
```
原版构建流程:
build.rs
├── build_common()
├── ffmpeg::build_ffmpeg()
│ ├── build_ffmpeg_ram()
│ ├── build_ffmpeg_vram() [已移除]
│ └── build_mux() [已移除]
└── sdk::build_sdk() [已移除]
简化版构建流程:
build.rs
├── build_common()
└── ffmpeg::build_ffmpeg()
└── build_ffmpeg_ram()
```
### 8.3 优势
1. **更快的编译**: 无需编译外部 SDK 代码
2. **更少的依赖**: 无需下载 ~9MB 的外部 SDK
3. **更简单的维护**: 代码量减少约 67%
4. **更小的二进制**: 不包含未使用的功能

View File

@@ -1,69 +0,0 @@
# RustDesk 通信协议技术报告
## 概述
本报告详细分析 RustDesk 远程桌面软件的客户端与服务器之间的通信协议,包括 Rendezvous 服务器hbbs、Relay 服务器hbbr以及客户端之间的 P2P 连接机制。
## 文档结构
| 文档 | 内容 |
|------|------|
| [01-architecture.md](01-architecture.md) | 整体架构设计 |
| [02-rendezvous-protocol.md](02-rendezvous-protocol.md) | Rendezvous 服务器协议 |
| [03-relay-protocol.md](03-relay-protocol.md) | Relay 服务器协议 |
| [04-p2p-connection.md](04-p2p-connection.md) | P2P 连接流程 |
| [05-message-format.md](05-message-format.md) | 消息格式定义 |
| [06-encryption.md](06-encryption.md) | 加密机制 |
| [07-nat-traversal.md](07-nat-traversal.md) | NAT 穿透技术 |
| [08-onekvm-comparison.md](08-onekvm-comparison.md) | **One-KVM 实现对比分析** |
## 核心组件
### 1. Rendezvous Server (hbbs)
- **功能**: ID 注册、Peer 发现、NAT 类型检测、连接协调
- **端口**: 21116 (TCP/UDP), 21115 (NAT 测试), 21118 (WebSocket)
- **源文件**: `rustdesk-server/src/rendezvous_server.rs`
### 2. Relay Server (hbbr)
- **功能**: 当 P2P 连接失败时提供数据中转
- **端口**: 21117 (TCP), 21119 (WebSocket)
- **源文件**: `rustdesk-server/src/relay_server.rs`
### 3. 客户端 (RustDesk)
- **功能**: 远程桌面控制、文件传输、屏幕共享
- **核心模块**:
- `rendezvous_mediator.rs` - 与 Rendezvous 服务器通信
- `client.rs` - 客户端连接逻辑
- `server/connection.rs` - 被控端连接处理
## 协议栈
```
┌─────────────────────────────────────────┐
│ Application Layer │
│ (Video/Audio/Keyboard/Mouse/File) │
├─────────────────────────────────────────┤
│ Message Layer │
│ (Protobuf Messages) │
├─────────────────────────────────────────┤
│ Security Layer │
│ (Sodium: X25519 + ChaCha20) │
├─────────────────────────────────────────┤
│ Transport Layer │
│ (TCP/UDP/WebSocket/KCP) │
└─────────────────────────────────────────┘
```
## 关键技术特点
1. **混合连接模式**: 优先尝试 P2P 直连,失败后自动切换到 Relay 中转
2. **多协议支持**: TCP、UDP、WebSocket、KCP
3. **端到端加密**: 使用 libsodium 实现的 X25519 密钥交换和 ChaCha20-Poly1305 对称加密
4. **NAT 穿透**: 支持 UDP 打洞和 TCP 打洞技术
5. **服务器签名**: 可选的服务器公钥签名验证,防止中间人攻击
## 版本信息
- 分析基于 RustDesk 最新版本源码
- Protocol Buffer 版本: proto3
- 加密库: libsodium (sodiumoxide)

View File

@@ -1,218 +0,0 @@
# RustDesk 架构设计
## 系统架构图
```
┌──────────────────────┐
│ Rendezvous Server │
│ (hbbs) │
│ Port: 21116 │
└──────────┬───────────┘
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Client A │ │ Client B │ │ Client C │
│ (控制端) │ │ (被控端) │ │ (被控端) │
└───────┬───────┘ └───────┬───────┘ └───────────────┘
│ │
│ P2P Connection │
│◄────────────────────────►│
│ │
│ (如果 P2P 失败) │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
└─►│ Relay Server │◄──────┘
│ (hbbr) │
│ Port: 21117 │
└───────────────┘
```
## 服务器组件详解
### Rendezvous Server (hbbs)
**监听端口:**
| 端口 | 协议 | 用途 |
|------|------|------|
| 21116 | TCP | 主要通信端口,处理 punch hole 请求 |
| 21116 | UDP | Peer 注册、NAT 类型检测 |
| 21115 | TCP | NAT 测试专用端口 |
| 21118 | WebSocket | Web 客户端支持 |
**核心数据结构:**
```rust
// rustdesk-server/src/rendezvous_server.rs:64-83
pub struct RendezvousServer {
tcp_punch: Arc<Mutex<HashMap<SocketAddr, Sink>>>, // TCP punch hole 连接
pm: PeerMap, // Peer 映射表
tx: Sender, // 消息发送通道
relay_servers: Arc<RelayServers>, // 可用 Relay 服务器列表
relay_servers0: Arc<RelayServers>, // 原始 Relay 服务器列表
rendezvous_servers: Arc<Vec<String>>, // Rendezvous 服务器列表
inner: Arc<Inner>, // 内部配置
}
struct Inner {
serial: i32, // 配置序列号
version: String, // 软件版本
software_url: String, // 软件更新 URL
mask: Option<Ipv4Network>, // LAN 掩码
local_ip: String, // 本地 IP
sk: Option<sign::SecretKey>, // 服务器签名密钥
}
```
**Peer 数据结构:**
```rust
// rustdesk-server/src/peer.rs:32-42
pub struct Peer {
pub socket_addr: SocketAddr, // 最后注册的地址
pub last_reg_time: Instant, // 最后注册时间
pub guid: Vec<u8>, // 数据库 GUID
pub uuid: Bytes, // 设备 UUID
pub pk: Bytes, // 公钥
pub info: PeerInfo, // Peer 信息
pub reg_pk: (u32, Instant), // 注册频率限制
}
```
### Relay Server (hbbr)
**监听端口:**
| 端口 | 协议 | 用途 |
|------|------|------|
| 21117 | TCP | 主要中转端口 |
| 21119 | WebSocket | Web 客户端支持 |
**核心特性:**
```rust
// rustdesk-server/src/relay_server.rs:40-44
static DOWNGRADE_THRESHOLD_100: AtomicUsize = AtomicUsize::new(66); // 降级阈值
static DOWNGRADE_START_CHECK: AtomicUsize = AtomicUsize::new(1_800_000); // 检测开始时间(ms)
static LIMIT_SPEED: AtomicUsize = AtomicUsize::new(32 * 1024 * 1024); // 限速(bit/s)
static TOTAL_BANDWIDTH: AtomicUsize = AtomicUsize::new(1024 * 1024 * 1024);// 总带宽
static SINGLE_BANDWIDTH: AtomicUsize = AtomicUsize::new(128 * 1024 * 1024);// 单连接带宽
```
## 客户端架构
### 核心模块
```
rustdesk/src/
├── rendezvous_mediator.rs # Rendezvous 服务器通信
├── client.rs # 控制端核心逻辑
├── server/
│ ├── mod.rs # 被控端服务
│ ├── connection.rs # 连接处理
│ ├── video_service.rs # 视频服务
│ ├── audio_service.rs # 音频服务
│ └── input_service.rs # 输入服务
├── common.rs # 通用函数(加密、解密)
└── platform/ # 平台特定代码
```
### RendezvousMediator
```rust
// rustdesk/src/rendezvous_mediator.rs:44-50
pub struct RendezvousMediator {
addr: TargetAddr<'static>, // 服务器地址
host: String, // 服务器主机名
host_prefix: String, // 主机前缀
keep_alive: i32, // 保活间隔
}
```
**两种连接模式:**
1. **UDP 模式** (默认):
- 用于 Peer 注册和心跳
- 更低延迟
- 可能被某些防火墙阻止
2. **TCP 模式**:
- 用于代理环境
- WebSocket 模式
- 更可靠
## 连接流程概述
### 被控端启动流程
```
1. 生成设备 ID 和密钥对
2. 连接 Rendezvous Server
3. 发送 RegisterPeer 消息
4. 如果需要,发送 RegisterPk 注册公钥
5. 定期发送心跳保持在线状态
6. 等待 PunchHole 或 RequestRelay 请求
```
### 控制端连接流程
```
1. 输入目标设备 ID
2. 连接 Rendezvous Server
3. 发送 PunchHoleRequest 消息
4. 根据响应决定连接方式:
a. 直连 (P2P): 使用 PunchHole 信息尝试打洞
b. 局域网: 使用 LocalAddr 信息直连
c. 中转: 通过 Relay Server 连接
5. 建立安全加密通道
6. 发送 LoginRequest 进行身份验证
7. 开始远程控制会话
```
## 数据流
### 视频流
```
被控端 控制端
│ │
│ VideoFrame (H264/VP9/...) │
├─────────────────────────────────►│
│ │
│ 加密 → 传输 → 解密 → 解码 → 显示 │
```
### 输入流
```
控制端 被控端
│ │
│ MouseEvent/KeyEvent │
├─────────────────────────────────►│
│ │
│ 加密 → 传输 → 解密 → 模拟输入 │
```
## 高可用设计
### 多服务器支持
- 客户端可配置多个 Rendezvous Server
- 自动选择延迟最低的服务器
- 连接失败时自动切换备用服务器
### Relay Server 选择
- 支持配置多个 Relay Server
- 轮询算法分配负载
- 定期检查 Relay Server 可用性
### 重连机制
```rust
// 连接超时和重试参数
const REG_INTERVAL: i64 = 12_000; // 注册间隔 12 秒
const REG_TIMEOUT: i32 = 30_000; // 注册超时 30 秒
const CONNECT_TIMEOUT: u64 = 18_000; // 连接超时 18 秒
```

View File

@@ -1,438 +0,0 @@
# Rendezvous 服务器协议
## 概述
Rendezvous Serverhbbs是 RustDesk 的核心协调服务器,负责:
- Peer ID 注册和发现
- 公钥存储和分发
- NAT 类型检测
- P2P 连接协调(打洞辅助)
- Relay Server 分配
## 协议消息定义
所有消息使用 Protocol Buffers 定义在 `protos/rendezvous.proto`
```protobuf
message RendezvousMessage {
oneof union {
RegisterPeer register_peer = 6;
RegisterPeerResponse register_peer_response = 7;
PunchHoleRequest punch_hole_request = 8;
PunchHole punch_hole = 9;
PunchHoleSent punch_hole_sent = 10;
PunchHoleResponse punch_hole_response = 11;
FetchLocalAddr fetch_local_addr = 12;
LocalAddr local_addr = 13;
ConfigUpdate configure_update = 14;
RegisterPk register_pk = 15;
RegisterPkResponse register_pk_response = 16;
SoftwareUpdate software_update = 17;
RequestRelay request_relay = 18;
RelayResponse relay_response = 19;
TestNatRequest test_nat_request = 20;
TestNatResponse test_nat_response = 21;
PeerDiscovery peer_discovery = 22;
OnlineRequest online_request = 23;
OnlineResponse online_response = 24;
KeyExchange key_exchange = 25;
HealthCheck hc = 26;
}
}
```
## 核心流程
### 1. Peer 注册流程
**客户端 → 服务器RegisterPeer**
```protobuf
message RegisterPeer {
string id = 1; // Peer ID (如 "123456789")
int32 serial = 2; // 配置序列号
}
```
**服务器处理逻辑:**
```rust
// rustdesk-server/src/rendezvous_server.rs:318-333
Some(rendezvous_message::Union::RegisterPeer(rp)) => {
if !rp.id.is_empty() {
log::trace!("New peer registered: {:?} {:?}", &rp.id, &addr);
self.update_addr(rp.id, addr, socket).await?;
// 如果服务器配置更新,发送 ConfigUpdate
if self.inner.serial > rp.serial {
let mut msg_out = RendezvousMessage::new();
msg_out.set_configure_update(ConfigUpdate {
serial: self.inner.serial,
rendezvous_servers: (*self.rendezvous_servers).clone(),
..Default::default()
});
socket.send(&msg_out, addr).await?;
}
}
}
```
**服务器 → 客户端RegisterPeerResponse**
```protobuf
message RegisterPeerResponse {
bool request_pk = 2; // 是否需要注册公钥
}
```
### 2. 公钥注册流程
当服务器检测到 Peer 的公钥为空或 IP 变化时,会请求注册公钥。
**客户端 → 服务器RegisterPk**
```protobuf
message RegisterPk {
string id = 1; // Peer ID
bytes uuid = 2; // 设备 UUID
bytes pk = 3; // Ed25519 公钥
string old_id = 4; // 旧 ID如果更换
}
```
**服务器处理逻辑:**
```rust
// rustdesk-server/src/rendezvous_server.rs:334-418
Some(rendezvous_message::Union::RegisterPk(rk)) => {
// 验证 UUID 和公钥
if rk.uuid.is_empty() || rk.pk.is_empty() {
return Ok(());
}
let id = rk.id;
let ip = addr.ip().to_string();
// ID 长度检查
if id.len() < 6 {
return send_rk_res(socket, addr, UUID_MISMATCH).await;
}
// IP 封锁检查
if !self.check_ip_blocker(&ip, &id).await {
return send_rk_res(socket, addr, TOO_FREQUENT).await;
}
// UUID 匹配验证
let peer = self.pm.get_or(&id).await;
// ... UUID 验证逻辑 ...
// 更新数据库
if changed {
self.pm.update_pk(id, peer, addr, rk.uuid, rk.pk, ip).await;
}
// 发送成功响应
msg_out.set_register_pk_response(RegisterPkResponse {
result: register_pk_response::Result::OK.into(),
..Default::default()
});
}
```
**服务器 → 客户端RegisterPkResponse**
```protobuf
message RegisterPkResponse {
enum Result {
OK = 0;
UUID_MISMATCH = 2;
ID_EXISTS = 3;
TOO_FREQUENT = 4;
INVALID_ID_FORMAT = 5;
NOT_SUPPORT = 6;
SERVER_ERROR = 7;
}
Result result = 1;
int32 keep_alive = 2; // 心跳间隔
}
```
### 3. Punch Hole 请求流程
当控制端要连接被控端时,首先发送 PunchHoleRequest。
**控制端 → 服务器PunchHoleRequest**
```protobuf
message PunchHoleRequest {
string id = 1; // 目标 Peer ID
NatType nat_type = 2; // 请求方的 NAT 类型
string licence_key = 3; // 许可证密钥
ConnType conn_type = 4; // 连接类型
string token = 5; // 认证令牌
string version = 6; // 客户端版本
}
enum NatType {
UNKNOWN_NAT = 0;
ASYMMETRIC = 1;
SYMMETRIC = 2;
}
enum ConnType {
DEFAULT_CONN = 0;
FILE_TRANSFER = 1;
PORT_FORWARD = 2;
RDP = 3;
VIEW_CAMERA = 4;
}
```
**服务器处理逻辑:**
```rust
// rustdesk-server/src/rendezvous_server.rs:674-765
async fn handle_punch_hole_request(...) {
// 1. 验证许可证密钥
if !key.is_empty() && ph.licence_key != key {
return Ok((PunchHoleResponse { failure: LICENSE_MISMATCH }, None));
}
// 2. 查找目标 Peer
if let Some(peer) = self.pm.get(&id).await {
let (elapsed, peer_addr) = peer.read().await;
// 3. 检查在线状态
if elapsed >= REG_TIMEOUT {
return Ok((PunchHoleResponse { failure: OFFLINE }, None));
}
// 4. 判断是否同一局域网
let same_intranet = (peer_is_lan && is_lan) ||
(peer_addr.ip() == addr.ip());
if same_intranet {
// 请求获取本地地址
msg_out.set_fetch_local_addr(FetchLocalAddr {
socket_addr: AddrMangle::encode(addr).into(),
relay_server,
});
} else {
// 发送 Punch Hole 请求给被控端
msg_out.set_punch_hole(PunchHole {
socket_addr: AddrMangle::encode(addr).into(),
nat_type: ph.nat_type,
relay_server,
});
}
return Ok((msg_out, Some(peer_addr)));
}
// Peer 不存在
Ok((PunchHoleResponse { failure: ID_NOT_EXIST }, None))
}
```
**服务器 → 被控端PunchHole 或 FetchLocalAddr**
```protobuf
message PunchHole {
bytes socket_addr = 1; // 控制端地址(编码)
string relay_server = 2; // Relay 服务器地址
NatType nat_type = 3; // 控制端 NAT 类型
}
message FetchLocalAddr {
bytes socket_addr = 1; // 控制端地址(编码)
string relay_server = 2; // Relay 服务器地址
}
```
### 4. 被控端响应流程
**被控端 → 服务器PunchHoleSent 或 LocalAddr**
```protobuf
message PunchHoleSent {
bytes socket_addr = 1; // 控制端地址
string id = 2; // 被控端 ID
string relay_server = 3; // Relay 服务器
NatType nat_type = 4; // 被控端 NAT 类型
string version = 5; // 客户端版本
}
message LocalAddr {
bytes socket_addr = 1; // 控制端地址
bytes local_addr = 2; // 被控端本地地址
string relay_server = 3; // Relay 服务器
string id = 4; // 被控端 ID
string version = 5; // 客户端版本
}
```
**服务器 → 控制端PunchHoleResponse**
```protobuf
message PunchHoleResponse {
bytes socket_addr = 1; // 被控端地址
bytes pk = 2; // 被控端公钥(已签名)
enum Failure {
ID_NOT_EXIST = 0;
OFFLINE = 2;
LICENSE_MISMATCH = 3;
LICENSE_OVERUSE = 4;
}
Failure failure = 3;
string relay_server = 4;
oneof union {
NatType nat_type = 5;
bool is_local = 6; // 是否为局域网连接
}
string other_failure = 7;
int32 feedback = 8;
}
```
### 5. Relay 请求流程
当 P2P 连接失败或 NAT 类型不支持打洞时,使用 Relay。
**客户端 → 服务器RequestRelay**
```protobuf
message RequestRelay {
string id = 1; // 目标 Peer ID
string uuid = 2; // 连接 UUID用于配对
bytes socket_addr = 3; // 本端地址
string relay_server = 4; // 指定的 Relay 服务器
bool secure = 5; // 是否使用加密
string licence_key = 6; // 许可证密钥
ConnType conn_type = 7; // 连接类型
string token = 8; // 认证令牌
}
```
**服务器 → 客户端RelayResponse**
```protobuf
message RelayResponse {
bytes socket_addr = 1; // 对端地址
string uuid = 2; // 连接 UUID
string relay_server = 3; // Relay 服务器地址
oneof union {
string id = 4; // 对端 ID
bytes pk = 5; // 对端公钥
}
string refuse_reason = 6; // 拒绝原因
string version = 7; // 版本
int32 feedback = 9;
}
```
## NAT 类型检测
**客户端 → 服务器TestNatRequest**
```protobuf
message TestNatRequest {
int32 serial = 1; // 配置序列号
}
```
**服务器 → 客户端TestNatResponse**
```protobuf
message TestNatResponse {
int32 port = 1; // 观测到的源端口
ConfigUpdate cu = 2; // 配置更新
}
```
NAT 检测原理:
1. 客户端同时向主端口21116和 NAT 测试端口21115发送请求
2. 比较两次响应中观测到的源端口
3. 如果端口一致,则为 ASYMMETRIC NAT适合打洞
4. 如果端口不一致,则为 SYMMETRIC NAT需要 Relay
## 地址编码
RustDesk 使用 `AddrMangle` 对 SocketAddr 进行编码:
```rust
// 编码示例
// IPv4: 4 bytes IP + 2 bytes port = 6 bytes
// IPv6: 16 bytes IP + 2 bytes port = 18 bytes
pub fn encode(addr: SocketAddr) -> Vec<u8>;
pub fn decode(bytes: &[u8]) -> SocketAddr;
```
## 安全机制
### 服务器签名
当服务器配置了私钥时,会对 Peer 的公钥进行签名:
```rust
// rustdesk-server/src/rendezvous_server.rs:1160-1182
async fn get_pk(&mut self, version: &str, id: String) -> Bytes {
if version.is_empty() || self.inner.sk.is_none() {
Bytes::new()
} else {
match self.pm.get(&id).await {
Some(peer) => {
let pk = peer.read().await.pk.clone();
// 使用服务器私钥签名 IdPk
sign::sign(
&IdPk { id, pk, ..Default::default() }
.write_to_bytes()
.unwrap_or_default(),
self.inner.sk.as_ref().unwrap(),
).into()
}
_ => Bytes::new(),
}
}
}
```
### IP 封锁
服务器实现了 IP 封锁机制防止滥用:
```rust
// rustdesk-server/src/rendezvous_server.rs:866-894
async fn check_ip_blocker(&self, ip: &str, id: &str) -> bool {
let mut lock = IP_BLOCKER.lock().await;
if let Some(old) = lock.get_mut(ip) {
// 每秒请求超过 30 次则封锁
if counter.0 > 30 {
return false;
}
// 每天超过 300 个不同 ID 则封锁
if counter.0.len() > 300 {
return !is_new;
}
}
true
}
```
## 时序图
### 完整连接建立流程
```
控制端 Rendezvous Server 被控端
│ │ │
│ PunchHoleRequest │ │
├──────────────────────►│ │
│ │ PunchHole │
│ ├──────────────────────►│
│ │ │
│ │ PunchHoleSent │
│ │◄──────────────────────┤
│ PunchHoleResponse │ │
│◄──────────────────────┤ │
│ │ │
│ ─────────── P2P Connection ──────────────────│
│◄─────────────────────────────────────────────►│
```

View File

@@ -1,318 +0,0 @@
# Relay 服务器协议
## 概述
Relay Serverhbbr是 RustDesk 的数据中转服务器,当 P2P 连接无法建立时(如双方都在 Symmetric NAT 后面),所有通信数据通过 Relay Server 转发。
## 服务器架构
### 监听端口
| 端口 | 协议 | 用途 |
|------|------|------|
| 21117 | TCP | 主要中转端口 |
| 21119 | WebSocket | Web 客户端支持 |
### 核心配置
```rust
// rustdesk-server/src/relay_server.rs:40-46
static DOWNGRADE_THRESHOLD_100: AtomicUsize = AtomicUsize::new(66); // 0.66
static DOWNGRADE_START_CHECK: AtomicUsize = AtomicUsize::new(1_800_000); // 30分钟 (ms)
static LIMIT_SPEED: AtomicUsize = AtomicUsize::new(32 * 1024 * 1024); // 32 Mb/s
static TOTAL_BANDWIDTH: AtomicUsize = AtomicUsize::new(1024 * 1024 * 1024);// 1024 Mb/s
static SINGLE_BANDWIDTH: AtomicUsize = AtomicUsize::new(128 * 1024 * 1024);// 128 Mb/s
const BLACKLIST_FILE: &str = "blacklist.txt";
const BLOCKLIST_FILE: &str = "blocklist.txt";
```
## 连接配对机制
### 配对原理
Relay Server 使用 UUID 来配对两个客户端的连接:
1. 第一个客户端连接并发送 `RequestRelay` 消息(包含 UUID
2. 服务器将该连接存储在等待队列中
3. 第二个客户端使用相同的 UUID 连接
4. 服务器将两个连接配对,开始转发数据
### 配对流程
```rust
// rustdesk-server/src/relay_server.rs:425-462
async fn make_pair_(stream: impl StreamTrait, addr: SocketAddr, key: &str, limiter: Limiter) {
let mut stream = stream;
if let Ok(Some(Ok(bytes))) = timeout(30_000, stream.recv()).await {
if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(&bytes) {
if let Some(rendezvous_message::Union::RequestRelay(rf)) = msg_in.union {
// 验证许可证密钥
if !key.is_empty() && rf.licence_key != key {
log::warn!("Relay authentication failed from {}", addr);
return;
}
if !rf.uuid.is_empty() {
// 尝试查找配对
let mut peer = PEERS.lock().await.remove(&rf.uuid);
if let Some(peer) = peer.as_mut() {
// 找到配对,开始中转
log::info!("Relay request {} got paired", rf.uuid);
relay(addr, &mut stream, peer, limiter).await;
} else {
// 没找到,存储等待配对
log::info!("New relay request {} from {}", rf.uuid, addr);
PEERS.lock().await.insert(rf.uuid.clone(), Box::new(stream));
sleep(30.).await; // 等待 30 秒
PEERS.lock().await.remove(&rf.uuid); // 超时移除
}
}
}
}
}
}
```
## 数据转发
### 转发逻辑
```rust
// rustdesk-server/src/relay_server.rs:464-566
async fn relay(
addr: SocketAddr,
stream: &mut impl StreamTrait,
peer: &mut Box<dyn StreamTrait>,
total_limiter: Limiter,
) -> ResultType<()> {
let limiter = <Limiter>::new(SINGLE_BANDWIDTH.load(Ordering::SeqCst) as f64);
let blacklist_limiter = <Limiter>::new(LIMIT_SPEED.load(Ordering::SeqCst) as _);
loop {
tokio::select! {
// 从 peer 接收数据,发送给 stream
res = peer.recv() => {
if let Some(Ok(bytes)) = res {
// 带宽限制
if blacked || downgrade {
blacklist_limiter.consume(bytes.len() * 8).await;
} else {
limiter.consume(bytes.len() * 8).await;
}
total_limiter.consume(bytes.len() * 8).await;
stream.send_raw(bytes.into()).await?;
} else {
break;
}
},
// 从 stream 接收数据,发送给 peer
res = stream.recv() => {
if let Some(Ok(bytes)) = res {
// 带宽限制
limiter.consume(bytes.len() * 8).await;
total_limiter.consume(bytes.len() * 8).await;
peer.send_raw(bytes.into()).await?;
} else {
break;
}
},
_ = timer.tick() => {
// 超时检测
if last_recv_time.elapsed().as_secs() > 30 {
bail!("Timeout");
}
}
}
// 降级检测
if elapsed > DOWNGRADE_START_CHECK && total > elapsed * downgrade_threshold {
downgrade = true;
log::info!("Downgrade {}, exceed threshold", id);
}
}
Ok(())
}
```
### 原始模式
当两端都支持原始模式时,跳过 protobuf 解析以提高性能:
```rust
// rustdesk-server/src/relay_server.rs:440-444
if !stream.is_ws() && !peer.is_ws() {
peer.set_raw();
stream.set_raw();
log::info!("Both are raw");
}
```
## 带宽控制
### 多级限速
1. **总带宽限制**:整个服务器的总带宽
2. **单连接限制**:每个中转连接的带宽
3. **黑名单限速**:对黑名单 IP 的特殊限制
### 降级机制
当连接持续占用高带宽时,会触发降级:
```rust
// 条件:
// 1. 连接时间 > DOWNGRADE_START_CHECK (30分钟)
// 2. 平均带宽 > SINGLE_BANDWIDTH * 0.66
// 降级后使用 LIMIT_SPEED (32 Mb/s) 限速
if elapsed > DOWNGRADE_START_CHECK.load(Ordering::SeqCst)
&& !downgrade
&& total > elapsed * downgrade_threshold
{
downgrade = true;
}
```
## 安全控制
### 黑名单
用于限速特定 IP
```
# blacklist.txt
192.168.1.100
10.0.0.50
```
### 封锁名单
用于完全拒绝特定 IP
```
# blocklist.txt
1.2.3.4
5.6.7.8
```
### 运行时管理命令
通过本地 TCP 连接(仅限 localhost发送命令
```rust
// rustdesk-server/src/relay_server.rs:152-324
match fds.next() {
Some("h") => // 帮助
Some("blacklist-add" | "ba") => // 添加黑名单
Some("blacklist-remove" | "br") => // 移除黑名单
Some("blacklist" | "b") => // 查看黑名单
Some("blocklist-add" | "Ba") => // 添加封锁名单
Some("blocklist-remove" | "Br") => // 移除封锁名单
Some("blocklist" | "B") => // 查看封锁名单
Some("downgrade-threshold" | "dt") => // 设置降级阈值
Some("downgrade-start-check" | "t") => // 设置降级检测时间
Some("limit-speed" | "ls") => // 设置限速
Some("total-bandwidth" | "tb") => // 设置总带宽
Some("single-bandwidth" | "sb") => // 设置单连接带宽
Some("usage" | "u") => // 查看使用统计
}
```
## 协议消息
### RequestRelay
用于建立中转连接的请求消息:
```protobuf
message RequestRelay {
string id = 1; // 目标 Peer ID
string uuid = 2; // 连接 UUID配对用
bytes socket_addr = 3; // 本端地址
string relay_server = 4; // Relay 服务器
bool secure = 5; // 是否加密
string licence_key = 6; // 许可证密钥
ConnType conn_type = 7; // 连接类型
string token = 8; // 认证令牌
}
```
## 时序图
### 中转连接建立
```
客户端 A Relay Server 客户端 B
│ │ │
│ RequestRelay(uuid) │ │
├─────────────────────────►│ │
│ │ │
│ │ (存储等待配对) │
│ │ │
│ │ RequestRelay(uuid) │
│ │◄───────────────────────────┤
│ │ │
│ │ (配对成功) │
│ │ │
│ ◄────────── 数据转发 ─────────────────────────────────►│
│ │ │
```
### 数据转发
```
客户端 A Relay Server 客户端 B
│ │ │
│ ────[数据]───────► │ │
│ │ ────[数据]───────► │
│ │ │
│ │ ◄───[数据]──────── │
│ ◄───[数据]──────── │ │
│ │ │
```
## 性能优化
### 零拷贝
使用 `Bytes` 类型减少内存拷贝:
```rust
async fn send_raw(&mut self, bytes: Bytes) -> ResultType<()>;
```
### WebSocket 支持
支持 WebSocket 协议以穿越防火墙:
```rust
#[async_trait]
impl StreamTrait for tokio_tungstenite::WebSocketStream<TcpStream> {
async fn recv(&mut self) -> Option<Result<BytesMut, Error>> {
if let Some(msg) = self.next().await {
match msg {
Ok(tungstenite::Message::Binary(bytes)) => {
Some(Ok(bytes[..].into()))
}
// ...
}
}
}
}
```
## 监控指标
服务器跟踪以下指标:
| 指标 | 说明 |
|------|------|
| elapsed | 连接持续时间 (ms) |
| total | 总传输数据量 (bit) |
| highest | 最高瞬时速率 (kb/s) |
| speed | 当前速率 (kb/s) |
通过 `usage` 命令查看:
```
192.168.1.100:12345: 3600s 1024.00MB 50000kb/s 45000kb/s 42000kb/s
```

View File

@@ -1,424 +0,0 @@
# P2P 连接流程
## 概述
RustDesk 优先尝试建立 P2P 直连,只有在直连失败时才使用 Relay 中转。P2P 连接支持多种方式:
- TCP 打洞
- UDP 打洞KCP
- 局域网直连
- IPv6 直连
## 连接决策流程
```
开始连接
┌──────────────┐
│ 是否强制 Relay
└──────┬───────┘
是 │ 否
┌─────────┴─────────┐
▼ ▼
使用 Relay 检查 NAT 类型
┌──────────────┴──────────────┐
│ │
▼ ▼
双方都是对称 NAT 有一方是可穿透 NAT
│ │
是 │ │
▼ ▼
使用 Relay 尝试 P2P 连接
┌─────────────┴─────────────┐
│ │
▼ ▼
同一局域网? 不同网络
│ │
是 │ │
▼ ▼
局域网直连 尝试打洞
┌──────────────┴──────────────┐
│ │
▼ ▼
TCP 打洞成功? UDP 打洞成功?
│ │
是 │ 否 是 │ 否
▼ │ ▼ │
TCP P2P 连接 └───────────► KCP P2P 连接 │
使用 Relay
```
## 客户端连接入口
```rust
// rustdesk/src/client.rs:188-230
impl Client {
pub async fn start(
peer: &str,
key: &str,
token: &str,
conn_type: ConnType,
interface: impl Interface,
) -> ResultType<...> {
// 检查是否为 IP 直连
if hbb_common::is_ip_str(peer) {
return connect_tcp_local(check_port(peer, RELAY_PORT + 1), None, CONNECT_TIMEOUT).await;
}
// 检查是否为域名:端口格式
if hbb_common::is_domain_port_str(peer) {
return connect_tcp_local(peer, None, CONNECT_TIMEOUT).await;
}
// 通过 Rendezvous Server 连接
let (rendezvous_server, servers, _) = crate::get_rendezvous_server(1_000).await;
Self::_start_inner(peer, key, token, conn_type, interface, rendezvous_server, servers).await
}
}
```
## 被控端处理连接请求
### 处理 PunchHole 消息
```rust
// rustdesk/src/rendezvous_mediator.rs:554-619
async fn handle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&ph.socket_addr);
let relay_server = self.get_relay_server(ph.relay_server);
// 判断是否需要 Relay
if ph.nat_type.enum_value() == Ok(NatType::SYMMETRIC)
|| Config::get_nat_type() == NatType::SYMMETRIC as i32
|| relay
{
// 使用 Relay
let uuid = Uuid::new_v4().to_string();
return self.create_relay(ph.socket_addr, relay_server, uuid, server, true, true).await;
}
// 尝试 UDP 打洞
if ph.udp_port > 0 {
peer_addr.set_port(ph.udp_port as u16);
self.punch_udp_hole(peer_addr, server, msg_punch).await?;
return Ok(());
}
// 尝试 TCP 打洞
log::debug!("Punch tcp hole to {:?}", peer_addr);
let socket = {
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 关键步骤:尝试连接对方,让 NAT 建立映射
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
socket
};
// 发送 PunchHoleSent 告知 Rendezvous Server
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(PunchHoleSent {
socket_addr: ph.socket_addr,
id: Config::get_id(),
relay_server,
nat_type: nat_type.into(),
version: crate::VERSION.to_owned(),
});
socket.send_raw(msg_out.write_to_bytes()?).await?;
// 接受控制端连接
crate::accept_connection(server.clone(), socket, peer_addr, true).await;
Ok(())
}
```
### 处理 FetchLocalAddr局域网连接
```rust
// rustdesk/src/rendezvous_mediator.rs:481-552
async fn handle_intranet(&self, fla: FetchLocalAddr, server: ServerPtr) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&fla.socket_addr);
let relay_server = self.get_relay_server(fla.relay_server.clone());
// 尝试局域网直连
if is_ipv4(&self.addr) && !relay && !config::is_disable_tcp_listen() {
if let Err(err) = self.handle_intranet_(fla.clone(), server.clone(), relay_server.clone()).await {
log::debug!("Failed to handle intranet: {:?}, will try relay", err);
} else {
return Ok(());
}
}
// 局域网直连失败,使用 Relay
let uuid = Uuid::new_v4().to_string();
self.create_relay(fla.socket_addr, relay_server, uuid, server, true, true).await
}
async fn handle_intranet_(&self, fla: FetchLocalAddr, server: ServerPtr, relay_server: String) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&fla.socket_addr);
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 发送本地地址给 Rendezvous Server
let mut msg_out = Message::new();
msg_out.set_local_addr(LocalAddr {
id: Config::get_id(),
socket_addr: AddrMangle::encode(peer_addr).into(),
local_addr: AddrMangle::encode(local_addr).into(),
relay_server,
version: crate::VERSION.to_owned(),
});
socket.send_raw(msg_out.write_to_bytes()?).await?;
// 接受连接
crate::accept_connection(server.clone(), socket, peer_addr, true).await;
Ok(())
}
```
## UDP 打洞 (KCP)
### 打洞原理
UDP 打洞利用 NAT 的端口映射特性:
1. A 向 Rendezvous Server 注册NAT 创建映射 `A_internal:port1 → A_external:port2`
2. B 同样注册,创建映射 `B_internal:port3 → B_external:port4`
3. A 向 B 的外部地址发送 UDP 包A 的 NAT 创建到 B 的映射
4. B 向 A 的外部地址发送 UDP 包B 的 NAT 创建到 A 的映射
5. 如果 NAT 不是 Symmetric 类型,双方的包可以到达对方
```rust
// rustdesk/src/rendezvous_mediator.rs:621-642
async fn punch_udp_hole(
&self,
peer_addr: SocketAddr,
server: ServerPtr,
msg_punch: PunchHoleSent,
) -> ResultType<()> {
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(msg_punch);
let (socket, addr) = new_direct_udp_for(&self.host).await?;
let data = msg_out.write_to_bytes()?;
// 发送到 Rendezvous Server
socket.send_to(&data, addr).await?;
// 多次尝试发送以增加成功率
let socket_cloned = socket.clone();
tokio::spawn(async move {
for _ in 0..2 {
let tm = (hbb_common::time_based_rand() % 20 + 10) as f32 / 1000.;
hbb_common::sleep(tm).await;
socket.send_to(&data, addr).await.ok();
}
});
// 等待对方连接
udp_nat_listen(socket_cloned.clone(), peer_addr, peer_addr, server).await?;
Ok(())
}
```
### KCP 协议
RustDesk 在 UDP 上使用 KCP 协议提供可靠传输:
```rust
// rustdesk/src/rendezvous_mediator.rs:824-851
async fn udp_nat_listen(
socket: Arc<tokio::net::UdpSocket>,
peer_addr: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> ResultType<()> {
socket.connect(peer_addr).await?;
// 执行 UDP 打洞
let res = crate::punch_udp(socket.clone(), true).await?;
// 建立 KCP 流
let stream = crate::kcp_stream::KcpStream::accept(
socket,
Duration::from_millis(CONNECT_TIMEOUT as _),
res,
).await?;
// 创建连接
crate::server::create_tcp_connection(server, stream.1, peer_addr_v4, true).await?;
Ok(())
}
```
## TCP 打洞
### 原理
TCP 打洞比 UDP 更难,因为 TCP 需要三次握手。基本思路:
1. A 和 B 都尝试同时向对方发起连接
2. 第一个 SYN 包会被对方的 NAT 丢弃(因为没有映射)
3. 但这个 SYN 包会在 A 的 NAT 上创建映射
4. 当 B 的 SYN 包到达 A 的 NAT 时,由于已有映射,会被转发给 A
5. 连接建立
### 实现
```rust
// rustdesk/src/rendezvous_mediator.rs:604-617
log::debug!("Punch tcp hole to {:?}", peer_addr);
let mut socket = {
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 关键:使用相同的本地地址尝试连接对方
// 这会在 NAT 上创建映射,使对方的连接请求能够到达
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
socket
};
```
## Relay 连接
当 P2P 失败时,使用 Relay
```rust
// rustdesk/src/rendezvous_mediator.rs:434-479
async fn create_relay(
&self,
socket_addr: Vec<u8>,
relay_server: String,
uuid: String,
server: ServerPtr,
secure: bool,
initiate: bool,
) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&socket_addr);
log::info!(
"create_relay requested from {:?}, relay_server: {}, uuid: {}, secure: {}",
peer_addr, relay_server, uuid, secure,
);
// 连接 Rendezvous Server 发送 RelayResponse
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let mut msg_out = Message::new();
let mut rr = RelayResponse {
socket_addr: socket_addr.into(),
version: crate::VERSION.to_owned(),
..Default::default()
};
if initiate {
rr.uuid = uuid.clone();
rr.relay_server = relay_server.clone();
rr.set_id(Config::get_id());
}
msg_out.set_relay_response(rr);
socket.send(&msg_out).await?;
// 连接 Relay Server
crate::create_relay_connection(
server,
relay_server,
uuid,
peer_addr,
secure,
is_ipv4(&self.addr),
).await;
Ok(())
}
```
## IPv6 支持
RustDesk 优先尝试 IPv6 连接:
```rust
// rustdesk/src/rendezvous_mediator.rs:808-822
async fn start_ipv6(
peer_addr_v6: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> bytes::Bytes {
crate::test_ipv6().await;
if let Some((socket, local_addr_v6)) = crate::get_ipv6_socket().await {
let server = server.clone();
tokio::spawn(async move {
allow_err!(udp_nat_listen(socket.clone(), peer_addr_v6, peer_addr_v4, server).await);
});
return local_addr_v6;
}
Default::default()
}
```
## 连接状态机
```
┌─────────────────────────────────────────┐
│ │
▼ │
┌───────────┐ ┌────┴────┐
│ 等待连接 │──────PunchHoleRequest──────►│正在连接 │
└───────────┘ └────┬────┘
┌──────────────────────────────┼──────────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌─────────────┐
│ P2P TCP │ │ P2P UDP/KCP │ │ Relay │
│ 连接中 │ │ 连接中 │ │ 连接中 │
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
成功 │ 失败 成功 │ 失败 成功 │ 失败
│ │ │ │ │ │
▼ │ ▼ │ ▼ │
┌──────────┐│ ┌──────────┐│ ┌──────────┐│
│已连接 ││ │已连接 ││ │已连接 ││
│(直连) ││ │(UDP) ││ │(中转) ││
└──────────┘│ └──────────┘│ └──────────┘│
│ │ │
└──────────────►尝试 Relay◄───┘ │
│ │
└────────────────────────────────────────┘
```
## 直接连接模式
用户可以配置允许直接 TCP 连接(不经过 Rendezvous Server
```rust
// rustdesk/src/rendezvous_mediator.rs:727-792
async fn direct_server(server: ServerPtr) {
let mut listener = None;
let mut port = get_direct_port(); // 默认 21118
loop {
let disabled = !option2bool(OPTION_DIRECT_SERVER, &Config::get_option(OPTION_DIRECT_SERVER));
if !disabled && listener.is_none() {
match hbb_common::tcp::listen_any(port as _).await {
Ok(l) => {
listener = Some(l);
log::info!("Direct server listening on: {:?}", l.local_addr());
}
Err(err) => {
log::error!("Failed to start direct server: {}", err);
}
}
}
if let Some(l) = listener.as_mut() {
if let Ok(Ok((stream, addr))) = hbb_common::timeout(1000, l.accept()).await {
stream.set_nodelay(true).ok();
log::info!("direct access from {}", addr);
let server = server.clone();
tokio::spawn(async move {
crate::server::create_tcp_connection(server, stream, addr, false).await
});
}
}
}
}
```

View File

@@ -1,574 +0,0 @@
# 消息格式定义
## 概述
RustDesk 使用 Protocol Buffers (protobuf) 定义所有网络消息格式。主要有两个 proto 文件:
- `rendezvous.proto` - Rendezvous/Relay 服务器通信消息
- `message.proto` - 客户端之间通信消息
## Rendezvous 消息 (rendezvous.proto)
### 顶层消息
```protobuf
message RendezvousMessage {
oneof union {
RegisterPeer register_peer = 6;
RegisterPeerResponse register_peer_response = 7;
PunchHoleRequest punch_hole_request = 8;
PunchHole punch_hole = 9;
PunchHoleSent punch_hole_sent = 10;
PunchHoleResponse punch_hole_response = 11;
FetchLocalAddr fetch_local_addr = 12;
LocalAddr local_addr = 13;
ConfigUpdate configure_update = 14;
RegisterPk register_pk = 15;
RegisterPkResponse register_pk_response = 16;
SoftwareUpdate software_update = 17;
RequestRelay request_relay = 18;
RelayResponse relay_response = 19;
TestNatRequest test_nat_request = 20;
TestNatResponse test_nat_response = 21;
PeerDiscovery peer_discovery = 22;
OnlineRequest online_request = 23;
OnlineResponse online_response = 24;
KeyExchange key_exchange = 25;
HealthCheck hc = 26;
}
}
```
### 注册相关
```protobuf
// Peer 注册
message RegisterPeer {
string id = 1; // Peer ID
int32 serial = 2; // 配置序列号
}
message RegisterPeerResponse {
bool request_pk = 2; // 是否需要注册公钥
}
// 公钥注册
message RegisterPk {
string id = 1; // Peer ID
bytes uuid = 2; // 设备 UUID
bytes pk = 3; // Ed25519 公钥
string old_id = 4; // 旧 ID
}
message RegisterPkResponse {
enum Result {
OK = 0;
UUID_MISMATCH = 2;
ID_EXISTS = 3;
TOO_FREQUENT = 4;
INVALID_ID_FORMAT = 5;
NOT_SUPPORT = 6;
SERVER_ERROR = 7;
}
Result result = 1;
int32 keep_alive = 2;
}
```
### 连接协调相关
```protobuf
// 连接类型
enum ConnType {
DEFAULT_CONN = 0;
FILE_TRANSFER = 1;
PORT_FORWARD = 2;
RDP = 3;
VIEW_CAMERA = 4;
}
// NAT 类型
enum NatType {
UNKNOWN_NAT = 0;
ASYMMETRIC = 1; // 可打洞
SYMMETRIC = 2; // 需要中转
}
// Punch Hole 请求
message PunchHoleRequest {
string id = 1; // 目标 Peer ID
NatType nat_type = 2;
string licence_key = 3;
ConnType conn_type = 4;
string token = 5;
string version = 6;
}
// Punch Hole 响应
message PunchHoleResponse {
bytes socket_addr = 1; // 目标地址
bytes pk = 2; // 公钥(已签名)
enum Failure {
ID_NOT_EXIST = 0;
OFFLINE = 2;
LICENSE_MISMATCH = 3;
LICENSE_OVERUSE = 4;
}
Failure failure = 3;
string relay_server = 4;
oneof union {
NatType nat_type = 5;
bool is_local = 6;
}
string other_failure = 7;
int32 feedback = 8;
}
// 服务器转发给被控端
message PunchHole {
bytes socket_addr = 1; // 控制端地址
string relay_server = 2;
NatType nat_type = 3;
}
// 被控端发送给服务器
message PunchHoleSent {
bytes socket_addr = 1;
string id = 2;
string relay_server = 3;
NatType nat_type = 4;
string version = 5;
}
```
### Relay 相关
```protobuf
// Relay 请求
message RequestRelay {
string id = 1;
string uuid = 2; // 配对 UUID
bytes socket_addr = 3;
string relay_server = 4;
bool secure = 5;
string licence_key = 6;
ConnType conn_type = 7;
string token = 8;
}
// Relay 响应
message RelayResponse {
bytes socket_addr = 1;
string uuid = 2;
string relay_server = 3;
oneof union {
string id = 4;
bytes pk = 5;
}
string refuse_reason = 6;
string version = 7;
int32 feedback = 9;
}
```
## 会话消息 (message.proto)
### 顶层消息
```protobuf
message Message {
oneof union {
SignedId signed_id = 3;
PublicKey public_key = 4;
TestDelay test_delay = 5;
VideoFrame video_frame = 6;
LoginRequest login_request = 7;
LoginResponse login_response = 8;
Hash hash = 9;
MouseEvent mouse_event = 10;
AudioFrame audio_frame = 11;
CursorData cursor_data = 12;
CursorPosition cursor_position = 13;
uint64 cursor_id = 14;
KeyEvent key_event = 15;
Clipboard clipboard = 16;
FileAction file_action = 17;
FileResponse file_response = 18;
Misc misc = 19;
Cliprdr cliprdr = 20;
MessageBox message_box = 21;
SwitchSidesResponse switch_sides_response = 22;
VoiceCallRequest voice_call_request = 23;
VoiceCallResponse voice_call_response = 24;
PeerInfo peer_info = 25;
PointerDeviceEvent pointer_device_event = 26;
Auth2FA auth_2fa = 27;
MultiClipboards multi_clipboards = 28;
}
}
```
### 认证相关
```protobuf
// ID 和公钥
message IdPk {
string id = 1;
bytes pk = 2;
}
// 密钥交换
message PublicKey {
bytes asymmetric_value = 1; // X25519 公钥
bytes symmetric_value = 2; // 加密的对称密钥
}
// 签名的 ID
message SignedId {
bytes id = 1; // 签名的 IdPk
}
// 密码哈希挑战
message Hash {
string salt = 1;
string challenge = 2;
}
// 登录请求
message LoginRequest {
string username = 1;
bytes password = 2; // 加密的密码
string my_id = 4;
string my_name = 5;
OptionMessage option = 6;
oneof union {
FileTransfer file_transfer = 7;
PortForward port_forward = 8;
ViewCamera view_camera = 15;
}
bool video_ack_required = 9;
uint64 session_id = 10;
string version = 11;
OSLogin os_login = 12;
string my_platform = 13;
bytes hwid = 14;
}
// 登录响应
message LoginResponse {
oneof union {
string error = 1;
PeerInfo peer_info = 2;
}
bool enable_trusted_devices = 3;
}
// 2FA 认证
message Auth2FA {
string code = 1;
bytes hwid = 2;
}
```
### 视频相关
```protobuf
// 编码后的视频帧
message EncodedVideoFrame {
bytes data = 1;
bool key = 2; // 是否关键帧
int64 pts = 3; // 时间戳
}
message EncodedVideoFrames {
repeated EncodedVideoFrame frames = 1;
}
// 视频帧
message VideoFrame {
oneof union {
EncodedVideoFrames vp9s = 6;
RGB rgb = 7;
YUV yuv = 8;
EncodedVideoFrames h264s = 10;
EncodedVideoFrames h265s = 11;
EncodedVideoFrames vp8s = 12;
EncodedVideoFrames av1s = 13;
}
int32 display = 14; // 显示器索引
}
// 显示信息
message DisplayInfo {
sint32 x = 1;
sint32 y = 2;
int32 width = 3;
int32 height = 4;
string name = 5;
bool online = 6;
bool cursor_embedded = 7;
Resolution original_resolution = 8;
double scale = 9;
}
```
### 输入相关
```protobuf
// 鼠标事件
message MouseEvent {
int32 mask = 1; // 按钮掩码
sint32 x = 2;
sint32 y = 3;
repeated ControlKey modifiers = 4;
}
// 键盘事件
message KeyEvent {
bool down = 1; // 按下/释放
bool press = 2; // 单击
oneof union {
ControlKey control_key = 3;
uint32 chr = 4; // 字符码
uint32 unicode = 5; // Unicode
string seq = 6; // 字符序列
uint32 win2win_hotkey = 7;
}
repeated ControlKey modifiers = 8;
KeyboardMode mode = 9;
}
// 键盘模式
enum KeyboardMode {
Legacy = 0;
Map = 1;
Translate = 2;
Auto = 3;
}
// 控制键枚举(部分)
enum ControlKey {
Unknown = 0;
Alt = 1;
Backspace = 2;
CapsLock = 3;
Control = 4;
Delete = 5;
// ... 更多按键
CtrlAltDel = 100;
LockScreen = 101;
}
```
### 音频相关
```protobuf
// 音频格式
message AudioFormat {
uint32 sample_rate = 1;
uint32 channels = 2;
}
// 音频帧
message AudioFrame {
bytes data = 1; // Opus 编码数据
}
```
### 剪贴板相关
```protobuf
// 剪贴板格式
enum ClipboardFormat {
Text = 0;
Rtf = 1;
Html = 2;
ImageRgba = 21;
ImagePng = 22;
ImageSvg = 23;
Special = 31;
}
// 剪贴板内容
message Clipboard {
bool compress = 1;
bytes content = 2;
int32 width = 3;
int32 height = 4;
ClipboardFormat format = 5;
string special_name = 6;
}
message MultiClipboards {
repeated Clipboard clipboards = 1;
}
```
### 文件传输相关
```protobuf
// 文件操作
message FileAction {
oneof union {
ReadDir read_dir = 1;
FileTransferSendRequest send = 2;
FileTransferReceiveRequest receive = 3;
FileDirCreate create = 4;
FileRemoveDir remove_dir = 5;
FileRemoveFile remove_file = 6;
ReadAllFiles all_files = 7;
FileTransferCancel cancel = 8;
FileTransferSendConfirmRequest send_confirm = 9;
FileRename rename = 10;
ReadEmptyDirs read_empty_dirs = 11;
}
}
// 文件响应
message FileResponse {
oneof union {
FileDirectory dir = 1;
FileTransferBlock block = 2;
FileTransferError error = 3;
FileTransferDone done = 4;
FileTransferDigest digest = 5;
ReadEmptyDirsResponse empty_dirs = 6;
}
}
// 文件传输块
message FileTransferBlock {
int32 id = 1;
sint32 file_num = 2;
bytes data = 3;
bool compressed = 4;
uint32 blk_id = 5;
}
// 文件条目
message FileEntry {
FileType entry_type = 1;
string name = 2;
bool is_hidden = 3;
uint64 size = 4;
uint64 modified_time = 5;
}
```
### 杂项消息
```protobuf
message Misc {
oneof union {
ChatMessage chat_message = 4;
SwitchDisplay switch_display = 5;
PermissionInfo permission_info = 6;
OptionMessage option = 7;
AudioFormat audio_format = 8;
string close_reason = 9;
bool refresh_video = 10;
bool video_received = 12;
BackNotification back_notification = 13;
bool restart_remote_device = 14;
// ... 更多选项
}
}
// Peer 信息
message PeerInfo {
string username = 1;
string hostname = 2;
string platform = 3;
repeated DisplayInfo displays = 4;
int32 current_display = 5;
bool sas_enabled = 6;
string version = 7;
Features features = 9;
SupportedEncoding encoding = 10;
SupportedResolutions resolutions = 11;
string platform_additions = 12;
WindowsSessions windows_sessions = 13;
}
// 选项消息
message OptionMessage {
enum BoolOption {
NotSet = 0;
No = 1;
Yes = 2;
}
ImageQuality image_quality = 1;
BoolOption lock_after_session_end = 2;
BoolOption show_remote_cursor = 3;
BoolOption privacy_mode = 4;
BoolOption block_input = 5;
int32 custom_image_quality = 6;
BoolOption disable_audio = 7;
BoolOption disable_clipboard = 8;
BoolOption enable_file_transfer = 9;
SupportedDecoding supported_decoding = 10;
int32 custom_fps = 11;
// ... 更多选项
}
```
## 消息编码
### 长度前缀
TCP 传输时使用长度前缀编码:
```rust
// hbb_common/src/bytes_codec.rs
pub struct BytesCodec {
state: DecodeState,
raw: bool,
}
impl Decoder for BytesCodec {
type Item = BytesMut;
type Error = std::io::Error;
fn decode(&mut self, buf: &mut BytesMut) -> Result<Option<BytesMut>, Self::Error> {
if self.raw {
// 原始模式:直接返回数据
if buf.is_empty() {
Ok(None)
} else {
Ok(Some(buf.split()))
}
} else {
// 标准模式4 字节长度前缀 + 数据
match self.state {
DecodeState::Head => {
if buf.len() < 4 {
return Ok(None);
}
let len = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
buf.advance(4);
self.state = DecodeState::Data(len);
self.decode(buf)
}
DecodeState::Data(len) => {
if buf.len() < len {
return Ok(None);
}
let data = buf.split_to(len);
self.state = DecodeState::Head;
Ok(Some(data))
}
}
}
}
}
```
### 加密模式
当启用加密时,消息结构为:
```
┌─────────────┬─────────────┬─────────────────────────┐
│ Length(4) │ Nonce(8) │ Encrypted Data(N) │
└─────────────┴─────────────┴─────────────────────────┘
```

View File

@@ -1,342 +0,0 @@
# 加密机制
## 概述
RustDesk 使用 libsodium (sodiumoxide) 库实现端到端加密,主要包含:
- **Ed25519**: 用于身份签名和验证
- **X25519**: 用于密钥交换
- **ChaCha20-Poly1305**: 用于对称加密
## 密钥类型
### 1. 身份密钥对 (Ed25519)
用于 Peer 身份认证和签名:
```rust
// 生成密钥对
use sodiumoxide::crypto::sign;
let (pk, sk) = sign::gen_keypair();
// pk: sign::PublicKey (32 bytes)
// sk: sign::SecretKey (64 bytes)
```
### 2. 服务器签名密钥
Rendezvous Server 可以配置签名密钥,用于签名 Peer 公钥:
```rust
// rustdesk-server/src/rendezvous_server.rs:1185-1210
fn get_server_sk(key: &str) -> (String, Option<sign::SecretKey>) {
let mut out_sk = None;
let mut key = key.to_owned();
// 如果是 base64 编码的私钥
if let Ok(sk) = base64::decode(&key) {
if sk.len() == sign::SECRETKEYBYTES {
log::info!("The key is a crypto private key");
key = base64::encode(&sk[(sign::SECRETKEYBYTES / 2)..]); // 公钥部分
let mut tmp = [0u8; sign::SECRETKEYBYTES];
tmp[..].copy_from_slice(&sk);
out_sk = Some(sign::SecretKey(tmp));
}
}
// 如果是占位符,生成新密钥对
if key.is_empty() || key == "-" || key == "_" {
let (pk, sk) = crate::common::gen_sk(0);
out_sk = sk;
if !key.is_empty() {
key = pk;
}
}
if !key.is_empty() {
log::info!("Key: {}", key);
}
(key, out_sk)
}
```
### 3. 会话密钥 (X25519 + ChaCha20)
用于客户端之间的加密通信:
```rust
// hbb_common/src/tcp.rs:27-28
#[derive(Clone)]
pub struct Encrypt(pub Key, pub u64, pub u64);
// Key: secretbox::Key (32 bytes)
// u64: 发送计数器
// u64: 接收计数器
```
## 密钥交换流程
### 1. 身份验证
客户端首先交换签名的身份:
```protobuf
message IdPk {
string id = 1; // Peer ID
bytes pk = 2; // Ed25519 公钥
}
message SignedId {
bytes id = 1; // 签名的 IdPk (by server or self)
}
```
### 2. X25519 密钥交换
使用 X25519 ECDH 生成共享密钥:
```rust
// 生成临时密钥对
use sodiumoxide::crypto::box_;
let (our_pk, our_sk) = box_::gen_keypair();
// 计算共享密钥
let shared_secret = box_::curve25519xsalsa20poly1305::scalarmult(&our_sk, &their_pk);
// 派生对称密钥
let symmetric_key = secretbox::Key::from_slice(&shared_secret[..32]).unwrap();
```
### 3. 对称密钥消息
```protobuf
message PublicKey {
bytes asymmetric_value = 1; // X25519 公钥
bytes symmetric_value = 2; // 加密的对称密钥(用于额外安全)
}
```
## 会话加密
### 加密实现
```rust
// hbb_common/src/tcp.rs
impl Encrypt {
pub fn new(key: Key) -> Self {
Self(key, 0, 0) // 初始化计数器为 0
}
// 加密
pub fn enc(&mut self, data: &[u8]) -> Vec<u8> {
self.1 += 1; // 递增发送计数器
let nonce = self.get_nonce(self.1);
let encrypted = secretbox::seal(data, &nonce, &self.0);
// 格式: nonce (8 bytes) + encrypted data
let mut result = Vec::with_capacity(8 + encrypted.len());
result.extend_from_slice(&self.1.to_le_bytes());
result.extend_from_slice(&encrypted);
result
}
// 解密
pub fn dec(&mut self, data: &mut BytesMut) -> io::Result<()> {
if data.len() < 8 + secretbox::MACBYTES {
return Err(io::Error::new(io::ErrorKind::InvalidData, "too short"));
}
// 提取 nonce
let counter = u64::from_le_bytes(data[..8].try_into().unwrap());
// 防重放攻击检查
if counter <= self.2 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "replay attack"));
}
self.2 = counter;
let nonce = self.get_nonce(counter);
let plaintext = secretbox::open(&data[8..], &nonce, &self.0)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "decrypt failed"))?;
data.clear();
data.extend_from_slice(&plaintext);
Ok(())
}
fn get_nonce(&self, counter: u64) -> Nonce {
let mut nonce = [0u8; 24];
nonce[..8].copy_from_slice(&counter.to_le_bytes());
Nonce(nonce)
}
}
```
### 消息格式
加密后的消息结构:
```
┌──────────────────┬─────────────────────────────────────────┐
│ Counter (8B) │ Encrypted Data + MAC (N+16 bytes) │
└──────────────────┴─────────────────────────────────────────┘
```
## 密码验证
### 挑战-响应机制
被控端生成随机盐和挑战,控制端计算哈希响应:
```protobuf
message Hash {
string salt = 1; // 随机盐
string challenge = 2; // 随机挑战
}
```
### 密码处理
```rust
// 客户端计算密码哈希
fn get_password_hash(password: &str, salt: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt.as_bytes());
hasher.finalize().to_vec()
}
// 发送加密的密码(使用对称密钥加密)
fn encrypt_password(password_hash: &[u8], symmetric_key: &Key) -> Vec<u8> {
secretbox::seal(password_hash, &nonce, symmetric_key)
}
```
## 服务器公钥验证
### 签名验证
如果 Rendezvous Server 配置了密钥,会签名 Peer 公钥:
```rust
// 服务器签名 IdPk
let signed_id_pk = sign::sign(
&IdPk { id, pk, ..Default::default() }
.write_to_bytes()?,
&server_sk,
);
// 客户端验证
fn verify_server_signature(signed_pk: &[u8], server_pk: &sign::PublicKey) -> Option<IdPk> {
if let Ok(verified) = sign::verify(signed_pk, server_pk) {
return IdPk::parse_from_bytes(&verified).ok();
}
None
}
```
### 客户端获取服务器公钥
```rust
pub async fn get_rs_pk(id: &str) -> ResultType<(String, sign::PublicKey)> {
// 从配置或 Rendezvous Server 获取公钥
let key = Config::get_option("key");
if !key.is_empty() {
if let Ok(pk) = base64::decode(&key) {
if pk.len() == sign::PUBLICKEYBYTES {
return Ok((key, sign::PublicKey::from_slice(&pk).unwrap()));
}
}
}
// ... 从服务器获取
}
```
## TCP 连接加密
### 安全 TCP 握手
```rust
// rustdesk/src/common.rs
pub async fn secure_tcp(conn: &mut Stream, key: &str) -> ResultType<()> {
// 1. 生成临时 X25519 密钥对
let (our_pk, our_sk) = box_::gen_keypair();
// 2. 发送我们的公钥
let mut msg = Message::new();
msg.set_public_key(PublicKey {
asymmetric_value: our_pk.0.to_vec().into(),
..Default::default()
});
conn.send(&msg).await?;
// 3. 接收对方公钥
let msg = conn.next_timeout(CONNECT_TIMEOUT).await?
.ok_or_else(|| anyhow!("timeout"))?;
let their_pk = msg.get_public_key();
// 4. 计算共享密钥
let shared = box_::curve25519xsalsa20poly1305::scalarmult(
&our_sk,
&box_::PublicKey::from_slice(&their_pk.asymmetric_value)?,
);
// 5. 设置加密
conn.set_key(secretbox::Key::from_slice(&shared[..32]).unwrap());
Ok(())
}
```
## 安全特性
### 1. 前向保密
每个会话使用临时密钥对,即使长期密钥泄露,历史会话仍然安全。
### 2. 重放攻击防护
使用递增计数器作为 nonce 的一部分,拒绝旧的或重复的消息。
### 3. 中间人攻击防护
- 服务器签名 Peer 公钥
- 可配置服务器公钥验证
### 4. 密码暴力破解防护
- 使用盐和多次哈希
- 服务器端限流
## 加密算法参数
| 算法 | 密钥大小 | Nonce 大小 | MAC 大小 |
|------|----------|------------|----------|
| Ed25519 | 64 bytes (private), 32 bytes (public) | N/A | 64 bytes |
| X25519 | 32 bytes | N/A | N/A |
| ChaCha20-Poly1305 | 32 bytes | 24 bytes | 16 bytes |
## 密钥生命周期
```
┌─────────────────────────────────────────────────────────────┐
│ 长期密钥 (Ed25519) │
│ ┌─────────────────┐ │
│ │ 设备首次启动时生成 │ │
│ │ 存储在配置文件中 │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 会话密钥 (X25519) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 每次连接时生成 │───►│ 用于密钥协商 │ │
│ │ 临时密钥对 │ │ 派生对称密钥 │ │
│ └─────────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 对称密钥 (ChaCha20-Poly1305) │ │
│ │ 用于会话中的所有消息加密 │ │
│ │ 会话结束时销毁 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```

View File

@@ -1,410 +0,0 @@
# NAT 穿透技术
## 概述
RustDesk 实现了多种 NAT 穿透技术,以在不同网络环境下建立 P2P 连接:
- NAT 类型检测
- UDP 打洞
- TCP 打洞
- Relay 中转(作为后备)
## NAT 类型
### 分类
```protobuf
enum NatType {
UNKNOWN_NAT = 0; // 未知
ASYMMETRIC = 1; // 非对称 NAT (Cone NAT) - 可打洞
SYMMETRIC = 2; // 对称 NAT - 通常需要 Relay
}
```
### NAT 类型说明
| 类型 | 描述 | 可打洞 |
|------|------|--------|
| Full Cone | 外部端口固定,任何外部主机可访问 | ✅ 最容易 |
| Restricted Cone | 外部端口固定,仅允许曾发送过数据的 IP | ✅ 容易 |
| Port Restricted Cone | 外部端口固定,仅允许曾发送过数据的 IP:Port | ✅ 可能 |
| Symmetric | 每个目标地址使用不同外部端口 | ❌ 困难 |
## NAT 类型检测
### 检测原理
RustDesk 使用双端口检测法:
1. 客户端向 Rendezvous Server 的主端口 (21116) 发送 TestNatRequest
2. 同时向 NAT 测试端口 (21115) 发送 TestNatRequest
3. 比较两次响应中观测到的源端口
```
客户端 Rendezvous Server
│ │
│ TestNatRequest ────────►│ Port 21116
│ │
│ TestNatRequest ────────►│ Port 21115
│ │
│◄──────── TestNatResponse │ (包含观测到的源端口)
│ │
│ │
│ 比较两次源端口 │
│ 相同 → ASYMMETRIC │
│ 不同 → SYMMETRIC │
```
### 实现代码
**客户端发送检测请求:**
```rust
// rustdesk/src/lib.rs
pub fn test_nat_type() {
tokio::spawn(async move {
let rendezvous_server = Config::get_rendezvous_servers().first().cloned();
if let Some(host) = rendezvous_server {
// 连接主端口
let host = check_port(&host, RENDEZVOUS_PORT);
// 连接 NAT 测试端口
let host2 = crate::increase_port(&host, -1);
// 发送测试请求
let mut msg = RendezvousMessage::new();
msg.set_test_nat_request(TestNatRequest {
serial: Config::get_serial(),
});
// 收集两次响应的端口
let port1 = send_and_get_port(&host, &msg).await;
let port2 = send_and_get_port(&host2, &msg).await;
// 判断 NAT 类型
let nat_type = if port1 == port2 {
NatType::ASYMMETRIC // 可打洞
} else {
NatType::SYMMETRIC // 需要 Relay
};
Config::set_nat_type(nat_type as i32);
}
});
}
```
**服务器响应:**
```rust
// rustdesk-server/src/rendezvous_server.rs:1080-1087
Some(rendezvous_message::Union::TestNatRequest(_)) => {
let mut msg_out = RendezvousMessage::new();
msg_out.set_test_nat_response(TestNatResponse {
port: addr.port() as _, // 返回观测到的源端口
..Default::default()
});
stream.send(&msg_out).await.ok();
}
```
## UDP 打洞
### 原理
UDP 打洞利用 NAT 的端口映射机制:
```
A (内网) B (内网)
│ │
│ ──► NAT_A ──► Internet ──► NAT_B ──► (丢弃) │
│ │
│ │
│ (NAT_A 创建了映射 A:port → A_ext:port_a) │
│ │
│ │
│ (丢弃) ◄── NAT_A ◄── Internet ◄── NAT_B ◄── │
│ │
│ │
│ (NAT_B 创建了映射 B:port → B_ext:port_b) │
│ │
│ ──► NAT_A ──► Internet ──► NAT_B ──► │
│ (NAT_A 的映射存在,包被转发) │
│ │
│ ◄── NAT_A ◄── Internet ◄── NAT_B ◄── │
│ (NAT_B 的映射存在,包被转发) │
│ │
│ ◄───────── 双向通信建立 ──────────► │
```
### 实现
**被控端打洞:**
```rust
// rustdesk/src/rendezvous_mediator.rs:621-642
async fn punch_udp_hole(
&self,
peer_addr: SocketAddr,
server: ServerPtr,
msg_punch: PunchHoleSent,
) -> ResultType<()> {
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(msg_punch);
// 创建 UDP socket
let (socket, addr) = new_direct_udp_for(&self.host).await?;
let data = msg_out.write_to_bytes()?;
// 发送到 Rendezvous Server会转发给控制端
socket.send_to(&data, addr).await?;
// 多次发送以增加成功率
let socket_cloned = socket.clone();
tokio::spawn(async move {
for _ in 0..2 {
let tm = (hbb_common::time_based_rand() % 20 + 10) as f32 / 1000.;
hbb_common::sleep(tm).await;
socket.send_to(&data, addr).await.ok();
}
});
// 等待对方连接
udp_nat_listen(socket_cloned, peer_addr, peer_addr, server).await?;
Ok(())
}
```
**UDP 监听和 KCP 建立:**
```rust
// rustdesk/src/rendezvous_mediator.rs:824-851
async fn udp_nat_listen(
socket: Arc<tokio::net::UdpSocket>,
peer_addr: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> ResultType<()> {
// 连接到对方地址
socket.connect(peer_addr).await?;
// 执行 UDP 打洞
let res = crate::punch_udp(socket.clone(), true).await?;
// 建立 KCP 可靠传输层
let stream = crate::kcp_stream::KcpStream::accept(
socket,
Duration::from_millis(CONNECT_TIMEOUT as _),
res,
).await?;
// 创建连接
crate::server::create_tcp_connection(server, stream.1, peer_addr_v4, true).await?;
Ok(())
}
```
### KCP 协议
RustDesk 在 UDP 上使用 KCP 提供可靠传输KCP 特点:
- 更激进的重传策略
- 更低的延迟
- 可配置的可靠性级别
## TCP 打洞
### 原理
TCP 打洞比 UDP 困难,因为 TCP 需要三次握手。技巧是让双方同时发起连接:
```
A NAT_A NAT_B B
│ │ │ │
│ ─── SYN ───────────────►│─────────│────► (丢弃,无映射) │
│ │ │ │
│ (NAT_A 创建到 B 的映射) │ │ │
│ │ │ │
│ (丢弃,无映射) ◄─────────│─────────│◄─── SYN ───────────── │
│ │ │ │
│ │ │ (NAT_B 创建到 A 的映射) │
│ │ │ │
│ ─── SYN ───────────────►│─────────│────► SYN ───────────► │
│ │ │ (映射存在,转发成功) │
│ │ │ │
│ ◄─── SYN+ACK ──────────│─────────│◄─── SYN+ACK ───────── │
│ │ │ │
│ ─── ACK ───────────────►│─────────│────► ACK ───────────► │
│ │ │ │
│ ◄─────────── 连接建立 ─────────────────────────────────────►│
```
### 实现
```rust
// rustdesk/src/rendezvous_mediator.rs:604-617
log::debug!("Punch tcp hole to {:?}", peer_addr);
let mut socket = {
// 1. 先连接 Rendezvous Server 获取本地地址
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 2. 用相同的本地地址尝试连接对方
// 这会在 NAT 上创建映射
// 虽然连接会失败,但映射已建立
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
socket
};
// 3. 发送 PunchHoleSent 通知服务器
// 服务器会转发给控制端
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(msg_punch);
socket.send_raw(msg_out.write_to_bytes()?).await?;
// 4. 等待控制端连接
// 由于已有映射,控制端的连接可以成功
crate::accept_connection(server.clone(), socket, peer_addr, true).await;
```
## 局域网直连
### 检测同一局域网
```rust
// rustdesk-server/src/rendezvous_server.rs:721-728
let same_intranet: bool = !ws
&& (peer_is_lan && is_lan || {
match (peer_addr, addr) {
(SocketAddr::V4(a), SocketAddr::V4(b)) => a.ip() == b.ip(),
(SocketAddr::V6(a), SocketAddr::V6(b)) => a.ip() == b.ip(),
_ => false,
}
});
```
### 局域网连接流程
```
控制端 Rendezvous Server 被控端
│ │ │
│ PunchHoleRequest ────►│ │
│ │ │
│ │ (检测到同一局域网) │
│ │ │
│ │ FetchLocalAddr ──────►│
│ │ │
│ │◄────── LocalAddr ────────│
│ │ (包含被控端内网地址) │
│ │ │
│◄─ PunchHoleResponse ──│ │
│ (is_local=true) │ │
│ (socket_addr=内网地址)│ │
│ │ │
│ ─────────── 直接连接内网地址 ────────────────────►│
```
## IPv6 支持
IPv6 通常不需要 NAT 穿透,但 RustDesk 仍支持 IPv6 打洞以处理有状态防火墙:
```rust
// rustdesk/src/rendezvous_mediator.rs:808-822
async fn start_ipv6(
peer_addr_v6: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> bytes::Bytes {
crate::test_ipv6().await;
if let Some((socket, local_addr_v6)) = crate::get_ipv6_socket().await {
let server = server.clone();
tokio::spawn(async move {
allow_err!(udp_nat_listen(socket.clone(), peer_addr_v6, peer_addr_v4, server).await);
});
return local_addr_v6;
}
Default::default()
}
```
## 连接策略决策树
```
开始连接
┌───────────────┐
│ NAT 类型检测 │
└───────┬───────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
ASYMMETRIC UNKNOWN SYMMETRIC
│ │ │
▼ ▼ │
┌──────────┐ ┌──────────┐ │
│ 尝试 UDP │ │ 尝试 TCP │ │
│ 打洞 │ │ 打洞 │ │
└────┬─────┘ └────┬─────┘ │
│ │ │
成功 │ 失败 成功 │ 失败 │
▼ │ ▼ │ │
┌────────┐│ ┌────────┐│ │
│UDP P2P ││ │TCP P2P ││ │
└────────┘│ └────────┘│ │
│ │ │
└───────┬───────┘ │
│ │
▼ │
┌───────────────┐ │
│ 使用 Relay │◄─────────┘
└───────────────┘
```
## 性能优化
### 多路径尝试
RustDesk 同时尝试多种连接方式,选择最快成功的:
```rust
// rustdesk/src/client.rs:342-364
let mut connect_futures = Vec::new();
// 同时尝试 UDP 和 TCP
if udp.0.is_some() {
connect_futures.push(Self::_start_inner(..., udp).boxed());
}
connect_futures.push(Self::_start_inner(..., (None, None)).boxed());
// 使用 select_ok 选择第一个成功的
match select_ok(connect_futures).await {
Ok(conn) => Ok(conn),
Err(e) => Err(e),
}
```
### 超时控制
```rust
const CONNECT_TIMEOUT: u64 = 18_000; // 18 秒
const REG_TIMEOUT: i32 = 30_000; // 30 秒
// 连接超时处理
if let Ok(Ok((stream, addr))) = timeout(CONNECT_TIMEOUT, socket.accept()).await {
// 连接成功
} else {
// 超时,尝试其他方式
}
```
## 常见问题和解决方案
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| 双 Symmetric NAT | 两端都是对称 NAT | 使用 Relay |
| 防火墙阻止 UDP | 企业防火墙 | 使用 TCP 或 WebSocket |
| 端口预测失败 | NAT 端口分配不规律 | 多次尝试或使用 Relay |
| IPv6 不通 | ISP 或防火墙问题 | 回退到 IPv4 |

View File

@@ -1,401 +0,0 @@
# RustDesk 协议 vs One-KVM 实现对比分析
本文档对比分析 RustDesk 原始协议与 One-KVM 的实现差异。
## 1. 概述
One-KVM 作为 IP-KVM 解决方案,只实现了 RustDesk 协议的**被控端Controlled** 功能不实现控制端Controller功能。这是设计决策因为 KVM 设备只需要接收远程控制,不需要控制其他设备。
### 架构差异
| 方面 | RustDesk 原版 | One-KVM |
|------|---------------|---------|
| 角色 | 双向(控制端+被控端) | 单向(仅被控端) |
| 连接方式 | P2P + Relay | 仅 Relay (TCP) |
| NAT 穿透 | UDP/TCP 打洞 + TURN | 不支持 |
| 传输协议 | UDP/TCP | 仅 TCP |
## 2. 已实现功能
### 2.1 Rendezvous 协议 (hbbs 通信)
| 消息类型 | 实现状态 | 备注 |
|----------|----------|------|
| RegisterPeer | ✅ 已实现 | 注册设备到服务器 |
| RegisterPeerResponse | ✅ 已实现 | 处理注册响应 |
| RegisterPk | ✅ 已实现 | 注册公钥 |
| RegisterPkResponse | ✅ 已实现 | 处理公钥注册响应 |
| PunchHoleSent | ✅ 已实现 | 响应打洞请求 |
| FetchLocalAddr | ✅ 已实现 | 获取本地地址 |
| LocalAddr | ✅ 已实现 | 返回本地地址 |
| RequestRelay | ✅ 已实现 | 请求中继连接 |
| RelayResponse | ✅ 已实现 | 处理中继响应 |
| ConfigUpdate | ✅ 已实现 | 接收配置更新 |
**实现文件**: `src/rustdesk/rendezvous.rs` (~829 行)
```rust
// 核心结构
pub struct RendezvousMediator {
config: RustDeskConfig,
key_pair: KeyPair,
signing_key: SigningKeyPair,
socket: UdpSocket,
status: Arc<RwLock<RendezvousStatus>>,
// ...
}
```
### 2.2 连接协议 (客户端连接)
| 消息类型 | 实现状态 | 备注 |
|----------|----------|------|
| SignedId | ✅ 已实现 | 签名身份验证 |
| PublicKey | ✅ 已实现 | 公钥交换 |
| Hash | ✅ 已实现 | 哈希挑战响应 |
| LoginRequest | ✅ 已实现 | 登录认证 |
| LoginResponse | ✅ 已实现 | 登录响应 |
| TestDelay | ✅ 已实现 | 延迟测试 |
| VideoFrame | ✅ 已实现 | 视频帧发送 |
| AudioFrame | ✅ 已实现 | 音频帧发送 |
| CursorData | ✅ 已实现 | 光标图像 |
| CursorPosition | ✅ 已实现 | 光标位置 |
| MouseEvent | ✅ 已实现 | 鼠标事件接收 |
| KeyEvent | ✅ 已实现 | 键盘事件接收 |
**实现文件**: `src/rustdesk/connection.rs` (~1349 行)
```rust
// 连接状态机
pub enum ConnectionState {
WaitingForSignedId,
WaitingForPublicKey,
WaitingForHash,
WaitingForLogin,
Authenticated,
Streaming,
}
```
### 2.3 加密模块
| 功能 | 实现状态 | 备注 |
|------|----------|------|
| Curve25519 密钥对 | ✅ 已实现 | 用于加密 |
| Ed25519 签名密钥对 | ✅ 已实现 | 用于签名 |
| Ed25519 → Curve25519 转换 | ✅ 已实现 | 密钥派生 |
| XSalsa20-Poly1305 | ✅ 已实现 | 会话加密 (secretbox) |
| 密码哈希 | ✅ 已实现 | 单重/双重 SHA256 |
| 会话密钥协商 | ✅ 已实现 | 对称密钥派生 |
**实现文件**: `src/rustdesk/crypto.rs` (~468 行)
```rust
// 密钥对结构
pub struct KeyPair {
secret_key: [u8; 32], // Curve25519 私钥
public_key: [u8; 32], // Curve25519 公钥
}
pub struct SigningKeyPair {
secret_key: [u8; 64], // Ed25519 私钥
public_key: [u8; 32], // Ed25519 公钥
}
```
### 2.4 视频/音频流
| 编码格式 | 实现状态 | 备注 |
|----------|----------|------|
| H.264 | ✅ 已实现 | 主要格式 |
| H.265/HEVC | ✅ 已实现 | 高效编码 |
| VP8 | ✅ 已实现 | WebRTC 兼容 |
| VP9 | ✅ 已实现 | 高质量 |
| AV1 | ✅ 已实现 | 新一代编码 |
| Opus 音频 | ✅ 已实现 | 低延迟音频 |
**实现文件**: `src/rustdesk/frame_adapters.rs` (~316 行)
### 2.5 HID 事件
| 功能 | 实现状态 | 备注 |
|------|----------|------|
| 鼠标移动 | ✅ 已实现 | 绝对/相对坐标 |
| 鼠标按键 | ✅ 已实现 | 左/中/右键 |
| 鼠标滚轮 | ✅ 已实现 | 垂直滚动 |
| 键盘按键 | ✅ 已实现 | 按下/释放 |
| 控制键映射 | ✅ 已实现 | ControlKey → USB HID |
| X11 键码映射 | ✅ 已实现 | X11 → USB HID |
**实现文件**: `src/rustdesk/hid_adapter.rs` (~386 行)
### 2.6 协议帧编码
| 功能 | 实现状态 | 备注 |
|------|----------|------|
| BytesCodec | ✅ 已实现 | 变长帧编码 |
| 1-4 字节头 | ✅ 已实现 | 根据长度自动选择 |
| 最大 1GB 消息 | ✅ 已实现 | 与原版一致 |
**实现文件**: `src/rustdesk/bytes_codec.rs` (~253 行)
## 3. 未实现功能
### 3.1 NAT 穿透相关
| 功能 | 原因 |
|------|------|
| UDP 打洞 | One-KVM 仅使用 TCP 中继 |
| TCP 打洞 | 同上 |
| STUN/TURN | 不需要 NAT 类型检测 |
| TestNat | 同上 |
| P2P 直连 | 设计简化,仅支持中继 |
### 3.2 客户端发起功能
| 功能 | 原因 |
|------|------|
| PunchHole (发起) | KVM 只接收连接 |
| RelayRequest | 同上 |
| ConnectPeer | 同上 |
| OnlineRequest | 不需要查询其他设备 |
### 3.3 文件传输
| 功能 | 原因 |
|------|------|
| FileTransfer | 超出 KVM 功能范围 |
| FileAction | 同上 |
| FileResponse | 同上 |
| FileTransferBlock | 同上 |
### 3.4 高级功能
| 功能 | 原因 |
|------|------|
| 剪贴板同步 | 超出 KVM 功能范围 |
| 多显示器切换 | One-KVM 使用单一视频源 |
| 虚拟显示器 | 不适用 |
| 端口转发 | 超出 KVM 功能范围 |
| 语音通话 | 不需要 |
| RDP 输入 | 不需要 |
| 插件系统 | 不支持 |
| 软件更新 | One-KVM 有自己的更新机制 |
### 3.5 权限协商
| 功能 | 原因 |
|------|------|
| Option 消息 | One-KVM 假设完全控制权限 |
| 权限请求 | 同上 |
| PermissionInfo | 同上 |
## 4. 实现差异
### 4.1 连接模式
**RustDesk 原版:**
```
客户端 ──UDP打洞──> 被控端 (P2P 优先)
└──Relay──> 被控端 (回退)
```
**One-KVM:**
```
RustDesk客户端 ──TCP中继──> hbbr服务器 ──> One-KVM设备
```
One-KVM 只支持 TCP 中继连接,不支持 P2P 直连。这简化了实现,但可能增加延迟。
### 4.2 会话加密
**RustDesk 原版:**
- 支持 ChaCha20-Poly1305 (流式)
- 支持 XSalsa20-Poly1305 (secretbox)
- 动态协商加密方式
**One-KVM:**
- 仅支持 XSalsa20-Poly1305 (secretbox)
- 使用序列号作为 nonce
```rust
// One-KVM 的加密实现
fn encrypt_message(&mut self, plaintext: &[u8]) -> Vec<u8> {
let nonce = make_nonce(&self.send_nonce);
self.send_nonce = self.send_nonce.wrapping_add(1);
secretbox::seal(plaintext, &nonce, &self.session_key)
}
```
### 4.3 视频流方向
**RustDesk 原版:**
- 双向视频流(可控制和被控制)
- 远程桌面捕获
**One-KVM:**
- 单向视频流(仅发送)
- 从 V4L2 设备捕获
- 集成到 One-KVM 的 VideoStreamManager
```rust
// One-KVM 视频流集成
pub async fn start_video_stream(&self, state: &AppState) {
let stream_manager = &state.video_stream_manager;
// 从 One-KVM 的视频管理器获取帧
}
```
### 4.4 HID 事件处理
**RustDesk 原版:**
- 转发到远程系统的输入子系统
- 使用 enigo 或 uinput
**One-KVM:**
- 转发到 USB OTG/HID 设备
- 控制物理 KVM 目标机器
```rust
// One-KVM HID 适配
pub fn convert_mouse_event(event: &RustDeskMouseEvent) -> Option<OneKvmMouseEvent> {
// 转换 RustDesk 鼠标事件到 One-KVM HID 事件
}
pub fn convert_key_event(event: &RustDeskKeyEvent) -> Option<OneKvmKeyEvent> {
// 转换 RustDesk 键盘事件到 One-KVM HID 事件
}
```
### 4.5 配置管理
**RustDesk 原版:**
- 使用 TOML/JSON 配置文件
- 硬编码默认值
**One-KVM:**
- 集成到 SQLite 配置系统
- Web UI 管理
- 使用 typeshare 生成 TypeScript 类型
```rust
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RustDeskConfig {
pub enabled: bool,
pub rendezvous_server: String,
pub device_id: String,
// ...
}
```
### 4.6 设备 ID 生成
**RustDesk 原版:**
- 基于 MAC 地址和硬件信息
- 固定便携式 ID
**One-KVM:**
- 随机生成 9 位数字
- 存储在配置中
```rust
pub fn generate_device_id() -> String {
let mut rng = rand::thread_rng();
let id: u32 = rng.gen_range(100_000_000..999_999_999);
id.to_string()
}
```
## 5. 协议兼容性
### 5.1 完全兼容
| 功能 | 说明 |
|------|------|
| Rendezvous 注册 | 可与官方 hbbs 服务器通信 |
| 中继连接 | 可通过官方 hbbr 服务器中继 |
| 加密握手 | 与 RustDesk 客户端兼容 |
| 视频编码 | 支持所有主流编码格式 |
| HID 事件 | 接收标准 RustDesk 输入事件 |
### 5.2 部分兼容
| 功能 | 说明 |
|------|------|
| 密码认证 | 仅支持设备密码,不支持一次性密码 |
| 会话加密 | 仅 XSalsa20-Poly1305 |
### 5.3 不兼容
| 功能 | 说明 |
|------|------|
| P2P 连接 | 客户端必须通过中继连接 |
| 文件传输 | 不支持 |
| 剪贴板 | 不支持 |
## 6. 代码结构对比
### RustDesk 原版结构
```
rustdesk/
├── libs/hbb_common/ # 公共库
│ ├── protos/ # Protobuf 定义
│ └── src/
├── src/
│ ├── server/ # 被控端服务
│ ├── client/ # 控制端
│ ├── ui/ # 用户界面
│ └── rendezvous_mediator.rs
```
### One-KVM 结构
```
src/rustdesk/
├── mod.rs # 模块导出
├── config.rs # 配置类型 (~164 行)
├── crypto.rs # 加密模块 (~468 行)
├── bytes_codec.rs # 帧编码 (~253 行)
├── protocol.rs # 消息辅助 (~170 行)
├── rendezvous.rs # Rendezvous 中介 (~829 行)
├── connection.rs # 连接处理 (~1349 行)
├── hid_adapter.rs # HID 转换 (~386 行)
└── frame_adapters.rs # 视频/音频适配 (~316 行)
```
**总计**: ~3935 行代码
## 7. 总结
### 实现率统计
| 类别 | RustDesk 功能数 | One-KVM 实现数 | 实现率 |
|------|-----------------|----------------|--------|
| Rendezvous 协议 | 15+ | 10 | ~67% |
| 连接协议 | 30+ | 12 | ~40% |
| 加密功能 | 8 | 6 | 75% |
| 视频/音频 | 6 | 6 | 100% |
| HID 功能 | 6 | 6 | 100% |
### 设计理念
One-KVM 的 RustDesk 实现专注于 **IP-KVM 核心功能**:
1. **精简**: 只实现必要的被控端功能
2. **可靠**: 使用 TCP 中继保证连接稳定性
3. **集成**: 与 One-KVM 现有视频/HID 系统无缝集成
4. **安全**: 完整实现加密和认证机制
### 客户端兼容性
One-KVM 可与标准 RustDesk 客户端配合使用:
- RustDesk 桌面客户端 (Windows/macOS/Linux)
- RustDesk 移动客户端 (Android/iOS)
- RustDesk Web 客户端
只需确保:
1. 配置相同的 Rendezvous 服务器
2. 使用设备 ID 和密码连接
3. 客户端支持中继连接

View File

@@ -1,920 +0,0 @@
# One-KVM 系统架构文档
## 1. 项目概述
One-KVM 是一个用 Rust 编写的轻量级、开源 IP-KVM 解决方案。它提供 BIOS 级别的远程服务器管理能力,支持视频流、键鼠控制、虚拟存储、电源管理和音频等功能。
### 1.1 核心特性
- **单一二进制部署**Web UI + 后端一体化,无需额外配置文件
- **双流模式**:支持 WebRTCH264/H265/VP8/VP9和 MJPEG 两种流模式
- **USB OTG**:虚拟键鼠、虚拟存储、虚拟网卡
- **ATX 电源控制**GPIO/USB 继电器
- **RustDesk 协议集成**:支持跨平台访问
- **Vue3 SPA 前端**:支持中文/英文
- **SQLite 配置存储**:无需配置文件
### 1.2 目标平台
| 平台 | 架构 | 用途 |
|------|------|------|
| aarch64-unknown-linux-gnu | ARM64 | 主要目标Rockchip RK3328 等) |
| armv7-unknown-linux-gnueabihf | ARMv7 | 备选平台 |
| x86_64-unknown-linux-gnu | x86-64 | 开发/测试环境 |
---
## 2. 系统架构图
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ One-KVM System │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Web Frontend (Vue3) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Console │ │ Settings │ │ Login │ │ Setup │ │ Virtual │ │ │
│ │ │ View │ │ View │ │ View │ │ View │ │ Keyboard │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────┴─────────────────┐ │ │
│ │ │ Pinia State Store │ │ │
│ │ └─────────────────┬─────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ API Client Layer │ │ │
│ │ │ HTTP REST │ WebSocket │ WebRTC Signaling │ MJPEG │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ HTTP/WS/WebRTC │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Axum Web Server (routes.rs) │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Public │ │ User │ │ Admin │ │ Static │ │ │
│ │ │ Routes │ │ Routes │ │ Routes │ │ Files │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ AppState (state.rs) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Central State Hub │ │ │
│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ │ConfigStore │ │SessionStore│ │ UserStore │ │ │ │
│ │ │ │ (SQLite) │ │ (Memory) │ │ (SQLite) │ │ │ │
│ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ │ EventBus │ │ OtgService │ │ Extensions │ │ │ │
│ │ │ │ (Broadcast)│ │ (USB) │ │ Manager │ │ │ │
│ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┼─────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Video │ │ HID │ │ Audio │ │
│ │ Module │ │ Module │ │ Module │ │
│ ├────────────┤ ├────────────┤ ├────────────┤ │
│ │ Capture │ │ Controller │ │ Capture │ │
│ │ Encoder │ │ OTG Backend│ │ Encoder │ │
│ │ Streamer │ │ CH9329 │ │ Pipeline │ │
│ │ Pipeline │ │ Monitor │ │ (Opus) │ │
│ │ Manager │ │ DataChan │ │ Shared │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │ │ │ │
│ └───────────────────────────┼──────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┼─────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ MSD │ │ ATX │ │ RustDesk │ │
│ │ Module │ │ Module │ │ Module │ │
│ ├────────────┤ ├────────────┤ ├────────────┤ │
│ │ Controller │ │ Controller │ │ Service │ │
│ │ Image Mgr │ │ Executor │ │ Rendezvous │ │
│ │ Ventoy │ │ LED Monitor│ │ Connection │ │
│ │ Drive │ │ WOL │ │ Protocol │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hardware Layer │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ V4L2 Video │ │ USB OTG │ │ GPIO │ │ ALSA │ │
│ │ Device │ │ Gadget │ │ Sysfs │ │ Audio │ │
│ │/dev/video* │ │ ConfigFS │ │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 2.2 数据流架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Flow Overview │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Target PC │
└────────┬────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ HDMI Capture │ │ USB Port │ │ GPIO/Relay │
│ Card │ │ (OTG Mode) │ │ (ATX) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ /dev/video0 │ │ /dev/hidg* │ │ /sys/class/ │
│ (V4L2) │ │ (USB Gadget) │ │ gpio/gpio* │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ One-KVM Application │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Video │ │ HID │ │ ATX │ │
│ │ Pipeline │ │ Controller │ │ Controller │ │
│ └─────┬───────┘ └─────┬───────┘ └─────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Event Bus │ │
│ │ (tokio broadcast channel) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Web Server (Axum) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ MJPEG │ │ WebRTC │ │WebSocket │ │ │
│ │ │ Stream │ │ Stream │ │ Events │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Client Browser │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Video │ │ Input │ │ Control │ │
│ │ Display │ │ Events │ │ Panel │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. 模块依赖关系
### 3.1 模块层次图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Application Layer │
├─────────────────────────────────────────────────────────────────────────────┤
│ main.rs ──► state.rs ──► web/routes.rs │
│ │ │
│ ┌───────────┼───────────┬───────────┬───────────┬───────────┐ │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │video/│ │ hid/ │ │ msd/ │ │ atx/ │ │audio/│ │webrtc│ │
│ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │ │ │ │ │ │ │
│ │ └──────────┼──────────┘ │ │ │
│ │ │ │ │ │
│ │ ┌─────▼─────┐ │ │ │
│ │ │ otg/ │ │ │ │
│ │ │ (OtgSvc) │ │ │ │
│ │ └───────────┘ │ │ │
│ │ │ │ │
│ └──────────────────────────────────────────┼──────────┘ │
│ │ │
├─────────────────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ config/ │ │ auth/ │ │ events/ │ │extensions│ │
│ │(ConfigSt)│ │(Session) │ │(EventBus)│ │(ExtMgr) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ rustdesk/ (RustDeskService) │ │
│ │ connection.rs │ rendezvous.rs │ crypto.rs │ protocol.rs │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 3.2 依赖矩阵
| 模块 | 依赖的模块 |
|------|-----------|
| `main.rs` | state, config, auth, video, hid, msd, atx, audio, webrtc, web, rustdesk, events |
| `state.rs` | config, auth, video, hid, msd, atx, audio, webrtc, rustdesk, events, otg |
| `video/` | events, hwcodec (外部) |
| `hid/` | otg, events |
| `msd/` | otg, events |
| `atx/` | events |
| `audio/` | events |
| `webrtc/` | video, audio, hid, events |
| `web/` | state, auth, config, video, hid, msd, atx, audio, webrtc, events |
| `rustdesk/` | video, audio, hid, events |
| `otg/` | (无内部依赖) |
| `config/` | (无内部依赖) |
| `auth/` | config |
| `events/` | (无内部依赖) |
---
## 4. 核心组件详解
### 4.1 AppState (state.rs)
AppState 是整个应用的状态中枢,通过 `Arc` 包装的方式在所有 handler 之间共享。
```rust
pub struct AppState {
// 配置和存储
config: ConfigStore, // SQLite 配置存储
sessions: SessionStore, // 会话存储(内存)
users: UserStore, // SQLite 用户存储
// 核心服务
otg_service: Arc<OtgService>, // USB Gadget 统一管理HID/MSD 生命周期协调者)
stream_manager: Arc<VideoStreamManager>, // 视频流管理器MJPEG/WebRTC
hid: Arc<HidController>, // HID 控制器(键鼠控制)
msd: Arc<RwLock<Option<MsdController>>>, // MSD 控制器可选虚拟U盘
atx: Arc<RwLock<Option<AtxController>>>, // ATX 控制器(可选,电源控制)
audio: Arc<AudioController>, // 音频控制器ALSA + Opus
rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>, // RustDesk可选远程访问
extensions: Arc<ExtensionManager>,// 扩展管理器ttyd, gostc, easytier
// 通信和生命周期
events: Arc<EventBus>, // 事件总线tokio broadcast channel
shutdown_tx: broadcast::Sender<()>, // 关闭信号
data_dir: PathBuf, // 数据目录
}
```
### 4.2 视频流管道
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Video Pipeline Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────────┐
│ V4L2 Device │
│ /dev/video0 │
└─────────┬─────────┘
│ Raw MJPEG/YUYV/NV12
┌───────────────────┐
│ VideoCapturer │ ◄─── src/video/capture.rs
│ (capture.rs) │
└─────────┬─────────┘
│ VideoFrame
┌───────────────────────────────────────────────────────────────────────────┐
│ SharedVideoPipeline │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Decode Stage │ │
│ │ ┌─────────────┐ │ │
│ │ │ MJPEG → YUV │ turbojpeg / VAAPI │ │
│ │ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Convert Stage │ │
│ │ ┌─────────────┐ │ │
│ │ │YUV → Target │ libyuv (SIMD accelerated) │ │
│ │ │ Format │ │ │
│ │ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Encode Stage │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ H264 │ │ H265 │ │ VP8 │ │ VP9 │ │ │
│ │ │Encoder │ │Encoder │ │Encoder │ │Encoder │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ │ │ (VAAPI/RKMPP/V4L2 M2M/Software) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────┘
├──────────────────────────────┬──────────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ MJPEG Streamer │ │ WebRTC Streamer │ │ RustDesk Service │
│ (HTTP Stream) │ │ (RTP Packets) │ │ (P2P Stream) │
│ │ │ │ │ │
│ - HTTP/1.1 │ │ - DataChannel │ │ - TCP/UDP Relay │
│ - multipart/x- │ │ - SRTP │ │ - NaCl Encrypted │
│ mixed-replace │ │ - ICE/STUN/TURN │ │ - Rendezvous │
└───────────────────┘ └───────────────────┘ └───────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ Clients │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Browser │ │ Browser │ │ RustDesk Client │ │
│ │ (MJPEG) │ │ (WebRTC) │ │ (Desktop/Mobile) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────┘
```
### 4.3 OTG 服务架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ OTG Service Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ OtgService (service.rs) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Public Interface │ │
│ │ enable_hid() │ disable_hid() │ enable_msd() │ disable_msd() │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ OtgGadgetManager (manager.rs) │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ Gadget Lifecycle │ │ │
│ │ │ create_gadget() │ destroy_gadget() │ bind_udc() │ unbind() │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┼────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ HID Function │ │ MSD Function │ │ Endpoint Alloc │ │
│ │ (hid.rs) │ │ (msd.rs) │ │ (endpoint.rs) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ConfigFS Operations │ │
│ │ /sys/kernel/config/usb_gadget/one-kvm/ │ │
│ │ ├── idVendor, idProduct, strings/ │ │
│ │ ├── configs/c.1/ │ │
│ │ │ └── functions/ (symlinks) │ │
│ │ └── functions/ │ │
│ │ ├── hid.usb0, hid.usb1, hid.usb2 │ │
│ │ └── mass_storage.usb0 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ /dev/hidg* │ │ Mass Storage │ │
│ │ (HID devices) │ │ Backend │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 4.4 事件系统架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event System Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event Producers │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Video │ │ HID │ │ MSD │ │ ATX │ │ Audio │ │
│ │ Module │ │ Module │ │ Module │ │ Module │ │ Module │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │ │
│ └────────────┴────────────┼────────────┴────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ EventBus │ │
│ │ (tokio broadcast channel) │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ SystemEvent Enum │ │ │
│ │ │ StreamStateChanged │ HidStateChanged │ MsdStateChanged │ │ │
│ │ │ AtxStateChanged │ AudioStateChanged │ DeviceInfo │ Error │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────┼─────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │WebSocket │ │ DeviceInfo│ │ Internal │ │
│ │ Clients │ │Broadcaster│ │ Tasks │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 5. 初始化流程
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Application Startup Flow │
└─────────────────────────────────────────────────────────────────────────────┘
main()
├──► Parse CLI Arguments (clap)
│ - address, port, data_dir
│ - enable_https, ssl_cert, ssl_key
│ - verbosity (-v, -vv, -vvv)
├──► Initialize Logging (tracing)
├──► Create/Open SQLite Database
│ └─► ConfigStore::new()
│ └─► UserStore::new()
│ └─► SessionStore::new()
├──► Initialize Core Services
│ │
│ ├──► EventBus::new()
│ │ └─► Create tokio broadcast channel
│ │
│ ├──► OtgService::new()
│ │ └─► Detect UDC device (/sys/class/udc)
│ │ └─► Initialize OtgGadgetManager
│ │
│ ├──► HidController::new()
│ │ └─► Detect backend type (OTG/CH9329/None)
│ │ └─► Create controller with optional OtgService
│ │
│ ├──► HidController::init()
│ │ └─► Request HID function from OtgService
│ │ └─► Create HID devices (/dev/hidg0-3)
│ │ └─► Open device files with O_NONBLOCK
│ │ └─► Initialize HidHealthMonitor
│ │
│ ├──► MsdController::init() (if configured)
│ │ └─► Request MSD function from OtgService
│ │ └─► Create mass storage device
│ │ └─► Initialize Ventoy drive (if available)
│ │
│ ├──► AtxController::init() (if configured)
│ │ └─► Setup GPIO pins or USB relay
│ │
│ ├──► AudioController::init()
│ │ └─► Open ALSA device
│ │ └─► Initialize Opus encoder
│ │
│ ├──► VideoStreamManager::new()
│ │ └─► Initialize SharedVideoPipeline
│ │ └─► Setup encoder registry (H264/H265/VP8/VP9)
│ │ └─► Detect hardware acceleration (VAAPI/RKMPP/V4L2 M2M)
│ │
│ └──► RustDeskService::new() (if configured)
│ └─► Load/generate device ID and keys
│ └─► Connect to rendezvous server
├──► Create AppState
│ └─► Wrap all services in Arc<>
├──► Spawn Background Tasks
│ ├──► spawn_device_info_broadcaster()
│ ├──► extension_health_check_task()
│ └──► rustdesk_reconnect_task()
├──► Create Axum Router
│ └─► create_router(app_state)
└──► Start HTTP/HTTPS Server
└─► axum::serve() or axum_server with TLS
```
---
## 6. 目录结构
```
One-KVM-RUST/
├── src/ # Rust 源代码
│ ├── main.rs # 应用入口点
│ ├── lib.rs # 库导出
│ ├── state.rs # AppState 定义
│ ├── error.rs # 错误类型定义
│ │
│ ├── video/ # 视频模块
│ │ ├── mod.rs
│ │ ├── capture.rs # V4L2 采集
│ │ ├── streamer.rs # 视频流服务
│ │ ├── stream_manager.rs # 流管理器
│ │ ├── shared_video_pipeline.rs # 共享视频管道
│ │ ├── format.rs # 像素格式
│ │ ├── frame.rs # 视频帧
│ │ ├── convert.rs # 格式转换
│ │ └── encoder/ # 编码器
│ │ ├── mod.rs
│ │ ├── traits.rs
│ │ ├── h264.rs
│ │ ├── h265.rs
│ │ ├── vp8.rs
│ │ ├── vp9.rs
│ │ └── jpeg.rs
│ │
│ ├── hid/ # HID 模块
│ │ ├── mod.rs # HidController主控制器
│ │ ├── backend.rs # HidBackend trait 和 HidBackendType
│ │ ├── otg.rs # OTG 后端USB Gadget HID
│ │ ├── ch9329.rs # CH9329 串口后端
│ │ ├── consumer.rs # Consumer Control usage codes
│ │ ├── keymap.rs # JS keyCode → USB HID 转换表
│ │ ├── types.rs # 事件类型定义
│ │ ├── monitor.rs # HidHealthMonitor错误跟踪与恢复
│ │ ├── datachannel.rs # DataChannel 二进制协议解析
│ │ └── websocket.rs # WebSocket 二进制协议适配
│ │
│ ├── otg/ # USB OTG 模块
│ │ ├── mod.rs
│ │ ├── service.rs # OtgService
│ │ ├── manager.rs # GadgetManager
│ │ ├── hid.rs # HID Function
│ │ ├── msd.rs # MSD Function
│ │ ├── configfs.rs # ConfigFS 操作
│ │ ├── endpoint.rs # 端点分配
│ │ └── report_desc.rs # HID 报告描述符
│ │
│ ├── msd/ # MSD 模块
│ │ ├── mod.rs
│ │ ├── controller.rs # MsdController
│ │ ├── image.rs # 镜像管理
│ │ ├── ventoy_drive.rs # Ventoy 驱动
│ │ ├── monitor.rs # 健康监视
│ │ └── types.rs # 类型定义
│ │
│ ├── atx/ # ATX 模块
│ │ ├── mod.rs
│ │ ├── controller.rs # AtxController
│ │ ├── executor.rs # 动作执行器
│ │ ├── types.rs # 类型定义
│ │ ├── led.rs # LED 监视
│ │ └── wol.rs # Wake-on-LAN
│ │
│ ├── audio/ # 音频模块
│ │ ├── mod.rs
│ │ ├── controller.rs # AudioController
│ │ ├── capture.rs # ALSA 采集
│ │ ├── encoder.rs # Opus 编码
│ │ ├── shared_pipeline.rs # 共享管道
│ │ ├── monitor.rs # 健康监视
│ │ └── device.rs # 设备枚举
│ │
│ ├── webrtc/ # WebRTC 模块
│ │ ├── mod.rs
│ │ ├── webrtc_streamer.rs # WebRTC 管理器
│ │ ├── universal_session.rs # 会话管理
│ │ ├── video_track.rs # 视频轨道
│ │ ├── rtp.rs # RTP 打包
│ │ ├── h265_payloader.rs # H265 RTP
│ │ ├── peer.rs # PeerConnection
│ │ ├── config.rs # 配置
│ │ ├── signaling.rs # 信令
│ │ └── track.rs # 轨道基类
│ │
│ ├── auth/ # 认证模块
│ │ ├── mod.rs
│ │ ├── user.rs # 用户管理
│ │ ├── session.rs # 会话管理
│ │ ├── password.rs # 密码哈希
│ │ └── middleware.rs # Axum 中间件
│ │
│ ├── config/ # 配置模块
│ │ ├── mod.rs
│ │ ├── schema.rs # 配置结构定义
│ │ └── store.rs # SQLite 存储
│ │
│ ├── events/ # 事件模块
│ │ └── mod.rs # EventBus
│ │
│ ├── rustdesk/ # RustDesk 模块
│ │ ├── mod.rs # RustDeskService
│ │ ├── connection.rs # 连接管理
│ │ ├── rendezvous.rs # 渲染服务器通信
│ │ ├── crypto.rs # NaCl 加密
│ │ ├── config.rs # 配置
│ │ ├── hid_adapter.rs # HID 适配
│ │ ├── frame_adapters.rs # 帧格式转换
│ │ ├── protocol.rs # 协议包装
│ │ └── bytes_codec.rs # 帧编码
│ │
│ ├── extensions/ # 扩展模块
│ │ └── mod.rs # ExtensionManager
│ │
│ ├── web/ # Web 模块
│ │ ├── mod.rs
│ │ ├── routes.rs # 路由定义
│ │ ├── ws.rs # WebSocket
│ │ ├── audio_ws.rs # 音频 WebSocket
│ │ ├── static_files.rs # 静态文件
│ │ └── handlers/ # API 处理器
│ │ ├── mod.rs
│ │ └── config/
│ │
│ ├── stream/ # MJPEG 流
│ │ └── mod.rs
│ │
│ └── utils/ # 工具函数
│ └── mod.rs
├── web/ # Vue3 前端
│ ├── src/
│ │ ├── views/ # 页面组件
│ │ ├── components/ # UI 组件
│ │ ├── api/ # API 客户端
│ │ ├── stores/ # Pinia 状态
│ │ ├── router/ # 路由配置
│ │ ├── i18n/ # 国际化
│ │ └── types/ # TypeScript 类型
│ └── package.json
├── libs/ # 外部库
│ ├── hwcodec/ # 硬件视频编码
│ └── ventoy-img-rs/ # Ventoy 支持
├── protos/ # Protobuf 定义
│ ├── message.proto # RustDesk 消息
│ └── rendezvous.proto # RustDesk 渲染
├── docs/ # 文档
├── scripts/ # 脚本
├── Cargo.toml # Rust 配置
├── build.rs # 构建脚本
└── README.md
```
---
## 7. 安全架构
### 7.1 认证流程
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Authentication Flow │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Client │ │ Axum │ │ Auth │ │ SQLite │
│ Browser │ │ Server │ │ Module │ │ Database │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │
│ POST /auth/login │ │ │
│ {username, pass} │ │ │
│───────────────────►│ │ │
│ │ verify_user() │ │
│ │───────────────────►│ │
│ │ │ SELECT user │
│ │ │───────────────────►│
│ │ │◄───────────────────│
│ │ │ │
│ │ │ Argon2 verify │
│ │ │ ────────────► │
│ │ │ │
│ │ session_token │ │
│ │◄───────────────────│ │
│ │ │ │
│ Set-Cookie: │ │ │
│ session_id=token │ │ │
│◄───────────────────│ │ │
│ │ │ │
│ GET /api/... │ │ │
│ Cookie: session │ │ │
│───────────────────►│ │ │
│ │ validate_session()│ │
│ │───────────────────►│ │
│ │ user_info │ │
│ │◄───────────────────│ │
│ │ │ │
│ Response │ │ │
│◄───────────────────│ │ │
```
### 7.2 权限层级
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Permission Levels │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Public (No Auth) │
│ ├── GET /health │
│ ├── POST /auth/login │
│ ├── GET /setup │
│ └── POST /setup/init │
├─────────────────────────────────────────────────────────────────────────────┤
│ User (Authenticated) │
│ ├── GET /info (系统信息) │
│ ├── GET /devices (设备列表) │
│ ├── GET/POST /stream/* (流控制) │
│ ├── POST /webrtc/* (WebRTC 信令) │
│ ├── POST /hid/* (HID 控制) │
│ ├── POST /audio/* (音频控制) │
│ └── WebSocket endpoints (实时通信) │
├─────────────────────────────────────────────────────────────────────────────┤
│ Admin (Admin Role) │
│ ├── GET/PATCH /config/* (配置管理) │
│ ├── POST /msd/* (MSD 操作) │
│ ├── POST /atx/* (电源控制) │
│ ├── POST /extensions/* (扩展管理) │
│ ├── POST /rustdesk/* (RustDesk 配置) │
│ └── POST /users/* (用户管理) │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 8. 部署架构
### 8.1 单机部署
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Single Binary Deployment │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ARM64 Device (e.g., Rockchip RK3328) │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ one-kvm (single binary, ~15MB) │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Embedded Assets (rust-embed, gzip compressed) │ │ │
│ │ │ - index.html, app.js, app.css, assets/* │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Runtime Data (data_dir) │ │ │
│ │ │ - one-kvm.db (SQLite) │ │ │
│ │ │ - images/ (MSD images) │ │ │
│ │ │ - certs/ (SSL certificates) │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ Hardware Connections: │
│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │
│ │ HDMI Input │ │ USB OTG Port │ │ GPIO Header │ │
│ │ (/dev/video0) │ │ (USB Gadget) │ │ (ATX Control) │ │
│ └───────────────────┘ └───────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ USB Cable
┌─────────────────────────────────────────────────────────────────────────────┐
│ Target PC │
│ - Receives USB HID events (keyboard/mouse) │
│ - Provides HDMI video output │
│ - Can boot from virtual USB drive │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 8.2 网络拓扑
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Network Topology │
└─────────────────────────────────────────────────────────────────────────────┘
Internet
┌───────────┴───────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ RustDesk │ │ Client │
│ Server │ │ Browser │
│ (hbbs/hbbr) │ │ │
└───────────────┘ └───────────────┘
│ │
│ │
└───────────┬───────────┘
┌────┴────┐
│ Router │
│ NAT │
└────┬────┘
Local Network
┌───────────┴───────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ One-KVM │───────│ Target PC │
│ Device │ USB │ │
│ :8080/:8443 │ HID │ │
└───────────────┘ └───────────────┘
Access Methods:
1. Local: http://one-kvm.local:8080
2. HTTPS: https://one-kvm.local:8443
3. RustDesk: Via RustDesk client with device ID
```
---
## 9. 扩展点
### 9.1 添加新编码器
```rust
// 1. 实现 Encoder trait
impl Encoder for MyEncoder {
fn encode(&mut self, frame: &VideoFrame) -> Result<Vec<u8>>;
fn codec(&self) -> Codec;
fn bitrate(&self) -> u32;
// ...
}
// 2. 在 registry 中注册
encoder_registry.register("my-encoder", || Box::new(MyEncoder::new()));
```
### 9.2 添加新 HID 后端
```rust
// 1. 在 backend.rs 中定义新后端类型
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum HidBackendType {
Otg,
Ch9329 { port: String, baud_rate: u32 },
MyBackend { /* 配置参数 */ }, // 新增
None,
}
// 2. 实现 HidBackend trait
#[async_trait]
impl HidBackend for MyBackend {
fn name(&self) -> &'static str { "MyBackend" }
async fn init(&self) -> Result<()> { /* ... */ }
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { /* ... */ }
async fn send_mouse(&self, event: MouseEvent) -> Result<()> { /* ... */ }
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { /* ... */ }
async fn reset(&self) -> Result<()> { /* ... */ }
async fn shutdown(&self) -> Result<()> { /* ... */ }
fn supports_absolute_mouse(&self) -> bool { true }
fn screen_resolution(&self) -> Option<(u32, u32)> { None }
fn set_screen_resolution(&mut self, width: u32, height: u32) { /* ... */ }
}
// 3. 在 HidController::init() 中添加分支
match backend_type {
HidBackendType::MyBackend { /* params */ } => {
Box::new(MyBackend::new(/* params */)?)
}
// ...
}
```
### 9.3 添加新扩展
```rust
// 通过 ExtensionManager 管理外部进程
extension_manager.register("my-extension", ExtensionConfig {
command: "my-binary",
args: vec!["--port", "9000"],
health_check: HealthCheckConfig::Http { url: "http://localhost:9000/health" },
});
```
---
## 10. 参考资料
- [Axum Web Framework](https://github.com/tokio-rs/axum)
- [webrtc-rs](https://github.com/webrtc-rs/webrtc)
- [V4L2 Documentation](https://www.kernel.org/doc/html/latest/userspace-api/media/v4l/v4l2.html)
- [Linux USB Gadget](https://www.kernel.org/doc/html/latest/usb/gadget_configfs.html)
- [RustDesk Protocol](https://github.com/rustdesk/rustdesk)

File diff suppressed because it is too large Load Diff

View File

@@ -162,10 +162,19 @@ int linux_support_v4l2m2m() {
};
// Check common V4L2 M2M device paths used by various ARM SoCs
// /dev/video10 - Standard on many SoCs
// /dev/video11 - Standard on many SoCs (often decoder)
// /dev/video0 - Some platforms (like RPi) might use this
// /dev/video1 - Alternate RPi path
// /dev/video2 - Alternate path
// /dev/video32 - Some Allwinner/Rockchip legacy
const char *m2m_devices[] = {
"/dev/video10", // Common M2M encoder device
"/dev/video11", // Common M2M decoder device
"/dev/video0", // Some SoCs use video0 for M2M
"/dev/video10",
"/dev/video11",
"/dev/video0",
"/dev/video1",
"/dev/video2",
"/dev/video32",
};
for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {

View File

@@ -147,11 +147,11 @@ bool set_lantency_free(void *priv_data, const std::string &name) {
// V4L2 M2M hardware encoder - minimize buffer latency
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", 2, 0)) < 0) {
if ((ret = av_opt_set_int(priv_data, "num_output_buffers", 4, 0)) < 0) {
LOG_WARN(std::string("v4l2m2m set num_output_buffers failed, ret = ") + av_err2str(ret));
// Not fatal
}
if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 2, 0)) < 0) {
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));
// Not fatal
}

View File

@@ -271,6 +271,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
*out_data = nullptr;
*out_len = 0;
*out_keyframe = 0;
bool encoded = false;
av_packet_unref(ctx->dec_pkt);
int ret = av_new_packet(ctx->dec_pkt, len);
@@ -290,7 +291,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
while (true) {
ret = avcodec_receive_frame(ctx->dec_ctx, ctx->dec_frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
return 0;
return encoded ? 1 : 0;
}
if (ret < 0) {
set_last_error(make_err("avcodec_receive_frame failed", ret));
@@ -370,19 +371,27 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
return -1;
}
while (true) {
av_packet_unref(ctx->enc_pkt);
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
if (ret == AVERROR(EAGAIN)) {
av_frame_unref(ctx->dec_frame);
return 0;
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
}
if (ret < 0) {
set_last_error(make_err("avcodec_receive_packet failed", ret));
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame);
return -1;
}
if (ctx->enc_pkt->size > 0) {
if (ctx->enc_pkt->size <= 0) {
set_last_error("avcodec_receive_packet failed, pkt size is 0");
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame);
return -1;
}
if (!encoded) {
uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size);
if (!buf) {
set_last_error("malloc for output packet failed");
@@ -394,9 +403,8 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
*out_data = buf;
*out_len = ctx->enc_pkt->size;
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame);
return 1;
encoded = true;
}
}
av_frame_unref(ctx->dec_frame);

View File

@@ -6,6 +6,7 @@
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ffi.rs"));
use serde_derive::{Deserialize, Serialize};
use std::env;
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub enum AVHWDeviceType {
@@ -53,7 +54,36 @@ pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const std::os::r
pub(crate) fn init_av_log() {
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| unsafe {
av_log_set_level(AV_LOG_ERROR as i32);
av_log_set_level(parse_ffmpeg_log_level());
hwcodec_set_av_log_callback();
});
}
fn parse_ffmpeg_log_level() -> i32 {
let raw = match env::var("ONE_KVM_FFMPEG_LOG") {
Ok(value) => value,
Err(_) => return AV_LOG_ERROR as i32,
};
let value = raw.trim().to_ascii_lowercase();
if value.is_empty() {
return AV_LOG_ERROR as i32;
}
if let Ok(level) = value.parse::<i32>() {
return level;
}
match value.as_str() {
"quiet" => AV_LOG_QUIET as i32,
"panic" => AV_LOG_PANIC as i32,
"fatal" => AV_LOG_FATAL as i32,
"error" => AV_LOG_ERROR as i32,
"warn" | "warning" => AV_LOG_WARNING as i32,
"info" => AV_LOG_INFO as i32,
"verbose" => AV_LOG_VERBOSE as i32,
"debug" => AV_LOG_DEBUG as i32,
"trace" => AV_LOG_TRACE as i32,
_ => AV_LOG_ERROR as i32,
}
}

View File

@@ -31,8 +31,10 @@ unsafe impl Send for HwMjpegH26xPipeline {}
impl HwMjpegH26xPipeline {
pub fn new(config: HwMjpegH26xConfig) -> Result<Self, String> {
unsafe {
let dec = CString::new(config.decoder.as_str()).map_err(|_| "decoder name invalid".to_string())?;
let enc = CString::new(config.encoder.as_str()).map_err(|_| "encoder name invalid".to_string())?;
let dec = CString::new(config.decoder.as_str())
.map_err(|_| "decoder name invalid".to_string())?;
let enc = CString::new(config.encoder.as_str())
.map_err(|_| "encoder name invalid".to_string())?;
let ctx = ffmpeg_hw_mjpeg_h26x_new(
dec.as_ptr(),
enc.as_ptr(),

View File

@@ -1,8 +1,7 @@
use crate::{
ffmpeg::{init_av_log, AVPixelFormat},
ffmpeg_ram::{
ffmpeg_ram_decode, ffmpeg_ram_free_decoder, ffmpeg_ram_last_error,
ffmpeg_ram_new_decoder,
ffmpeg_ram_decode, ffmpeg_ram_free_decoder, ffmpeg_ram_last_error, ffmpeg_ram_new_decoder,
},
};
use std::{

View File

@@ -15,12 +15,412 @@ use std::{
slice,
};
use super::Priority;
#[cfg(any(windows, target_os = "linux"))]
use crate::common::Driver;
/// Timeout for encoder test in milliseconds
const TEST_TIMEOUT_MS: u64 = 3000;
const PRIORITY_NVENC: i32 = 0;
const PRIORITY_QSV: i32 = 1;
const PRIORITY_AMF: i32 = 2;
const PRIORITY_RKMPP: i32 = 3;
const PRIORITY_VAAPI: i32 = 4;
const PRIORITY_V4L2M2M: i32 = 5;
#[derive(Clone, Copy)]
struct CandidateCodecSpec {
name: &'static str,
format: DataFormat,
priority: i32,
}
fn push_candidate(codecs: &mut Vec<CodecInfo>, candidate: CandidateCodecSpec) {
codecs.push(CodecInfo {
name: candidate.name.to_owned(),
format: candidate.format,
priority: candidate.priority,
..Default::default()
});
}
#[cfg(target_os = "linux")]
fn linux_support_vaapi() -> bool {
let entries = match std::fs::read_dir("/dev/dri") {
Ok(entries) => entries,
Err(_) => return false,
};
entries.flatten().any(|entry| {
entry
.file_name()
.to_str()
.map(|name| name.starts_with("renderD"))
.unwrap_or(false)
})
}
#[cfg(not(target_os = "linux"))]
fn linux_support_vaapi() -> bool {
false
}
#[cfg(target_os = "linux")]
fn linux_support_rkmpp() -> bool {
extern "C" {
fn linux_support_rkmpp() -> c_int;
}
unsafe { linux_support_rkmpp() == 0 }
}
#[cfg(not(target_os = "linux"))]
fn linux_support_rkmpp() -> bool {
false
}
#[cfg(target_os = "linux")]
fn linux_support_v4l2m2m() -> bool {
extern "C" {
fn linux_support_v4l2m2m() -> c_int;
}
unsafe { linux_support_v4l2m2m() == 0 }
}
#[cfg(not(target_os = "linux"))]
fn linux_support_v4l2m2m() -> bool {
false
}
#[cfg(any(windows, target_os = "linux"))]
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
use log::debug;
let mut codecs = Vec::new();
let contains = |_vendor: Driver, _format: DataFormat| {
// Without VRAM feature, we can't check SDK availability.
// Keep the prefilter coarse and let FFmpeg validation do the real check.
true
};
let (nv, amf, intel) = crate::common::supported_gpu(true);
debug!(
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
nv, amf, intel
);
if nv && contains(Driver::NV, H264) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_nvenc",
format: H264,
priority: PRIORITY_NVENC,
},
);
}
if nv && contains(Driver::NV, H265) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_nvenc",
format: H265,
priority: PRIORITY_NVENC,
},
);
}
if intel && contains(Driver::MFX, H264) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_qsv",
format: H264,
priority: PRIORITY_QSV,
},
);
}
if intel && contains(Driver::MFX, H265) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_qsv",
format: H265,
priority: PRIORITY_QSV,
},
);
}
if amf && contains(Driver::AMF, H264) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_amf",
format: H264,
priority: PRIORITY_AMF,
},
);
}
if amf && contains(Driver::AMF, H265) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_amf",
format: H265,
priority: PRIORITY_AMF,
},
);
}
if linux_support_rkmpp() {
debug!("RKMPP hardware detected, adding Rockchip encoders");
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_rkmpp",
format: H264,
priority: PRIORITY_RKMPP,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_rkmpp",
format: H265,
priority: PRIORITY_RKMPP,
},
);
}
if cfg!(target_os = "linux") && linux_support_vaapi() {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_vaapi",
format: H264,
priority: PRIORITY_VAAPI,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_vaapi",
format: H265,
priority: PRIORITY_VAAPI,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "vp8_vaapi",
format: VP8,
priority: PRIORITY_VAAPI,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "vp9_vaapi",
format: VP9,
priority: PRIORITY_VAAPI,
},
);
}
if linux_support_v4l2m2m() {
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_v4l2m2m",
format: H264,
priority: PRIORITY_V4L2M2M,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_v4l2m2m",
format: H265,
priority: PRIORITY_V4L2M2M,
},
);
}
codecs.retain(|codec| {
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && codec.name.contains("qsv"))
});
codecs
}
#[derive(Clone, Copy)]
struct ProbePolicy {
max_attempts: usize,
request_keyframe: bool,
accept_any_output: bool,
}
impl ProbePolicy {
fn for_codec(codec_name: &str) -> Self {
if codec_name.contains("v4l2m2m") {
Self {
max_attempts: 5,
request_keyframe: true,
accept_any_output: true,
}
} else {
Self {
max_attempts: 1,
request_keyframe: false,
accept_any_output: false,
}
}
}
fn prepare_attempt(&self, encoder: &mut Encoder) {
if self.request_keyframe {
encoder.request_keyframe();
}
}
fn passed(&self, frames: &[EncodeFrame], elapsed_ms: u128) -> bool {
if elapsed_ms >= TEST_TIMEOUT_MS as u128 {
return false;
}
if self.accept_any_output {
!frames.is_empty()
} else {
frames.len() == 1 && frames[0].key == 1
}
}
}
fn log_failed_probe_attempt(
codec_name: &str,
policy: ProbePolicy,
attempt: usize,
frames: &[EncodeFrame],
elapsed_ms: u128,
) {
use log::debug;
if policy.accept_any_output {
if frames.is_empty() {
debug!(
"Encoder {} test produced no output on attempt {}",
codec_name, attempt
);
} else {
debug!(
"Encoder {} test failed on attempt {} - frames: {}, timeout: {}ms",
codec_name,
attempt,
frames.len(),
elapsed_ms
);
}
} else if frames.len() == 1 {
debug!(
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
codec_name, attempt, frames[0].key, elapsed_ms
);
} else {
debug!(
"Encoder {} test failed on attempt {} - wrong frame count: {}",
codec_name,
attempt,
frames.len()
);
}
}
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
use log::debug;
debug!("Testing encoder: {}", codec.name);
let test_ctx = EncodeContext {
name: codec.name.clone(),
mc_name: codec.mc_name.clone(),
..ctx.clone()
};
match Encoder::new(test_ctx) {
Ok(mut encoder) => {
debug!("Encoder {} created successfully", codec.name);
let policy = ProbePolicy::for_codec(&codec.name);
let mut last_err: Option<i32> = None;
for attempt in 0..policy.max_attempts {
let attempt_no = attempt + 1;
policy.prepare_attempt(&mut encoder);
let pts = (attempt as i64) * 33;
let start = std::time::Instant::now();
match encoder.encode(yuv, pts) {
Ok(frames) => {
let elapsed = start.elapsed().as_millis();
if policy.passed(frames, elapsed) {
if policy.accept_any_output {
debug!(
"Encoder {} test passed on attempt {} (frames: {})",
codec.name,
attempt_no,
frames.len()
);
} else {
debug!(
"Encoder {} test passed on attempt {}",
codec.name, attempt_no
);
}
return true;
} else {
log_failed_probe_attempt(
&codec.name,
policy,
attempt_no,
frames,
elapsed,
);
}
}
Err(err) => {
last_err = Some(err);
debug!(
"Encoder {} test attempt {} returned error: {}",
codec.name, attempt_no, err
);
}
}
}
debug!(
"Encoder {} test failed after retries{}",
codec.name,
last_err
.map(|e| format!(" (last err: {})", e))
.unwrap_or_default()
);
false
}
Err(_) => {
debug!("Failed to create encoder {}", codec.name);
false
}
}
}
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
use log::debug;
for fallback in CodecInfo::soft().into_vec() {
if !codecs.iter().any(|codec| codec.format == fallback.format) {
debug!(
"Adding software {:?} encoder: {}",
fallback.format, fallback.name
);
codecs.push(fallback);
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EncodeContext {
@@ -185,275 +585,21 @@ impl Encoder {
if !(cfg!(windows) || cfg!(target_os = "linux")) {
return vec![];
}
let mut codecs: Vec<CodecInfo> = vec![];
#[cfg(any(windows, target_os = "linux"))]
{
let contains = |_vendor: Driver, _format: DataFormat| {
// Without VRAM feature, we can't check SDK availability
// Just return true and let FFmpeg handle the actual detection
true
};
let (_nv, amf, _intel) = crate::common::supported_gpu(true);
debug!(
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
_nv, amf, _intel
);
#[cfg(windows)]
if _intel && contains(Driver::MFX, H264) {
codecs.push(CodecInfo {
name: "h264_qsv".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
}
#[cfg(windows)]
if _intel && contains(Driver::MFX, H265) {
codecs.push(CodecInfo {
name: "hevc_qsv".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
if _nv && contains(Driver::NV, H264) {
codecs.push(CodecInfo {
name: "h264_nvenc".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
}
if _nv && contains(Driver::NV, H265) {
codecs.push(CodecInfo {
name: "hevc_nvenc".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
if amf && contains(Driver::AMF, H264) {
codecs.push(CodecInfo {
name: "h264_amf".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
}
if amf {
codecs.push(CodecInfo {
name: "hevc_amf".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
#[cfg(target_os = "linux")]
{
codecs.push(CodecInfo {
name: "h264_vaapi".to_owned(),
format: H264,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "hevc_vaapi".to_owned(),
format: H265,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "vp8_vaapi".to_owned(),
format: VP8,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "vp9_vaapi".to_owned(),
format: VP9,
priority: Priority::Good as _,
..Default::default()
});
// Rockchip MPP hardware encoder support
use std::ffi::c_int;
extern "C" {
fn linux_support_rkmpp() -> c_int;
fn linux_support_v4l2m2m() -> c_int;
}
if unsafe { linux_support_rkmpp() } == 0 {
debug!("RKMPP hardware detected, adding Rockchip encoders");
codecs.push(CodecInfo {
name: "h264_rkmpp".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "hevc_rkmpp".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
// V4L2 Memory-to-Memory hardware encoder support (generic ARM)
if unsafe { linux_support_v4l2m2m() } == 0 {
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
codecs.push(CodecInfo {
name: "h264_v4l2m2m".to_owned(),
format: H264,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "hevc_v4l2m2m".to_owned(),
format: H265,
priority: Priority::Good as _,
..Default::default()
});
}
}
}
// qsv doesn't support yuv420p
codecs.retain(|c| {
let ctx = ctx.clone();
if ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && c.name.contains("qsv") {
return false;
}
return true;
});
let mut res = vec![];
#[cfg(any(windows, target_os = "linux"))]
let codecs = enumerate_candidate_codecs(&ctx);
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
for codec in codecs {
// Skip if this format already exists in results
if res
.iter()
.any(|existing: &CodecInfo| existing.format == codec.format)
{
continue;
}
debug!("Testing encoder: {}", codec.name);
let c = EncodeContext {
name: codec.name.clone(),
mc_name: codec.mc_name.clone(),
..ctx
};
match Encoder::new(c) {
Ok(mut encoder) => {
debug!("Encoder {} created successfully", codec.name);
let mut passed = false;
let mut last_err: Option<i32> = None;
let max_attempts = 1;
for attempt in 0..max_attempts {
let pts = (attempt as i64) * 33; // 33ms is an approximation for 30 FPS (1000 / 30)
let start = std::time::Instant::now();
match encoder.encode(&yuv, pts) {
Ok(frames) => {
let elapsed = start.elapsed().as_millis();
if frames.len() == 1 {
if frames[0].key == 1 && elapsed < TEST_TIMEOUT_MS as _ {
debug!(
"Encoder {} test passed on attempt {}",
codec.name,
attempt + 1
);
res.push(codec.clone());
passed = true;
break;
} else {
debug!(
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
codec.name,
attempt + 1,
frames[0].key,
elapsed
);
}
} else {
debug!(
"Encoder {} test failed on attempt {} - wrong frame count: {}",
codec.name,
attempt + 1,
frames.len()
);
}
}
Err(err) => {
last_err = Some(err);
debug!(
"Encoder {} test attempt {} returned error: {}",
codec.name,
attempt + 1,
err
);
}
}
}
if !passed {
debug!(
"Encoder {} test failed after retries{}",
codec.name,
last_err
.map(|e| format!(" (last err: {})", e))
.unwrap_or_default()
);
}
}
Err(_) => {
debug!("Failed to create encoder {}", codec.name);
}
if validate_candidate(&codec, &ctx, &yuv) {
res.push(codec);
}
}
} else {
debug!("Failed to generate dummy YUV data");
}
// Add software encoders as fallback
let soft_codecs = CodecInfo::soft();
// Add H264 software encoder if not already present
if !res.iter().any(|c| c.format == H264) {
if let Some(h264_soft) = soft_codecs.h264 {
debug!("Adding software H264 encoder: {}", h264_soft.name);
res.push(h264_soft);
}
}
// Add H265 software encoder if not already present
if !res.iter().any(|c| c.format == H265) {
if let Some(h265_soft) = soft_codecs.h265 {
debug!("Adding software H265 encoder: {}", h265_soft.name);
res.push(h265_soft);
}
}
// Add VP8 software encoder if not already present
if !res.iter().any(|c| c.format == VP8) {
if let Some(vp8_soft) = soft_codecs.vp8 {
debug!("Adding software VP8 encoder: {}", vp8_soft.name);
res.push(vp8_soft);
}
}
// Add VP9 software encoder if not already present
if !res.iter().any(|c| c.format == VP9) {
if let Some(vp9_soft) = soft_codecs.vp9 {
debug!("Adding software VP9 encoder: {}", vp9_soft.name);
res.push(vp9_soft);
}
}
add_software_fallback(&mut res);
res
}

View File

@@ -86,6 +86,40 @@ impl Default for CodecInfo {
}
impl CodecInfo {
pub fn software(format: DataFormat) -> Option<Self> {
match format {
H264 => Some(CodecInfo {
name: "libx264".to_owned(),
mc_name: Default::default(),
format: H264,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
H265 => Some(CodecInfo {
name: "libx265".to_owned(),
mc_name: Default::default(),
format: H265,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
VP8 => Some(CodecInfo {
name: "libvpx".to_owned(),
mc_name: Default::default(),
format: VP8,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
VP9 => Some(CodecInfo {
name: "libvpx-vp9".to_owned(),
mc_name: Default::default(),
format: VP9,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
AV1 => None,
}
}
pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos {
let mut h264: Option<CodecInfo> = None;
let mut h265: Option<CodecInfo> = None;
@@ -148,34 +182,10 @@ impl CodecInfo {
pub fn soft() -> CodecInfos {
CodecInfos {
h264: Some(CodecInfo {
name: "libx264".to_owned(),
mc_name: Default::default(),
format: H264,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
h265: Some(CodecInfo {
name: "libx265".to_owned(),
mc_name: Default::default(),
format: H265,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
vp8: Some(CodecInfo {
name: "libvpx".to_owned(),
mc_name: Default::default(),
format: VP8,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
vp9: Some(CodecInfo {
name: "libvpx-vp9".to_owned(),
mc_name: Default::default(),
format: VP9,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
h264: CodecInfo::software(H264),
h265: CodecInfo::software(H265),
vp8: CodecInfo::software(VP8),
vp9: CodecInfo::software(VP9),
av1: None,
}
}
@@ -191,6 +201,23 @@ pub struct CodecInfos {
}
impl CodecInfos {
pub fn into_vec(self) -> Vec<CodecInfo> {
let mut codecs = Vec::new();
if let Some(codec) = self.h264 {
codecs.push(codec);
}
if let Some(codec) = self.h265 {
codecs.push(codec);
}
if let Some(codec) = self.vp8 {
codecs.push(codec);
}
if let Some(codec) = self.vp9 {
codecs.push(codec);
}
codecs
}
pub fn serialize(&self) -> Result<String, ()> {
match serde_json::to_string_pretty(self) {
Ok(s) => Ok(s),

View File

@@ -8,11 +8,11 @@ use tracing::{debug, info, warn};
use super::executor::{timing, AtxKeyExecutor};
use super::led::LedSensor;
use super::types::{AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus};
use super::types::{AtxAction, AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus};
use crate::error::{AppError, Result};
/// ATX power control configuration
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct AtxControllerConfig {
/// Whether ATX is enabled
pub enabled: bool,
@@ -24,17 +24,6 @@ pub struct AtxControllerConfig {
pub led: AtxLedConfig,
}
impl Default for AtxControllerConfig {
fn default() -> Self {
Self {
enabled: false,
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
}
}
}
/// Internal state holding all ATX components
/// Grouped together to reduce lock acquisitions
struct AtxInner {
@@ -54,34 +43,7 @@ pub struct AtxController {
}
impl AtxController {
/// Create a new ATX controller with the specified configuration
pub fn new(config: AtxControllerConfig) -> Self {
Self {
inner: RwLock::new(AtxInner {
config,
power_executor: None,
reset_executor: None,
led_sensor: None,
}),
}
}
/// Create a disabled ATX controller
pub fn disabled() -> Self {
Self::new(AtxControllerConfig::default())
}
/// Initialize the ATX controller and its executors
pub async fn init(&self) -> Result<()> {
let mut inner = self.inner.write().await;
if !inner.config.enabled {
info!("ATX disabled in configuration");
return Ok(());
}
info!("Initializing ATX controller");
async fn init_components(inner: &mut AtxInner) {
// Initialize power executor
if inner.config.power.is_configured() {
let mut executor = AtxKeyExecutor::new(inner.config.power.clone());
@@ -123,234 +85,180 @@ impl AtxController {
inner.led_sensor = Some(sensor);
}
}
info!("ATX controller initialized successfully");
Ok(())
}
/// Reload the ATX controller with new configuration
///
/// This is called when configuration changes and supports hot-reload.
pub async fn reload(&self, new_config: AtxControllerConfig) -> Result<()> {
info!("Reloading ATX controller with new configuration");
async fn shutdown_components(inner: &mut AtxInner) {
if let Some(executor) = inner.power_executor.as_mut() {
if let Err(e) = executor.shutdown().await {
warn!("Failed to shutdown power executor: {}", e);
}
}
inner.power_executor = None;
// Shutdown existing executors
self.shutdown_internal().await?;
if let Some(executor) = inner.reset_executor.as_mut() {
if let Err(e) = executor.shutdown().await {
warn!("Failed to shutdown reset executor: {}", e);
}
}
inner.reset_executor = None;
// Update configuration and re-initialize
{
if let Some(sensor) = inner.led_sensor.as_mut() {
if let Err(e) = sensor.shutdown().await {
warn!("Failed to shutdown LED sensor: {}", e);
}
}
inner.led_sensor = None;
}
/// Create a new ATX controller with the specified configuration
pub fn new(config: AtxControllerConfig) -> Self {
Self {
inner: RwLock::new(AtxInner {
config,
power_executor: None,
reset_executor: None,
led_sensor: None,
}),
}
}
/// Create a disabled ATX controller
pub fn disabled() -> Self {
Self::new(AtxControllerConfig::default())
}
/// Initialize the ATX controller and its executors
pub async fn init(&self) -> Result<()> {
let mut inner = self.inner.write().await;
inner.config = new_config;
if !inner.config.enabled {
info!("ATX disabled in configuration");
return Ok(());
}
// Re-initialize
self.init().await?;
info!("Initializing ATX controller");
Self::init_components(&mut inner).await;
info!("ATX controller reloaded successfully");
Ok(())
}
/// Get current ATX state (single lock acquisition)
/// Reload ATX controller configuration
pub async fn reload(&self, config: AtxControllerConfig) -> Result<()> {
let mut inner = self.inner.write().await;
info!("Reloading ATX controller configuration");
// Shutdown existing components first, then rebuild with new config.
Self::shutdown_components(&mut inner).await;
inner.config = config;
if !inner.config.enabled {
info!("ATX disabled after reload");
return Ok(());
}
Self::init_components(&mut inner).await;
info!("ATX controller reloaded");
Ok(())
}
/// Shutdown ATX controller and release all resources
pub async fn shutdown(&self) -> Result<()> {
let mut inner = self.inner.write().await;
Self::shutdown_components(&mut inner).await;
info!("ATX controller shutdown complete");
Ok(())
}
/// Trigger a power action (short/long/reset)
pub async fn trigger_power_action(&self, action: AtxAction) -> Result<()> {
let inner = self.inner.read().await;
match action {
AtxAction::Short | AtxAction::Long => {
if let Some(executor) = &inner.power_executor {
let duration = match action {
AtxAction::Short => timing::SHORT_PRESS,
AtxAction::Long => timing::LONG_PRESS,
_ => unreachable!(),
};
executor.pulse(duration).await?;
} else {
return Err(AppError::Config(
"Power button not configured for ATX controller".to_string(),
));
}
}
AtxAction::Reset => {
if let Some(executor) = &inner.reset_executor {
executor.pulse(timing::RESET_PRESS).await?;
} else {
return Err(AppError::Config(
"Reset button not configured for ATX controller".to_string(),
));
}
}
}
Ok(())
}
/// Trigger a short power button press
pub async fn power_short(&self) -> Result<()> {
self.trigger_power_action(AtxAction::Short).await
}
/// Trigger a long power button press
pub async fn power_long(&self) -> Result<()> {
self.trigger_power_action(AtxAction::Long).await
}
/// Trigger a reset button press
pub async fn reset(&self) -> Result<()> {
self.trigger_power_action(AtxAction::Reset).await
}
/// Get the current power status using the LED sensor (if configured)
pub async fn power_status(&self) -> PowerStatus {
let inner = self.inner.read().await;
if let Some(sensor) = &inner.led_sensor {
match sensor.read().await {
Ok(status) => status,
Err(e) => {
debug!("Failed to read ATX LED sensor: {}", e);
PowerStatus::Unknown
}
}
} else {
PowerStatus::Unknown
}
}
/// Get a snapshot of the ATX state for API responses
pub async fn state(&self) -> AtxState {
let inner = self.inner.read().await;
let power_status = if let Some(sensor) = inner.led_sensor.as_ref() {
sensor.read().await.unwrap_or(PowerStatus::Unknown)
let power_status = if let Some(sensor) = &inner.led_sensor {
match sensor.read().await {
Ok(status) => status,
Err(e) => {
debug!("Failed to read ATX LED sensor: {}", e);
PowerStatus::Unknown
}
}
} else {
PowerStatus::Unknown
};
AtxState {
available: inner.config.enabled,
power_configured: inner
.power_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false),
reset_configured: inner
.reset_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false),
power_configured: inner.power_executor.is_some(),
reset_configured: inner.reset_executor.is_some(),
power_status,
led_supported: inner
.led_sensor
.as_ref()
.map(|s| s.is_initialized())
.unwrap_or(false),
led_supported: inner.led_sensor.is_some(),
}
}
/// Get current state as SystemEvent
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
let state = self.state().await;
crate::events::SystemEvent::AtxStateChanged {
power_status: state.power_status,
}
}
/// Check if ATX is available
pub async fn is_available(&self) -> bool {
let inner = self.inner.read().await;
inner.config.enabled
}
/// Check if power button is configured and initialized
pub async fn is_power_ready(&self) -> bool {
let inner = self.inner.read().await;
inner
.power_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false)
}
/// Check if reset button is configured and initialized
pub async fn is_reset_ready(&self) -> bool {
let inner = self.inner.read().await;
inner
.reset_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false)
}
/// Short press power button (turn on or graceful shutdown)
pub async fn power_short(&self) -> Result<()> {
let inner = self.inner.read().await;
let executor = inner
.power_executor
.as_ref()
.ok_or_else(|| AppError::Internal("Power button not configured".to_string()))?;
info!(
"ATX: Short press power button ({}ms)",
timing::SHORT_PRESS.as_millis()
);
executor.pulse(timing::SHORT_PRESS).await
}
/// Long press power button (force power off)
pub async fn power_long(&self) -> Result<()> {
let inner = self.inner.read().await;
let executor = inner
.power_executor
.as_ref()
.ok_or_else(|| AppError::Internal("Power button not configured".to_string()))?;
info!(
"ATX: Long press power button ({}ms)",
timing::LONG_PRESS.as_millis()
);
executor.pulse(timing::LONG_PRESS).await
}
/// Press reset button
pub async fn reset(&self) -> Result<()> {
let inner = self.inner.read().await;
let executor = inner
.reset_executor
.as_ref()
.ok_or_else(|| AppError::Internal("Reset button not configured".to_string()))?;
info!(
"ATX: Press reset button ({}ms)",
timing::RESET_PRESS.as_millis()
);
executor.pulse(timing::RESET_PRESS).await
}
/// Get current power status from LED sensor
pub async fn power_status(&self) -> Result<PowerStatus> {
let inner = self.inner.read().await;
match inner.led_sensor.as_ref() {
Some(sensor) => sensor.read().await,
None => Ok(PowerStatus::Unknown),
}
}
/// Shutdown the ATX controller
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down ATX controller");
self.shutdown_internal().await?;
info!("ATX controller shutdown complete");
Ok(())
}
/// Internal shutdown helper
async fn shutdown_internal(&self) -> Result<()> {
let mut inner = self.inner.write().await;
// Shutdown power executor
if let Some(mut executor) = inner.power_executor.take() {
executor.shutdown().await.ok();
}
// Shutdown reset executor
if let Some(mut executor) = inner.reset_executor.take() {
executor.shutdown().await.ok();
}
// Shutdown LED sensor
if let Some(mut sensor) = inner.led_sensor.take() {
sensor.shutdown().await.ok();
}
Ok(())
}
}
impl Drop for AtxController {
fn drop(&mut self) {
debug!("ATX controller dropped");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_controller_config_default() {
let config = AtxControllerConfig::default();
assert!(!config.enabled);
assert!(!config.power.is_configured());
assert!(!config.reset.is_configured());
assert!(!config.led.is_configured());
}
#[test]
fn test_controller_creation() {
let controller = AtxController::disabled();
assert!(controller.inner.try_read().is_ok());
}
#[tokio::test]
async fn test_controller_disabled_state() {
let controller = AtxController::disabled();
let state = controller.state().await;
assert!(!state.available);
assert!(!state.power_configured);
assert!(!state.reset_configured);
}
#[tokio::test]
async fn test_controller_init_disabled() {
let controller = AtxController::disabled();
let result = controller.init().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_controller_is_available() {
let controller = AtxController::disabled();
assert!(!controller.is_available().await);
let config = AtxControllerConfig {
enabled: true,
..Default::default()
};
let controller = AtxController::new(config);
assert!(controller.is_available().await);
}
}

View File

@@ -4,6 +4,7 @@
//! Each executor handles one button (power or reset) with its own hardware binding.
use gpio_cdev::{Chip, LineHandle, LineRequestFlags};
use serialport::SerialPort;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -38,6 +39,8 @@ pub struct AtxKeyExecutor {
gpio_handle: Mutex<Option<LineHandle>>,
/// Cached USB relay file handle to avoid repeated open/close syscalls
usb_relay_handle: Mutex<Option<File>>,
/// Cached Serial port handle
serial_handle: Mutex<Option<Box<dyn SerialPort>>>,
initialized: AtomicBool,
}
@@ -48,6 +51,7 @@ impl AtxKeyExecutor {
config,
gpio_handle: Mutex::new(None),
usb_relay_handle: Mutex::new(None),
serial_handle: Mutex::new(None),
initialized: AtomicBool::new(false),
}
}
@@ -69,9 +73,12 @@ impl AtxKeyExecutor {
return Ok(());
}
self.validate_runtime_config()?;
match self.config.driver {
AtxDriverType::Gpio => self.init_gpio().await?,
AtxDriverType::UsbRelay => self.init_usb_relay().await?,
AtxDriverType::Serial => self.init_serial().await?,
AtxDriverType::None => {}
}
@@ -79,6 +86,39 @@ impl AtxKeyExecutor {
Ok(())
}
fn validate_runtime_config(&self) -> Result<()> {
match self.config.driver {
AtxDriverType::Serial => {
if self.config.pin == 0 {
return Err(AppError::Config(
"Serial ATX channel must be 1-based (>= 1)".to_string(),
));
}
if self.config.pin > u8::MAX as u32 {
return Err(AppError::Config(format!(
"Serial ATX channel must be <= {}",
u8::MAX
)));
}
if self.config.baud_rate == 0 {
return Err(AppError::Config(
"Serial ATX baud_rate must be greater than 0".to_string(),
));
}
}
AtxDriverType::UsbRelay => {
if self.config.pin > u8::MAX as u32 {
return Err(AppError::Config(format!(
"USB relay channel must be <= {}",
u8::MAX
)));
}
}
AtxDriverType::Gpio | AtxDriverType::None => {}
}
Ok(())
}
/// Initialize GPIO backend
async fn init_gpio(&mut self) -> Result<()> {
info!(
@@ -134,6 +174,32 @@ impl AtxKeyExecutor {
Ok(())
}
/// Initialize Serial relay backend
async fn init_serial(&self) -> Result<()> {
info!(
"Initializing Serial relay ATX executor on {} channel {}",
self.config.device, self.config.pin
);
let baud_rate = self.config.baud_rate;
let port = serialport::new(&self.config.device, baud_rate)
.timeout(Duration::from_millis(100))
.open()
.map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?;
*self.serial_handle.lock().unwrap() = Some(port);
// Ensure relay is off initially
self.send_serial_relay_command(false)?;
debug!(
"Serial relay channel {} configured successfully",
self.config.pin
);
Ok(())
}
/// Pulse the button for the specified duration
pub async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_configured() {
@@ -147,6 +213,7 @@ impl AtxKeyExecutor {
match self.config.driver {
AtxDriverType::Gpio => self.pulse_gpio(duration).await,
AtxDriverType::UsbRelay => self.pulse_usb_relay(duration).await,
AtxDriverType::Serial => self.pulse_serial(duration).await,
AtxDriverType::None => Ok(()),
}
}
@@ -199,7 +266,13 @@ impl AtxKeyExecutor {
/// Send USB relay command using cached handle
fn send_usb_relay_command(&self, on: bool) -> Result<()> {
let channel = self.config.pin as u8;
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"USB relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
))
})?;
// Standard HID relay command format
let cmd = if on {
@@ -220,6 +293,61 @@ impl AtxKeyExecutor {
Ok(())
}
/// Pulse Serial relay
async fn pulse_serial(&self, duration: Duration) -> Result<()> {
info!(
"Pulse serial relay on {} pin {}",
self.config.device, self.config.pin
);
// Turn relay on
self.send_serial_relay_command(true)?;
// Wait for duration
sleep(duration).await;
// Turn relay off
self.send_serial_relay_command(false)?;
Ok(())
}
/// Send Serial relay command using cached handle
fn send_serial_relay_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"Serial relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
))
})?;
if channel == 0 {
return Err(AppError::Config(
"Serial relay channel must be 1-based (>= 1)".to_string(),
));
}
// LCUS-Type Protocol
// Frame: [StopByte(A0), Channel, State, Checksum]
// Checksum = A0 + channel + state
let state = if on { 1 } else { 0 };
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
// Example for Channel 1:
// ON: A0 01 01 A2
// OFF: A0 01 00 A1
let cmd = [0xA0, channel, state, checksum];
let mut guard = self.serial_handle.lock().unwrap();
let port = guard
.as_mut()
.ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?;
port.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?;
Ok(())
}
/// Shutdown the executor
pub async fn shutdown(&mut self) -> Result<()> {
if !self.is_initialized() {
@@ -237,6 +365,12 @@ impl AtxKeyExecutor {
// Release USB relay handle
*self.usb_relay_handle.lock().unwrap() = None;
}
AtxDriverType::Serial => {
// Ensure relay is off before closing handle
let _ = self.send_serial_relay_command(false);
// Release Serial relay handle
*self.serial_handle.lock().unwrap() = None;
}
AtxDriverType::None => {}
}
@@ -256,6 +390,12 @@ impl Drop for AtxKeyExecutor {
let _ = self.send_usb_relay_command(false);
}
*self.usb_relay_handle.lock().unwrap() = None;
// Ensure Serial relay is off and handle released
if self.config.driver == AtxDriverType::Serial && self.is_initialized() {
let _ = self.send_serial_relay_command(false);
}
*self.serial_handle.lock().unwrap() = None;
}
}
@@ -278,6 +418,7 @@ mod tests {
device: "/dev/gpiochip0".to_string(),
pin: 5,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
assert!(executor.is_configured());
@@ -291,6 +432,20 @@ mod tests {
device: "/dev/hidraw0".to_string(),
pin: 0,
active_level: ActiveLevel::High, // Ignored for USB relay
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
assert!(executor.is_configured());
}
#[test]
fn test_executor_with_serial_config() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: ActiveLevel::High, // Ignored
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
assert!(executor.is_configured());
@@ -302,4 +457,46 @@ mod tests {
assert_eq!(timing::LONG_PRESS.as_millis(), 5000);
assert_eq!(timing::RESET_PRESS.as_millis(), 500);
}
#[tokio::test]
async fn test_executor_init_rejects_serial_channel_zero() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 0,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_serial_channel_overflow() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 256,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_zero_serial_baud_rate() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: ActiveLevel::High,
baud_rate: 0,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
}

View File

@@ -28,12 +28,14 @@
//! device: "/dev/gpiochip0".to_string(),
//! pin: 5,
//! active_level: ActiveLevel::High,
//! baud_rate: 9600,
//! },
//! reset: AtxKeyConfig {
//! driver: AtxDriverType::UsbRelay,
//! device: "/dev/hidraw0".to_string(),
//! pin: 0,
//! active_level: ActiveLevel::High,
//! baud_rate: 9600,
//! },
//! led: Default::default(),
//! };
@@ -72,12 +74,15 @@ pub fn discover_devices() -> AtxDevices {
devices.gpio_chips.push(format!("/dev/{}", name_str));
} else if name_str.starts_with("hidraw") {
devices.usb_relays.push(format!("/dev/{}", name_str));
} else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") {
devices.serial_ports.push(format!("/dev/{}", name_str));
}
}
}
devices.gpio_chips.sort();
devices.usb_relays.sort();
devices.serial_ports.sort();
devices
}
@@ -88,10 +93,7 @@ mod tests {
#[test]
fn test_discover_devices() {
let devices = discover_devices();
// Just verify the function runs without error
assert!(devices.gpio_chips.len() >= 0);
assert!(devices.usb_relays.len() >= 0);
let _devices = discover_devices();
}
#[test]

View File

@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Power status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PowerStatus {
/// Power is on
@@ -15,51 +15,38 @@ pub enum PowerStatus {
/// Power is off
Off,
/// Power status unknown (no LED connected)
#[default]
Unknown,
}
impl Default for PowerStatus {
fn default() -> Self {
Self::Unknown
}
}
/// Driver type for ATX key operations
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AtxDriverType {
/// GPIO control via Linux character device
Gpio,
/// USB HID relay module
UsbRelay,
/// Serial/COM port relay (taobao LCUS type)
Serial,
/// Disabled / Not configured
#[default]
None,
}
impl Default for AtxDriverType {
fn default() -> Self {
Self::None
}
}
/// Active level for GPIO pins
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ActiveLevel {
/// Active high (default for most cases)
#[default]
High,
/// Active low (inverted)
Low,
}
impl Default for ActiveLevel {
fn default() -> Self {
Self::High
}
}
/// Configuration for a single ATX key (power or reset)
/// This is the "four-tuple" configuration: (driver, device, pin/channel, level)
#[typeshare]
@@ -75,9 +62,12 @@ pub struct AtxKeyConfig {
/// Pin or channel number:
/// - For GPIO: GPIO pin number
/// - For USB Relay: relay channel (0-based)
/// - For Serial Relay (LCUS): relay channel (1-based)
pub pin: u32,
/// Active level (only applicable to GPIO, ignored for USB Relay)
pub active_level: ActiveLevel,
/// Baud rate for serial relay (start with 9600)
pub baud_rate: u32,
}
impl Default for AtxKeyConfig {
@@ -87,6 +77,7 @@ impl Default for AtxKeyConfig {
device: String::new(),
pin: 0,
active_level: ActiveLevel::High,
baud_rate: 9600,
}
}
}
@@ -100,7 +91,7 @@ impl AtxKeyConfig {
/// LED sensing configuration (optional)
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct AtxLedConfig {
/// Whether LED sensing is enabled
@@ -113,17 +104,6 @@ pub struct AtxLedConfig {
pub inverted: bool,
}
impl Default for AtxLedConfig {
fn default() -> Self {
Self {
enabled: false,
gpio_chip: String::new(),
gpio_pin: 0,
inverted: false,
}
}
}
impl AtxLedConfig {
/// Check if LED sensing is configured
pub fn is_configured(&self) -> bool {
@@ -132,7 +112,7 @@ impl AtxLedConfig {
}
/// ATX state information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AtxState {
/// Whether ATX feature is available/enabled
pub available: bool,
@@ -146,18 +126,6 @@ pub struct AtxState {
pub led_supported: bool,
}
impl Default for AtxState {
fn default() -> Self {
Self {
available: false,
power_configured: false,
reset_configured: false,
power_status: PowerStatus::Unknown,
led_supported: false,
}
}
}
/// ATX power action request
#[derive(Debug, Clone, Deserialize)]
pub struct AtxPowerRequest {
@@ -185,6 +153,8 @@ pub struct AtxDevices {
pub gpio_chips: Vec<String>,
/// Available USB HID relay devices (/dev/hidraw*)
pub usb_relays: Vec<String>,
/// Available Serial ports (/dev/ttyUSB*)
pub serial_ports: Vec<String>,
}
impl Default for AtxDevices {
@@ -192,6 +162,7 @@ impl Default for AtxDevices {
Self {
gpio_chips: Vec::new(),
usb_relays: Vec::new(),
serial_ports: Vec::new(),
}
}
}
@@ -266,5 +237,6 @@ mod tests {
assert!(!state.power_configured);
assert!(!state.reset_configured);
assert_eq!(state.power_status, PowerStatus::Unknown);
assert!(!state.led_supported);
}
}

View File

@@ -160,8 +160,8 @@ mod tests {
let packet = build_magic_packet(&mac);
// Check header (6 bytes of 0xFF)
for i in 0..6 {
assert_eq!(packet[i], 0xFF);
for byte in packet.iter().take(6) {
assert_eq!(*byte, 0xFF);
}
// Check MAC repetitions

View File

@@ -184,14 +184,7 @@ impl AudioCapturer {
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
capture_loop(
config,
state,
frame_tx,
stop_flag,
sequence,
log_throttler,
);
capture_loop(config, state, frame_tx, stop_flag, sequence, log_throttler);
});
*self.capture_handle.lock().await = Some(handle);

View File

@@ -13,7 +13,7 @@ use super::encoder::{OpusConfig, OpusFrame};
use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
use super::streamer::{AudioStreamer, AudioStreamerConfig};
use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::events::EventBus;
/// Audio quality presets
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -39,6 +39,7 @@ impl AudioQuality {
}
/// Parse from string
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"voice" | "low" => AudioQuality::Voice,
@@ -138,17 +139,15 @@ impl AudioController {
}
}
/// Set event bus for publishing audio events
/// Set event bus for internal state notifications.
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
*self.event_bus.write().await = Some(event_bus.clone());
// Also set event bus on the monitor for health notifications
self.monitor.set_event_bus(event_bus).await;
*self.event_bus.write().await = Some(event_bus);
}
/// Publish an event to the event bus
async fn publish_event(&self, event: SystemEvent) {
/// Mark the device-info snapshot as stale.
async fn mark_device_info_dirty(&self) {
if let Some(ref bus) = *self.event_bus.read().await {
bus.publish(event);
bus.mark_device_info_dirty();
}
}
@@ -206,12 +205,6 @@ impl AudioController {
config.device = device.to_string();
}
// Publish event
self.publish_event(SystemEvent::AudioDeviceSelected {
device: device.to_string(),
})
.await;
info!("Audio device selected: {}", device);
// If streaming, restart with new device
@@ -236,12 +229,6 @@ impl AudioController {
streamer.set_bitrate(quality.bitrate()).await?;
}
// Publish event
self.publish_event(SystemEvent::AudioQualityChanged {
quality: quality.to_string(),
})
.await;
info!(
"Audio quality set to: {:?} ({}bps)",
quality,
@@ -289,11 +276,7 @@ impl AudioController {
.report_error(Some(&config.device), &error_msg, "start_failed")
.await;
self.publish_event(SystemEvent::AudioStateChanged {
streaming: false,
device: None,
})
.await;
self.mark_device_info_dirty().await;
return Err(AppError::AudioError(error_msg));
}
@@ -305,12 +288,7 @@ impl AudioController {
self.monitor.report_recovered(Some(&config.device)).await;
}
// Publish event
self.publish_event(SystemEvent::AudioStateChanged {
streaming: true,
device: Some(config.device),
})
.await;
self.mark_device_info_dirty().await;
info!("Audio streaming started");
Ok(())
@@ -322,12 +300,7 @@ impl AudioController {
streamer.stop().await?;
}
// Publish event
self.publish_event(SystemEvent::AudioStateChanged {
streaming: false,
device: None,
})
.await;
self.mark_device_info_dirty().await;
info!("Audio streaming stopped");
Ok(())
@@ -407,7 +380,6 @@ impl AudioController {
/// Update full configuration
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
let was_streaming = self.is_streaming().await;
let old_config = self.config.read().await.clone();
// Stop streaming if running
if was_streaming {
@@ -422,21 +394,6 @@ impl AudioController {
self.start_streaming().await?;
}
// Publish events for changes
if old_config.device != new_config.device {
self.publish_event(SystemEvent::AudioDeviceSelected {
device: new_config.device.clone(),
})
.await;
}
if old_config.quality != new_config.quality {
self.publish_event(SystemEvent::AudioQualityChanged {
quality: new_config.quality.to_string(),
})
.await;
}
Ok(())
}

View File

@@ -85,9 +85,7 @@ pub fn enumerate_audio_devices_with_current(
let mut devices = Vec::new();
// Try to enumerate cards
let cards = match alsa::card::Iter::new() {
i => i,
};
let cards = alsa::card::Iter::new();
for card_result in cards {
let card = match card_result {

View File

@@ -3,22 +3,21 @@
//! This module provides health monitoring for audio capture devices, including:
//! - Device connectivity checks
//! - Automatic reconnection on failure
//! - Error tracking and notification
//! - Error tracking
//! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{info, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler;
/// Audio health status
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Default)]
pub enum AudioHealthStatus {
/// Device is healthy and operational
#[default]
Healthy,
/// Device has an error, attempting recovery
Error {
@@ -33,12 +32,6 @@ pub enum AudioHealthStatus {
Disconnected,
}
impl Default for AudioHealthStatus {
fn default() -> Self {
Self::Healthy
}
}
/// Audio health monitor configuration
#[derive(Debug, Clone)]
pub struct AudioMonitorConfig {
@@ -63,19 +56,13 @@ impl Default for AudioMonitorConfig {
/// Audio health monitor
///
/// Monitors audio device health and manages error recovery.
/// Publishes WebSocket events when device status changes.
pub struct AudioHealthMonitor {
/// Current health status
status: RwLock<AudioHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding
throttler: LogThrottler,
/// Configuration
config: AudioMonitorConfig,
/// Whether monitoring is active (reserved for future use)
#[allow(dead_code)]
running: AtomicBool,
/// Current retry count
retry_count: AtomicU32,
/// Last error code (for change detection)
@@ -88,10 +75,8 @@ impl AudioHealthMonitor {
let throttle_secs = config.log_throttle_secs;
Self {
status: RwLock::new(AudioHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs),
config,
running: AtomicBool::new(false),
retry_count: AtomicU32::new(0),
last_error_code: RwLock::new(None),
}
@@ -102,24 +87,19 @@ impl AudioHealthMonitor {
Self::new(AudioMonitorConfig::default())
}
/// Set the event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Report an error from audio operations
///
/// This method is called when an audio operation fails. It:
/// 1. Updates the health status
/// 2. Logs the error (with throttling)
/// 3. Publishes a WebSocket event if the error is new or changed
/// 3. Updates in-memory error state
///
/// # Arguments
///
/// * `device` - The audio device name (if known)
/// * `reason` - Human-readable error description
/// * `error_code` - Error code for programmatic handling
pub async fn report_error(&self, device: Option<&str>, reason: &str, error_code: &str) {
pub async fn report_error(&self, _device: Option<&str>, reason: &str, error_code: &str) {
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
// Check if error code changed
@@ -146,44 +126,17 @@ impl AudioHealthMonitor {
error_code: error_code.to_string(),
retry_count: count,
};
// Publish event (only if error changed or first occurrence)
if error_changed || count == 1 {
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::AudioDeviceLost {
device: device.map(|s| s.to_string()),
reason: reason.to_string(),
error_code: error_code.to_string(),
});
}
}
}
/// Report that a reconnection attempt is starting
///
/// Publishes a reconnecting event to notify clients.
pub async fn report_reconnecting(&self) {
let attempt = self.retry_count.load(Ordering::Relaxed);
// Only publish every 5 attempts to avoid event spam
if attempt == 1 || attempt % 5 == 0 {
debug!("Audio reconnecting, attempt {}", attempt);
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::AudioReconnecting { attempt });
}
}
}
/// Report that the device has recovered
///
/// This method is called when the audio device successfully reconnects.
/// It resets the error state and publishes a recovery event.
/// It resets the error state.
///
/// # Arguments
///
/// * `device` - The audio device name
pub async fn report_recovered(&self, device: Option<&str>) {
pub async fn report_recovered(&self, _device: Option<&str>) {
let prev_status = self.status.read().await.clone();
// Only report recovery if we were in an error state
@@ -196,13 +149,6 @@ impl AudioHealthMonitor {
self.throttler.clear("audio_");
*self.last_error_code.write().await = None;
*self.status.write().await = AudioHealthStatus::Healthy;
// Publish recovery event
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::AudioRecovered {
device: device.map(|s| s.to_string()),
});
}
}
}

View File

@@ -14,9 +14,10 @@ use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
use crate::error::{AppError, Result};
/// Audio stream state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AudioStreamState {
/// Stream is stopped
#[default]
Stopped,
/// Stream is starting up
Starting,
@@ -26,14 +27,8 @@ pub enum AudioStreamState {
Error,
}
impl Default for AudioStreamState {
fn default() -> Self {
Self::Stopped
}
}
/// Audio streamer configuration
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct AudioStreamerConfig {
/// Audio capture configuration
pub capture: AudioConfig,
@@ -41,15 +36,6 @@ pub struct AudioStreamerConfig {
pub opus: OpusConfig,
}
impl Default for AudioStreamerConfig {
fn default() -> Self {
Self {
capture: AudioConfig::default(),
opus: OpusConfig::default(),
}
}
}
impl AudioStreamerConfig {
/// Create config for a specific device with default quality
pub fn for_device(device_name: &str) -> Self {
@@ -290,11 +276,9 @@ impl AudioStreamer {
// Encode to Opus
let opus_result = {
let mut enc_guard = encoder.lock().await;
if let Some(ref mut enc) = *enc_guard {
Some(enc.encode_frame(&audio_frame))
} else {
None
}
(*enc_guard)
.as_mut()
.map(|enc| enc.encode_frame(&audio_frame))
};
match opus_result {

View File

@@ -92,11 +92,7 @@ fn is_public_endpoint(path: &str) -> bool {
// Note: paths here are relative to /api since middleware is applied within the nested router
matches!(
path,
"/"
| "/auth/login"
| "/health"
| "/setup"
| "/setup/init"
"/" | "/auth/login" | "/health" | "/setup" | "/setup/init"
) || path.starts_with("/assets/")
|| path.starts_with("/static/")
|| path.ends_with(".js")

View File

@@ -110,7 +110,9 @@ impl SessionStore {
/// Delete all expired sessions
pub async fn cleanup_expired(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < datetime('now')")
let now = Utc::now().to_rfc3339();
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?1")
.bind(now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())

View File

@@ -7,7 +7,7 @@ use super::password::{hash_password, verify_password};
use crate::error::{AppError, Result};
/// User row type from database
type UserRow = (String, String, String, i32, String, String);
type UserRow = (String, String, String, String, String);
/// User data
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -16,7 +16,6 @@ pub struct User {
pub username: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub is_admin: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -24,12 +23,11 @@ pub struct User {
impl User {
/// Convert from database row to User
fn from_row(row: UserRow) -> Self {
let (id, username, password_hash, is_admin, created_at, updated_at) = row;
let (id, username, password_hash, created_at, updated_at) = row;
Self {
id,
username,
password_hash,
is_admin: is_admin != 0,
created_at: DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
@@ -53,7 +51,7 @@ impl UserStore {
}
/// Create a new user
pub async fn create(&self, username: &str, password: &str, is_admin: bool) -> Result<User> {
pub async fn create(&self, username: &str, password: &str) -> Result<User> {
// Check if username already exists
if self.get_by_username(username).await?.is_some() {
return Err(AppError::BadRequest(format!(
@@ -68,21 +66,19 @@ impl UserStore {
id: Uuid::new_v4().to_string(),
username: username.to_string(),
password_hash,
is_admin,
created_at: now,
updated_at: now,
};
sqlx::query(
r#"
INSERT INTO users (id, username, password_hash, is_admin, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
INSERT INTO users (id, username, password_hash, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
"#,
)
.bind(&user.id)
.bind(&user.username)
.bind(&user.password_hash)
.bind(user.is_admin as i32)
.bind(user.created_at.to_rfc3339())
.bind(user.updated_at.to_rfc3339())
.execute(&self.pool)
@@ -94,7 +90,7 @@ impl UserStore {
/// Get user by ID
pub async fn get(&self, user_id: &str) -> Result<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users WHERE id = ?1",
"SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = ?1",
)
.bind(user_id)
.fetch_optional(&self.pool)
@@ -106,7 +102,7 @@ impl UserStore {
/// Get user by username
pub async fn get_by_username(&self, username: &str) -> Result<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users WHERE username = ?1",
"SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?1",
)
.bind(username)
.fetch_optional(&self.pool)
@@ -161,8 +157,7 @@ impl UserStore {
}
let now = Utc::now();
let result =
sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3")
let result = sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3")
.bind(new_username)
.bind(now.to_rfc3339())
.bind(user_id)
@@ -179,7 +174,7 @@ impl UserStore {
/// List all users
pub async fn list(&self) -> Result<Vec<User>> {
let rows: Vec<UserRow> = sqlx::query_as(
"SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users ORDER BY created_at",
"SELECT id, username, password_hash, created_at, updated_at FROM users ORDER BY created_at",
)
.fetch_all(&self.pool)
.await?;

View File

@@ -11,6 +11,7 @@ pub use crate::rustdesk::config::RustDeskConfig;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct AppConfig {
/// Whether initial setup has been completed
pub initialized: bool,
@@ -34,24 +35,8 @@ pub struct AppConfig {
pub extensions: ExtensionsConfig,
/// RustDesk remote access settings
pub rustdesk: RustDeskConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
initialized: false,
auth: AuthConfig::default(),
video: VideoConfig::default(),
hid: HidConfig::default(),
msd: MsdConfig::default(),
atx: AtxConfig::default(),
audio: AudioConfig::default(),
stream: StreamConfig::default(),
web: WebConfig::default(),
extensions: ExtensionsConfig::default(),
rustdesk: RustDeskConfig::default(),
}
}
/// RTSP streaming settings
pub rtsp: RtspConfig,
}
/// Authentication configuration
@@ -116,21 +101,17 @@ impl Default for VideoConfig {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum HidBackend {
/// USB OTG HID gadget
Otg,
/// CH9329 serial HID controller
Ch9329,
/// Disabled
#[default]
None,
}
impl Default for HidBackend {
fn default() -> Self {
Self::None
}
}
/// OTG USB device descriptor configuration
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -163,15 +144,15 @@ impl Default for OtgDescriptorConfig {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum OtgHidProfile {
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
#[default]
#[serde(alias = "full_no_msd")]
Full,
/// Full HID device set without MSD
FullNoMsd,
/// Full HID device set without consumer control
#[serde(alias = "full_no_consumer_no_msd")]
FullNoConsumer,
/// Full HID device set without consumer control and MSD
FullNoConsumerNoMsd,
/// Legacy profile: only keyboard
LegacyKeyboard,
/// Legacy profile: only relative mouse
@@ -180,15 +161,52 @@ pub enum OtgHidProfile {
Custom,
}
impl Default for OtgHidProfile {
fn default() -> Self {
Self::Full
/// OTG endpoint budget policy.
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum OtgEndpointBudget {
/// Derive a safe default from the selected UDC.
#[default]
Auto,
/// Limit OTG gadget functions to 5 endpoints.
Five,
/// Limit OTG gadget functions to 6 endpoints.
Six,
/// Do not impose a software endpoint budget.
Unlimited,
}
impl OtgEndpointBudget {
pub fn default_for_udc_name(udc: Option<&str>) -> Self {
if udc.is_some_and(crate::otg::configfs::is_low_endpoint_udc) {
Self::Five
} else {
Self::Six
}
}
pub fn resolved(self, udc: Option<&str>) -> Self {
match self {
Self::Auto => Self::default_for_udc_name(udc),
other => other,
}
}
pub fn endpoint_limit(self, udc: Option<&str>) -> Option<u8> {
match self.resolved(udc) {
Self::Five => Some(5),
Self::Six => Some(6),
Self::Unlimited => None,
Self::Auto => unreachable!("auto budget must be resolved before use"),
}
}
}
/// OTG HID function selection (used when profile is Custom)
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct OtgHidFunctions {
pub keyboard: bool,
@@ -237,6 +255,26 @@ impl OtgHidFunctions {
pub fn is_empty(&self) -> bool {
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
}
pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 {
let mut endpoints = 0;
if self.keyboard {
endpoints += 1;
if keyboard_leds {
endpoints += 1;
}
}
if self.mouse_relative {
endpoints += 1;
}
if self.mouse_absolute {
endpoints += 1;
}
if self.consumer {
endpoints += 1;
}
endpoints
}
}
impl Default for OtgHidFunctions {
@@ -246,12 +284,21 @@ impl Default for OtgHidFunctions {
}
impl OtgHidProfile {
pub fn from_legacy_str(value: &str) -> Option<Self> {
match value {
"full" | "full_no_msd" => Some(Self::Full),
"full_no_consumer" | "full_no_consumer_no_msd" => Some(Self::FullNoConsumer),
"legacy_keyboard" => Some(Self::LegacyKeyboard),
"legacy_mouse_relative" => Some(Self::LegacyMouseRelative),
"custom" => Some(Self::Custom),
_ => None,
}
}
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
match self {
Self::Full => OtgHidFunctions::full(),
Self::FullNoMsd => OtgHidFunctions::full(),
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
Self::Custom => custom.clone(),
@@ -266,10 +313,6 @@ impl OtgHidProfile {
pub struct HidConfig {
/// HID backend type
pub backend: HidBackend,
/// OTG keyboard device path
pub otg_keyboard: String,
/// OTG mouse device path
pub otg_mouse: String,
/// OTG UDC (USB Device Controller) name
pub otg_udc: Option<String>,
/// OTG USB device descriptor configuration
@@ -278,9 +321,15 @@ pub struct HidConfig {
/// OTG HID function profile
#[serde(default)]
pub otg_profile: OtgHidProfile,
/// OTG endpoint budget policy
#[serde(default)]
pub otg_endpoint_budget: OtgEndpointBudget,
/// OTG HID function selection (used when profile is Custom)
#[serde(default)]
pub otg_functions: OtgHidFunctions,
/// Enable keyboard LED/status feedback for OTG keyboard
#[serde(default)]
pub otg_keyboard_leds: bool,
/// CH9329 serial port
pub ch9329_port: String,
/// CH9329 baud rate
@@ -293,12 +342,12 @@ impl Default for HidConfig {
fn default() -> Self {
Self {
backend: HidBackend::None,
otg_keyboard: "/dev/hidg0".to_string(),
otg_mouse: "/dev/hidg1".to_string(),
otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(),
otg_endpoint_budget: OtgEndpointBudget::default(),
otg_functions: OtgHidFunctions::default(),
otg_keyboard_leds: false,
ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600,
mouse_absolute: true,
@@ -310,6 +359,62 @@ impl HidConfig {
pub fn effective_otg_functions(&self) -> OtgHidFunctions {
self.otg_profile.resolve_functions(&self.otg_functions)
}
pub fn resolved_otg_udc(&self) -> Option<String> {
crate::otg::configfs::resolve_udc_name(self.otg_udc.as_deref())
}
pub fn resolved_otg_endpoint_budget(&self) -> OtgEndpointBudget {
self.otg_endpoint_budget
.resolved(self.resolved_otg_udc().as_deref())
}
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
self.otg_endpoint_budget
.endpoint_limit(self.resolved_otg_udc().as_deref())
}
pub fn effective_otg_keyboard_leds(&self) -> bool {
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
}
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
self.effective_otg_functions()
}
pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 {
let functions = self.effective_otg_functions();
let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds());
if msd_enabled {
endpoints += 2;
}
endpoints
}
pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> {
if self.backend != HidBackend::Otg {
return Ok(());
}
let functions = self.effective_otg_functions();
if functions.is_empty() {
return Err(crate::error::AppError::BadRequest(
"OTG HID functions cannot be empty".to_string(),
));
}
let required = self.effective_otg_required_endpoints(msd_enabled);
if let Some(limit) = self.resolved_otg_endpoint_limit() {
if required > limit {
return Err(crate::error::AppError::BadRequest(format!(
"OTG selection requires {} endpoints, but the configured limit is {}",
required, limit
)));
}
}
Ok(())
}
}
/// MSD configuration
@@ -360,6 +465,7 @@ pub use crate::atx::{ActiveLevel, AtxDriverType, AtxKeyConfig, AtxLedConfig};
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct AtxConfig {
/// Enable ATX functionality
pub enabled: bool,
@@ -373,18 +479,6 @@ pub struct AtxConfig {
pub wol_interface: String,
}
impl Default for AtxConfig {
fn default() -> Self {
Self {
enabled: false,
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
wol_interface: String::new(),
}
}
}
impl AtxConfig {
/// Convert to AtxControllerConfig for the controller
pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig {
@@ -427,16 +521,62 @@ impl Default for AudioConfig {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum StreamMode {
/// WebRTC with H264/H265
WebRTC,
/// MJPEG over HTTP
#[default]
Mjpeg,
}
impl Default for StreamMode {
/// RTSP output codec
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum RtspCodec {
#[default]
H264,
H265,
}
/// RTSP configuration
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RtspConfig {
/// Enable RTSP output
pub enabled: bool,
/// Bind IP address
pub bind: String,
/// RTSP TCP listen port
pub port: u16,
/// Stream path (without leading slash)
pub path: String,
/// Allow only one client connection at a time
pub allow_one_client: bool,
/// Output codec (H264/H265)
pub codec: RtspCodec,
/// Optional username for authentication
pub username: Option<String>,
/// Optional password for authentication
#[typeshare(skip)]
pub password: Option<String>,
}
impl Default for RtspConfig {
fn default() -> Self {
Self::Mjpeg
Self {
enabled: false,
bind: "0.0.0.0".to_string(),
port: 8554,
path: "live".to_string(),
allow_one_client: true,
codec: RtspCodec::H264,
username: None,
password: None,
}
}
}
@@ -444,8 +584,10 @@ impl Default for StreamMode {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum EncoderType {
/// Auto-detect best encoder
#[default]
Auto,
/// Software encoder (libx264)
Software,
@@ -463,12 +605,6 @@ pub enum EncoderType {
V4l2m2m,
}
impl Default for EncoderType {
fn default() -> Self {
Self::Auto
}
}
impl EncoderType {
/// Convert to EncoderBackend for registry queries
pub fn to_backend(&self) -> Option<crate::video::encoder::registry::EncoderBackend> {

View File

@@ -82,7 +82,6 @@ impl ConfigStore {
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
@@ -121,6 +120,26 @@ impl ConfigStore {
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS wol_history (
mac_address TEXT PRIMARY KEY,
updated_at INTEGER NOT NULL
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at
ON wol_history(updated_at DESC)
"#,
)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -7,7 +7,7 @@ pub mod types;
pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
VideoDeviceInfo,
TtydDeviceInfo, VideoDeviceInfo,
};
use tokio::sync::broadcast;
@@ -15,6 +15,39 @@ use tokio::sync::broadcast;
/// Event channel capacity (ring buffer size)
const EVENT_CHANNEL_CAPACITY: usize = 256;
const EXACT_TOPICS: &[&str] = &[
"stream.mode_switching",
"stream.state_changed",
"stream.config_changing",
"stream.config_applied",
"stream.device_lost",
"stream.reconnecting",
"stream.recovered",
"stream.webrtc_ready",
"stream.stats_update",
"stream.mode_changed",
"stream.mode_ready",
"webrtc.ice_candidate",
"webrtc.ice_complete",
"msd.upload_progress",
"msd.download_progress",
"system.device_info",
"error",
];
const PREFIX_TOPICS: &[&str] = &["stream.*", "webrtc.*", "msd.*", "system.*"];
fn make_sender() -> broadcast::Sender<SystemEvent> {
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
tx
}
fn topic_prefix(event_name: &str) -> Option<String> {
event_name
.split_once('.')
.map(|(prefix, _)| format!("{}.*", prefix))
}
/// Global event bus for broadcasting system events
///
/// The event bus uses tokio's broadcast channel to distribute events
@@ -43,13 +76,31 @@ const EVENT_CHANNEL_CAPACITY: usize = 256;
/// ```
pub struct EventBus {
tx: broadcast::Sender<SystemEvent>,
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
prefix_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
device_info_dirty_tx: broadcast::Sender<()>,
}
impl EventBus {
/// Create a new event bus
pub fn new() -> Self {
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Self { tx }
let tx = make_sender();
let exact_topics = EXACT_TOPICS
.iter()
.map(|topic| (*topic, make_sender()))
.collect();
let prefix_topics = PREFIX_TOPICS
.iter()
.map(|topic| (*topic, make_sender()))
.collect();
let (device_info_dirty_tx, _dirty_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Self {
tx,
exact_topics,
prefix_topics,
device_info_dirty_tx,
}
}
/// Publish an event to all subscribers
@@ -57,6 +108,18 @@ impl EventBus {
/// If there are no active subscribers, the event is silently dropped.
/// This is by design - events are fire-and-forget notifications.
pub fn publish(&self, event: SystemEvent) {
let event_name = event.event_name();
if let Some(tx) = self.exact_topics.get(event_name) {
let _ = tx.send(event.clone());
}
if let Some(prefix) = topic_prefix(event_name) {
if let Some(tx) = self.prefix_topics.get(prefix.as_str()) {
let _ = tx.send(event.clone());
}
}
// If no subscribers, send returns Err which is normal
let _ = self.tx.send(event);
}
@@ -70,6 +133,35 @@ impl EventBus {
self.tx.subscribe()
}
/// Subscribe to a specific topic.
///
/// Supports exact event names, namespace wildcards like `stream.*`, and
/// `*` for the full event stream.
pub fn subscribe_topic(&self, topic: &str) -> Option<broadcast::Receiver<SystemEvent>> {
if topic == "*" {
return Some(self.tx.subscribe());
}
if topic.ends_with(".*") {
return self.prefix_topics.get(topic).map(|tx| tx.subscribe());
}
self.exact_topics.get(topic).map(|tx| tx.subscribe())
}
/// Mark the device-info snapshot as stale.
///
/// This is an internal trigger used to refresh the latest `system.device_info`
/// snapshot without exposing another public WebSocket event.
pub fn mark_device_info_dirty(&self) {
let _ = self.device_info_dirty_tx.send(());
}
/// Subscribe to internal device-info refresh triggers.
pub fn subscribe_device_info_dirty(&self) -> broadcast::Receiver<()> {
self.device_info_dirty_tx.subscribe()
}
/// Get the current number of active subscribers
///
/// Useful for monitoring and debugging.
@@ -110,17 +202,50 @@ mod tests {
assert_eq!(bus.subscriber_count(), 2);
bus.publish(SystemEvent::SystemError {
module: "test".to_string(),
severity: "info".to_string(),
message: "test message".to_string(),
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: Some("/dev/video0".to_string()),
});
let event1 = rx1.recv().await.unwrap();
let event2 = rx2.recv().await.unwrap();
assert!(matches!(event1, SystemEvent::SystemError { .. }));
assert!(matches!(event2, SystemEvent::SystemError { .. }));
assert!(matches!(event1, SystemEvent::StreamStateChanged { .. }));
assert!(matches!(event2, SystemEvent::StreamStateChanged { .. }));
}
#[tokio::test]
async fn test_subscribe_topic_exact() {
let bus = EventBus::new();
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: None,
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
}
#[tokio::test]
async fn test_subscribe_topic_prefix() {
let bus = EventBus::new();
let mut rx = bus.subscribe_topic("stream.*").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: None,
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
}
#[test]
fn test_subscribe_topic_unknown() {
let bus = EventBus::new();
assert!(bus.subscribe_topic("unknown.topic").is_none());
}
#[test]
@@ -129,10 +254,9 @@ mod tests {
assert_eq!(bus.subscriber_count(), 0);
// Should not panic when publishing with no subscribers
bus.publish(SystemEvent::SystemError {
module: "test".to_string(),
severity: "info".to_string(),
message: "test".to_string(),
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: None,
});
}
}

View File

@@ -2,12 +2,10 @@
//!
//! Defines all event types that can be broadcast through the event bus.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::atx::PowerStatus;
use crate::msd::MsdMode;
use crate::hid::LedState;
// ============================================================================
// Device Info Structures (for system.device_info event)
@@ -45,12 +43,20 @@ pub struct HidDeviceInfo {
pub backend: String,
/// Whether backend is initialized and ready
pub initialized: bool,
/// Whether backend is currently online
pub online: bool,
/// Whether absolute mouse positioning is supported
pub supports_absolute_mouse: bool,
/// Whether keyboard LED/status feedback is enabled.
pub keyboard_leds_enabled: bool,
/// Last known keyboard LED state.
pub led_state: LedState,
/// Device path (e.g., serial port for CH9329)
pub device: Option<String>,
/// Error message if any, None if OK
pub error: Option<String>,
/// Error code if any, None if OK
pub error_code: Option<String>,
}
/// MSD device information
@@ -100,6 +106,15 @@ pub struct AudioDeviceInfo {
pub error: Option<String>,
}
/// ttyd status information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TtydDeviceInfo {
/// Whether ttyd binary is available
pub available: bool,
/// Whether ttyd is currently running
pub running: bool,
}
/// Per-client statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientStats {
@@ -124,6 +139,7 @@ pub struct ClientStats {
/// ```
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "event", content = "data")]
#[allow(clippy::large_enum_variant)]
pub enum SystemEvent {
// ============================================================================
// Video Stream Events
@@ -274,89 +290,9 @@ pub enum SystemEvent {
mode: String,
},
// ============================================================================
// HID Events
// ============================================================================
/// HID backend state changed
#[serde(rename = "hid.state_changed")]
HidStateChanged {
/// Backend type: "otg", "ch9329", "none"
backend: String,
/// Whether backend is initialized and ready
initialized: bool,
/// Error message if any, None if OK
error: Option<String>,
/// Error code for programmatic handling: "epipe", "eagain", "port_not_found", etc.
error_code: Option<String>,
},
/// HID backend is being switched
#[serde(rename = "hid.backend_switching")]
HidBackendSwitching {
/// Current backend
from: String,
/// New backend
to: String,
},
/// HID device lost (device file missing or I/O error)
#[serde(rename = "hid.device_lost")]
HidDeviceLost {
/// Backend type: "otg", "ch9329"
backend: String,
/// Device path that was lost (e.g., /dev/hidg0 or /dev/ttyUSB0)
device: Option<String>,
/// Human-readable reason for loss
reason: String,
/// Error code: "epipe", "eshutdown", "eagain", "enxio", "port_not_found", "io_error"
error_code: String,
},
/// HID device is reconnecting
#[serde(rename = "hid.reconnecting")]
HidReconnecting {
/// Backend type: "otg", "ch9329"
backend: String,
/// Current retry attempt number
attempt: u32,
},
/// HID device has recovered after error
#[serde(rename = "hid.recovered")]
HidRecovered {
/// Backend type: "otg", "ch9329"
backend: String,
},
// ============================================================================
// MSD (Mass Storage Device) Events
// ============================================================================
/// MSD state changed
#[serde(rename = "msd.state_changed")]
MsdStateChanged {
/// Operating mode
mode: MsdMode,
/// Whether storage is connected to target
connected: bool,
},
/// Image has been mounted
#[serde(rename = "msd.image_mounted")]
MsdImageMounted {
/// Image ID
image_id: String,
/// Image filename
image_name: String,
/// Image size in bytes
size: u64,
/// Mount as CD-ROM (read-only)
cdrom: bool,
},
/// Image has been unmounted
#[serde(rename = "msd.image_unmounted")]
MsdImageUnmounted,
/// File upload progress (for large file uploads)
#[serde(rename = "msd.upload_progress")]
MsdUploadProgress {
@@ -391,132 +327,6 @@ pub enum SystemEvent {
status: String,
},
/// USB gadget connection status changed (host connected/disconnected)
#[serde(rename = "msd.usb_status_changed")]
MsdUsbStatusChanged {
/// Whether host is connected to USB device
connected: bool,
/// USB device state from kernel (e.g., "configured", "not attached")
device_state: String,
},
/// MSD operation error (configfs, image mount, etc.)
#[serde(rename = "msd.error")]
MsdError {
/// Human-readable reason for error
reason: String,
/// Error code: "configfs_error", "image_not_found", "mount_failed", "io_error"
error_code: String,
},
/// MSD has recovered after error
#[serde(rename = "msd.recovered")]
MsdRecovered,
// ============================================================================
// ATX (Power Control) Events
// ============================================================================
/// ATX power state changed
#[serde(rename = "atx.state_changed")]
AtxStateChanged {
/// Power status
power_status: PowerStatus,
},
/// ATX action was executed
#[serde(rename = "atx.action_executed")]
AtxActionExecuted {
/// Action: "short", "long", "reset"
action: String,
/// When the action was executed
timestamp: DateTime<Utc>,
},
// ============================================================================
// Audio Events
// ============================================================================
/// Audio state changed (streaming started/stopped)
#[serde(rename = "audio.state_changed")]
AudioStateChanged {
/// Whether audio is currently streaming
streaming: bool,
/// Current device (None if stopped)
device: Option<String>,
},
/// Audio device was selected
#[serde(rename = "audio.device_selected")]
AudioDeviceSelected {
/// Selected device name
device: String,
},
/// Audio quality was changed
#[serde(rename = "audio.quality_changed")]
AudioQualityChanged {
/// New quality setting: "voice", "balanced", "high"
quality: String,
},
/// Audio device lost (capture error or device disconnected)
#[serde(rename = "audio.device_lost")]
AudioDeviceLost {
/// Audio device name (e.g., "hw:0,0")
device: Option<String>,
/// Human-readable reason for loss
reason: String,
/// Error code: "device_busy", "device_disconnected", "capture_error", "io_error"
error_code: String,
},
/// Audio device is reconnecting
#[serde(rename = "audio.reconnecting")]
AudioReconnecting {
/// Current retry attempt number
attempt: u32,
},
/// Audio device has recovered after error
#[serde(rename = "audio.recovered")]
AudioRecovered {
/// Audio device name
device: Option<String>,
},
// ============================================================================
// System Events
// ============================================================================
/// A device was added (hot-plug)
#[serde(rename = "system.device_added")]
SystemDeviceAdded {
/// Device type: "video", "audio", "hid", etc.
device_type: String,
/// Device path
device_path: String,
/// Device name/description
device_name: String,
},
/// A device was removed (hot-unplug)
#[serde(rename = "system.device_removed")]
SystemDeviceRemoved {
/// Device type
device_type: String,
/// Device path that was removed
device_path: String,
},
/// System error or warning
#[serde(rename = "system.error")]
SystemError {
/// Module that generated the error: "stream", "hid", "msd", "atx"
module: String,
/// Severity: "warning", "error", "critical"
severity: String,
/// Error message
message: String,
},
/// Complete device information (sent on WebSocket connect and state changes)
#[serde(rename = "system.device_info")]
DeviceInfo {
@@ -530,6 +340,8 @@ pub enum SystemEvent {
atx: Option<AtxDeviceInfo>,
/// Audio device information (None if audio not enabled)
audio: Option<AudioDeviceInfo>,
/// ttyd status information
ttyd: TtydDeviceInfo,
},
/// WebSocket error notification (for connection-level errors like lag)
@@ -557,30 +369,8 @@ impl SystemEvent {
Self::StreamModeReady { .. } => "stream.mode_ready",
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
Self::WebRTCIceComplete { .. } => "webrtc.ice_complete",
Self::HidStateChanged { .. } => "hid.state_changed",
Self::HidBackendSwitching { .. } => "hid.backend_switching",
Self::HidDeviceLost { .. } => "hid.device_lost",
Self::HidReconnecting { .. } => "hid.reconnecting",
Self::HidRecovered { .. } => "hid.recovered",
Self::MsdStateChanged { .. } => "msd.state_changed",
Self::MsdImageMounted { .. } => "msd.image_mounted",
Self::MsdImageUnmounted => "msd.image_unmounted",
Self::MsdUploadProgress { .. } => "msd.upload_progress",
Self::MsdDownloadProgress { .. } => "msd.download_progress",
Self::MsdUsbStatusChanged { .. } => "msd.usb_status_changed",
Self::MsdError { .. } => "msd.error",
Self::MsdRecovered => "msd.recovered",
Self::AtxStateChanged { .. } => "atx.state_changed",
Self::AtxActionExecuted { .. } => "atx.action_executed",
Self::AudioStateChanged { .. } => "audio.state_changed",
Self::AudioDeviceSelected { .. } => "audio.device_selected",
Self::AudioQualityChanged { .. } => "audio.quality_changed",
Self::AudioDeviceLost { .. } => "audio.device_lost",
Self::AudioReconnecting { .. } => "audio.reconnecting",
Self::AudioRecovered { .. } => "audio.recovered",
Self::SystemDeviceAdded { .. } => "system.device_added",
Self::SystemDeviceRemoved { .. } => "system.device_removed",
Self::SystemError { .. } => "system.error",
Self::DeviceInfo { .. } => "system.device_info",
Self::Error { .. } => "error",
}
@@ -619,14 +409,6 @@ mod tests {
device: Some("/dev/video0".to_string()),
};
assert_eq!(event.event_name(), "stream.state_changed");
let event = SystemEvent::MsdImageMounted {
image_id: "123".to_string(),
image_name: "ubuntu.iso".to_string(),
size: 1024,
cdrom: true,
};
assert_eq!(event.event_name(), "msd.image_mounted");
}
#[test]

View File

@@ -10,6 +10,7 @@ use tokio::process::{Child, Command};
use tokio::sync::RwLock;
use super::types::*;
use crate::events::EventBus;
/// Maximum number of log lines to keep per extension
const LOG_BUFFER_SIZE: usize = 200;
@@ -31,6 +32,7 @@ pub struct ExtensionManager {
processes: RwLock<HashMap<ExtensionId, ExtensionProcess>>,
/// Cached availability status (checked once at startup)
availability: HashMap<ExtensionId, bool>,
event_bus: RwLock<Option<Arc<EventBus>>>,
}
impl Default for ExtensionManager {
@@ -51,6 +53,22 @@ impl ExtensionManager {
Self {
processes: RwLock::new(HashMap::new()),
availability,
event_bus: RwLock::new(None),
}
}
/// Set event bus for ttyd status notifications.
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
*self.event_bus.write().await = Some(event_bus);
}
async fn mark_ttyd_status_dirty(&self, id: ExtensionId) {
if id != ExtensionId::Ttyd {
return;
}
if let Some(ref event_bus) = *self.event_bus.read().await {
event_bus.mark_device_info_dirty();
}
}
@@ -65,17 +83,38 @@ impl ExtensionManager {
return ExtensionStatus::Unavailable;
}
let processes = self.processes.read().await;
match processes.get(&id) {
Some(proc) => {
if let Some(pid) = proc.child.id() {
ExtensionStatus::Running { pid }
} else {
ExtensionStatus::Stopped
}
let mut processes = self.processes.write().await;
let exited = {
let Some(proc) = processes.get_mut(&id) else {
return ExtensionStatus::Stopped;
};
match proc.child.try_wait() {
Ok(Some(status)) => {
tracing::info!("Extension {} exited with status {}", id, status);
true
}
Ok(None) => {
return match proc.child.id() {
Some(pid) => ExtensionStatus::Running { pid },
None => ExtensionStatus::Stopped,
};
}
Err(e) => {
tracing::warn!("Failed to query status for {}: {}", id, e);
return match proc.child.id() {
Some(pid) => ExtensionStatus::Running { pid },
None => ExtensionStatus::Stopped,
};
}
}
};
if exited {
processes.remove(&id);
}
ExtensionStatus::Stopped
}
/// Start an extension with the given configuration
@@ -134,6 +173,8 @@ impl ExtensionManager {
let mut processes = self.processes.write().await;
processes.insert(id, ExtensionProcess { child, logs });
drop(processes);
self.mark_ttyd_status_dirty(id).await;
Ok(())
}
@@ -146,6 +187,8 @@ impl ExtensionManager {
if let Err(e) = proc.child.kill().await {
tracing::warn!("Failed to kill {}: {}", id, e);
}
drop(processes);
self.mark_ttyd_status_dirty(id).await;
}
Ok(())
}
@@ -230,13 +273,6 @@ impl ExtensionManager {
"-W".to_string(), // Writable (allow input)
];
// Add credential if set (still useful for additional security layer)
if let Some(ref cred) = c.credential {
if !cred.is_empty() {
args.extend(["-c".to_string(), cred.clone()]);
}
}
// Add shell as last argument
args.push(c.shell.clone());
Ok(args)

View File

@@ -102,9 +102,6 @@ pub struct TtydConfig {
pub port: u16,
/// Shell to execute
pub shell: String,
/// Credential in format "user:password" (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub credential: Option<String>,
}
impl Default for TtydConfig {
@@ -113,7 +110,6 @@ impl Default for TtydConfig {
enabled: false,
port: 7681,
shell: "/bin/bash".to_string(),
credential: None,
}
}
}
@@ -149,6 +145,7 @@ impl Default for GostcConfig {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct EasytierConfig {
/// Enable auto-start
pub enabled: bool,
@@ -165,18 +162,6 @@ pub struct EasytierConfig {
pub virtual_ip: Option<String>,
}
impl Default for EasytierConfig {
fn default() -> Self {
Self {
enabled: false,
network_name: String::new(),
network_secret: String::new(),
peer_urls: Vec::new(),
virtual_ip: None,
}
}
}
/// Combined extensions configuration
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]

View File

@@ -2,7 +2,9 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use super::otg::LedState;
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
use crate::error::Result;
@@ -14,6 +16,7 @@ fn default_ch9329_baud_rate() -> u32 {
/// HID backend type
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[derive(Default)]
pub enum HidBackendType {
/// USB OTG gadget mode
Otg,
@@ -26,15 +29,10 @@ pub enum HidBackendType {
baud_rate: u32,
},
/// No HID backend (disabled)
#[default]
None,
}
impl Default for HidBackendType {
fn default() -> Self {
Self::None
}
}
impl HidBackendType {
/// Check if OTG backend is available on this system
pub fn otg_available() -> bool {
@@ -79,12 +77,32 @@ impl HidBackendType {
}
}
/// Current runtime status reported by a HID backend.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HidBackendRuntimeSnapshot {
/// Whether the backend has been initialized and can accept requests.
pub initialized: bool,
/// Whether the backend is currently online and communicating successfully.
pub online: bool,
/// Whether absolute mouse positioning is supported.
pub supports_absolute_mouse: bool,
/// Whether keyboard LED/status feedback is currently enabled.
pub keyboard_leds_enabled: bool,
/// Last known keyboard LED state.
pub led_state: LedState,
/// Screen resolution for absolute mouse mode.
pub screen_resolution: Option<(u32, u32)>,
/// Device identifier associated with the backend, if any.
pub device: Option<String>,
/// Current user-facing error, if any.
pub error: Option<String>,
/// Current programmatic error code, if any.
pub error_code: Option<String>,
}
/// HID backend trait
#[async_trait]
pub trait HidBackend: Send + Sync {
/// Get backend name
fn name(&self) -> &'static str;
/// Initialize the backend
async fn init(&self) -> Result<()>;
@@ -108,15 +126,11 @@ pub trait HidBackend: Send + Sync {
/// Shutdown the backend
async fn shutdown(&self) -> Result<()>;
/// Check if backend supports absolute mouse positioning
fn supports_absolute_mouse(&self) -> bool {
false
}
/// Get the current backend runtime snapshot.
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
/// Get screen resolution (for absolute mouse)
fn screen_resolution(&self) -> Option<(u32, u32)> {
None
}
/// Subscribe to backend runtime changes.
fn subscribe_runtime(&self) -> watch::Receiver<()>;
/// Set screen resolution (for absolute mouse)
fn set_screen_resolution(&mut self, _width: u32, _height: u32) {}

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
//!
//! Keyboard event (type 0x01):
//! - Byte 1: Event type (0x00 = down, 0x01 = up)
//! - Byte 2: Key code (USB HID usage code or JS keyCode)
//! - Byte 2: Canonical key code (stable One-KVM key id aligned with HID usage)
//! - Byte 3: Modifiers bitmask
//! - Bit 0: Left Ctrl
//! - Bit 1: Left Shift
@@ -38,7 +38,8 @@ use tracing::warn;
use super::types::ConsumerEvent;
use super::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
MouseEventType,
};
/// Message types
@@ -101,7 +102,13 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
}
};
let key = data[1];
let key = match CanonicalKey::from_hid_usage(data[1]) {
Some(key) => key,
None => {
warn!("Unknown canonical keyboard key code: 0x{:02X}", data[1]);
return None;
}
};
let modifiers_byte = data[2];
let modifiers = KeyboardModifiers {
@@ -119,7 +126,6 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
event_type,
key,
modifiers,
is_usb_hid: false, // WebRTC datachannel sends JS keycodes
}))
}
@@ -193,7 +199,12 @@ pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
let modifiers = event.modifiers.to_hid_byte();
vec![MSG_KEYBOARD, event_type, event.key, modifiers]
vec![
MSG_KEYBOARD,
event_type,
event.key.to_hid_usage(),
modifiers,
]
}
/// Encode a mouse event to binary format (for sending to client if needed)
@@ -242,7 +253,7 @@ mod tests {
match event {
HidChannelEvent::Keyboard(kb) => {
assert!(matches!(kb.event_type, KeyEventType::Down));
assert_eq!(kb.key, 0x04);
assert_eq!(kb.key, CanonicalKey::KeyA);
assert!(kb.modifiers.left_ctrl);
assert!(!kb.modifiers.left_shift);
}
@@ -269,7 +280,7 @@ mod tests {
fn test_encode_keyboard() {
let event = KeyboardEvent {
event_type: KeyEventType::Down,
key: 0x04,
key: CanonicalKey::KeyA,
modifiers: KeyboardModifiers {
left_ctrl: true,
left_shift: false,
@@ -280,7 +291,6 @@ mod tests {
right_alt: false,
right_meta: false,
},
is_usb_hid: false,
};
let encoded = encode_keyboard_event(&event);

409
src/hid/keyboard.rs Normal file
View File

@@ -0,0 +1,409 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Shared canonical keyboard key identifiers used across frontend and backend.
///
/// The enum names intentionally mirror `KeyboardEvent.code` style values so the
/// browser, virtual keyboard, and HID backend can all speak the same language.
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CanonicalKey {
KeyA,
KeyB,
KeyC,
KeyD,
KeyE,
KeyF,
KeyG,
KeyH,
KeyI,
KeyJ,
KeyK,
KeyL,
KeyM,
KeyN,
KeyO,
KeyP,
KeyQ,
KeyR,
KeyS,
KeyT,
KeyU,
KeyV,
KeyW,
KeyX,
KeyY,
KeyZ,
Digit1,
Digit2,
Digit3,
Digit4,
Digit5,
Digit6,
Digit7,
Digit8,
Digit9,
Digit0,
Enter,
Escape,
Backspace,
Tab,
Space,
Minus,
Equal,
BracketLeft,
BracketRight,
Backslash,
Semicolon,
Quote,
Backquote,
Comma,
Period,
Slash,
CapsLock,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
PrintScreen,
ScrollLock,
Pause,
Insert,
Home,
PageUp,
Delete,
End,
PageDown,
ArrowRight,
ArrowLeft,
ArrowDown,
ArrowUp,
NumLock,
NumpadDivide,
NumpadMultiply,
NumpadSubtract,
NumpadAdd,
NumpadEnter,
Numpad1,
Numpad2,
Numpad3,
Numpad4,
Numpad5,
Numpad6,
Numpad7,
Numpad8,
Numpad9,
Numpad0,
NumpadDecimal,
IntlBackslash,
ContextMenu,
F13,
F14,
F15,
F16,
F17,
F18,
F19,
F20,
F21,
F22,
F23,
F24,
ControlLeft,
ShiftLeft,
AltLeft,
MetaLeft,
ControlRight,
ShiftRight,
AltRight,
MetaRight,
}
impl CanonicalKey {
/// Convert the canonical key to a stable wire code.
///
/// The wire code intentionally matches the USB HID usage for keyboard page
/// keys so existing low-level behavior stays intact while the semantic type
/// becomes explicit.
pub const fn to_hid_usage(self) -> u8 {
match self {
Self::KeyA => 0x04,
Self::KeyB => 0x05,
Self::KeyC => 0x06,
Self::KeyD => 0x07,
Self::KeyE => 0x08,
Self::KeyF => 0x09,
Self::KeyG => 0x0A,
Self::KeyH => 0x0B,
Self::KeyI => 0x0C,
Self::KeyJ => 0x0D,
Self::KeyK => 0x0E,
Self::KeyL => 0x0F,
Self::KeyM => 0x10,
Self::KeyN => 0x11,
Self::KeyO => 0x12,
Self::KeyP => 0x13,
Self::KeyQ => 0x14,
Self::KeyR => 0x15,
Self::KeyS => 0x16,
Self::KeyT => 0x17,
Self::KeyU => 0x18,
Self::KeyV => 0x19,
Self::KeyW => 0x1A,
Self::KeyX => 0x1B,
Self::KeyY => 0x1C,
Self::KeyZ => 0x1D,
Self::Digit1 => 0x1E,
Self::Digit2 => 0x1F,
Self::Digit3 => 0x20,
Self::Digit4 => 0x21,
Self::Digit5 => 0x22,
Self::Digit6 => 0x23,
Self::Digit7 => 0x24,
Self::Digit8 => 0x25,
Self::Digit9 => 0x26,
Self::Digit0 => 0x27,
Self::Enter => 0x28,
Self::Escape => 0x29,
Self::Backspace => 0x2A,
Self::Tab => 0x2B,
Self::Space => 0x2C,
Self::Minus => 0x2D,
Self::Equal => 0x2E,
Self::BracketLeft => 0x2F,
Self::BracketRight => 0x30,
Self::Backslash => 0x31,
Self::Semicolon => 0x33,
Self::Quote => 0x34,
Self::Backquote => 0x35,
Self::Comma => 0x36,
Self::Period => 0x37,
Self::Slash => 0x38,
Self::CapsLock => 0x39,
Self::F1 => 0x3A,
Self::F2 => 0x3B,
Self::F3 => 0x3C,
Self::F4 => 0x3D,
Self::F5 => 0x3E,
Self::F6 => 0x3F,
Self::F7 => 0x40,
Self::F8 => 0x41,
Self::F9 => 0x42,
Self::F10 => 0x43,
Self::F11 => 0x44,
Self::F12 => 0x45,
Self::PrintScreen => 0x46,
Self::ScrollLock => 0x47,
Self::Pause => 0x48,
Self::Insert => 0x49,
Self::Home => 0x4A,
Self::PageUp => 0x4B,
Self::Delete => 0x4C,
Self::End => 0x4D,
Self::PageDown => 0x4E,
Self::ArrowRight => 0x4F,
Self::ArrowLeft => 0x50,
Self::ArrowDown => 0x51,
Self::ArrowUp => 0x52,
Self::NumLock => 0x53,
Self::NumpadDivide => 0x54,
Self::NumpadMultiply => 0x55,
Self::NumpadSubtract => 0x56,
Self::NumpadAdd => 0x57,
Self::NumpadEnter => 0x58,
Self::Numpad1 => 0x59,
Self::Numpad2 => 0x5A,
Self::Numpad3 => 0x5B,
Self::Numpad4 => 0x5C,
Self::Numpad5 => 0x5D,
Self::Numpad6 => 0x5E,
Self::Numpad7 => 0x5F,
Self::Numpad8 => 0x60,
Self::Numpad9 => 0x61,
Self::Numpad0 => 0x62,
Self::NumpadDecimal => 0x63,
Self::IntlBackslash => 0x64,
Self::ContextMenu => 0x65,
Self::F13 => 0x68,
Self::F14 => 0x69,
Self::F15 => 0x6A,
Self::F16 => 0x6B,
Self::F17 => 0x6C,
Self::F18 => 0x6D,
Self::F19 => 0x6E,
Self::F20 => 0x6F,
Self::F21 => 0x70,
Self::F22 => 0x71,
Self::F23 => 0x72,
Self::F24 => 0x73,
Self::ControlLeft => 0xE0,
Self::ShiftLeft => 0xE1,
Self::AltLeft => 0xE2,
Self::MetaLeft => 0xE3,
Self::ControlRight => 0xE4,
Self::ShiftRight => 0xE5,
Self::AltRight => 0xE6,
Self::MetaRight => 0xE7,
}
}
/// Convert a wire code / USB HID usage to its canonical key.
pub const fn from_hid_usage(usage: u8) -> Option<Self> {
match usage {
0x04 => Some(Self::KeyA),
0x05 => Some(Self::KeyB),
0x06 => Some(Self::KeyC),
0x07 => Some(Self::KeyD),
0x08 => Some(Self::KeyE),
0x09 => Some(Self::KeyF),
0x0A => Some(Self::KeyG),
0x0B => Some(Self::KeyH),
0x0C => Some(Self::KeyI),
0x0D => Some(Self::KeyJ),
0x0E => Some(Self::KeyK),
0x0F => Some(Self::KeyL),
0x10 => Some(Self::KeyM),
0x11 => Some(Self::KeyN),
0x12 => Some(Self::KeyO),
0x13 => Some(Self::KeyP),
0x14 => Some(Self::KeyQ),
0x15 => Some(Self::KeyR),
0x16 => Some(Self::KeyS),
0x17 => Some(Self::KeyT),
0x18 => Some(Self::KeyU),
0x19 => Some(Self::KeyV),
0x1A => Some(Self::KeyW),
0x1B => Some(Self::KeyX),
0x1C => Some(Self::KeyY),
0x1D => Some(Self::KeyZ),
0x1E => Some(Self::Digit1),
0x1F => Some(Self::Digit2),
0x20 => Some(Self::Digit3),
0x21 => Some(Self::Digit4),
0x22 => Some(Self::Digit5),
0x23 => Some(Self::Digit6),
0x24 => Some(Self::Digit7),
0x25 => Some(Self::Digit8),
0x26 => Some(Self::Digit9),
0x27 => Some(Self::Digit0),
0x28 => Some(Self::Enter),
0x29 => Some(Self::Escape),
0x2A => Some(Self::Backspace),
0x2B => Some(Self::Tab),
0x2C => Some(Self::Space),
0x2D => Some(Self::Minus),
0x2E => Some(Self::Equal),
0x2F => Some(Self::BracketLeft),
0x30 => Some(Self::BracketRight),
0x31 => Some(Self::Backslash),
0x33 => Some(Self::Semicolon),
0x34 => Some(Self::Quote),
0x35 => Some(Self::Backquote),
0x36 => Some(Self::Comma),
0x37 => Some(Self::Period),
0x38 => Some(Self::Slash),
0x39 => Some(Self::CapsLock),
0x3A => Some(Self::F1),
0x3B => Some(Self::F2),
0x3C => Some(Self::F3),
0x3D => Some(Self::F4),
0x3E => Some(Self::F5),
0x3F => Some(Self::F6),
0x40 => Some(Self::F7),
0x41 => Some(Self::F8),
0x42 => Some(Self::F9),
0x43 => Some(Self::F10),
0x44 => Some(Self::F11),
0x45 => Some(Self::F12),
0x46 => Some(Self::PrintScreen),
0x47 => Some(Self::ScrollLock),
0x48 => Some(Self::Pause),
0x49 => Some(Self::Insert),
0x4A => Some(Self::Home),
0x4B => Some(Self::PageUp),
0x4C => Some(Self::Delete),
0x4D => Some(Self::End),
0x4E => Some(Self::PageDown),
0x4F => Some(Self::ArrowRight),
0x50 => Some(Self::ArrowLeft),
0x51 => Some(Self::ArrowDown),
0x52 => Some(Self::ArrowUp),
0x53 => Some(Self::NumLock),
0x54 => Some(Self::NumpadDivide),
0x55 => Some(Self::NumpadMultiply),
0x56 => Some(Self::NumpadSubtract),
0x57 => Some(Self::NumpadAdd),
0x58 => Some(Self::NumpadEnter),
0x59 => Some(Self::Numpad1),
0x5A => Some(Self::Numpad2),
0x5B => Some(Self::Numpad3),
0x5C => Some(Self::Numpad4),
0x5D => Some(Self::Numpad5),
0x5E => Some(Self::Numpad6),
0x5F => Some(Self::Numpad7),
0x60 => Some(Self::Numpad8),
0x61 => Some(Self::Numpad9),
0x62 => Some(Self::Numpad0),
0x63 => Some(Self::NumpadDecimal),
0x64 => Some(Self::IntlBackslash),
0x65 => Some(Self::ContextMenu),
0x68 => Some(Self::F13),
0x69 => Some(Self::F14),
0x6A => Some(Self::F15),
0x6B => Some(Self::F16),
0x6C => Some(Self::F17),
0x6D => Some(Self::F18),
0x6E => Some(Self::F19),
0x6F => Some(Self::F20),
0x70 => Some(Self::F21),
0x71 => Some(Self::F22),
0x72 => Some(Self::F23),
0x73 => Some(Self::F24),
0xE0 => Some(Self::ControlLeft),
0xE1 => Some(Self::ShiftLeft),
0xE2 => Some(Self::AltLeft),
0xE3 => Some(Self::MetaLeft),
0xE4 => Some(Self::ControlRight),
0xE5 => Some(Self::ShiftRight),
0xE6 => Some(Self::AltRight),
0xE7 => Some(Self::MetaRight),
_ => None,
}
}
pub const fn is_modifier(self) -> bool {
matches!(
self,
Self::ControlLeft
| Self::ShiftLeft
| Self::AltLeft
| Self::MetaLeft
| Self::ControlRight
| Self::ShiftRight
| Self::AltRight
| Self::MetaRight
)
}
pub const fn modifier_bit(self) -> Option<u8> {
match self {
Self::ControlLeft => Some(0x01),
Self::ShiftLeft => Some(0x02),
Self::AltLeft => Some(0x04),
Self::MetaLeft => Some(0x08),
Self::ControlRight => Some(0x10),
Self::ShiftRight => Some(0x20),
Self::AltRight => Some(0x40),
Self::MetaRight => Some(0x80),
_ => None,
}
}
}

View File

@@ -1,430 +0,0 @@
//! USB HID keyboard key codes mapping
//!
//! This module provides mapping between JavaScript key codes and USB HID usage codes.
//! Reference: USB HID Usage Tables 1.12, Section 10 (Keyboard/Keypad Page)
/// USB HID key codes (Usage Page 0x07)
#[allow(dead_code)]
pub mod usb {
// Letters A-Z (0x04 - 0x1D)
pub const KEY_A: u8 = 0x04;
pub const KEY_B: u8 = 0x05;
pub const KEY_C: u8 = 0x06;
pub const KEY_D: u8 = 0x07;
pub const KEY_E: u8 = 0x08;
pub const KEY_F: u8 = 0x09;
pub const KEY_G: u8 = 0x0A;
pub const KEY_H: u8 = 0x0B;
pub const KEY_I: u8 = 0x0C;
pub const KEY_J: u8 = 0x0D;
pub const KEY_K: u8 = 0x0E;
pub const KEY_L: u8 = 0x0F;
pub const KEY_M: u8 = 0x10;
pub const KEY_N: u8 = 0x11;
pub const KEY_O: u8 = 0x12;
pub const KEY_P: u8 = 0x13;
pub const KEY_Q: u8 = 0x14;
pub const KEY_R: u8 = 0x15;
pub const KEY_S: u8 = 0x16;
pub const KEY_T: u8 = 0x17;
pub const KEY_U: u8 = 0x18;
pub const KEY_V: u8 = 0x19;
pub const KEY_W: u8 = 0x1A;
pub const KEY_X: u8 = 0x1B;
pub const KEY_Y: u8 = 0x1C;
pub const KEY_Z: u8 = 0x1D;
// Numbers 1-9, 0 (0x1E - 0x27)
pub const KEY_1: u8 = 0x1E;
pub const KEY_2: u8 = 0x1F;
pub const KEY_3: u8 = 0x20;
pub const KEY_4: u8 = 0x21;
pub const KEY_5: u8 = 0x22;
pub const KEY_6: u8 = 0x23;
pub const KEY_7: u8 = 0x24;
pub const KEY_8: u8 = 0x25;
pub const KEY_9: u8 = 0x26;
pub const KEY_0: u8 = 0x27;
// Control keys
pub const KEY_ENTER: u8 = 0x28;
pub const KEY_ESCAPE: u8 = 0x29;
pub const KEY_BACKSPACE: u8 = 0x2A;
pub const KEY_TAB: u8 = 0x2B;
pub const KEY_SPACE: u8 = 0x2C;
pub const KEY_MINUS: u8 = 0x2D;
pub const KEY_EQUAL: u8 = 0x2E;
pub const KEY_LEFT_BRACKET: u8 = 0x2F;
pub const KEY_RIGHT_BRACKET: u8 = 0x30;
pub const KEY_BACKSLASH: u8 = 0x31;
pub const KEY_HASH: u8 = 0x32; // Non-US # and ~
pub const KEY_SEMICOLON: u8 = 0x33;
pub const KEY_APOSTROPHE: u8 = 0x34;
pub const KEY_GRAVE: u8 = 0x35;
pub const KEY_COMMA: u8 = 0x36;
pub const KEY_PERIOD: u8 = 0x37;
pub const KEY_SLASH: u8 = 0x38;
pub const KEY_CAPS_LOCK: u8 = 0x39;
// Function keys F1-F12
pub const KEY_F1: u8 = 0x3A;
pub const KEY_F2: u8 = 0x3B;
pub const KEY_F3: u8 = 0x3C;
pub const KEY_F4: u8 = 0x3D;
pub const KEY_F5: u8 = 0x3E;
pub const KEY_F6: u8 = 0x3F;
pub const KEY_F7: u8 = 0x40;
pub const KEY_F8: u8 = 0x41;
pub const KEY_F9: u8 = 0x42;
pub const KEY_F10: u8 = 0x43;
pub const KEY_F11: u8 = 0x44;
pub const KEY_F12: u8 = 0x45;
// Special keys
pub const KEY_PRINT_SCREEN: u8 = 0x46;
pub const KEY_SCROLL_LOCK: u8 = 0x47;
pub const KEY_PAUSE: u8 = 0x48;
pub const KEY_INSERT: u8 = 0x49;
pub const KEY_HOME: u8 = 0x4A;
pub const KEY_PAGE_UP: u8 = 0x4B;
pub const KEY_DELETE: u8 = 0x4C;
pub const KEY_END: u8 = 0x4D;
pub const KEY_PAGE_DOWN: u8 = 0x4E;
pub const KEY_RIGHT_ARROW: u8 = 0x4F;
pub const KEY_LEFT_ARROW: u8 = 0x50;
pub const KEY_DOWN_ARROW: u8 = 0x51;
pub const KEY_UP_ARROW: u8 = 0x52;
// Numpad
pub const KEY_NUM_LOCK: u8 = 0x53;
pub const KEY_NUMPAD_DIVIDE: u8 = 0x54;
pub const KEY_NUMPAD_MULTIPLY: u8 = 0x55;
pub const KEY_NUMPAD_MINUS: u8 = 0x56;
pub const KEY_NUMPAD_PLUS: u8 = 0x57;
pub const KEY_NUMPAD_ENTER: u8 = 0x58;
pub const KEY_NUMPAD_1: u8 = 0x59;
pub const KEY_NUMPAD_2: u8 = 0x5A;
pub const KEY_NUMPAD_3: u8 = 0x5B;
pub const KEY_NUMPAD_4: u8 = 0x5C;
pub const KEY_NUMPAD_5: u8 = 0x5D;
pub const KEY_NUMPAD_6: u8 = 0x5E;
pub const KEY_NUMPAD_7: u8 = 0x5F;
pub const KEY_NUMPAD_8: u8 = 0x60;
pub const KEY_NUMPAD_9: u8 = 0x61;
pub const KEY_NUMPAD_0: u8 = 0x62;
pub const KEY_NUMPAD_DECIMAL: u8 = 0x63;
// Additional keys
pub const KEY_NON_US_BACKSLASH: u8 = 0x64;
pub const KEY_APPLICATION: u8 = 0x65; // Context menu
pub const KEY_POWER: u8 = 0x66;
pub const KEY_NUMPAD_EQUAL: u8 = 0x67;
// F13-F24
pub const KEY_F13: u8 = 0x68;
pub const KEY_F14: u8 = 0x69;
pub const KEY_F15: u8 = 0x6A;
pub const KEY_F16: u8 = 0x6B;
pub const KEY_F17: u8 = 0x6C;
pub const KEY_F18: u8 = 0x6D;
pub const KEY_F19: u8 = 0x6E;
pub const KEY_F20: u8 = 0x6F;
pub const KEY_F21: u8 = 0x70;
pub const KEY_F22: u8 = 0x71;
pub const KEY_F23: u8 = 0x72;
pub const KEY_F24: u8 = 0x73;
// Modifier keys (these are handled separately in the modifier byte)
pub const KEY_LEFT_CTRL: u8 = 0xE0;
pub const KEY_LEFT_SHIFT: u8 = 0xE1;
pub const KEY_LEFT_ALT: u8 = 0xE2;
pub const KEY_LEFT_META: u8 = 0xE3;
pub const KEY_RIGHT_CTRL: u8 = 0xE4;
pub const KEY_RIGHT_SHIFT: u8 = 0xE5;
pub const KEY_RIGHT_ALT: u8 = 0xE6;
pub const KEY_RIGHT_META: u8 = 0xE7;
}
/// JavaScript key codes (event.keyCode / event.code)
#[allow(dead_code)]
pub mod js {
// Letters
pub const KEY_A: u8 = 65;
pub const KEY_B: u8 = 66;
pub const KEY_C: u8 = 67;
pub const KEY_D: u8 = 68;
pub const KEY_E: u8 = 69;
pub const KEY_F: u8 = 70;
pub const KEY_G: u8 = 71;
pub const KEY_H: u8 = 72;
pub const KEY_I: u8 = 73;
pub const KEY_J: u8 = 74;
pub const KEY_K: u8 = 75;
pub const KEY_L: u8 = 76;
pub const KEY_M: u8 = 77;
pub const KEY_N: u8 = 78;
pub const KEY_O: u8 = 79;
pub const KEY_P: u8 = 80;
pub const KEY_Q: u8 = 81;
pub const KEY_R: u8 = 82;
pub const KEY_S: u8 = 83;
pub const KEY_T: u8 = 84;
pub const KEY_U: u8 = 85;
pub const KEY_V: u8 = 86;
pub const KEY_W: u8 = 87;
pub const KEY_X: u8 = 88;
pub const KEY_Y: u8 = 89;
pub const KEY_Z: u8 = 90;
// Numbers (top row)
pub const KEY_0: u8 = 48;
pub const KEY_1: u8 = 49;
pub const KEY_2: u8 = 50;
pub const KEY_3: u8 = 51;
pub const KEY_4: u8 = 52;
pub const KEY_5: u8 = 53;
pub const KEY_6: u8 = 54;
pub const KEY_7: u8 = 55;
pub const KEY_8: u8 = 56;
pub const KEY_9: u8 = 57;
// Function keys
pub const KEY_F1: u8 = 112;
pub const KEY_F2: u8 = 113;
pub const KEY_F3: u8 = 114;
pub const KEY_F4: u8 = 115;
pub const KEY_F5: u8 = 116;
pub const KEY_F6: u8 = 117;
pub const KEY_F7: u8 = 118;
pub const KEY_F8: u8 = 119;
pub const KEY_F9: u8 = 120;
pub const KEY_F10: u8 = 121;
pub const KEY_F11: u8 = 122;
pub const KEY_F12: u8 = 123;
// Control keys
pub const KEY_BACKSPACE: u8 = 8;
pub const KEY_TAB: u8 = 9;
pub const KEY_ENTER: u8 = 13;
pub const KEY_SHIFT: u8 = 16;
pub const KEY_CTRL: u8 = 17;
pub const KEY_ALT: u8 = 18;
pub const KEY_PAUSE: u8 = 19;
pub const KEY_CAPS_LOCK: u8 = 20;
pub const KEY_ESCAPE: u8 = 27;
pub const KEY_SPACE: u8 = 32;
pub const KEY_PAGE_UP: u8 = 33;
pub const KEY_PAGE_DOWN: u8 = 34;
pub const KEY_END: u8 = 35;
pub const KEY_HOME: u8 = 36;
pub const KEY_LEFT: u8 = 37;
pub const KEY_UP: u8 = 38;
pub const KEY_RIGHT: u8 = 39;
pub const KEY_DOWN: u8 = 40;
pub const KEY_INSERT: u8 = 45;
pub const KEY_DELETE: u8 = 46;
// Punctuation
pub const KEY_SEMICOLON: u8 = 186;
pub const KEY_EQUAL: u8 = 187;
pub const KEY_COMMA: u8 = 188;
pub const KEY_MINUS: u8 = 189;
pub const KEY_PERIOD: u8 = 190;
pub const KEY_SLASH: u8 = 191;
pub const KEY_GRAVE: u8 = 192;
pub const KEY_LEFT_BRACKET: u8 = 219;
pub const KEY_BACKSLASH: u8 = 220;
pub const KEY_RIGHT_BRACKET: u8 = 221;
pub const KEY_APOSTROPHE: u8 = 222;
// Numpad
pub const KEY_NUMPAD_0: u8 = 96;
pub const KEY_NUMPAD_1: u8 = 97;
pub const KEY_NUMPAD_2: u8 = 98;
pub const KEY_NUMPAD_3: u8 = 99;
pub const KEY_NUMPAD_4: u8 = 100;
pub const KEY_NUMPAD_5: u8 = 101;
pub const KEY_NUMPAD_6: u8 = 102;
pub const KEY_NUMPAD_7: u8 = 103;
pub const KEY_NUMPAD_8: u8 = 104;
pub const KEY_NUMPAD_9: u8 = 105;
pub const KEY_NUMPAD_MULTIPLY: u8 = 106;
pub const KEY_NUMPAD_ADD: u8 = 107;
pub const KEY_NUMPAD_SUBTRACT: u8 = 109;
pub const KEY_NUMPAD_DECIMAL: u8 = 110;
pub const KEY_NUMPAD_DIVIDE: u8 = 111;
// Lock keys
pub const KEY_NUM_LOCK: u8 = 144;
pub const KEY_SCROLL_LOCK: u8 = 145;
// Windows keys
pub const KEY_META_LEFT: u8 = 91;
pub const KEY_META_RIGHT: u8 = 92;
pub const KEY_CONTEXT_MENU: u8 = 93;
}
/// JavaScript keyCode to USB HID keyCode mapping table
/// Using a fixed-size array for O(1) lookup instead of HashMap
/// Index = JavaScript keyCode, Value = USB HID keyCode (0 means unmapped)
static JS_TO_USB_TABLE: [u8; 256] = {
let mut table = [0u8; 256];
// Letters A-Z (JS 65-90 -> USB 0x04-0x1D)
let mut i = 0u8;
while i < 26 {
table[(65 + i) as usize] = usb::KEY_A + i;
i += 1;
}
// Numbers 1-9, 0 (JS 49-57, 48 -> USB 0x1E-0x27)
table[49] = usb::KEY_1; // 1
table[50] = usb::KEY_2; // 2
table[51] = usb::KEY_3; // 3
table[52] = usb::KEY_4; // 4
table[53] = usb::KEY_5; // 5
table[54] = usb::KEY_6; // 6
table[55] = usb::KEY_7; // 7
table[56] = usb::KEY_8; // 8
table[57] = usb::KEY_9; // 9
table[48] = usb::KEY_0; // 0
// Function keys F1-F12 (JS 112-123 -> USB 0x3A-0x45)
table[112] = usb::KEY_F1;
table[113] = usb::KEY_F2;
table[114] = usb::KEY_F3;
table[115] = usb::KEY_F4;
table[116] = usb::KEY_F5;
table[117] = usb::KEY_F6;
table[118] = usb::KEY_F7;
table[119] = usb::KEY_F8;
table[120] = usb::KEY_F9;
table[121] = usb::KEY_F10;
table[122] = usb::KEY_F11;
table[123] = usb::KEY_F12;
// Control keys
table[13] = usb::KEY_ENTER; // Enter
table[27] = usb::KEY_ESCAPE; // Escape
table[8] = usb::KEY_BACKSPACE; // Backspace
table[9] = usb::KEY_TAB; // Tab
table[32] = usb::KEY_SPACE; // Space
table[20] = usb::KEY_CAPS_LOCK; // Caps Lock
// Punctuation (JS codes vary by browser/layout)
table[189] = usb::KEY_MINUS; // -
table[187] = usb::KEY_EQUAL; // =
table[219] = usb::KEY_LEFT_BRACKET; // [
table[221] = usb::KEY_RIGHT_BRACKET; // ]
table[220] = usb::KEY_BACKSLASH; // \
table[186] = usb::KEY_SEMICOLON; // ;
table[222] = usb::KEY_APOSTROPHE; // '
table[192] = usb::KEY_GRAVE; // `
table[188] = usb::KEY_COMMA; // ,
table[190] = usb::KEY_PERIOD; // .
table[191] = usb::KEY_SLASH; // /
// Navigation keys
table[45] = usb::KEY_INSERT;
table[46] = usb::KEY_DELETE;
table[36] = usb::KEY_HOME;
table[35] = usb::KEY_END;
table[33] = usb::KEY_PAGE_UP;
table[34] = usb::KEY_PAGE_DOWN;
// Arrow keys
table[39] = usb::KEY_RIGHT_ARROW;
table[37] = usb::KEY_LEFT_ARROW;
table[40] = usb::KEY_DOWN_ARROW;
table[38] = usb::KEY_UP_ARROW;
// Numpad
table[144] = usb::KEY_NUM_LOCK;
table[111] = usb::KEY_NUMPAD_DIVIDE;
table[106] = usb::KEY_NUMPAD_MULTIPLY;
table[109] = usb::KEY_NUMPAD_MINUS;
table[107] = usb::KEY_NUMPAD_PLUS;
table[96] = usb::KEY_NUMPAD_0;
table[97] = usb::KEY_NUMPAD_1;
table[98] = usb::KEY_NUMPAD_2;
table[99] = usb::KEY_NUMPAD_3;
table[100] = usb::KEY_NUMPAD_4;
table[101] = usb::KEY_NUMPAD_5;
table[102] = usb::KEY_NUMPAD_6;
table[103] = usb::KEY_NUMPAD_7;
table[104] = usb::KEY_NUMPAD_8;
table[105] = usb::KEY_NUMPAD_9;
table[110] = usb::KEY_NUMPAD_DECIMAL;
// Special keys
table[19] = usb::KEY_PAUSE;
table[145] = usb::KEY_SCROLL_LOCK;
table[93] = usb::KEY_APPLICATION; // Context menu
// Modifier keys
table[17] = usb::KEY_LEFT_CTRL;
table[16] = usb::KEY_LEFT_SHIFT;
table[18] = usb::KEY_LEFT_ALT;
table[91] = usb::KEY_LEFT_META; // Left Windows/Command
table[92] = usb::KEY_RIGHT_META; // Right Windows/Command
table
};
/// Convert JavaScript keyCode to USB HID keyCode
///
/// Uses a fixed-size lookup table for O(1) performance.
/// Returns None if the key code is not mapped.
#[inline]
pub fn js_to_usb(js_code: u8) -> Option<u8> {
let usb_code = JS_TO_USB_TABLE[js_code as usize];
if usb_code != 0 {
Some(usb_code)
} else {
None
}
}
/// Check if a key code is a modifier key
pub fn is_modifier_key(usb_code: u8) -> bool {
(0xE0..=0xE7).contains(&usb_code)
}
/// Get modifier bit for a modifier key
pub fn modifier_bit(usb_code: u8) -> Option<u8> {
match usb_code {
usb::KEY_LEFT_CTRL => Some(0x01),
usb::KEY_LEFT_SHIFT => Some(0x02),
usb::KEY_LEFT_ALT => Some(0x04),
usb::KEY_LEFT_META => Some(0x08),
usb::KEY_RIGHT_CTRL => Some(0x10),
usb::KEY_RIGHT_SHIFT => Some(0x20),
usb::KEY_RIGHT_ALT => Some(0x40),
usb::KEY_RIGHT_META => Some(0x80),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_letter_mapping() {
assert_eq!(js_to_usb(65), Some(usb::KEY_A)); // A
assert_eq!(js_to_usb(90), Some(usb::KEY_Z)); // Z
}
#[test]
fn test_number_mapping() {
assert_eq!(js_to_usb(48), Some(usb::KEY_0));
assert_eq!(js_to_usb(49), Some(usb::KEY_1));
}
#[test]
fn test_modifier_key() {
assert!(is_modifier_key(usb::KEY_LEFT_CTRL));
assert!(is_modifier_key(usb::KEY_RIGHT_SHIFT));
assert!(!is_modifier_key(usb::KEY_A));
}
}

View File

@@ -15,14 +15,13 @@ pub mod backend;
pub mod ch9329;
pub mod consumer;
pub mod datachannel;
pub mod keymap;
pub mod monitor;
pub mod keyboard;
pub mod otg;
pub mod types;
pub mod websocket;
pub use backend::{HidBackend, HidBackendType};
pub use monitor::{HidHealthMonitor, HidHealthStatus, HidMonitorConfig};
pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType};
pub use keyboard::CanonicalKey;
pub use otg::LedState;
pub use types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
@@ -33,7 +32,7 @@ pub use types::{
#[derive(Debug, Clone)]
pub struct HidInfo {
/// Backend name
pub name: &'static str,
pub name: String,
/// Whether backend is initialized
pub initialized: bool,
/// Whether absolute mouse positioning is supported
@@ -42,17 +41,100 @@ pub struct HidInfo {
pub screen_resolution: Option<(u32, u32)>,
}
use std::sync::Arc;
/// Unified HID runtime state used by snapshots and events.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HidRuntimeState {
/// Whether a backend is configured and expected to exist.
pub available: bool,
/// Stable backend key: "otg", "ch9329", "none".
pub backend: String,
/// Whether the backend is currently initialized and operational.
pub initialized: bool,
/// Whether the backend is currently online.
pub online: bool,
/// Whether absolute mouse positioning is supported.
pub supports_absolute_mouse: bool,
/// Whether keyboard LED/status feedback is enabled.
pub keyboard_leds_enabled: bool,
/// Last known keyboard LED state.
pub led_state: LedState,
/// Screen resolution for absolute mouse mode.
pub screen_resolution: Option<(u32, u32)>,
/// Device path associated with the backend, if any.
pub device: Option<String>,
/// Current user-facing error, if any.
pub error: Option<String>,
/// Current programmatic error code, if any.
pub error_code: Option<String>,
}
impl HidRuntimeState {
fn from_backend_type(backend_type: &HidBackendType) -> Self {
Self {
available: !matches!(backend_type, HidBackendType::None),
backend: backend_type.name_str().to_string(),
initialized: false,
online: false,
supports_absolute_mouse: false,
keyboard_leds_enabled: false,
led_state: LedState::default(),
screen_resolution: None,
device: device_for_backend_type(backend_type),
error: None,
error_code: None,
}
}
fn from_backend(backend_type: &HidBackendType, snapshot: HidBackendRuntimeSnapshot) -> Self {
Self {
available: !matches!(backend_type, HidBackendType::None),
backend: backend_type.name_str().to_string(),
initialized: snapshot.initialized,
online: snapshot.online,
supports_absolute_mouse: snapshot.supports_absolute_mouse,
keyboard_leds_enabled: snapshot.keyboard_leds_enabled,
led_state: snapshot.led_state,
screen_resolution: snapshot.screen_resolution,
device: snapshot
.device
.or_else(|| device_for_backend_type(backend_type)),
error: snapshot.error,
error_code: snapshot.error_code,
}
}
fn with_error(
backend_type: &HidBackendType,
current: &Self,
reason: impl Into<String>,
error_code: impl Into<String>,
) -> Self {
let mut next = current.clone();
next.available = !matches!(backend_type, HidBackendType::None);
next.backend = backend_type.name_str().to_string();
next.initialized = false;
next.online = false;
next.keyboard_leds_enabled = false;
next.led_state = LedState::default();
next.device = device_for_backend_type(backend_type);
next.error = Some(reason.into());
next.error_code = Some(error_code.into());
next
}
}
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::error::{AppError, Result};
use crate::events::EventBus;
use crate::otg::OtgService;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use std::time::Duration;
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
@@ -74,9 +156,9 @@ pub struct HidController {
/// Backend type (mutable for reload)
backend_type: Arc<RwLock<HidBackendType>>,
/// Event bus for broadcasting state changes (optional)
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
/// Health monitor for error tracking and recovery
monitor: Arc<HidHealthMonitor>,
events: Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
/// Unified HID runtime state.
runtime_state: Arc<RwLock<HidRuntimeState>>,
/// HID event queue sender (non-blocking)
hid_tx: mpsc::Sender<HidEvent>,
/// HID event queue receiver (moved into worker on first start)
@@ -87,8 +169,10 @@ pub struct HidController {
pending_move_flag: Arc<AtomicBool>,
/// Worker task handle
hid_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend availability fast flag
backend_available: AtomicBool,
/// Backend runtime subscription task handle
runtime_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend initialization fast flag
backend_available: Arc<AtomicBool>,
}
impl HidController {
@@ -100,23 +184,24 @@ impl HidController {
Self {
otg_service,
backend: Arc::new(RwLock::new(None)),
backend_type: Arc::new(RwLock::new(backend_type)),
events: tokio::sync::RwLock::new(None),
monitor: Arc::new(HidHealthMonitor::with_defaults()),
backend_type: Arc::new(RwLock::new(backend_type.clone())),
events: Arc::new(tokio::sync::RwLock::new(None)),
runtime_state: Arc::new(RwLock::new(HidRuntimeState::from_backend_type(
&backend_type,
))),
hid_tx,
hid_rx: Mutex::new(Some(hid_rx)),
pending_move: Arc::new(parking_lot::Mutex::new(None)),
pending_move_flag: Arc::new(AtomicBool::new(false)),
hid_worker: Mutex::new(None),
backend_available: AtomicBool::new(false),
runtime_worker: Mutex::new(None),
backend_available: Arc::new(AtomicBool::new(false)),
}
}
/// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<crate::events::EventBus>) {
*self.events.write().await = Some(events.clone());
// Also set event bus on the monitor for health notifications
self.monitor.set_event_bus(events).await;
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Initialize the HID backend
@@ -124,16 +209,15 @@ impl HidController {
let backend_type = self.backend_type.read().await.clone();
let backend: Arc<dyn HidBackend> = match backend_type {
HidBackendType::Otg => {
// Request HID functions from OtgService
let otg_service = self
.otg_service
.as_ref()
.ok_or_else(|| AppError::Internal("OtgService not available".into()))?;
info!("Requesting HID functions from OtgService");
let handles = otg_service.enable_hid().await?;
let handles = otg_service.hid_device_paths().await.ok_or_else(|| {
AppError::Config("OTG HID paths are not available".to_string())
})?;
// Create OtgBackend from handles (no longer manages gadget itself)
info!("Creating OTG HID backend from device paths");
Arc::new(otg::OtgBackend::from_handles(handles)?)
}
@@ -153,12 +237,28 @@ impl HidController {
}
};
backend.init().await?;
if let Err(e) = backend.init().await {
self.backend_available.store(false, Ordering::Release);
let error_state = {
let backend_type = self.backend_type.read().await.clone();
let current = self.runtime_state.read().await.clone();
HidRuntimeState::with_error(
&backend_type,
&current,
format!("Failed to initialize HID backend: {}", e),
"init_failed",
)
};
self.apply_runtime_state(error_state).await;
return Err(e);
}
*self.backend.write().await = Some(backend);
self.backend_available.store(true, Ordering::Release);
self.sync_runtime_state_from_backend().await;
// Start HID event worker (once)
self.start_event_worker().await;
self.restart_runtime_worker().await;
info!("HID backend initialized: {:?}", backend_type);
Ok(())
@@ -167,19 +267,24 @@ impl HidController {
/// Shutdown the HID backend and release resources
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller");
self.stop_runtime_worker().await;
// Close the backend
*self.backend.write().await = None;
if let Some(backend) = self.backend.write().await.take() {
if let Err(e) = backend.shutdown().await {
warn!("Error shutting down HID backend: {}", e);
}
}
self.backend_available.store(false, Ordering::Release);
// If OTG backend, notify OtgService to disable HID
let backend_type = self.backend_type.read().await.clone();
if matches!(backend_type, HidBackendType::Otg) {
if let Some(ref otg_service) = self.otg_service {
info!("Disabling HID functions in OtgService");
otg_service.disable_hid().await?;
}
let mut shutdown_state = HidRuntimeState::from_backend_type(&backend_type);
if matches!(backend_type, HidBackendType::None) {
shutdown_state.available = false;
} else {
shutdown_state.error = Some("HID backend stopped".to_string());
shutdown_state.error_code = Some("shutdown".to_string());
}
self.apply_runtime_state(shutdown_state).await;
info!("HID controller shutdown complete");
Ok(())
@@ -203,7 +308,10 @@ impl HidController {
));
}
if matches!(event.event_type, MouseEventType::Move | MouseEventType::MoveAbs) {
if matches!(
event.event_type,
MouseEventType::Move | MouseEventType::MoveAbs
) {
// Best-effort: drop/merge move events if queue is full
self.enqueue_mouse_move(event)
} else {
@@ -232,7 +340,7 @@ impl HidController {
/// Check if backend is available
pub async fn is_available(&self) -> bool {
self.backend.read().await.is_some()
self.backend_available.load(Ordering::Acquire)
}
/// Get backend type
@@ -242,59 +350,29 @@ impl HidController {
/// Get backend info
pub async fn info(&self) -> Option<HidInfo> {
let backend = self.backend.read().await;
backend.as_ref().map(|b| HidInfo {
name: b.name(),
initialized: true,
supports_absolute_mouse: b.supports_absolute_mouse(),
screen_resolution: b.screen_resolution(),
let state = self.runtime_state.read().await.clone();
if !state.available {
return None;
}
Some(HidInfo {
name: state.backend,
initialized: state.initialized,
supports_absolute_mouse: state.supports_absolute_mouse,
screen_resolution: state.screen_resolution,
})
}
/// Get current state as SystemEvent
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
let backend = self.backend.read().await;
let backend_type = self.backend_type().await;
let (backend_name, initialized) = match backend.as_ref() {
Some(b) => (b.name(), true),
None => (backend_type.name_str(), false),
};
// Include error information from monitor
let (error, error_code) = match self.monitor.status().await {
HidHealthStatus::Error {
reason, error_code, ..
} => (Some(reason), Some(error_code)),
_ => (None, None),
};
crate::events::SystemEvent::HidStateChanged {
backend: backend_name.to_string(),
initialized,
error,
error_code,
}
}
/// Get the health monitor reference
pub fn monitor(&self) -> &Arc<HidHealthMonitor> {
&self.monitor
}
/// Get current health status
pub async fn health_status(&self) -> HidHealthStatus {
self.monitor.status().await
}
/// Check if the HID backend is healthy
pub async fn is_healthy(&self) -> bool {
self.monitor.is_healthy().await
/// Get current HID runtime state snapshot.
pub async fn snapshot(&self) -> HidRuntimeState {
self.runtime_state.read().await.clone()
}
/// Reload the HID backend with new type
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release);
self.stop_runtime_worker().await;
// Shutdown existing backend first
if let Some(backend) = self.backend.write().await.take() {
@@ -319,9 +397,8 @@ impl HidController {
}
};
// Request HID functions from OtgService
match otg_service.enable_hid().await {
Ok(handles) => {
match otg_service.hid_device_paths().await {
Some(handles) => {
// Create OtgBackend from handles
match otg::OtgBackend::from_handles(handles) {
Ok(backend) => {
@@ -333,29 +410,18 @@ impl HidController {
}
Err(e) => {
warn!("Failed to initialize OTG backend: {}", e);
// Cleanup: disable HID in OtgService
if let Err(e2) = otg_service.disable_hid().await {
warn!(
"Failed to cleanup HID after init failure: {}",
e2
);
}
None
}
}
}
Err(e) => {
warn!("Failed to create OTG backend: {}", e);
// Cleanup: disable HID in OtgService
if let Err(e2) = otg_service.disable_hid().await {
warn!("Failed to cleanup HID after creation failure: {}", e2);
}
None
}
}
}
Err(e) => {
warn!("Failed to enable HID in OtgService: {}", e);
None => {
warn!("OTG HID paths are not available");
None
}
}
@@ -393,26 +459,22 @@ impl HidController {
*self.backend.write().await = new_backend;
if matches!(new_backend_type, HidBackendType::None) {
*self.backend_type.write().await = HidBackendType::None;
self.apply_runtime_state(HidRuntimeState::from_backend_type(&HidBackendType::None))
.await;
return Ok(());
}
if self.backend.read().await.is_some() {
info!("HID backend reloaded successfully: {:?}", new_backend_type);
self.backend_available.store(true, Ordering::Release);
self.start_event_worker().await;
// Update backend_type on success
*self.backend_type.write().await = new_backend_type.clone();
// Reset monitor state on successful reload
self.monitor.reset().await;
// Publish HID state changed event
let backend_name = new_backend_type.name_str().to_string();
self.publish_event(crate::events::SystemEvent::HidStateChanged {
backend: backend_name,
initialized: true,
error: None,
error_code: None,
})
.await;
self.sync_runtime_state_from_backend().await;
self.restart_runtime_worker().await;
Ok(())
} else {
@@ -422,14 +484,14 @@ impl HidController {
// Update backend_type even on failure (to reflect the attempted change)
*self.backend_type.write().await = new_backend_type.clone();
// Publish event with initialized=false
self.publish_event(crate::events::SystemEvent::HidStateChanged {
backend: new_backend_type.name_str().to_string(),
initialized: false,
error: Some("Failed to initialize HID backend".to_string()),
error_code: Some("init_failed".to_string()),
})
.await;
let current = self.runtime_state.read().await.clone();
let error_state = HidRuntimeState::with_error(
&new_backend_type,
&current,
"Failed to initialize HID backend",
"init_failed",
);
self.apply_runtime_state(error_state).await;
Err(AppError::Internal(
"Failed to reload HID backend".to_string(),
@@ -437,11 +499,20 @@ impl HidController {
}
}
/// Publish event to event bus if available
async fn publish_event(&self, event: crate::events::SystemEvent) {
if let Some(events) = self.events.read().await.as_ref() {
events.publish(event);
async fn apply_runtime_state(&self, next: HidRuntimeState) {
apply_runtime_state(&self.runtime_state, &self.events, next).await;
}
async fn sync_runtime_state_from_backend(&self) {
let backend_opt = self.backend.read().await.clone();
apply_backend_runtime_state(
&self.backend_type,
&self.runtime_state,
&self.events,
self.backend_available.as_ref(),
backend_opt.as_deref(),
)
.await;
}
async fn start_event_worker(&self) {
@@ -457,8 +528,6 @@ impl HidController {
};
let backend = self.backend.clone();
let monitor = self.monitor.clone();
let backend_type = self.backend_type.clone();
let pending_move = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone();
@@ -470,25 +539,13 @@ impl HidController {
None => break,
};
process_hid_event(
event,
&backend,
&monitor,
&backend_type,
)
.await;
process_hid_event(event, &backend).await;
// After each event, flush latest move if pending
if pending_move_flag.swap(false, Ordering::AcqRel) {
let move_event = { pending_move.lock().take() };
if let Some(move_event) = move_event {
process_hid_event(
HidEvent::Mouse(move_event),
&backend,
&monitor,
&backend_type,
)
.await;
process_hid_event(HidEvent::Mouse(move_event), &backend).await;
}
}
}
@@ -497,6 +554,46 @@ impl HidController {
*worker_guard = Some(handle);
}
async fn restart_runtime_worker(&self) {
self.stop_runtime_worker().await;
let backend_opt = self.backend.read().await.clone();
let Some(backend) = backend_opt else {
return;
};
let mut runtime_rx = backend.subscribe_runtime();
let runtime_state = self.runtime_state.clone();
let events = self.events.clone();
let backend_available = self.backend_available.clone();
let backend_type = self.backend_type.clone();
let handle = tokio::spawn(async move {
loop {
if runtime_rx.changed().await.is_err() {
break;
}
apply_backend_runtime_state(
&backend_type,
&runtime_state,
&events,
backend_available.as_ref(),
Some(backend.as_ref()),
)
.await;
}
});
*self.runtime_worker.lock().await = Some(handle);
}
async fn stop_runtime_worker(&self) {
if let Some(handle) = self.runtime_worker.lock().await.take() {
handle.abort();
}
}
fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()),
@@ -505,9 +602,9 @@ impl HidController {
self.pending_move_flag.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
"HID event queue closed".to_string(),
)),
Err(mpsc::error::TrySendError::Closed(_)) => {
Err(AppError::BadRequest("HID event queue closed".to_string()))
}
}
}
@@ -517,8 +614,10 @@ impl HidController {
Err(mpsc::error::TrySendError::Full(ev)) => {
// For non-move events, wait briefly to avoid dropping critical input
let tx = self.hid_tx.clone();
let send_result =
tokio::time::timeout(Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS), tx.send(ev))
let send_result = tokio::time::timeout(
Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS),
tx.send(ev),
)
.await;
if send_result.is_ok() {
Ok(())
@@ -527,32 +626,44 @@ impl HidController {
Ok(())
}
}
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
"HID event queue closed".to_string(),
)),
Err(mpsc::error::TrySendError::Closed(_)) => {
Err(AppError::BadRequest("HID event queue closed".to_string()))
}
}
}
}
async fn process_hid_event(
event: HidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
monitor: &Arc<HidHealthMonitor>,
async fn apply_backend_runtime_state(
backend_type: &Arc<RwLock<HidBackendType>>,
runtime_state: &Arc<RwLock<HidRuntimeState>>,
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
backend_available: &AtomicBool,
backend: Option<&dyn HidBackend>,
) {
let backend_kind = backend_type.read().await.clone();
let next = match backend {
Some(backend) => HidRuntimeState::from_backend(&backend_kind, backend.runtime_snapshot()),
None => HidRuntimeState::from_backend_type(&backend_kind),
};
backend_available.store(next.initialized, Ordering::Release);
apply_runtime_state(runtime_state, events, next).await;
}
async fn process_hid_event(event: HidEvent, backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>) {
let backend_opt = backend.read().await.clone();
let backend = match backend_opt {
Some(b) => b,
None => return,
};
let backend_for_send = backend.clone();
let result = tokio::task::spawn_blocking(move || {
futures::executor::block_on(async move {
match event {
HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await,
HidEvent::Mouse(ev) => backend.send_mouse(ev).await,
HidEvent::Consumer(ev) => backend.send_consumer(ev).await,
HidEvent::Reset => backend.reset().await,
HidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await,
HidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await,
HidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await,
HidEvent::Reset => backend_for_send.reset().await,
}
})
})
@@ -564,25 +675,9 @@ async fn process_hid_event(
};
match result {
Ok(_) => {
if monitor.is_error().await {
let backend_type = backend_type.read().await;
monitor.report_recovered(backend_type.name_str()).await;
}
}
Ok(_) => {}
Err(e) => {
if let AppError::HidError {
ref backend,
ref reason,
ref error_code,
} = e
{
if error_code != "eagain_retry" {
monitor
.report_error(backend, None, reason, error_code)
.await;
}
}
warn!("HID event processing failed: {}", e);
}
}
}
@@ -592,3 +687,34 @@ impl Default for HidController {
Self::new(HidBackendType::None, None)
}
}
fn device_for_backend_type(backend_type: &HidBackendType) -> Option<String> {
match backend_type {
HidBackendType::Ch9329 { port, .. } => Some(port.clone()),
_ => None,
}
}
async fn apply_runtime_state(
runtime_state: &Arc<RwLock<HidRuntimeState>>,
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
next: HidRuntimeState,
) {
let changed = {
let mut guard = runtime_state.write().await;
if *guard == next {
false
} else {
*guard = next.clone();
true
}
};
if !changed {
return;
}
if let Some(events) = events.read().await.as_ref() {
events.mark_device_info_dirty();
}
}

View File

@@ -1,421 +0,0 @@
//! HID device health monitoring
//!
//! This module provides health monitoring for HID devices, including:
//! - Device connectivity checks
//! - Automatic reconnection on failure
//! - Error tracking and notification
//! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler;
/// HID health status
#[derive(Debug, Clone, PartialEq)]
pub enum HidHealthStatus {
/// Device is healthy and operational
Healthy,
/// Device has an error, attempting recovery
Error {
/// Human-readable error reason
reason: String,
/// Error code for programmatic handling
error_code: String,
/// Number of recovery attempts made
retry_count: u32,
},
/// Device is disconnected
Disconnected,
}
impl Default for HidHealthStatus {
fn default() -> Self {
Self::Healthy
}
}
/// HID health monitor configuration
#[derive(Debug, Clone)]
pub struct HidMonitorConfig {
/// Health check interval in milliseconds
pub check_interval_ms: u64,
/// Retry interval when device is lost (milliseconds)
pub retry_interval_ms: u64,
/// Maximum retry attempts before giving up (0 = infinite)
pub max_retries: u32,
/// Log throttle interval in seconds
pub log_throttle_secs: u64,
/// Recovery cooldown in milliseconds (suppress logs after recovery)
pub recovery_cooldown_ms: u64,
}
impl Default for HidMonitorConfig {
fn default() -> Self {
Self {
check_interval_ms: 1000,
retry_interval_ms: 1000,
max_retries: 0, // infinite retry
log_throttle_secs: 5,
recovery_cooldown_ms: 1000, // 1 second cooldown after recovery
}
}
}
/// HID health monitor
///
/// Monitors HID device health and manages error recovery.
/// Publishes WebSocket events when device status changes.
pub struct HidHealthMonitor {
/// Current health status
status: RwLock<HidHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding
throttler: LogThrottler,
/// Configuration
config: HidMonitorConfig,
/// Whether monitoring is active (reserved for future use)
#[allow(dead_code)]
running: AtomicBool,
/// Current retry count
retry_count: AtomicU32,
/// Last error code (for change detection)
last_error_code: RwLock<Option<String>>,
/// Last recovery timestamp (milliseconds since start, for cooldown)
last_recovery_ms: AtomicU64,
/// Start instant for timing
start_instant: Instant,
}
impl HidHealthMonitor {
/// Create a new HID health monitor with the specified configuration
pub fn new(config: HidMonitorConfig) -> Self {
let throttle_secs = config.log_throttle_secs;
Self {
status: RwLock::new(HidHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs),
config,
running: AtomicBool::new(false),
retry_count: AtomicU32::new(0),
last_error_code: RwLock::new(None),
last_recovery_ms: AtomicU64::new(0),
start_instant: Instant::now(),
}
}
/// Create a new HID health monitor with default configuration
pub fn with_defaults() -> Self {
Self::new(HidMonitorConfig::default())
}
/// Set the event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Report an error from HID operations
///
/// This method is called when an HID operation fails. It:
/// 1. Updates the health status
/// 2. Logs the error (with throttling and cooldown respect)
/// 3. Publishes a WebSocket event if the error is new or changed
///
/// # Arguments
///
/// * `backend` - The HID backend type ("otg" or "ch9329")
/// * `device` - The device path (if known)
/// * `reason` - Human-readable error description
/// * `error_code` - Error code for programmatic handling
pub async fn report_error(
&self,
backend: &str,
device: Option<&str>,
reason: &str,
error_code: &str,
) {
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
// Check if we're in cooldown period after recent recovery
let current_ms = self.start_instant.elapsed().as_millis() as u64;
let last_recovery = self.last_recovery_ms.load(Ordering::Relaxed);
let in_cooldown =
last_recovery > 0 && current_ms < last_recovery + self.config.recovery_cooldown_ms;
// Check if error code changed
let error_changed = {
let last = self.last_error_code.read().await;
last.as_ref().map(|s| s.as_str()) != Some(error_code)
};
// Log with throttling (skip if in cooldown period unless error type changed)
let throttle_key = format!("hid_{}_{}", backend, error_code);
if !in_cooldown && (error_changed || self.throttler.should_log(&throttle_key)) {
warn!(
"HID {} error: {} (code: {}, attempt: {})",
backend, reason, error_code, count
);
}
// Update last error code
*self.last_error_code.write().await = Some(error_code.to_string());
// Update status
*self.status.write().await = HidHealthStatus::Error {
reason: reason.to_string(),
error_code: error_code.to_string(),
retry_count: count,
};
// Publish event (only if error changed or first occurrence, and not in cooldown)
if !in_cooldown && (error_changed || count == 1) {
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::HidDeviceLost {
backend: backend.to_string(),
device: device.map(|s| s.to_string()),
reason: reason.to_string(),
error_code: error_code.to_string(),
});
}
}
}
/// Report that a reconnection attempt is starting
///
/// Publishes a reconnecting event to notify clients.
///
/// # Arguments
///
/// * `backend` - The HID backend type
pub async fn report_reconnecting(&self, backend: &str) {
let attempt = self.retry_count.load(Ordering::Relaxed);
// Only publish every 5 attempts to avoid event spam
if attempt == 1 || attempt % 5 == 0 {
debug!("HID {} reconnecting, attempt {}", backend, attempt);
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::HidReconnecting {
backend: backend.to_string(),
attempt,
});
}
}
}
/// Report that the device has recovered
///
/// This method is called when the HID device successfully reconnects.
/// It resets the error state and publishes a recovery event.
///
/// # Arguments
///
/// * `backend` - The HID backend type
pub async fn report_recovered(&self, backend: &str) {
let prev_status = self.status.read().await.clone();
// Only report recovery if we were in an error state
if prev_status != HidHealthStatus::Healthy {
let retry_count = self.retry_count.load(Ordering::Relaxed);
// Set cooldown timestamp
let current_ms = self.start_instant.elapsed().as_millis() as u64;
self.last_recovery_ms.store(current_ms, Ordering::Relaxed);
// Only log and publish events if there were multiple retries
// (avoid log spam for transient single-retry recoveries)
if retry_count > 1 {
debug!("HID {} recovered after {} retries", backend, retry_count);
// Publish recovery event
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::HidRecovered {
backend: backend.to_string(),
});
// Also publish state changed to indicate healthy state
events.publish(SystemEvent::HidStateChanged {
backend: backend.to_string(),
initialized: true,
error: None,
error_code: None,
});
}
}
// Reset state (always reset, even for single-retry recoveries)
self.retry_count.store(0, Ordering::Relaxed);
*self.last_error_code.write().await = None;
*self.status.write().await = HidHealthStatus::Healthy;
}
}
/// Get the current health status
pub async fn status(&self) -> HidHealthStatus {
self.status.read().await.clone()
}
/// Get the current retry count
pub fn retry_count(&self) -> u32 {
self.retry_count.load(Ordering::Relaxed)
}
/// Check if the monitor is in an error state
pub async fn is_error(&self) -> bool {
matches!(*self.status.read().await, HidHealthStatus::Error { .. })
}
/// Check if the monitor is healthy
pub async fn is_healthy(&self) -> bool {
matches!(*self.status.read().await, HidHealthStatus::Healthy)
}
/// Reset the monitor to healthy state without publishing events
///
/// This is useful during initialization.
pub async fn reset(&self) {
self.retry_count.store(0, Ordering::Relaxed);
*self.last_error_code.write().await = None;
*self.status.write().await = HidHealthStatus::Healthy;
self.throttler.clear_all();
}
/// Get the configuration
pub fn config(&self) -> &HidMonitorConfig {
&self.config
}
/// Check if we should continue retrying
///
/// Returns `false` if max_retries is set and we've exceeded it.
pub fn should_retry(&self) -> bool {
if self.config.max_retries == 0 {
return true; // Infinite retry
}
self.retry_count.load(Ordering::Relaxed) < self.config.max_retries
}
/// Get the retry interval
pub fn retry_interval(&self) -> Duration {
Duration::from_millis(self.config.retry_interval_ms)
}
}
impl Default for HidHealthMonitor {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_initial_status() {
let monitor = HidHealthMonitor::with_defaults();
assert!(monitor.is_healthy().await);
assert!(!monitor.is_error().await);
assert_eq!(monitor.retry_count(), 0);
}
#[tokio::test]
async fn test_report_error() {
let monitor = HidHealthMonitor::with_defaults();
monitor
.report_error("otg", Some("/dev/hidg0"), "Device not found", "enoent")
.await;
assert!(monitor.is_error().await);
assert_eq!(monitor.retry_count(), 1);
if let HidHealthStatus::Error {
reason,
error_code,
retry_count,
} = monitor.status().await
{
assert_eq!(reason, "Device not found");
assert_eq!(error_code, "enoent");
assert_eq!(retry_count, 1);
} else {
panic!("Expected Error status");
}
}
#[tokio::test]
async fn test_report_recovered() {
let monitor = HidHealthMonitor::with_defaults();
// First report an error
monitor
.report_error("ch9329", None, "Port not found", "port_not_found")
.await;
assert!(monitor.is_error().await);
// Then report recovery
monitor.report_recovered("ch9329").await;
assert!(monitor.is_healthy().await);
assert_eq!(monitor.retry_count(), 0);
}
#[tokio::test]
async fn test_retry_count_increments() {
let monitor = HidHealthMonitor::with_defaults();
for i in 1..=5 {
monitor.report_error("otg", None, "Error", "io_error").await;
assert_eq!(monitor.retry_count(), i);
}
}
#[tokio::test]
async fn test_should_retry_infinite() {
let monitor = HidHealthMonitor::new(HidMonitorConfig {
max_retries: 0, // infinite
..Default::default()
});
for _ in 0..100 {
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.should_retry());
}
}
#[tokio::test]
async fn test_should_retry_limited() {
let monitor = HidHealthMonitor::new(HidMonitorConfig {
max_retries: 3,
..Default::default()
});
assert!(monitor.should_retry());
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.should_retry()); // 1 < 3
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.should_retry()); // 2 < 3
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(!monitor.should_retry()); // 3 >= 3
}
#[tokio::test]
async fn test_reset() {
let monitor = HidHealthMonitor::with_defaults();
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.is_error().await);
monitor.reset().await;
assert!(monitor.is_healthy().await);
assert_eq!(monitor.retry_count(), 0);
}
}

View File

@@ -1,10 +1,12 @@
//! OTG USB Gadget HID backend
//!
//! This backend uses Linux USB Gadget API to emulate USB HID devices.
//! It creates and manages three HID devices:
//! - hidg0: Keyboard (8-byte reports, with LED feedback)
//! - hidg1: Relative Mouse (4-byte reports)
//! - hidg2: Absolute Mouse (6-byte reports)
//! It opens the HID gadget device nodes created by `OtgService`.
//! Depending on the configured OTG profile, this may include:
//! - hidg0: Keyboard
//! - hidg1: Relative Mouse
//! - hidg2: Absolute Mouse
//! - hidg3: Consumer Control Keyboard
//!
//! Requirements:
//! - USB OTG/Device controller (UDC)
@@ -20,16 +22,20 @@
use async_trait::async_trait;
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsFd;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tokio::sync::watch;
use tracing::{debug, info, trace, warn};
use super::backend::HidBackend;
use super::keymap;
use super::backend::{HidBackend, HidBackendRuntimeSnapshot};
use super::types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
};
@@ -46,7 +52,7 @@ enum DeviceType {
}
/// Keyboard LED state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct LedState {
/// Num Lock LED
pub num_lock: bool,
@@ -124,24 +130,36 @@ pub struct OtgBackend {
mouse_abs_dev: Mutex<Option<File>>,
/// Consumer control device file
consumer_dev: Mutex<Option<File>>,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds_enabled: bool,
/// Current keyboard state
keyboard_state: Mutex<KeyboardReport>,
/// Current mouse button state
mouse_buttons: AtomicU8,
/// Last known LED state (using parking_lot::RwLock for sync access)
led_state: parking_lot::RwLock<LedState>,
led_state: Arc<parking_lot::RwLock<LedState>>,
/// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access)
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
/// UDC name for state checking (e.g., "fcc00000.usb")
udc_name: parking_lot::RwLock<Option<String>>,
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
/// Whether the backend has been initialized.
initialized: AtomicBool,
/// Whether the device is currently online (UDC configured and devices accessible)
online: AtomicBool,
/// Last backend error state.
last_error: parking_lot::RwLock<Option<(String, String)>>,
/// Last error log time for throttling (using parking_lot for sync)
last_error_log: parking_lot::Mutex<std::time::Instant>,
/// Error count since last successful operation (for log throttling)
error_count: AtomicU8,
/// Consecutive EAGAIN count (for offline threshold detection)
eagain_count: AtomicU8,
/// Runtime change notifier.
runtime_notify_tx: watch::Sender<()>,
/// Runtime monitor stop flag.
runtime_worker_stop: Arc<AtomicBool>,
/// Runtime monitor thread.
runtime_worker: Mutex<Option<thread::JoinHandle<()>>>,
}
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
@@ -153,6 +171,7 @@ impl OtgBackend {
/// This is the ONLY way to create an OtgBackend - it no longer manages
/// the USB gadget itself. The gadget must already be set up by OtgService.
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
Ok(Self {
keyboard_path: paths.keyboard,
mouse_rel_path: paths.mouse_relative,
@@ -162,18 +181,59 @@ impl OtgBackend {
mouse_rel_dev: Mutex::new(None),
mouse_abs_dev: Mutex::new(None),
consumer_dev: Mutex::new(None),
keyboard_leds_enabled: paths.keyboard_leds_enabled,
keyboard_state: Mutex::new(KeyboardReport::default()),
mouse_buttons: AtomicU8::new(0),
led_state: parking_lot::RwLock::new(LedState::default()),
led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
udc_name: parking_lot::RwLock::new(None),
udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
initialized: AtomicBool::new(false),
online: AtomicBool::new(false),
last_error: parking_lot::RwLock::new(None),
last_error_log: parking_lot::Mutex::new(std::time::Instant::now()),
error_count: AtomicU8::new(0),
eagain_count: AtomicU8::new(0),
runtime_notify_tx,
runtime_worker_stop: Arc::new(AtomicBool::new(false)),
runtime_worker: Mutex::new(None),
})
}
fn notify_runtime_changed(&self) {
let _ = self.runtime_notify_tx.send(());
}
fn clear_error(&self) {
let mut error = self.last_error.write();
if error.is_some() {
*error = None;
self.notify_runtime_changed();
}
}
fn record_error(&self, reason: impl Into<String>, error_code: impl Into<String>) {
let reason = reason.into();
let error_code = error_code.into();
let was_online = self.online.swap(false, Ordering::Relaxed);
let mut error = self.last_error.write();
let changed = error.as_ref() != Some(&(reason.clone(), error_code.clone()));
*error = Some((reason, error_code));
drop(error);
if was_online || changed {
self.notify_runtime_changed();
}
}
fn mark_online(&self) {
let was_online = self.online.swap(true, Ordering::Relaxed);
let mut error = self.last_error.write();
let cleared_error = error.take().is_some();
drop(error);
if !was_online || cleared_error {
self.notify_runtime_changed();
}
}
/// Log throttled error message (max once per second)
fn log_throttled_error(&self, msg: &str) {
let mut last_log = self.last_error_log.lock();
@@ -228,7 +288,7 @@ impl OtgBackend {
Ok(false)
}
Ok(_) => Ok(false),
Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e)),
Err(e) => Err(std::io::Error::other(e)),
}
}
@@ -237,13 +297,16 @@ impl OtgBackend {
*self.udc_name.write() = Some(udc.to_string());
}
/// Check if the UDC is in "configured" state
///
/// This is based on PiKVM's `__is_udc_configured()` method.
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget.
pub fn is_udc_configured(&self) -> bool {
let udc_name = self.udc_name.read();
if let Some(ref udc) = *udc_name {
fn read_udc_configured(udc_name: &parking_lot::RwLock<Option<String>>) -> bool {
let current_udc = udc_name.read().clone().or_else(Self::find_udc);
if let Some(udc) = current_udc {
{
let mut guard = udc_name.write();
if guard.as_ref() != Some(&udc) {
*guard = Some(udc.clone());
}
}
let state_path = format!("/sys/class/udc/{}/state", udc);
match fs::read_to_string(&state_path) {
Ok(content) => {
@@ -253,24 +316,20 @@ impl OtgBackend {
}
Err(e) => {
debug!("Failed to read UDC state from {}: {}", state_path, e);
// If we can't read the state, assume it might be configured
// to avoid blocking operations unnecessarily
true
}
}
} else {
// No UDC name set, try to auto-detect
if let Some(udc) = Self::find_udc() {
drop(udc_name);
*self.udc_name.write() = Some(udc.clone());
let state_path = format!("/sys/class/udc/{}/state", udc);
fs::read_to_string(&state_path)
.map(|s| s.trim().to_lowercase() == "configured")
.unwrap_or(true)
} else {
true
}
}
/// Check if the UDC is in "configured" state
///
/// This is based on PiKVM's `__is_udc_configured()` method.
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget.
pub fn is_udc_configured(&self) -> bool {
Self::read_udc_configured(&self.udc_name)
}
/// Find the first available UDC
@@ -286,11 +345,6 @@ impl OtgBackend {
None
}
/// Check if device is online
pub fn is_online(&self) -> bool {
self.online.load(Ordering::Relaxed)
}
/// Ensure a device is open and ready for I/O
///
/// This method is based on PiKVM's `__ensure_device()` pattern:
@@ -308,12 +362,13 @@ impl OtgBackend {
let path = match path_opt {
Some(p) => p,
None => {
self.online.store(false, Ordering::Relaxed);
return Err(AppError::HidError {
let err = AppError::HidError {
backend: "otg".to_string(),
reason: "Device disabled".to_string(),
error_code: "disabled".to_string(),
});
};
self.record_error("Device disabled", "disabled");
return Err(err);
}
};
@@ -328,10 +383,11 @@ impl OtgBackend {
);
*dev = None;
}
self.online.store(false, Ordering::Relaxed);
let reason = format!("Device not found: {}", path.display());
self.record_error(reason.clone(), "enoent");
return Err(AppError::HidError {
backend: "otg".to_string(),
reason: format!("Device not found: {}", path.display()),
reason,
error_code: "enoent".to_string(),
});
}
@@ -346,12 +402,16 @@ impl OtgBackend {
}
Err(e) => {
warn!("Failed to reopen HID device {}: {}", path.display(), e);
self.record_error(
format!("Failed to reopen HID device {}: {}", path.display(), e),
"not_opened",
);
return Err(e);
}
}
}
self.online.store(true, Ordering::Relaxed);
self.mark_online();
Ok(())
}
@@ -372,8 +432,8 @@ impl OtgBackend {
}
/// Convert I/O error to HidError with appropriate error code
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
let error_code = match e.raw_os_error() {
fn io_error_code(e: &std::io::Error) -> &'static str {
match e.raw_os_error() {
Some(32) => "epipe", // EPIPE - broken pipe
Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown
Some(11) => "eagain", // EAGAIN - resource temporarily unavailable
@@ -382,7 +442,11 @@ impl OtgBackend {
Some(5) => "eio", // EIO - I/O error
Some(2) => "enoent", // ENOENT - no such file or directory
_ => "io_error",
};
}
}
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
let error_code = Self::io_error_code(&e);
AppError::HidError {
backend: "otg".to_string(),
@@ -393,21 +457,10 @@ impl OtgBackend {
/// Check if all HID device files exist
pub fn check_devices_exist(&self) -> bool {
self.keyboard_path
.as_ref()
.map_or(true, |p| p.exists())
&& self
.mouse_rel_path
.as_ref()
.map_or(true, |p| p.exists())
&& self
.mouse_abs_path
.as_ref()
.map_or(true, |p| p.exists())
&& self
.consumer_path
.as_ref()
.map_or(true, |p| p.exists())
self.keyboard_path.as_ref().is_none_or(|p| p.exists())
&& self.mouse_rel_path.as_ref().is_none_or(|p| p.exists())
&& self.mouse_abs_path.as_ref().is_none_or(|p| p.exists())
&& self.consumer_path.as_ref().is_none_or(|p| p.exists())
}
/// Get list of missing device paths
@@ -449,7 +502,7 @@ impl OtgBackend {
let data = report.to_bytes();
match self.write_with_timeout(file, &data) {
Ok(true) => {
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
debug!("Sent keyboard report: {:02X?}", data);
Ok(())
@@ -465,10 +518,13 @@ impl OtgBackend {
match error_code {
Some(108) => {
// ESHUTDOWN - endpoint closed, need to reopen device
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
debug!("Keyboard ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write keyboard report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write keyboard report",
@@ -480,9 +536,12 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
warn!("Keyboard write error: {}", e);
self.record_error(
format!("Failed to write keyboard report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write keyboard report",
@@ -518,7 +577,7 @@ impl OtgBackend {
let data = [buttons, dx as u8, dy as u8, wheel as u8];
match self.write_with_timeout(file, &data) {
Ok(true) => {
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
trace!("Sent relative mouse report: {:02X?}", data);
Ok(())
@@ -532,10 +591,13 @@ impl OtgBackend {
match error_code {
Some(108) => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
debug!("Relative mouse ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write mouse report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write mouse report",
@@ -546,9 +608,12 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
warn!("Relative mouse write error: {}", e);
self.record_error(
format!("Failed to write mouse report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write mouse report",
@@ -591,7 +656,7 @@ impl OtgBackend {
];
match self.write_with_timeout(file, &data) {
Ok(true) => {
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
Ok(())
}
@@ -604,10 +669,13 @@ impl OtgBackend {
match error_code {
Some(108) => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
debug!("Absolute mouse ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write mouse report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write mouse report",
@@ -618,9 +686,12 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
warn!("Absolute mouse write error: {}", e);
self.record_error(
format!("Failed to write mouse report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write mouse report",
@@ -659,7 +730,7 @@ impl OtgBackend {
// Send release (0x0000)
let release = [0u8, 0u8];
let _ = self.write_with_timeout(file, &release);
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
Ok(())
}
@@ -671,9 +742,12 @@ impl OtgBackend {
let error_code = e.raw_os_error();
match error_code {
Some(108) => {
self.online.store(false, Ordering::Relaxed);
debug!("Consumer control ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write consumer report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write consumer report",
@@ -684,8 +758,11 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
warn!("Consumer control write error: {}", e);
self.record_error(
format!("Failed to write consumer report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write consumer report",
@@ -708,50 +785,205 @@ impl OtgBackend {
self.send_consumer_report(event.usage)
}
/// Read keyboard LED state (non-blocking)
pub fn read_led_state(&self) -> Result<Option<LedState>> {
let mut dev = self.keyboard_dev.lock();
if let Some(ref mut file) = *dev {
let mut buf = [0u8; 1];
match file.read(&mut buf) {
Ok(1) => {
let state = LedState::from_byte(buf[0]);
// Update LED state (using parking_lot RwLock)
*self.led_state.write() = state;
Ok(Some(state))
}
Ok(_) => Ok(None), // No data available
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(AppError::Internal(format!(
"Failed to read LED state: {}",
e
))),
}
} else {
Ok(None)
}
}
/// Get last known LED state
pub fn led_state(&self) -> LedState {
*self.led_state.read()
}
fn build_runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
let initialized = self.initialized.load(Ordering::Relaxed);
let mut online = initialized && self.online.load(Ordering::Relaxed);
let mut error = self.last_error.read().clone();
if initialized && !self.check_devices_exist() {
online = false;
let missing = self.get_missing_devices();
error = Some((
format!("HID device node missing: {}", missing.join(", ")),
"enoent".to_string(),
));
} else if initialized && !self.is_udc_configured() {
online = false;
error = Some((
"UDC is not in configured state".to_string(),
"udc_not_configured".to_string(),
));
}
HidBackendRuntimeSnapshot {
initialized,
online,
supports_absolute_mouse: self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()),
keyboard_leds_enabled: self.keyboard_leds_enabled,
led_state: self.led_state(),
screen_resolution: *self.screen_resolution.read(),
device: self.udc_name.read().clone(),
error: error.as_ref().map(|(reason, _)| reason.clone()),
error_code: error.as_ref().map(|(_, code)| code.clone()),
}
}
fn poll_keyboard_led_once(
file: &mut Option<File>,
path: &PathBuf,
led_state: &Arc<parking_lot::RwLock<LedState>>,
) -> bool {
if file.is_none() {
match OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
{
Ok(opened) => {
*file = Some(opened);
}
Err(err) => {
warn!(
"Failed to open OTG keyboard LED listener {}: {}",
path.display(),
err
);
thread::sleep(Duration::from_millis(500));
return false;
}
}
}
let Some(file_ref) = file.as_mut() else {
return false;
};
let mut pollfd = [PollFd::new(
file_ref.as_fd(),
PollFlags::POLLIN | PollFlags::POLLERR | PollFlags::POLLHUP,
)];
match poll(&mut pollfd, PollTimeout::from(500u16)) {
Ok(0) => false,
Ok(_) => {
let Some(revents) = pollfd[0].revents() else {
return false;
};
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
*file = None;
return true;
}
if !revents.contains(PollFlags::POLLIN) {
return false;
}
let mut buf = [0u8; 1];
match file_ref.read(&mut buf) {
Ok(1) => {
let next = LedState::from_byte(buf[0]);
let mut guard = led_state.write();
if *guard == next {
false
} else {
*guard = next;
true
}
}
Ok(_) => false,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => false,
Err(err) => {
warn!("OTG keyboard LED listener read failed: {}", err);
*file = None;
true
}
}
}
Err(err) => {
warn!("OTG keyboard LED listener poll failed: {}", err);
*file = None;
true
}
}
}
fn start_runtime_worker(&self) {
let mut worker = self.runtime_worker.lock();
if worker.is_some() {
return;
}
self.runtime_worker_stop.store(false, Ordering::Relaxed);
let stop = self.runtime_worker_stop.clone();
let keyboard_leds_enabled = self.keyboard_leds_enabled;
let keyboard_path = self.keyboard_path.clone();
let led_state = self.led_state.clone();
let udc_name = self.udc_name.clone();
let runtime_notify_tx = self.runtime_notify_tx.clone();
let handle = thread::Builder::new()
.name("otg-runtime-monitor".to_string())
.spawn(move || {
let mut last_udc_configured = Some(Self::read_udc_configured(&udc_name));
let mut keyboard_led_file: Option<File> = None;
while !stop.load(Ordering::Relaxed) {
let mut changed = false;
let current_udc_configured = Self::read_udc_configured(&udc_name);
if last_udc_configured != Some(current_udc_configured) {
last_udc_configured = Some(current_udc_configured);
changed = true;
}
if keyboard_leds_enabled {
if let Some(path) = keyboard_path.as_ref() {
changed |= Self::poll_keyboard_led_once(
&mut keyboard_led_file,
path,
&led_state,
);
} else {
thread::sleep(Duration::from_millis(500));
}
} else {
thread::sleep(Duration::from_millis(500));
}
if changed {
let _ = runtime_notify_tx.send(());
}
}
});
match handle {
Ok(handle) => {
*worker = Some(handle);
}
Err(err) => {
warn!("Failed to spawn OTG runtime monitor: {}", err);
}
}
}
fn stop_runtime_worker(&self) {
self.runtime_worker_stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.runtime_worker.lock().take() {
let _ = handle.join();
}
}
}
#[async_trait]
impl HidBackend for OtgBackend {
fn name(&self) -> &'static str {
"OTG USB Gadget"
}
async fn init(&self) -> Result<()> {
info!("Initializing OTG HID backend");
// Auto-detect UDC name for state checking
// Auto-detect UDC name for state checking only if OtgService did not provide one
if self.udc_name.read().is_none() {
if let Some(udc) = Self::find_udc() {
info!("Auto-detected UDC: {}", udc);
self.set_udc_name(&udc);
}
} else if let Some(udc) = self.udc_name.read().clone() {
info!("Using configured UDC: {}", udc);
}
// Wait for devices to appear (they should already exist from OtgService)
let mut device_paths = Vec::new();
@@ -823,24 +1055,22 @@ impl HidBackend for OtgBackend {
}
// Mark as online if all devices opened successfully
self.online.store(true, Ordering::Relaxed);
self.initialized.store(true, Ordering::Relaxed);
self.notify_runtime_changed();
self.start_runtime_worker();
self.mark_online();
Ok(())
}
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
// Convert JS keycode to USB HID if needed (skip if already USB HID)
let usb_key = if event.is_usb_hid {
event.key
} else {
keymap::js_to_usb(event.key).unwrap_or(event.key)
};
let usb_key = event.key.to_hid_usage();
// Handle modifier keys separately
if keymap::is_modifier_key(usb_key) {
if event.key.is_modifier() {
let mut state = self.keyboard_state.lock();
if let Some(bit) = keymap::modifier_bit(usb_key) {
if let Some(bit) = event.key.modifier_bit() {
match event.event_type {
KeyEventType::Down => state.modifiers |= bit,
KeyEventType::Up => state.modifiers &= !bit,
@@ -936,6 +1166,8 @@ impl HidBackend for OtgBackend {
}
async fn shutdown(&self) -> Result<()> {
self.stop_runtime_worker();
// Reset before closing
self.reset().await?;
@@ -946,27 +1178,30 @@ impl HidBackend for OtgBackend {
*self.consumer_dev.lock() = None;
// Gadget cleanup is handled by OtgService, not here
self.initialized.store(false, Ordering::Relaxed);
self.online.store(false, Ordering::Relaxed);
self.clear_error();
self.notify_runtime_changed();
info!("OTG backend shutdown");
Ok(())
}
fn supports_absolute_mouse(&self) -> bool {
self.mouse_abs_path
.as_ref()
.map_or(false, |p| p.exists())
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
self.build_runtime_snapshot()
}
fn subscribe_runtime(&self) -> watch::Receiver<()> {
self.runtime_notify_tx.subscribe()
}
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
self.send_consumer_report(event.usage)
}
fn screen_resolution(&self) -> Option<(u32, u32)> {
*self.screen_resolution.read()
}
fn set_screen_resolution(&mut self, width: u32, height: u32) {
*self.screen_resolution.write() = Some((width, height));
self.notify_runtime_changed();
}
}
@@ -983,6 +1218,10 @@ pub fn is_otg_available() -> bool {
/// Implement Drop for OtgBackend to close device files
impl Drop for OtgBackend {
fn drop(&mut self) {
self.runtime_worker_stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.runtime_worker.get_mut().take() {
let _ = handle.join();
}
// Close device files
// Note: Gadget cleanup is handled by OtgService, not here
*self.keyboard_dev.lock() = None;

View File

@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize};
use super::keyboard::CanonicalKey;
/// Keyboard event type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -105,34 +107,29 @@ pub struct KeyboardEvent {
/// Event type (down/up)
#[serde(rename = "type")]
pub event_type: KeyEventType,
/// Key code (USB HID usage code or JavaScript key code)
pub key: u8,
/// Canonical keyboard key identifier shared across frontend and backend
pub key: CanonicalKey,
/// Modifier keys state
#[serde(default)]
pub modifiers: KeyboardModifiers,
/// If true, key is already USB HID code (skip js_to_usb conversion)
#[serde(default)]
pub is_usb_hid: bool,
}
impl KeyboardEvent {
/// Create a key down event (JS keycode, needs conversion)
pub fn key_down(key: u8, modifiers: KeyboardModifiers) -> Self {
/// Create a key down event
pub fn key_down(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
Self {
event_type: KeyEventType::Down,
key,
modifiers,
is_usb_hid: false,
}
}
/// Create a key up event (JS keycode, needs conversion)
pub fn key_up(key: u8, modifiers: KeyboardModifiers) -> Self {
/// Create a key up event
pub fn key_up(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
Self {
event_type: KeyEventType::Up,
key,
modifiers,
is_usb_hid: false,
}
}
}

View File

@@ -14,9 +14,11 @@ pub mod hid;
pub mod modules;
pub mod msd;
pub mod otg;
pub mod rtsp;
pub mod rustdesk;
pub mod state;
pub mod stream;
pub mod update;
pub mod utils;
pub mod video;
pub mod web;

View File

@@ -7,7 +7,7 @@ use axum_server::tls_rustls::RustlsConfig;
use clap::{Parser, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::broadcast;
use tokio::sync::{broadcast, mpsc};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use one_kvm::atx::AtxController;
@@ -18,10 +18,15 @@ use one_kvm::events::EventBus;
use one_kvm::extensions::ExtensionManager;
use one_kvm::hid::{HidBackendType, HidController};
use one_kvm::msd::MsdController;
use one_kvm::otg::{configfs, OtgService};
use one_kvm::otg::OtgService;
use one_kvm::rtsp::RtspService;
use one_kvm::rustdesk::RustDeskService;
use one_kvm::state::AppState;
use one_kvm::update::UpdateService;
use one_kvm::utils::bind_tcp_listener;
use one_kvm::video::codec_constraints::{
enforce_constraints_with_stream_manager, StreamCodecConstraints,
};
use one_kvm::video::format::{PixelFormat, Resolution};
use one_kvm::video::{Streamer, VideoStreamManager};
use one_kvm::web;
@@ -158,7 +163,11 @@ async fn main() -> anyhow::Result<()> {
}
let bind_ips = resolve_bind_addresses(&config.web)?;
let scheme = if config.web.https_enabled { "https" } else { "http" };
let scheme = if config.web.https_enabled {
"https"
} else {
"http"
};
let bind_port = if config.web.https_enabled {
config.web.https_port
} else {
@@ -310,32 +319,9 @@ async fn main() -> anyhow::Result<()> {
let otg_service = Arc::new(OtgService::new());
tracing::info!("OTG Service created");
// Pre-enable OTG functions to avoid gadget recreation (prevents kernel crashes)
let will_use_otg_hid = matches!(config.hid.backend, config::HidBackend::Otg);
let will_use_msd = config.msd.enabled;
if will_use_otg_hid {
let mut hid_functions = config.hid.effective_otg_functions();
if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) {
if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
tracing::warn!(
"UDC {} has low endpoint resources, disabling consumer control",
udc
);
hid_functions.consumer = false;
}
}
if let Err(e) = otg_service.update_hid_functions(hid_functions).await {
tracing::warn!("Failed to apply HID functions: {}", e);
}
if let Err(e) = otg_service.enable_hid().await {
tracing::warn!("Failed to pre-enable HID: {}", e);
}
}
if will_use_msd {
if let Err(e) = otg_service.enable_msd().await {
tracing::warn!("Failed to pre-enable MSD: {}", e);
}
// Reconcile OTG once from the persisted config so controllers only consume its result.
if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await {
tracing::warn!("Failed to apply OTG config: {}", e);
}
// Create HID controller based on config
@@ -530,7 +516,24 @@ async fn main() -> anyhow::Result<()> {
None
};
// Create RTSP service (optional, based on config)
let rtsp = if 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
};
// Create application state
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
let state = AppState::new(
config_store.clone(),
session_store,
@@ -542,12 +545,16 @@ async fn main() -> anyhow::Result<()> {
atx,
audio,
rustdesk.clone(),
rtsp.clone(),
extensions.clone(),
events.clone(),
update_service,
shutdown_tx.clone(),
data_dir.clone(),
);
extensions.set_event_bus(events.clone()).await;
// Start RustDesk service if enabled
if let Some(ref service) = rustdesk {
if let Err(e) = service.start().await {
@@ -573,6 +580,30 @@ async fn main() -> anyhow::Result<()> {
}
}
// Start RTSP service if enabled
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");
}
}
// Enforce startup codec constraints (e.g. RTSP/RustDesk locks)
{
let runtime_config = state.config.get();
let constraints = StreamCodecConstraints::from_config(&runtime_config);
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),
}
}
// Start enabled extensions
{
let ext_config = config_store.get();
@@ -594,6 +625,8 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Extension health check task started");
}
state.publish_device_info().await;
// Start device info broadcast task
// This monitors state change events and broadcasts DeviceInfo to all clients
spawn_device_info_broadcaster(state.clone(), events);
@@ -646,7 +679,7 @@ async fn main() -> anyhow::Result<()> {
let server = axum_server::from_tcp_rustls(listener, tls_config.clone())?
.serve(app.clone().into_make_service());
servers.push(async move { server.await });
servers.push(server);
}
tokio::select! {
@@ -712,10 +745,13 @@ fn init_logging(level: LogLevel, verbose_count: u8) {
let env_filter =
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into());
tracing_subscriber::registry()
if let Err(err) = tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer())
.init();
.try_init()
{
eprintln!("failed to initialize tracing: {}", err);
}
}
/// Get the application data directory
@@ -799,12 +835,86 @@ fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyP
/// Spawn a background task that monitors state change events
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
use one_kvm::events::SystemEvent;
use std::time::{Duration, Instant};
let mut rx = events.subscribe();
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;
@@ -814,32 +924,24 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
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), rx.recv()).await
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
} else {
Ok(rx.recv().await)
Ok(trigger_rx.recv().await)
};
match recv_result {
Ok(Ok(event)) => {
let should_broadcast = matches!(
event,
SystemEvent::StreamStateChanged { .. }
| SystemEvent::StreamConfigApplied { .. }
| SystemEvent::StreamModeReady { .. }
| SystemEvent::HidStateChanged { .. }
| SystemEvent::MsdStateChanged { .. }
| SystemEvent::AtxStateChanged { .. }
| SystemEvent::AudioStateChanged { .. }
Ok(Some(DeviceInfoTrigger::Event)) => {
pending_broadcast = true;
}
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
tracing::warn!(
"DeviceInfo broadcaster lagged by {} events on topic {}",
count,
topic
);
if should_broadcast {
pending_broadcast = true;
}
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
tracing::warn!("DeviceInfo broadcaster lagged by {} events", n);
pending_broadcast = true;
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
Ok(None) => {
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
break;
}
@@ -879,6 +981,15 @@ async fn cleanup(state: &Arc<AppState>) {
}
}
// Stop RTSP service
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");
}
}
// Stop video
if let Err(e) = state.stream_manager.stop().await {
tracing::warn!("Failed to stop streamer: {}", e);

View File

@@ -19,9 +19,6 @@ use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMo
use crate::error::{AppError, Result};
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
/// USB Gadget path (system constant)
const GADGET_PATH: &str = "/sys/kernel/config/usb_gadget/one-kvm";
/// MSD Controller
pub struct MsdController {
/// OTG Service reference
@@ -52,10 +49,7 @@ impl MsdController {
/// # Parameters
/// * `otg_service` - OTG service for gadget management
/// * `msd_dir` - Base directory for MSD storage
pub fn new(
otg_service: Arc<OtgService>,
msd_dir: impl Into<PathBuf>,
) -> Self {
pub fn new(otg_service: Arc<OtgService>, msd_dir: impl Into<PathBuf>) -> Self {
let msd_dir = msd_dir.into();
let images_path = msd_dir.join("images");
let ventoy_dir = msd_dir.join("ventoy");
@@ -86,9 +80,11 @@ impl MsdController {
warn!("Failed to create ventoy directory: {}", e);
}
// 2. Request MSD function from OtgService
info!("Requesting MSD function from OtgService");
let msd_func = self.otg_service.enable_msd().await?;
// 2. Get active MSD function from OtgService
info!("Fetching MSD function from OtgService");
let msd_func = self.otg_service.msd_function().await.ok_or_else(|| {
AppError::Internal("MSD function is not active in OtgService".to_string())
})?;
// 3. Store function handle
*self.msd_function.write().await = Some(msd_func);
@@ -118,15 +114,6 @@ impl MsdController {
Ok(())
}
/// Get current state as SystemEvent
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
let state = self.state.read().await;
crate::events::SystemEvent::MsdStateChanged {
mode: state.mode.clone(),
connected: state.connected,
}
}
/// Get current MSD state
pub async fn state(&self) -> MsdState {
self.state.read().await.clone()
@@ -134,9 +121,7 @@ impl MsdController {
/// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
*self.events.write().await = Some(events.clone());
// Also set event bus on the monitor for health notifications
self.monitor.set_event_bus(events).await;
*self.events.write().await = Some(events);
}
/// Publish an event to the event bus
@@ -146,6 +131,12 @@ impl MsdController {
}
}
async fn mark_device_info_dirty(&self) {
if let Some(ref bus) = *self.events.read().await {
bus.mark_device_info_dirty();
}
}
/// Check if MSD is available
pub async fn is_available(&self) -> bool {
self.state.read().await.available
@@ -198,7 +189,7 @@ impl MsdController {
MsdLunConfig::disk(image.path.clone(), read_only)
};
let gadget_path = PathBuf::from(GADGET_PATH);
let gadget_path = self.active_gadget_path().await?;
if let Some(ref msd) = *self.msd_function.read().await {
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
let error_msg = format!("Failed to configure LUN: {}", e);
@@ -233,20 +224,7 @@ impl MsdController {
self.monitor.report_recovered().await;
}
// Publish events
self.publish_event(crate::events::SystemEvent::MsdImageMounted {
image_id: image.id.clone(),
image_name: image.name.clone(),
size: image.size,
cdrom,
})
.await;
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
mode: MsdMode::Image,
connected: true,
})
.await;
self.mark_device_info_dirty().await;
Ok(())
}
@@ -285,7 +263,7 @@ impl MsdController {
// Configure LUN as read-write disk
let config = MsdLunConfig::disk(self.drive_path.clone(), false);
let gadget_path = PathBuf::from(GADGET_PATH);
let gadget_path = self.active_gadget_path().await?;
if let Some(ref msd) = *self.msd_function.read().await {
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
let error_msg = format!("Failed to configure LUN: {}", e);
@@ -317,12 +295,7 @@ impl MsdController {
self.monitor.report_recovered().await;
}
// Publish event
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
mode: MsdMode::Drive,
connected: true,
})
.await;
self.mark_device_info_dirty().await;
Ok(())
}
@@ -339,7 +312,7 @@ impl MsdController {
return Ok(());
}
let gadget_path = PathBuf::from(GADGET_PATH);
let gadget_path = self.active_gadget_path().await?;
if let Some(ref msd) = *self.msd_function.read().await {
msd.disconnect_lun_async(&gadget_path, 0).await?;
}
@@ -354,15 +327,7 @@ impl MsdController {
drop(state);
drop(_op_guard);
// Publish events
self.publish_event(crate::events::SystemEvent::MsdImageUnmounted)
.await;
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
mode: MsdMode::None,
connected: false,
})
.await;
self.mark_device_info_dirty().await;
Ok(())
}
@@ -546,6 +511,13 @@ impl MsdController {
downloads.keys().cloned().collect()
}
async fn active_gadget_path(&self) -> Result<PathBuf> {
self.otg_service
.gadget_path()
.await
.ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string()))
}
/// Shutdown the controller
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down MSD controller");
@@ -555,11 +527,7 @@ impl MsdController {
warn!("Error disconnecting during shutdown: {}", e);
}
// 2. Notify OtgService to disable MSD
info!("Disabling MSD function in OtgService");
self.otg_service.disable_msd().await?;
// 3. Clear local state
// 2. Clear local state
*self.msd_function.write().await = None;
let mut state = self.state.write().await;

View File

@@ -87,8 +87,7 @@ impl ImageManager {
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
.unwrap_or_else(|| Utc::now().into())
chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_else(Utc::now)
})
.unwrap_or_else(Utc::now);
@@ -400,7 +399,7 @@ impl ImageManager {
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(|s| extract_filename_from_content_disposition(s));
.and_then(extract_filename_from_content_disposition);
if let Some(name) = from_header {
sanitize_filename(&name)

View File

@@ -3,21 +3,20 @@
//! This module provides health monitoring for MSD operations, including:
//! - ConfigFS operation error tracking
//! - Image mount/unmount error tracking
//! - Error notification
//! - Error state tracking
//! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler;
/// MSD health status
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Default)]
pub enum MsdHealthStatus {
/// Device is healthy and operational
#[default]
Healthy,
/// Device has an error
Error {
@@ -28,12 +27,6 @@ pub enum MsdHealthStatus {
},
}
impl Default for MsdHealthStatus {
fn default() -> Self {
Self::Healthy
}
}
/// MSD health monitor configuration
#[derive(Debug, Clone)]
pub struct MsdMonitorConfig {
@@ -51,21 +44,12 @@ impl Default for MsdMonitorConfig {
/// MSD health monitor
///
/// Monitors MSD operation health and manages error notifications.
/// Publishes WebSocket events when operation status changes.
/// Monitors MSD operation health and manages error state.
pub struct MsdHealthMonitor {
/// Current health status
status: RwLock<MsdHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding
throttler: LogThrottler,
/// Configuration
#[allow(dead_code)]
config: MsdMonitorConfig,
/// Whether monitoring is active (reserved for future use)
#[allow(dead_code)]
running: AtomicBool,
/// Error count (for tracking)
error_count: AtomicU32,
/// Last error code (for change detection)
@@ -78,10 +62,7 @@ impl MsdHealthMonitor {
let throttle_secs = config.log_throttle_secs;
Self {
status: RwLock::new(MsdHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs),
config,
running: AtomicBool::new(false),
error_count: AtomicU32::new(0),
last_error_code: RwLock::new(None),
}
@@ -92,17 +73,12 @@ impl MsdHealthMonitor {
Self::new(MsdMonitorConfig::default())
}
/// Set the event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Report an error from MSD operations
///
/// This method is called when an MSD operation fails. It:
/// 1. Updates the health status
/// 2. Logs the error (with throttling)
/// 3. Publishes a WebSocket event if the error is new or changed
/// 3. Updates in-memory error state
///
/// # Arguments
///
@@ -134,22 +110,12 @@ impl MsdHealthMonitor {
reason: reason.to_string(),
error_code: error_code.to_string(),
};
// Publish event (only if error changed or first occurrence)
if error_changed || count == 1 {
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::MsdError {
reason: reason.to_string(),
error_code: error_code.to_string(),
});
}
}
}
/// Report that the MSD has recovered from error
///
/// This method is called when an MSD operation succeeds after errors.
/// It resets the error state and publishes a recovery event.
/// It resets the error state.
pub async fn report_recovered(&self) {
let prev_status = self.status.read().await.clone();
@@ -163,11 +129,6 @@ impl MsdHealthMonitor {
self.throttler.clear_all();
*self.last_error_code.write().await = None;
*self.status.write().await = MsdHealthStatus::Healthy;
// Publish recovery event
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::MsdRecovered);
}
}
}

View File

@@ -7,8 +7,10 @@ use std::path::PathBuf;
/// MSD operating mode
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum MsdMode {
/// No storage connected
#[default]
None,
/// Image file mounted (ISO/IMG)
Image,
@@ -16,12 +18,6 @@ pub enum MsdMode {
Drive,
}
impl Default for MsdMode {
fn default() -> Self {
Self::None
}
}
/// Image file metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageInfo {

View File

@@ -328,10 +328,7 @@ impl VentoyDrive {
let image = match VentoyImage::open(&path) {
Ok(img) => img,
Err(e) => {
let _ = rt.block_on(tx.send(Err(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))));
let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string()))));
return;
}
};
@@ -341,10 +338,7 @@ impl VentoyDrive {
// Stream the file through the writer
if let Err(e) = image.read_file_to_writer(&file_path_owned, &mut chunk_writer) {
let _ = rt.block_on(tx.send(Err(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))));
let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string()))));
}
});
@@ -543,17 +537,14 @@ mod tests {
/// Decompress xz file using system command
fn decompress_xz(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
let output = Command::new("xz")
.args(&["-d", "-k", "-c", src.to_str().unwrap()])
.args(["-d", "-k", "-c", src.to_str().unwrap()])
.output()?;
if !output.status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!(
return Err(std::io::Error::other(format!(
"xz decompress failed: {}",
String::from_utf8_lossy(&output.stderr)
),
));
)));
}
std::fs::write(dst, &output.stdout)?;

View File

@@ -3,6 +3,7 @@
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::process::Command;
use crate::error::{AppError, Result};
@@ -29,6 +30,42 @@ pub fn is_configfs_available() -> bool {
Path::new(CONFIGFS_PATH).exists()
}
/// Ensure libcomposite support is available for USB gadget operations.
///
/// This is a best-effort runtime fallback for systems where `libcomposite`
/// is built as a module and not loaded yet. It does not try to mount configfs;
/// mounting remains an explicit system responsibility.
pub fn ensure_libcomposite_loaded() -> Result<()> {
if is_configfs_available() {
return Ok(());
}
if !Path::new("/sys/module/libcomposite").exists() {
let status = Command::new("modprobe")
.arg("libcomposite")
.status()
.map_err(|e| {
AppError::Internal(format!("Failed to run modprobe libcomposite: {}", e))
})?;
if !status.success() {
return Err(AppError::Internal(format!(
"modprobe libcomposite failed with status {}",
status
)));
}
}
if is_configfs_available() {
Ok(())
} else {
Err(AppError::Internal(
"libcomposite is not available after modprobe; check configfs mount and kernel support"
.to_string(),
))
}
}
/// Find available UDC (USB Device Controller)
pub fn find_udc() -> Option<String> {
let udc_path = Path::new("/sys/class/udc");

View File

@@ -7,14 +7,15 @@ use super::configfs::{
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
};
use super::function::{FunctionMeta, GadgetFunction};
use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
use super::report_desc::{
CONSUMER_CONTROL, KEYBOARD, KEYBOARD_WITH_LED, MOUSE_ABSOLUTE, MOUSE_RELATIVE,
};
use crate::error::Result;
/// HID function type
#[derive(Debug, Clone)]
pub enum HidFunctionType {
/// Keyboard (no LED feedback)
/// Uses 1 endpoint: IN
/// Keyboard
Keyboard,
/// Relative mouse (traditional mouse movement)
/// Uses 1 endpoint: IN
@@ -28,7 +29,7 @@ pub enum HidFunctionType {
}
impl HidFunctionType {
/// Get endpoints required for this function type
/// Get the base endpoint cost for this function type.
pub fn endpoints(&self) -> u8 {
match self {
HidFunctionType::Keyboard => 1,
@@ -59,7 +60,7 @@ impl HidFunctionType {
}
/// Get report length in bytes
pub fn report_length(&self) -> u8 {
pub fn report_length(&self, _keyboard_leds: bool) -> u8 {
match self {
HidFunctionType::Keyboard => 8,
HidFunctionType::MouseRelative => 4,
@@ -69,9 +70,15 @@ impl HidFunctionType {
}
/// Get report descriptor
pub fn report_desc(&self) -> &'static [u8] {
pub fn report_desc(&self, keyboard_leds: bool) -> &'static [u8] {
match self {
HidFunctionType::Keyboard => KEYBOARD,
HidFunctionType::Keyboard => {
if keyboard_leds {
KEYBOARD_WITH_LED
} else {
KEYBOARD
}
}
HidFunctionType::MouseRelative => MOUSE_RELATIVE,
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
@@ -98,15 +105,18 @@ pub struct HidFunction {
func_type: HidFunctionType,
/// Cached function name (avoids repeated allocation)
name: String,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds: bool,
}
impl HidFunction {
/// Create a keyboard function
pub fn keyboard(instance: u8) -> Self {
pub fn keyboard(instance: u8, keyboard_leds: bool) -> Self {
Self {
instance,
func_type: HidFunctionType::Keyboard,
name: format!("hid.usb{}", instance),
keyboard_leds,
}
}
@@ -116,6 +126,7 @@ impl HidFunction {
instance,
func_type: HidFunctionType::MouseRelative,
name: format!("hid.usb{}", instance),
keyboard_leds: false,
}
}
@@ -125,6 +136,7 @@ impl HidFunction {
instance,
func_type: HidFunctionType::MouseAbsolute,
name: format!("hid.usb{}", instance),
keyboard_leds: false,
}
}
@@ -134,6 +146,7 @@ impl HidFunction {
instance,
func_type: HidFunctionType::ConsumerControl,
name: format!("hid.usb{}", instance),
keyboard_leds: false,
}
}
@@ -181,11 +194,14 @@ impl GadgetFunction for HidFunction {
)?;
write_file(
&func_path.join("report_length"),
&self.func_type.report_length().to_string(),
&self.func_type.report_length(self.keyboard_leds).to_string(),
)?;
// Write report descriptor
write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?;
write_bytes(
&func_path.join("report_desc"),
self.func_type.report_desc(self.keyboard_leds),
)?;
debug!(
"Created HID function: {} at {}",
@@ -232,14 +248,15 @@ mod tests {
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
assert_eq!(HidFunctionType::Keyboard.report_length(), 8);
assert_eq!(HidFunctionType::MouseRelative.report_length(), 4);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(), 6);
assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(false), 6);
}
#[test]
fn test_hid_function_names() {
let kb = HidFunction::keyboard(0);
let kb = HidFunction::keyboard(0, false);
assert_eq!(kb.name(), "hid.usb0");
assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0"));

View File

@@ -19,7 +19,7 @@ use crate::error::{AppError, Result};
const REBIND_DELAY_MS: u64 = 300;
/// USB Gadget device descriptor configuration
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GadgetDescriptor {
pub vendor_id: u16,
pub product_id: u16,
@@ -131,8 +131,8 @@ impl OtgGadgetManager {
/// Add keyboard function
/// Returns the expected device path (e.g., /dev/hidg0)
pub fn add_keyboard(&mut self) -> Result<PathBuf> {
let func = HidFunction::keyboard(self.hid_instance);
pub fn add_keyboard(&mut self, keyboard_leds: bool) -> Result<PathBuf> {
let func = HidFunction::keyboard(self.hid_instance, keyboard_leds);
let device_path = func.device_path();
self.add_function(Box::new(func))?;
self.hid_instance += 1;
@@ -245,12 +245,8 @@ impl OtgGadgetManager {
Ok(())
}
/// Bind gadget to UDC
pub fn bind(&mut self) -> Result<()> {
let udc = Self::find_udc().ok_or_else(|| {
AppError::Internal("No USB Device Controller (UDC) found".to_string())
})?;
/// Bind gadget to a specific UDC
pub fn bind(&mut self, udc: &str) -> Result<()> {
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind
if let Err(e) = self.recreate_config_links() {
warn!("Failed to recreate gadget config links before bind: {}", e);
@@ -258,7 +254,7 @@ impl OtgGadgetManager {
info!("Binding gadget to UDC: {}", udc);
write_file(&self.gadget_path.join("UDC"), &udc)?;
self.bound_udc = Some(udc);
self.bound_udc = Some(udc.to_string());
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
Ok(())
@@ -422,7 +418,11 @@ impl OtgGadgetManager {
if dest.exists() {
if let Err(e) = remove_file(&dest) {
warn!("Failed to remove existing config link {}: {}", dest.display(), e);
warn!(
"Failed to remove existing config link {}: {}",
dest.display(),
e
);
continue;
}
}
@@ -500,7 +500,7 @@ mod tests {
let mut manager = OtgGadgetManager::with_config("test", 8);
// Keyboard uses 1 endpoint
let _ = manager.add_keyboard();
let _ = manager.add_keyboard(false);
assert_eq!(manager.endpoint_allocator.used(), 1);
// Mouse uses 1 endpoint each

View File

@@ -32,4 +32,4 @@ pub use hid::{HidFunction, HidFunctionType};
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
pub use msd::{MsdFunction, MsdLunConfig};
pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
pub use service::{HidDevicePaths, OtgService, OtgServiceState};
pub use service::{HidDevicePaths, OtgDesiredState, OtgService, OtgServiceState};

View File

@@ -1,6 +1,6 @@
//! HID Report Descriptors
/// Keyboard HID Report Descriptor (no LED output - saves 1 endpoint)
/// Keyboard HID Report Descriptor (no LED output)
/// Report format (8 bytes input):
/// [0] Modifier keys (8 bits)
/// [1] Reserved
@@ -34,6 +34,53 @@ pub const KEYBOARD: &[u8] = &[
0xC0, // End Collection
];
/// Keyboard HID Report Descriptor with LED output support.
/// Input report format (8 bytes):
/// [0] Modifier keys (8 bits)
/// [1] Reserved
/// [2-7] Key codes (6 keys)
/// Output report format (1 byte):
/// [0] Num Lock / Caps Lock / Scroll Lock / Compose / Kana
pub const KEYBOARD_WITH_LED: &[u8] = &[
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
// Modifier keys input (8 bits)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224) - Left Control
0x29, 0xE7, // Usage Maximum (231) - Right GUI
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
// Reserved byte
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant) - Reserved byte
// LED output bits
0x95, 0x05, // Report Count (5)
0x75, 0x01, // Report Size (1)
0x05, 0x08, // Usage Page (LEDs)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x05, // Usage Maximum (5)
0x91, 0x02, // Output (Data, Variable, Absolute)
// LED padding
0x95, 0x01, // Report Count (1)
0x75, 0x03, // Report Size (3)
0x91, 0x01, // Output (Constant)
// Key array (6 bytes)
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x2A, 0xFF, 0x00, // Usage Maximum (255)
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
0xC0, // End Collection
];
/// Relative Mouse HID Report Descriptor (4 bytes report)
/// Report format:
/// [0] Buttons (5 bits) + padding (3 bits)
@@ -155,6 +202,7 @@ mod tests {
#[test]
fn test_report_descriptor_sizes() {
assert!(!KEYBOARD.is_empty());
assert!(!KEYBOARD_WITH_LED.is_empty());
assert!(!MOUSE_RELATIVE.is_empty());
assert!(!MOUSE_ABSOLUTE.is_empty());
assert!(!CONSUMER_CONTROL.is_empty());

View File

@@ -1,57 +1,27 @@
//! OTG Service - unified gadget lifecycle management
//!
//! This module provides centralized management for USB OTG gadget functions.
//! It solves the ownership problem where both HID and MSD need access to the
//! same USB gadget but should be independently configurable.
//!
//! Architecture:
//! ```text
//! ┌─────────────────────────┐
//! │ OtgService │
//! │ ┌───────────────────┐ │
//! │ │ OtgGadgetManager │ │
//! │ └───────────────────┘ │
//! │ ↓ ↓ │
//! │ ┌─────┐ ┌─────┐ │
//! │ │ HID │ │ MSD │ │
//! │ └─────┘ └─────┘ │
//! └─────────────────────────┘
//! ↑ ↑
//! HidController MsdController
//! ```
//! It is the single owner of the USB gadget desired state and reconciles
//! ConfigFS to match that state.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::MsdFunction;
use crate::config::{OtgDescriptorConfig, OtgHidFunctions};
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions};
use crate::error::{AppError, Result};
/// Bitflags for requested functions (lock-free)
const FLAG_HID: u8 = 0b01;
const FLAG_MSD: u8 = 0b10;
/// HID device paths
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct HidDevicePaths {
pub keyboard: Option<PathBuf>,
pub mouse_relative: Option<PathBuf>,
pub mouse_absolute: Option<PathBuf>,
pub consumer: Option<PathBuf>,
}
impl Default for HidDevicePaths {
fn default() -> Self {
Self {
keyboard: None,
mouse_relative: None,
mouse_absolute: None,
consumer: None,
}
}
pub udc: Option<String>,
pub keyboard_leds_enabled: bool,
}
impl HidDevicePaths {
@@ -73,6 +43,59 @@ impl HidDevicePaths {
}
}
/// Desired OTG gadget state derived from configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OtgDesiredState {
pub udc: Option<String>,
pub descriptor: GadgetDescriptor,
pub hid_functions: Option<OtgHidFunctions>,
pub keyboard_leds: bool,
pub msd_enabled: bool,
pub max_endpoints: u8,
}
impl Default for OtgDesiredState {
fn default() -> Self {
Self {
udc: None,
descriptor: GadgetDescriptor::default(),
hid_functions: None,
keyboard_leds: false,
msd_enabled: false,
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
}
}
}
impl OtgDesiredState {
pub fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result<Self> {
let hid_functions = if hid.backend == HidBackend::Otg {
let functions = hid.constrained_otg_functions();
Some(functions)
} else {
None
};
hid.validate_otg_endpoint_budget(msd.enabled)?;
Ok(Self {
udc: hid.resolved_otg_udc(),
descriptor: GadgetDescriptor::from(&hid.otg_descriptor),
hid_functions,
keyboard_leds: hid.effective_otg_keyboard_leds(),
msd_enabled: msd.enabled,
max_endpoints: hid
.resolved_otg_endpoint_limit()
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
})
}
#[inline]
pub fn hid_enabled(&self) -> bool {
self.hid_functions.is_some()
}
}
/// OTG Service state
#[derive(Debug, Clone, Default)]
pub struct OtgServiceState {
@@ -82,19 +105,23 @@ pub struct OtgServiceState {
pub hid_enabled: bool,
/// Whether MSD function is enabled
pub msd_enabled: bool,
/// Bound UDC name
pub configured_udc: Option<String>,
/// HID device paths (set after gadget setup)
pub hid_paths: Option<HidDevicePaths>,
/// HID function selection (set after gadget setup)
pub hid_functions: Option<OtgHidFunctions>,
/// Whether keyboard LED/status feedback is enabled.
pub keyboard_leds_enabled: bool,
/// Applied endpoint budget.
pub max_endpoints: u8,
/// Applied descriptor configuration
pub descriptor: Option<GadgetDescriptor>,
/// Error message if setup failed
pub error: Option<String>,
}
/// OTG Service - unified gadget lifecycle management
///
/// This service owns the OtgGadgetManager and provides a high-level interface
/// for enabling/disabling HID and MSD functions. It ensures proper coordination
/// between the two subsystems and handles gadget lifecycle management.
pub struct OtgService {
/// The underlying gadget manager
manager: Mutex<Option<OtgGadgetManager>>,
@@ -102,12 +129,8 @@ pub struct OtgService {
state: RwLock<OtgServiceState>,
/// MSD function handle (for runtime LUN configuration)
msd_function: RwLock<Option<MsdFunction>>,
/// Requested functions flags (atomic, lock-free read/write)
requested_flags: AtomicU8,
/// Requested HID function set
hid_functions: RwLock<OtgHidFunctions>,
/// Current descriptor configuration
current_descriptor: RwLock<GadgetDescriptor>,
/// Desired OTG state
desired: RwLock<OtgDesiredState>,
}
impl OtgService {
@@ -117,41 +140,7 @@ impl OtgService {
manager: Mutex::new(None),
state: RwLock::new(OtgServiceState::default()),
msd_function: RwLock::new(None),
requested_flags: AtomicU8::new(0),
hid_functions: RwLock::new(OtgHidFunctions::default()),
current_descriptor: RwLock::new(GadgetDescriptor::default()),
}
}
/// Check if HID is requested (lock-free)
#[inline]
fn is_hid_requested(&self) -> bool {
self.requested_flags.load(Ordering::Acquire) & FLAG_HID != 0
}
/// Check if MSD is requested (lock-free)
#[inline]
fn is_msd_requested(&self) -> bool {
self.requested_flags.load(Ordering::Acquire) & FLAG_MSD != 0
}
/// Set HID requested flag (lock-free)
#[inline]
fn set_hid_requested(&self, requested: bool) {
if requested {
self.requested_flags.fetch_or(FLAG_HID, Ordering::Release);
} else {
self.requested_flags.fetch_and(!FLAG_HID, Ordering::Release);
}
}
/// Set MSD requested flag (lock-free)
#[inline]
fn set_msd_requested(&self, requested: bool) {
if requested {
self.requested_flags.fetch_or(FLAG_MSD, Ordering::Release);
} else {
self.requested_flags.fetch_and(!FLAG_MSD, Ordering::Release);
desired: RwLock::new(OtgDesiredState::default()),
}
}
@@ -191,260 +180,119 @@ impl OtgService {
self.state.read().await.hid_paths.clone()
}
/// Get current HID function selection
pub async fn hid_functions(&self) -> OtgHidFunctions {
self.hid_functions.read().await.clone()
}
/// Update HID function selection
pub async fn update_hid_functions(&self, functions: OtgHidFunctions) -> Result<()> {
if functions.is_empty() {
return Err(AppError::BadRequest(
"OTG HID functions cannot be empty".to_string(),
));
}
{
let mut current = self.hid_functions.write().await;
if *current == functions {
return Ok(());
}
*current = functions;
}
// If HID is active, recreate gadget with new function set
if self.is_hid_requested() {
self.recreate_gadget().await?;
}
Ok(())
}
/// Get MSD function handle (for LUN configuration)
pub async fn msd_function(&self) -> Option<MsdFunction> {
self.msd_function.read().await.clone()
}
/// Enable HID functions
///
/// This will create the gadget if not already created, add HID functions,
/// and bind the gadget to UDC.
pub async fn enable_hid(&self) -> Result<HidDevicePaths> {
info!("Enabling HID functions via OtgService");
/// Apply desired OTG state derived from the current application config.
pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> {
let desired = OtgDesiredState::from_config(hid, msd)?;
self.apply_desired_state(desired).await
}
// Mark HID as requested (lock-free)
self.set_hid_requested(true);
// Check if already enabled and function set unchanged
let requested_functions = self.hid_functions.read().await.clone();
/// Apply a fully materialized desired OTG state.
pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> {
{
let state = self.state.read().await;
if state.hid_enabled {
if state.hid_functions.as_ref() == Some(&requested_functions) {
if let Some(ref paths) = state.hid_paths {
info!("HID already enabled, returning existing paths");
return Ok(paths.clone());
}
}
}
let mut current = self.desired.write().await;
*current = desired;
}
// Recreate gadget with both HID and MSD if needed
self.recreate_gadget().await?;
// Get HID paths from state
let state = self.state.read().await;
state
.hid_paths
.clone()
.ok_or_else(|| AppError::Internal("HID paths not set after gadget setup".to_string()))
self.reconcile_gadget().await
}
/// Disable HID functions
///
/// This will unbind the gadget, remove HID functions, and optionally
/// recreate the gadget with only MSD if MSD is still enabled.
pub async fn disable_hid(&self) -> Result<()> {
info!("Disabling HID functions via OtgService");
// Mark HID as not requested (lock-free)
self.set_hid_requested(false);
// Check if HID is enabled
{
let state = self.state.read().await;
if !state.hid_enabled {
info!("HID already disabled");
return Ok(());
}
}
// Recreate gadget without HID (or destroy if MSD also disabled)
self.recreate_gadget().await
}
/// Enable MSD function
///
/// This will create the gadget if not already created, add MSD function,
/// and bind the gadget to UDC.
pub async fn enable_msd(&self) -> Result<MsdFunction> {
info!("Enabling MSD function via OtgService");
// Mark MSD as requested (lock-free)
self.set_msd_requested(true);
// Check if already enabled
{
let state = self.state.read().await;
if state.msd_enabled {
let msd = self.msd_function.read().await;
if let Some(ref func) = *msd {
info!("MSD already enabled, returning existing function");
return Ok(func.clone());
}
}
}
// Recreate gadget with both HID and MSD if needed
self.recreate_gadget().await?;
// Get MSD function
let msd = self.msd_function.read().await;
msd.clone().ok_or_else(|| {
AppError::Internal("MSD function not set after gadget setup".to_string())
})
}
/// Disable MSD function
///
/// This will unbind the gadget, remove MSD function, and optionally
/// recreate the gadget with only HID if HID is still enabled.
pub async fn disable_msd(&self) -> Result<()> {
info!("Disabling MSD function via OtgService");
// Mark MSD as not requested (lock-free)
self.set_msd_requested(false);
// Check if MSD is enabled
{
let state = self.state.read().await;
if !state.msd_enabled {
info!("MSD already disabled");
return Ok(());
}
}
// Recreate gadget without MSD (or destroy if HID also disabled)
self.recreate_gadget().await
}
/// Recreate the gadget with currently requested functions
///
/// This is called whenever the set of enabled functions changes.
/// It will:
/// 1. Check if recreation is needed (function set changed)
/// 2. If needed: cleanup existing gadget
/// 3. Create new gadget with requested functions
/// 4. Setup and bind
async fn recreate_gadget(&self) -> Result<()> {
// Read requested flags atomically (lock-free)
let hid_requested = self.is_hid_requested();
let msd_requested = self.is_msd_requested();
let hid_functions = if hid_requested {
self.hid_functions.read().await.clone()
} else {
OtgHidFunctions::default()
};
async fn reconcile_gadget(&self) -> Result<()> {
let desired = self.desired.read().await.clone();
info!(
"Recreating gadget with: HID={}, MSD={}",
hid_requested, msd_requested
"Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}",
desired.hid_enabled(),
desired.msd_enabled,
desired.udc
);
// Check if gadget already matches requested state
{
let state = self.state.read().await;
let functions_match = if hid_requested {
state.hid_functions.as_ref() == Some(&hid_functions)
} else {
state.hid_functions.is_none()
};
if state.gadget_active
&& state.hid_enabled == hid_requested
&& state.msd_enabled == msd_requested
&& functions_match
&& state.hid_enabled == desired.hid_enabled()
&& state.msd_enabled == desired.msd_enabled
&& state.configured_udc == desired.udc
&& state.hid_functions == desired.hid_functions
&& state.keyboard_leds_enabled == desired.keyboard_leds
&& state.max_endpoints == desired.max_endpoints
&& state.descriptor.as_ref() == Some(&desired.descriptor)
{
info!("Gadget already has requested functions, skipping recreate");
info!("OTG gadget already matches desired state");
return Ok(());
}
}
// Cleanup existing gadget
{
let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() {
info!("Cleaning up existing gadget before recreate");
info!("Cleaning up existing gadget before OTG reconcile");
if let Err(e) = m.cleanup() {
warn!("Error cleaning up existing gadget: {}", e);
}
}
}
// Clear MSD function
*self.msd_function.write().await = None;
// Update state to inactive
{
let mut state = self.state.write().await;
state.gadget_active = false;
state.hid_enabled = false;
state.msd_enabled = false;
state.configured_udc = None;
state.hid_paths = None;
state.hid_functions = None;
state.keyboard_leds_enabled = false;
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
state.descriptor = None;
state.error = None;
}
// If nothing requested, we're done
if !hid_requested && !msd_requested {
info!("No functions requested, gadget destroyed");
if !desired.hid_enabled() && !desired.msd_enabled {
info!("OTG desired state is empty, gadget removed");
return Ok(());
}
// Check if OTG is available
if !Self::is_available() {
let error = "OTG not available: ConfigFS not mounted or no UDC found".to_string();
let mut state = self.state.write().await;
state.error = Some(error.clone());
if let Err(e) = super::configfs::ensure_libcomposite_loaded() {
warn!("Failed to ensure libcomposite is available: {}", e);
}
if !OtgGadgetManager::is_available() {
let error = "OTG not available: ConfigFS not mounted".to_string();
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
// Create new gadget manager with current descriptor
let descriptor = self.current_descriptor.read().await.clone();
let udc = desired.udc.clone().ok_or_else(|| {
let error = "OTG not available: no UDC found".to_string();
AppError::Internal(error)
})?;
let mut manager = OtgGadgetManager::with_descriptor(
super::configfs::DEFAULT_GADGET_NAME,
super::endpoint::DEFAULT_MAX_ENDPOINTS,
descriptor,
desired.max_endpoints,
desired.descriptor.clone(),
);
let mut hid_paths = None;
// Add HID functions if requested
if hid_requested {
if hid_functions.is_empty() {
let error = "HID functions set is empty".to_string();
let mut state = self.state.write().await;
state.error = Some(error.clone());
return Err(AppError::BadRequest(error));
}
let mut paths = HidDevicePaths::default();
if let Some(hid_functions) = desired.hid_functions.clone() {
let mut paths = HidDevicePaths {
udc: Some(udc.clone()),
keyboard_leds_enabled: desired.keyboard_leds,
..Default::default()
};
if hid_functions.keyboard {
match manager.add_keyboard() {
match manager.add_keyboard(desired.keyboard_leds) {
Ok(kb) => paths.keyboard = Some(kb),
Err(e) => {
let error = format!("Failed to add keyboard HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -455,8 +303,7 @@ impl OtgService {
Ok(rel) => paths.mouse_relative = Some(rel),
Err(e) => {
let error = format!("Failed to add relative mouse HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -467,8 +314,7 @@ impl OtgService {
Ok(abs) => paths.mouse_absolute = Some(abs),
Err(e) => {
let error = format!("Failed to add absolute mouse HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -479,8 +325,7 @@ impl OtgService {
Ok(consumer) => paths.consumer = Some(consumer),
Err(e) => {
let error = format!("Failed to add consumer HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -490,8 +335,7 @@ impl OtgService {
debug!("HID functions added to gadget");
}
// Add MSD function if requested
let msd_func = if msd_requested {
let msd_func = if desired.msd_enabled {
match manager.add_msd() {
Ok(func) => {
debug!("MSD function added to gadget");
@@ -499,8 +343,7 @@ impl OtgService {
}
Err(e) => {
let error = format!("Failed to add MSD function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -508,25 +351,19 @@ impl OtgService {
None
};
// Setup gadget
if let Err(e) = manager.setup() {
let error = format!("Failed to setup gadget: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
// Bind to UDC
if let Err(e) = manager.bind() {
let error = format!("Failed to bind gadget to UDC: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
// Cleanup on failure
if let Err(e) = manager.bind(&udc) {
let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
self.state.write().await.error = Some(error.clone());
let _ = manager.cleanup();
return Err(AppError::Internal(error));
}
// Wait for HID devices to appear
if let Some(ref paths) = hid_paths {
let device_paths = paths.existing_paths();
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await {
@@ -534,103 +371,36 @@ impl OtgService {
}
}
// Store manager and update state
{
*self.manager.lock().await = Some(manager);
}
{
*self.msd_function.write().await = msd_func;
}
{
let mut state = self.state.write().await;
state.gadget_active = true;
state.hid_enabled = hid_requested;
state.msd_enabled = msd_requested;
state.hid_enabled = desired.hid_enabled();
state.msd_enabled = desired.msd_enabled;
state.configured_udc = Some(udc);
state.hid_paths = hid_paths;
state.hid_functions = if hid_requested {
Some(hid_functions)
} else {
None
};
state.hid_functions = desired.hid_functions;
state.keyboard_leds_enabled = desired.keyboard_leds;
state.max_endpoints = desired.max_endpoints;
state.descriptor = Some(desired.descriptor);
state.error = None;
}
info!("Gadget created successfully");
info!("OTG gadget reconciled successfully");
Ok(())
}
/// Update the descriptor configuration
///
/// This updates the stored descriptor and triggers a gadget recreation
/// if the gadget is currently active.
pub async fn update_descriptor(&self, config: &OtgDescriptorConfig) -> Result<()> {
let new_descriptor = GadgetDescriptor {
vendor_id: config.vendor_id,
product_id: config.product_id,
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
manufacturer: config.manufacturer.clone(),
product: config.product.clone(),
serial_number: config
.serial_number
.clone()
.unwrap_or_else(|| "0123456789".to_string()),
};
// Update stored descriptor
*self.current_descriptor.write().await = new_descriptor;
// If gadget is active, recreate it with new descriptor
let state = self.state.read().await;
if state.gadget_active {
drop(state); // Release read lock before calling recreate
info!("Descriptor changed, recreating gadget");
self.force_recreate_gadget().await?;
}
Ok(())
}
/// Force recreate the gadget (used when descriptor changes)
async fn force_recreate_gadget(&self) -> Result<()> {
// Cleanup existing gadget
{
let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() {
info!("Cleaning up existing gadget for descriptor change");
if let Err(e) = m.cleanup() {
warn!("Error cleaning up existing gadget: {}", e);
}
}
}
// Clear MSD function
*self.msd_function.write().await = None;
// Update state to inactive
{
let mut state = self.state.write().await;
state.gadget_active = false;
state.hid_enabled = false;
state.msd_enabled = false;
state.hid_paths = None;
state.hid_functions = None;
state.error = None;
}
// Recreate with current requested functions
self.recreate_gadget().await
}
/// Shutdown the OTG service and cleanup all resources
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down OTG service");
// Mark nothing as requested (lock-free)
self.requested_flags.store(0, Ordering::Release);
{
let mut desired = self.desired.write().await;
*desired = OtgDesiredState::default();
}
// Cleanup gadget
let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() {
if let Err(e) = m.cleanup() {
@@ -638,7 +408,6 @@ impl OtgService {
}
}
// Clear state
*self.msd_function.write().await = None;
{
let mut state = self.state.write().await;
@@ -658,11 +427,26 @@ impl Default for OtgService {
impl Drop for OtgService {
fn drop(&mut self) {
// Gadget cleanup is handled by OtgGadgetManager's Drop
debug!("OtgService dropping");
}
}
impl From<&OtgDescriptorConfig> for GadgetDescriptor {
fn from(config: &OtgDescriptorConfig) -> Self {
Self {
vendor_id: config.vendor_id,
product_id: config.product_id,
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
manufacturer: config.manufacturer.clone(),
product: config.product.clone(),
serial_number: config
.serial_number
.clone()
.unwrap_or_else(|| "0123456789".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -670,8 +454,7 @@ mod tests {
#[test]
fn test_service_creation() {
let _service = OtgService::new();
// Just test that creation doesn't panic
assert!(!OtgService::is_available() || true); // Depends on environment
let _ = OtgService::is_available();
}
#[tokio::test]

3
src/rtsp/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod service;
pub use service::{RtspService, RtspServiceStatus};

1343
src/rtsp/service.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@ fn decode_header(first_byte: u8, header_bytes: &[u8]) -> (usize, usize) {
let head_len = ((first_byte & 0x3) + 1) as usize;
let mut n = first_byte as usize;
if head_len > 1 && header_bytes.len() >= 1 {
if head_len > 1 && !header_bytes.is_empty() {
n |= (header_bytes[0] as usize) << 8;
}
if head_len > 2 && header_bytes.len() >= 2 {

View File

@@ -202,9 +202,11 @@ mod tests {
#[test]
fn test_rendezvous_addr() {
let mut config = RustDeskConfig::default();
let mut config = RustDeskConfig {
rendezvous_server: "example.com".to_string(),
..Default::default()
};
config.rendezvous_server = "example.com".to_string();
assert_eq!(config.rendezvous_addr(), "example.com:21116");
config.rendezvous_server = "example.com:21116".to_string();
@@ -217,10 +219,12 @@ mod tests {
#[test]
fn test_relay_addr() {
let mut config = RustDeskConfig::default();
let mut config = RustDeskConfig {
rendezvous_server: "example.com".to_string(),
..Default::default()
};
// Rendezvous server configured, relay defaults to same host
config.rendezvous_server = "example.com".to_string();
assert_eq!(config.relay_addr(), Some("example.com:21117".to_string()));
// Explicit relay server
@@ -238,10 +242,12 @@ mod tests {
#[test]
fn test_effective_rendezvous_server() {
let mut config = RustDeskConfig::default();
let mut config = RustDeskConfig {
rendezvous_server: "custom.example.com".to_string(),
..Default::default()
};
// When user sets a server, use it
config.rendezvous_server = "custom.example.com".to_string();
assert_eq!(config.effective_rendezvous_server(), "custom.example.com");
// When empty, returns empty

View File

@@ -22,7 +22,10 @@ use tokio::sync::{broadcast, mpsc, Mutex};
use tracing::{debug, error, info, warn};
use crate::audio::AudioController;
use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
use crate::video::codec_constraints::{
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
};
use crate::video::encoder::registry::{EncoderRegistry, VideoEncoderType};
use crate::video::encoder::BitratePreset;
use crate::video::stream_manager::VideoStreamManager;
@@ -627,7 +630,7 @@ impl Connection {
// Select the best available video codec
// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding)
let negotiated = self.negotiate_video_codec();
let negotiated = self.negotiate_video_codec().await;
self.negotiated_codec = Some(negotiated);
info!("Negotiated video codec: {:?}", negotiated);
@@ -641,28 +644,51 @@ impl Connection {
/// Negotiate video codec - select the best available encoder
/// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding on embedded devices)
fn negotiate_video_codec(&self) -> VideoEncoderType {
async fn negotiate_video_codec(&self) -> VideoEncoderType {
let registry = EncoderRegistry::global();
let constraints = self.current_codec_constraints().await;
// Check availability in priority order
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
// and most RustDesk clients support H264 hardware decoding
if registry.is_format_available(VideoEncoderType::H264, false) {
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
&& registry.is_codec_available(VideoEncoderType::H264)
{
return VideoEncoderType::H264;
}
if registry.is_format_available(VideoEncoderType::H265, false) {
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
&& registry.is_codec_available(VideoEncoderType::H265)
{
return VideoEncoderType::H265;
}
if registry.is_format_available(VideoEncoderType::VP8, false) {
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
&& registry.is_codec_available(VideoEncoderType::VP8)
{
return VideoEncoderType::VP8;
}
if registry.is_format_available(VideoEncoderType::VP9, false) {
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
&& registry.is_codec_available(VideoEncoderType::VP9)
{
return VideoEncoderType::VP9;
}
// Fallback to H264 (should be available via hardware or software encoder)
warn!("No video encoder available, defaulting to H264");
VideoEncoderType::H264
// Fallback to preferred allowed codec
let preferred = constraints.preferred_webrtc_codec();
warn!(
"No allowed encoder available in priority order, falling back to {}",
encoder_codec_to_id(video_codec_to_encoder_codec(preferred))
);
video_codec_to_encoder_codec(preferred)
}
async fn current_codec_constraints(
&self,
) -> crate::video::codec_constraints::StreamCodecConstraints {
if let Some(ref video_manager) = self.video_manager {
video_manager.codec_constraints().await
} else {
crate::video::codec_constraints::StreamCodecConstraints::unrestricted()
}
}
/// Handle misc message with Arc writer
@@ -729,7 +755,7 @@ impl Connection {
}
// Check if client sent supported_decoding with a codec preference
if let Some(ref supported_decoding) = opt.supported_decoding.as_ref() {
if let Some(supported_decoding) = opt.supported_decoding.as_ref() {
let prefer = supported_decoding.prefer.value();
debug!("Client codec preference: prefer={}", prefer);
@@ -747,8 +773,18 @@ impl Connection {
if let Some(new_codec) = requested_codec {
// Check if this codec is different from current and available
if self.negotiated_codec != Some(new_codec) {
let constraints = self.current_codec_constraints().await;
if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec))
{
warn!(
"Client requested codec {:?} but it's blocked by constraints: {}",
new_codec, constraints.reason
);
return Ok(());
}
let registry = EncoderRegistry::global();
if registry.is_format_available(new_codec, false) {
if registry.is_codec_available(new_codec) {
info!(
"Client requested codec switch: {:?} -> {:?}",
self.negotiated_codec, new_codec
@@ -1080,12 +1116,21 @@ impl Connection {
if success {
// Dynamically detect available encoders
let registry = EncoderRegistry::global();
let constraints = self.current_codec_constraints().await;
// Check which encoders are available (include software fallback)
let h264_available = registry.is_format_available(VideoEncoderType::H264, false);
let h265_available = registry.is_format_available(VideoEncoderType::H265, false);
let vp8_available = registry.is_format_available(VideoEncoderType::VP8, false);
let vp9_available = registry.is_format_available(VideoEncoderType::VP9, false);
let h264_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
&& registry.is_codec_available(VideoEncoderType::H264);
let h265_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
&& registry.is_codec_available(VideoEncoderType::H265);
let vp8_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
&& registry.is_codec_available(VideoEncoderType::VP8);
let vp9_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
&& registry.is_codec_available(VideoEncoderType::VP9);
info!(
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",
@@ -1283,15 +1328,13 @@ impl Connection {
);
let caps_down = KeyboardEvent {
event_type: KeyEventType::Down,
key: 0x39, // USB HID CapsLock
key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
};
let caps_up = KeyboardEvent {
event_type: KeyEventType::Up,
key: 0x39,
key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
};
if let Err(e) = hid.send_keyboard(caps_down).await {
warn!("Failed to send CapsLock down: {}", e);
@@ -1306,7 +1349,7 @@ impl Connection {
if let Some(kb_event) = convert_key_event(ke) {
debug!(
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
kb_event.key,
kb_event.key.to_hid_usage(),
kb_event.event_type,
kb_event.modifiers.to_hid_byte()
);
@@ -1352,8 +1395,12 @@ impl Connection {
debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask);
// Convert RustDesk mouse event to One-KVM mouse events
let mouse_events =
convert_mouse_event(me, self.screen_width, self.screen_height, self.relative_mouse_active);
let mouse_events = convert_mouse_event(
me,
self.screen_width,
self.screen_height,
self.relative_mouse_active,
);
// Send to HID controller if available
if let Some(ref hid) = self.hid {
@@ -1616,7 +1663,10 @@ async fn run_video_streaming(
);
}
if let Err(e) = video_manager.request_keyframe().await {
debug!("Failed to request keyframe for connection {}: {}", conn_id, e);
debug!(
"Failed to request keyframe for connection {}: {}",
conn_id, e
);
}
// Inner loop: receives frames from current subscription

View File

@@ -189,7 +189,7 @@ pub fn hash_password_double(password: &str, salt: &str, challenge: &str) -> Vec<
// Second hash: SHA256(first_hash + challenge)
let mut hasher2 = Sha256::new();
hasher2.update(&first_hash);
hasher2.update(first_hash);
hasher2.update(challenge.as_bytes());
hasher2.finalize().to_vec()
}

View File

@@ -127,7 +127,7 @@ impl VideoFrameAdapter {
// Inject cached SPS/PPS before IDR when missing
if is_keyframe && (!has_sps || !has_pps) {
if let (Some(ref sps), Some(ref pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) {
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);

View File

@@ -5,8 +5,8 @@
use super::protocol::hbb::message::key_event as ke_union;
use super::protocol::{ControlKey, KeyEvent, MouseEvent};
use crate::hid::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent,
MouseEventType,
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEvent as OneKvmMouseEvent, MouseEventType,
};
use protobuf::Enum;
@@ -217,11 +217,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
// Handle control keys
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
if let Some(key) = control_key_to_hid(ck.value()) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent {
event_type,
key,
modifiers,
is_usb_hid: true, // Already converted to USB HID code
});
}
}
@@ -230,11 +230,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
if let Some(ke_union::Union::Chr(chr)) = &event.union {
// chr contains USB HID scancode on Windows, X11 keycode on Linux
if let Some(key) = keycode_to_hid(*chr) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent {
event_type,
key,
modifiers,
is_usb_hid: true, // Already converted to USB HID code
});
}
}
@@ -608,6 +608,6 @@ mod tests {
let kb_event = result.unwrap();
assert_eq!(kb_event.event_type, KeyEventType::Down);
assert_eq!(kb_event.key, 0x28); // Return key USB HID code
assert_eq!(kb_event.key, CanonicalKey::Enter);
}
}

View File

@@ -36,8 +36,8 @@ use tracing::{debug, error, info, warn};
use crate::audio::AudioController;
use crate::hid::HidController;
use crate::video::stream_manager::VideoStreamManager;
use crate::utils::bind_tcp_listener;
use crate::video::stream_manager::VideoStreamManager;
use self::config::RustDeskConfig;
use self::connection::ConnectionManager;
@@ -559,6 +559,7 @@ impl RustDeskService {
/// 2. Send RelayResponse with client's socket_addr
/// 3. Connect to RELAY server
/// 4. Accept connection without waiting for response
#[allow(clippy::too_many_arguments)]
async fn handle_relay_request(
rendezvous_addr: &str,
relay_server: &str,

View File

@@ -536,6 +536,10 @@ impl RendezvousMediator {
}
}
Some(rendezvous_message::Union::PunchHole(ph)) => {
let config = self.config.read().clone();
let effective_relay_server =
select_relay_server(config.relay_server.as_deref(), &ph.relay_server);
// Decode the peer's socket address
let peer_addr = if !ph.socket_addr.is_empty() {
AddrMangle::decode(&ph.socket_addr)
@@ -544,8 +548,12 @@ impl RendezvousMediator {
};
info!(
"Received PunchHole request: peer_addr={:?}, socket_addr_len={}, relay_server={}, nat_type={:?}",
peer_addr, ph.socket_addr.len(), ph.relay_server, ph.nat_type
"Received PunchHole request: peer_addr={:?}, socket_addr_len={}, relay_server={}, effective_relay_server={}, nat_type={:?}",
peer_addr,
ph.socket_addr.len(),
ph.relay_server,
effective_relay_server.as_deref().unwrap_or(""),
ph.nat_type
);
// Send PunchHoleSent to acknowledge
@@ -555,13 +563,19 @@ impl RendezvousMediator {
info!(
"Sending PunchHoleSent: id={}, peer_addr={:?}, relay_server={}",
id, peer_addr, ph.relay_server
id,
peer_addr,
effective_relay_server
.as_deref()
.unwrap_or(ph.relay_server.as_str())
);
let msg = make_punch_hole_sent(
&ph.socket_addr.to_vec(), // Use peer's socket_addr, not ours
&ph.socket_addr, // Use peer's socket_addr, not ours
&id,
&ph.relay_server,
effective_relay_server
.as_deref()
.unwrap_or(ph.relay_server.as_str()),
ph.nat_type.enum_value().unwrap_or(NatType::UNKNOWN_NAT),
env!("CARGO_PKG_VERSION"),
);
@@ -573,16 +587,10 @@ impl RendezvousMediator {
}
// Try P2P direct connection first, fall back to relay if needed
if !ph.relay_server.is_empty() {
let relay_server = if ph.relay_server.contains(':') {
ph.relay_server.clone()
} else {
format!("{}:21117", ph.relay_server)
};
if let Some(relay_server) = effective_relay_server {
// Generate a standard UUID v4 for relay pairing
// This must match the format used by RustDesk client
let uuid = uuid::Uuid::new_v4().to_string();
let config = self.config.read().clone();
let rendezvous_addr = config.rendezvous_addr();
let device_id = config.device_id.clone();
@@ -606,21 +614,25 @@ impl RendezvousMediator {
device_id,
);
}
} else {
debug!("No relay server available for PunchHole, skipping relay fallback");
}
}
Some(rendezvous_message::Union::RequestRelay(rr)) => {
let config = self.config.read().clone();
let effective_relay_server =
select_relay_server(config.relay_server.as_deref(), &rr.relay_server);
info!(
"Received RequestRelay: relay_server={}, uuid={}, secure={}",
rr.relay_server, rr.uuid, rr.secure
"Received RequestRelay: relay_server={}, effective_relay_server={}, uuid={}, secure={}",
rr.relay_server,
effective_relay_server.as_deref().unwrap_or(""),
rr.uuid,
rr.secure
);
// Call the relay callback to handle the connection
if let Some(callback) = self.relay_callback.read().as_ref() {
let relay_server = if rr.relay_server.contains(':') {
rr.relay_server.clone()
} else {
format!("{}:21117", rr.relay_server)
};
let config = self.config.read().clone();
if let Some(relay_server) = effective_relay_server {
let rendezvous_addr = config.rendezvous_addr();
let device_id = config.device_id.clone();
callback(
@@ -630,17 +642,28 @@ impl RendezvousMediator {
rr.socket_addr.to_vec(),
device_id,
);
} else {
debug!("No relay server available for RequestRelay callback");
}
}
}
Some(rendezvous_message::Union::FetchLocalAddr(fla)) => {
let config = self.config.read().clone();
let effective_relay_server =
select_relay_server(config.relay_server.as_deref(), &fla.relay_server)
.unwrap_or_default();
// Decode the peer address for logging
let peer_addr = AddrMangle::decode(&fla.socket_addr);
info!(
"Received FetchLocalAddr request: peer_addr={:?}, socket_addr_len={}, relay_server={}",
peer_addr, fla.socket_addr.len(), fla.relay_server
"Received FetchLocalAddr request: peer_addr={:?}, socket_addr_len={}, relay_server={}, effective_relay_server={}",
peer_addr,
fla.socket_addr.len(),
fla.relay_server,
effective_relay_server
);
// Respond with our local address for same-LAN direct connection
self.send_local_addr(socket, &fla.socket_addr, &fla.relay_server)
self.send_local_addr(socket, &fla.socket_addr, &effective_relay_server)
.await?;
}
Some(rendezvous_message::Union::ConfigureUpdate(cu)) => {
@@ -692,6 +715,25 @@ impl RendezvousMediator {
/// This encoding mangles the address to avoid detection.
pub struct AddrMangle;
fn normalize_relay_server(server: &str) -> Option<String> {
let trimmed = server.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.contains(':') {
Some(trimmed.to_string())
} else {
Some(format!("{}:21117", trimmed))
}
}
fn select_relay_server(local_relay: Option<&str>, server_relay: &str) -> Option<String> {
local_relay
.and_then(normalize_relay_server)
.or_else(|| normalize_relay_server(server_relay))
}
impl AddrMangle {
/// Encode a SocketAddr to bytes using RustDesk's mangle algorithm
pub fn encode(addr: SocketAddr) -> Vec<u8> {
@@ -876,3 +918,47 @@ fn get_local_addresses() -> Vec<std::net::IpAddr> {
addrs
}
#[cfg(test)]
mod tests {
use super::{normalize_relay_server, select_relay_server};
#[test]
fn test_normalize_relay_server() {
assert_eq!(normalize_relay_server(""), None);
assert_eq!(normalize_relay_server(" "), None);
assert_eq!(
normalize_relay_server("relay.example.com"),
Some("relay.example.com:21117".to_string())
);
assert_eq!(
normalize_relay_server("relay.example.com:22117"),
Some("relay.example.com:22117".to_string())
);
}
#[test]
fn test_select_relay_server_prefers_local() {
assert_eq!(
select_relay_server(Some("local.example.com:21117"), "server.example.com:21117"),
Some("local.example.com:21117".to_string())
);
assert_eq!(
select_relay_server(Some("local.example.com"), "server.example.com:21117"),
Some("local.example.com:21117".to_string())
);
assert_eq!(
select_relay_server(Some(" "), "server.example.com"),
Some("server.example.com:21117".to_string())
);
assert_eq!(
select_relay_server(None, "server.example.com:21117"),
Some("server.example.com:21117".to_string())
);
assert_eq!(select_relay_server(None, ""), None);
}
}

View File

@@ -1,5 +1,5 @@
use std::{collections::VecDeque, sync::Arc};
use tokio::sync::{broadcast, RwLock};
use tokio::sync::{broadcast, watch, RwLock};
use crate::atx::AtxController;
use crate::audio::AudioController;
@@ -7,13 +7,15 @@ use crate::auth::{SessionStore, UserStore};
use crate::config::ConfigStore;
use crate::events::{
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
VideoDeviceInfo,
TtydDeviceInfo, VideoDeviceInfo,
};
use crate::extensions::ExtensionManager;
use crate::extensions::{ExtensionId, ExtensionManager};
use crate::hid::HidController;
use crate::msd::MsdController;
use crate::otg::OtgService;
use crate::rtsp::RtspService;
use crate::rustdesk::RustDeskService;
use crate::update::UpdateService;
use crate::video::VideoStreamManager;
/// Application-wide state shared across handlers
@@ -50,10 +52,16 @@ pub struct AppState {
pub audio: Arc<AudioController>,
/// RustDesk remote access service (optional)
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
/// RTSP streaming service (optional)
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
/// Extension manager (ttyd, gostc, easytier)
pub extensions: Arc<ExtensionManager>,
/// Event bus for real-time notifications
pub events: Arc<EventBus>,
/// Latest device info snapshot for WebSocket clients
device_info_tx: watch::Sender<Option<SystemEvent>>,
/// Online update service
pub update: Arc<UpdateService>,
/// Shutdown signal sender
pub shutdown_tx: broadcast::Sender<()>,
/// Recently revoked session IDs (for client kick detection)
@@ -64,6 +72,7 @@ pub struct AppState {
impl AppState {
/// Create new application state
#[allow(clippy::too_many_arguments)]
pub fn new(
config: ConfigStore,
sessions: SessionStore,
@@ -75,11 +84,15 @@ impl AppState {
atx: Option<AtxController>,
audio: Arc<AudioController>,
rustdesk: Option<Arc<RustDeskService>>,
rtsp: Option<Arc<RtspService>>,
extensions: Arc<ExtensionManager>,
events: Arc<EventBus>,
update: Arc<UpdateService>,
shutdown_tx: broadcast::Sender<()>,
data_dir: std::path::PathBuf,
) -> Arc<Self> {
let (device_info_tx, _device_info_rx) = watch::channel(None);
Arc::new(Self {
config,
sessions,
@@ -91,8 +104,11 @@ impl AppState {
atx: Arc::new(RwLock::new(atx)),
audio,
rustdesk: Arc::new(RwLock::new(rustdesk)),
rtsp: Arc::new(RwLock::new(rtsp)),
extensions,
events,
device_info_tx,
update,
shutdown_tx,
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
data_dir,
@@ -109,6 +125,11 @@ impl AppState {
self.shutdown_tx.subscribe()
}
/// Subscribe to the latest device info snapshot.
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
self.device_info_tx.subscribe()
}
/// Record revoked session IDs (bounded queue)
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
if session_ids.is_empty() {
@@ -136,12 +157,13 @@ impl AppState {
/// Uses tokio::join! to collect all device info in parallel for better performance.
pub async fn get_device_info(&self) -> SystemEvent {
// Collect all device info in parallel
let (video, hid, msd, atx, audio) = tokio::join!(
let (video, hid, msd, atx, audio, ttyd) = tokio::join!(
self.collect_video_info(),
self.collect_hid_info(),
self.collect_msd_info(),
self.collect_atx_info(),
self.collect_audio_info(),
self.collect_ttyd_info(),
);
SystemEvent::DeviceInfo {
@@ -150,13 +172,14 @@ impl AppState {
msd,
atx,
audio,
ttyd,
}
}
/// Publish DeviceInfo event to all connected WebSocket clients
pub async fn publish_device_info(&self) {
let device_info = self.get_device_info().await;
self.events.publish(device_info);
let _ = self.device_info_tx.send(Some(device_info));
}
/// Collect video device information
@@ -167,32 +190,19 @@ impl AppState {
/// Collect HID device information
async fn collect_hid_info(&self) -> HidDeviceInfo {
let info = self.hid.info().await;
let backend_type = self.hid.backend_type().await;
let state = self.hid.snapshot().await;
match info {
Some(hid_info) => HidDeviceInfo {
available: true,
backend: hid_info.name.to_string(),
initialized: hid_info.initialized,
supports_absolute_mouse: hid_info.supports_absolute_mouse,
device: match backend_type {
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
_ => None,
},
error: None,
},
None => HidDeviceInfo {
available: false,
backend: backend_type.name_str().to_string(),
initialized: false,
supports_absolute_mouse: false,
device: match backend_type {
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
_ => None,
},
error: Some("HID backend not available".to_string()),
},
HidDeviceInfo {
available: state.available,
backend: state.backend,
initialized: state.initialized,
online: state.online,
supports_absolute_mouse: state.supports_absolute_mouse,
keyboard_leds_enabled: state.keyboard_leds_enabled,
led_state: state.led_state,
device: state.device,
error: state.error,
error_code: state.error_code,
}
}
@@ -202,6 +212,7 @@ impl AppState {
let msd = msd_guard.as_ref()?;
let state = msd.state().await;
let error = msd.monitor().error_message().await;
Some(MsdDeviceInfo {
available: state.available,
mode: match state.mode {
@@ -212,7 +223,7 @@ impl AppState {
.to_string(),
connected: state.connected,
image_id: state.current_image.map(|img| img.id),
error: None,
error,
})
}
@@ -255,4 +266,14 @@ impl AppState {
error: status.error,
})
}
/// Collect ttyd status information
async fn collect_ttyd_info(&self) -> TtydDeviceInfo {
let status = self.extensions.status(ExtensionId::Ttyd).await;
TtydDeviceInfo {
available: self.extensions.check_available(ExtensionId::Ttyd),
running: status.is_running(),
}
}
}

View File

@@ -1,719 +0,0 @@
//! MJPEG Streamer - High-level MJPEG/HTTP streaming manager
//!
//! This module provides a unified interface for MJPEG streaming mode,
//! integrating video capture, MJPEG distribution, and WebSocket HID.
//!
//! # Architecture
//!
//! ```text
//! MjpegStreamer
//! |
//! +-- VideoCapturer (V4L2 video capture)
//! +-- MjpegStreamHandler (HTTP multipart video)
//! +-- WsHidHandler (WebSocket HID)
//! ```
//!
//! Note: Audio WebSocket is handled separately by audio_ws.rs (/api/ws/audio)
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tracing::{error, info, warn};
use v4l::buffer::Type as BufferType;
use v4l::io::traits::CaptureStream;
use v4l::prelude::*;
use v4l::video::Capture;
use v4l::video::capture::Parameters;
use v4l::Format;
use crate::audio::AudioController;
use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::hid::HidController;
use crate::video::capture::{CaptureConfig, VideoCapturer};
use crate::video::device::{enumerate_devices, find_best_device, VideoDeviceInfo};
use crate::video::format::{PixelFormat, Resolution};
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use super::mjpeg::MjpegStreamHandler;
use super::ws_hid::WsHidHandler;
/// Minimum valid frame size for capture
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
/// Validate JPEG header every N frames to reduce overhead
const JPEG_VALIDATE_INTERVAL: u64 = 30;
/// MJPEG streamer configuration
#[derive(Debug, Clone)]
pub struct MjpegStreamerConfig {
/// Device path (None = auto-detect)
pub device_path: Option<PathBuf>,
/// Desired resolution
pub resolution: Resolution,
/// Desired format
pub format: PixelFormat,
/// Desired FPS
pub fps: u32,
/// JPEG quality (1-100)
pub jpeg_quality: u8,
}
impl Default for MjpegStreamerConfig {
fn default() -> Self {
Self {
device_path: None,
resolution: Resolution::HD1080,
format: PixelFormat::Mjpeg,
fps: 30,
jpeg_quality: 80,
}
}
}
/// MJPEG streamer state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MjpegStreamerState {
/// Not initialized
Uninitialized,
/// Ready but not streaming
Ready,
/// Actively streaming
Streaming,
/// No video signal
NoSignal,
/// Error occurred
Error,
}
impl std::fmt::Display for MjpegStreamerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MjpegStreamerState::Uninitialized => write!(f, "uninitialized"),
MjpegStreamerState::Ready => write!(f, "ready"),
MjpegStreamerState::Streaming => write!(f, "streaming"),
MjpegStreamerState::NoSignal => write!(f, "no_signal"),
MjpegStreamerState::Error => write!(f, "error"),
}
}
}
/// MJPEG streamer statistics
#[derive(Debug, Clone, Default)]
pub struct MjpegStreamerStats {
/// Current state
pub state: String,
/// Current device path
pub device: Option<String>,
/// Video resolution
pub resolution: Option<(u32, u32)>,
/// Video format
pub format: Option<String>,
/// Current FPS
pub fps: u32,
/// MJPEG client count
pub mjpeg_clients: u64,
/// WebSocket HID client count
pub ws_hid_clients: usize,
}
/// MJPEG Streamer
///
/// High-level manager for MJPEG/HTTP streaming mode.
/// Integrates video capture, MJPEG distribution, and WebSocket HID.
pub struct MjpegStreamer {
// === Video ===
config: RwLock<MjpegStreamerConfig>,
capturer: RwLock<Option<Arc<VideoCapturer>>>,
mjpeg_handler: Arc<MjpegStreamHandler>,
current_device: RwLock<Option<VideoDeviceInfo>>,
state: RwLock<MjpegStreamerState>,
// === Audio (controller reference only, WS handled by audio_ws.rs) ===
audio_controller: RwLock<Option<Arc<AudioController>>>,
audio_enabled: AtomicBool,
// === HID ===
ws_hid_handler: Arc<WsHidHandler>,
hid_controller: RwLock<Option<Arc<HidController>>>,
// === Control ===
start_lock: tokio::sync::Mutex<()>,
direct_stop: AtomicBool,
direct_active: AtomicBool,
direct_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
events: RwLock<Option<Arc<EventBus>>>,
config_changing: AtomicBool,
}
impl MjpegStreamer {
/// Create a new MJPEG streamer
pub fn new() -> Arc<Self> {
Arc::new(Self {
config: RwLock::new(MjpegStreamerConfig::default()),
capturer: RwLock::new(None),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
current_device: RwLock::new(None),
state: RwLock::new(MjpegStreamerState::Uninitialized),
audio_controller: RwLock::new(None),
audio_enabled: AtomicBool::new(false),
ws_hid_handler: WsHidHandler::new(),
hid_controller: RwLock::new(None),
start_lock: tokio::sync::Mutex::new(()),
direct_stop: AtomicBool::new(false),
direct_active: AtomicBool::new(false),
direct_handle: Mutex::new(None),
events: RwLock::new(None),
config_changing: AtomicBool::new(false),
})
}
/// Create with specific config
pub fn with_config(config: MjpegStreamerConfig) -> Arc<Self> {
Arc::new(Self {
config: RwLock::new(config),
capturer: RwLock::new(None),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
current_device: RwLock::new(None),
state: RwLock::new(MjpegStreamerState::Uninitialized),
audio_controller: RwLock::new(None),
audio_enabled: AtomicBool::new(false),
ws_hid_handler: WsHidHandler::new(),
hid_controller: RwLock::new(None),
start_lock: tokio::sync::Mutex::new(()),
direct_stop: AtomicBool::new(false),
direct_active: AtomicBool::new(false),
direct_handle: Mutex::new(None),
events: RwLock::new(None),
config_changing: AtomicBool::new(false),
})
}
// ========================================================================
// Configuration and Setup
// ========================================================================
/// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Set audio controller (for reference, WebSocket handled by audio_ws.rs)
pub async fn set_audio_controller(&self, audio: Arc<AudioController>) {
*self.audio_controller.write().await = Some(audio);
info!("MjpegStreamer: Audio controller set");
}
/// Set HID controller
pub async fn set_hid_controller(&self, hid: Arc<HidController>) {
*self.hid_controller.write().await = Some(hid.clone());
self.ws_hid_handler.set_hid_controller(hid);
info!("MjpegStreamer: HID controller set");
}
/// Enable or disable audio
pub fn set_audio_enabled(&self, enabled: bool) {
self.audio_enabled.store(enabled, Ordering::SeqCst);
}
/// Check if audio is enabled
pub fn is_audio_enabled(&self) -> bool {
self.audio_enabled.load(Ordering::SeqCst)
}
// ========================================================================
// State and Status
// ========================================================================
/// Get current state
pub async fn state(&self) -> MjpegStreamerState {
*self.state.read().await
}
/// Check if config is currently being changed
pub fn is_config_changing(&self) -> bool {
self.config_changing.load(Ordering::SeqCst)
}
/// Get current device info
pub async fn current_device(&self) -> Option<VideoDeviceInfo> {
self.current_device.read().await.clone()
}
/// Get statistics
pub async fn stats(&self) -> MjpegStreamerStats {
let state = *self.state.read().await;
let device = self.current_device.read().await;
let config = self.config.read().await;
let (resolution, format) = {
if self.direct_active.load(Ordering::Relaxed) {
(
Some((config.resolution.width, config.resolution.height)),
Some(config.format.to_string()),
)
} else if let Some(ref cap) = *self.capturer.read().await {
let _ = cap;
(
Some((config.resolution.width, config.resolution.height)),
Some(config.format.to_string()),
)
} else {
(None, None)
}
};
MjpegStreamerStats {
state: state.to_string(),
device: device.as_ref().map(|d| d.path.display().to_string()),
resolution,
format,
fps: config.fps,
mjpeg_clients: self.mjpeg_handler.client_count(),
ws_hid_clients: self.ws_hid_handler.client_count(),
}
}
// ========================================================================
// Handler Access
// ========================================================================
/// Get MJPEG handler for HTTP streaming
pub fn mjpeg_handler(&self) -> Arc<MjpegStreamHandler> {
self.mjpeg_handler.clone()
}
/// Get WebSocket HID handler
pub fn ws_hid_handler(&self) -> Arc<WsHidHandler> {
self.ws_hid_handler.clone()
}
// ========================================================================
// Initialization
// ========================================================================
/// Initialize with auto-detected device
pub async fn init_auto(self: &Arc<Self>) -> Result<()> {
let best = find_best_device()?;
self.init_with_device(best).await
}
/// Initialize with specific device
pub async fn init_with_device(self: &Arc<Self>, device: VideoDeviceInfo) -> Result<()> {
info!(
"MjpegStreamer: Initializing with device: {}",
device.path.display()
);
let config = self.config.read().await.clone();
self.mjpeg_handler.set_jpeg_quality(config.jpeg_quality);
// Create capture config
let capture_config = CaptureConfig {
device_path: device.path.clone(),
resolution: config.resolution,
format: config.format,
fps: config.fps,
buffer_count: 4,
timeout: std::time::Duration::from_secs(5),
jpeg_quality: config.jpeg_quality,
};
// Create capturer
let capturer = Arc::new(VideoCapturer::new(capture_config));
// Store device and capturer
*self.current_device.write().await = Some(device);
*self.capturer.write().await = Some(capturer);
*self.state.write().await = MjpegStreamerState::Ready;
self.publish_state_change().await;
Ok(())
}
// ========================================================================
// Streaming Control
// ========================================================================
/// Start streaming
pub async fn start(self: &Arc<Self>) -> Result<()> {
let _lock = self.start_lock.lock().await;
if self.config_changing.load(Ordering::SeqCst) {
return Err(AppError::VideoError(
"Config change in progress".to_string(),
));
}
let state = *self.state.read().await;
if state == MjpegStreamerState::Streaming {
return Ok(());
}
let device = self
.current_device
.read()
.await
.clone()
.ok_or_else(|| AppError::VideoError("Not initialized".to_string()))?;
let config = self.config.read().await.clone();
self.direct_stop.store(false, Ordering::SeqCst);
self.direct_active.store(true, Ordering::SeqCst);
let streamer = self.clone();
let handle = tokio::task::spawn_blocking(move || {
streamer.run_direct_capture(device.path, config);
});
*self.direct_handle.lock().await = Some(handle);
// Note: Audio WebSocket is handled separately by audio_ws.rs (/api/ws/audio)
*self.state.write().await = MjpegStreamerState::Streaming;
self.mjpeg_handler.set_online();
self.publish_state_change().await;
info!("MjpegStreamer: Streaming started");
Ok(())
}
/// Stop streaming
pub async fn stop(&self) -> Result<()> {
let state = *self.state.read().await;
if state != MjpegStreamerState::Streaming {
return Ok(());
}
self.direct_stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.direct_handle.lock().await.take() {
let _ = handle.await;
}
self.direct_active.store(false, Ordering::SeqCst);
// Stop capturer (legacy path)
if let Some(ref cap) = *self.capturer.read().await {
let _ = cap.stop().await;
}
// Set offline
self.mjpeg_handler.set_offline();
*self.state.write().await = MjpegStreamerState::Ready;
self.publish_state_change().await;
info!("MjpegStreamer: Streaming stopped");
Ok(())
}
/// Check if streaming
pub async fn is_streaming(&self) -> bool {
*self.state.read().await == MjpegStreamerState::Streaming
}
// ========================================================================
// Configuration Updates
// ========================================================================
/// Apply video configuration
///
/// This stops the current stream, reconfigures the capturer, and restarts.
pub async fn apply_config(self: &Arc<Self>, config: MjpegStreamerConfig) -> Result<()> {
info!("MjpegStreamer: Applying config: {:?}", config);
self.config_changing.store(true, Ordering::SeqCst);
// Stop current stream
self.stop().await?;
// Disconnect all MJPEG clients
self.mjpeg_handler.disconnect_all_clients();
// Release capturer
*self.capturer.write().await = None;
// Update config
*self.config.write().await = config.clone();
self.mjpeg_handler.set_jpeg_quality(config.jpeg_quality);
// Re-initialize if device path is set
if let Some(ref path) = config.device_path {
let devices = enumerate_devices()?;
let device = devices
.into_iter()
.find(|d| d.path == *path)
.ok_or_else(|| {
AppError::VideoError(format!("Device not found: {}", path.display()))
})?;
self.init_with_device(device).await?;
}
self.config_changing.store(false, Ordering::SeqCst);
self.publish_state_change().await;
Ok(())
}
// ========================================================================
// Internal
// ========================================================================
/// Publish state change event
async fn publish_state_change(&self) {
if let Some(ref events) = *self.events.read().await {
let state = *self.state.read().await;
let device = self.current_device.read().await;
events.publish(SystemEvent::StreamStateChanged {
state: state.to_string(),
device: device.as_ref().map(|d| d.path.display().to_string()),
});
}
}
/// Direct capture loop for MJPEG mode (single loop, no broadcast)
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, config: MjpegStreamerConfig) {
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
let handle = tokio::runtime::Handle::current();
let mut last_state = MjpegStreamerState::Streaming;
let mut set_state = |new_state: MjpegStreamerState| {
if new_state != last_state {
handle.block_on(async {
*self.state.write().await = new_state;
self.publish_state_change().await;
});
last_state = new_state;
}
};
let mut device_opt: Option<Device> = None;
let mut format_opt: Option<Format> = None;
let mut last_error: Option<String> = None;
for attempt in 0..MAX_RETRIES {
if self.direct_stop.load(Ordering::Relaxed) {
self.direct_active.store(false, Ordering::SeqCst);
return;
}
let device = match Device::with_path(&device_path) {
Ok(d) => d,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("busy") || err_str.contains("resource") {
warn!(
"Device busy on attempt {}/{}, retrying in {}ms...",
attempt + 1,
MAX_RETRIES,
RETRY_DELAY_MS
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
last_error = Some(err_str);
continue;
}
last_error = Some(err_str);
break;
}
};
let requested = Format::new(
config.resolution.width,
config.resolution.height,
config.format.to_fourcc(),
);
match device.set_format(&requested) {
Ok(actual) => {
device_opt = Some(device);
format_opt = Some(actual);
break;
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("busy") || err_str.contains("resource") {
warn!(
"Device busy on set_format attempt {}/{}, retrying in {}ms...",
attempt + 1,
MAX_RETRIES,
RETRY_DELAY_MS
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
last_error = Some(err_str);
continue;
}
last_error = Some(err_str);
break;
}
}
}
let (device, actual_format) = match (device_opt, format_opt) {
(Some(d), Some(f)) => (d, f),
_ => {
error!(
"Failed to open device {:?}: {}",
device_path,
last_error.unwrap_or_else(|| "unknown error".to_string())
);
set_state(MjpegStreamerState::Error);
self.mjpeg_handler.set_offline();
self.direct_active.store(false, Ordering::SeqCst);
return;
}
};
info!(
"Capture format: {}x{} {:?} stride={}",
actual_format.width, actual_format.height, actual_format.fourcc, actual_format.stride
);
let resolution = Resolution::new(actual_format.width, actual_format.height);
let pixel_format =
PixelFormat::from_fourcc(actual_format.fourcc).unwrap_or(config.format);
if config.fps > 0 {
if let Err(e) = device.set_params(&Parameters::with_fps(config.fps)) {
warn!("Failed to set hardware FPS: {}", e);
}
}
let mut stream = match MmapStream::with_buffers(&device, BufferType::VideoCapture, 4) {
Ok(s) => s,
Err(e) => {
error!("Failed to create capture stream: {}", e);
set_state(MjpegStreamerState::Error);
self.mjpeg_handler.set_offline();
self.direct_active.store(false, Ordering::SeqCst);
return;
}
};
let buffer_pool = Arc::new(FrameBufferPool::new(8));
let mut signal_present = true;
let mut sequence: u64 = 0;
let mut validate_counter: u64 = 0;
while !self.direct_stop.load(Ordering::Relaxed) {
let (buf, meta) = match stream.next() {
Ok(frame_data) => frame_data,
Err(e) => {
if e.kind() == io::ErrorKind::TimedOut {
if signal_present {
signal_present = false;
set_state(MjpegStreamerState::NoSignal);
}
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
let is_device_lost = match e.raw_os_error() {
Some(6) => true, // ENXIO
Some(19) => true, // ENODEV
Some(5) => true, // EIO
Some(32) => true, // EPIPE
Some(108) => true, // ESHUTDOWN
_ => false,
};
if is_device_lost {
error!("Video device lost: {} - {}", device_path.display(), e);
set_state(MjpegStreamerState::Error);
self.mjpeg_handler.set_offline();
self.direct_active.store(false, Ordering::SeqCst);
return;
}
error!("Capture error: {}", e);
continue;
}
};
let frame_size = meta.bytesused as usize;
if frame_size < MIN_CAPTURE_FRAME_SIZE {
continue;
}
validate_counter = validate_counter.wrapping_add(1);
if pixel_format.is_compressed()
&& validate_counter % JPEG_VALIDATE_INTERVAL == 0
&& !VideoFrame::is_valid_jpeg_bytes(&buf[..frame_size])
{
continue;
}
let mut owned = buffer_pool.take(frame_size);
owned.resize(frame_size, 0);
owned[..frame_size].copy_from_slice(&buf[..frame_size]);
let frame = VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
resolution,
pixel_format,
actual_format.stride,
sequence,
);
sequence = sequence.wrapping_add(1);
if !signal_present {
signal_present = true;
set_state(MjpegStreamerState::Streaming);
}
self.mjpeg_handler.update_frame(frame);
}
self.direct_active.store(false, Ordering::SeqCst);
}
}
impl Default for MjpegStreamer {
fn default() -> Self {
Self {
config: RwLock::new(MjpegStreamerConfig::default()),
capturer: RwLock::new(None),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
current_device: RwLock::new(None),
state: RwLock::new(MjpegStreamerState::Uninitialized),
audio_controller: RwLock::new(None),
audio_enabled: AtomicBool::new(false),
ws_hid_handler: WsHidHandler::new(),
hid_controller: RwLock::new(None),
start_lock: tokio::sync::Mutex::new(()),
direct_stop: AtomicBool::new(false),
direct_active: AtomicBool::new(false),
direct_handle: Mutex::new(None),
events: RwLock::new(None),
config_changing: AtomicBool::new(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mjpeg_streamer_creation() {
let streamer = MjpegStreamer::new();
assert!(!streamer.is_config_changing());
assert!(!streamer.is_audio_enabled());
}
#[test]
fn test_mjpeg_streamer_config_default() {
let config = MjpegStreamerConfig::default();
assert_eq!(config.resolution, Resolution::HD1080);
assert_eq!(config.format, PixelFormat::Mjpeg);
assert_eq!(config.fps, 30);
}
#[test]
fn test_mjpeg_streamer_state_display() {
assert_eq!(MjpegStreamerState::Streaming.to_string(), "streaming");
assert_eq!(MjpegStreamerState::Ready.to_string(), "ready");
}
}

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