Compare commits

..

28 Commits

Author SHA1 Message Date
SilentWind
64386b92ab Merge pull request #321 from mofeng-git/dev
Dev
2026-10-28 22:40:48 +08:00
mofeng-git
a351b67b6e chore: bump version to v0.2.9 2026-10-28 21:41:23 +08:00
mofeng-git
cd48df752c refactor: 清理未使用代码并整合运行时配置逻辑 2026-10-28 21:39:27 +08:00
mofeng-git
63ba4c3980 Merge pull request #308: preserve relative mouse movement
The applicable relative movement changes were adapted and tested in 2e4d4bcd. Retain that implementation, the current bounded connection writer, and the trackpad support from #320 rather than restoring the obsolete connection code.
2026-10-28 14:35:24 +08:00
mofeng-git
2e4d4bcd7f fix(rustdesk): preserve throttled relative mouse movement
Accumulate relative deltas in the existing pending move buffer and flush them on the existing timer or before button and wheel events. Preserve absolute coordinate replacement and trackpad throttling.

Split relative motion after HID queue coalescing so OTG and CH9329 reports preserve bounded displacement under congestion. Add socket timer, input ordering, trackpad, and queue saturation regression tests.

Adapted from the relative mouse improvements proposed in #308 (03866aa4), retaining the current connection writer architecture.
2026-10-28 12:41:51 +08:00
mofeng-git
5ddfc21bc1 Merge pull request #320: fix RustDesk trackpad scrolling 2026-10-28 12:29:25 +08:00
mofeng-git
d6eaeaccbe refactor(web): 精简前端结构与未使用代码
抽离远程访问设置逻辑,合并配置缓存、并发请求及错误处理,修复共享 loading 状态提前结束。统一控制台事件订阅与清理,移除未使用组件、旧 composable 和冗余状态。

已通过前端生产构建、模拟 API 浏览器检查和 OpenWrt ARMv7 部署验证。
2026-10-28 07:54:10 +08:00
mofeng-git
3c15168ddc feat(hid): 添加 Linux 键盘与 SysRq 快捷键 (#307)
控制台增加独立快捷键菜单,提供常用组合键和 Linux SysRq 指令,虚拟键盘补充 Linux 布局。
通过可靠且有序的 WebSocket 发送组合键,取消或失败时逐一释放按键,防止修饰键残留。
执行期间避免物理键盘与虚拟键盘交错输入,断连、失焦和切换布局时停止执行。
修正焦点移动后的物理按键释放,并将原虚拟键盘组合键迁移到统一菜单。

验证:独立暂存快照前端构建通过;6 项快捷键单测及 5 组浏览器测试通过,覆盖组合键序列、失败清理、取消及键盘焦点。

Refs #307
2026-10-28 07:21:52 +08:00
mofeng-git
8d58119b77 feat(system): 添加设备信息及重启关机控制
关于页展示宿主系统、处理器、架构、内核和资源占用,合并同一文件系统上的系统与 MSD 容量。
新增 Linux 宿主机重启和关机接口,在其他设置中提供二次确认、请求互斥及失败提示。
修正 Linux 磁盘已用空间计算,并补充设备信息和电源控制的后端单元测试。

验证:独立暂存快照前端构建通过;6 项后端测试通过;设置页保存、系统信息及模拟电源控制浏览器测试通过。
2026-10-28 07:20:44 +08:00
mofeng-git
fed753261b feat(web): 改版设置页与控制台界面
统一登录页、控制台和设置页的视觉样式,固定控制台设置入口并补充控件展开状态。
设置页改为按卡片保存,分别处理采集、编码、连接、HID 功能和设备标识,保留相邻卡片草稿。
增加共享帮助提示和移动端导航,固定前端构建依赖。

验证:独立暂存快照前端构建通过;控制台浏览器测试 5 组通过,覆盖登录、三种布局、窄屏、主题和语言切换。
2026-10-28 07:18:59 +08:00
mofeng-git
fc99c6f274 feat(hid): 支持控制台鼠标抖动
在控制台鼠键配置菜单增加鼠标抖动开关和间隔设置,默认关闭,间隔支持 10–3600 秒。
设备端按键鼠空闲时间调度,支持配置持久化、热更新、真实输入打断和断连后重新计时。
兼容绝对及相对鼠标模式,按键或按钮按住期间暂停抖动。

验证:608 项 Rust 单测通过;暂存快照前端构建和浏览器交互测试通过;已完成 ARMv7 OpenWrt 部署验证。

Refs #316
2026-10-28 07:11:44 +08:00
toruticas
85123e1118 fix(rustdesk): handle trackpad scrolling 2026-10-27 01:52:42 +00:00
mofeng-git
f303a8cb2f feat(hid): 支持 CH9329 + OTG 组合模式
CH9329 负责键鼠,OTG 提供虚拟媒体、USB 网络和音频;分别配置设备、报告运行状态,并在 OTG 配置变更和回滚时保留未变化的 CH9329 实例。

补齐双设备校验、串口冲突检查、初始化引导和功能入口,修复组合模式下鼠标兼容选项缺失。

验证:独立功能版本的 591 项 Rust 库测试、前端生产构建、3 项引导逻辑测试及 2 组浏览器回归通过。未进行目标 KVM 硬件验证。
2026-10-26 14:33:38 +08:00
mofeng-git
b4161f3534 fix(deb): 修正 amd64 的 libmfx 运行依赖 (#319)
amd64 程序直接链接 libmfx.so.1,由 libmfx1 提供。移除 libmfx-gen1.2 的替代依赖,避免依赖满足但缺少实际动态库导致启动失败。
2026-10-19 14:01:07 +08:00
mofeng-git
7ea2936436 feat(mcp): 用 MCP 服务替换内置 AI Agent (#319)
在现有 HTTP 服务挂载 /mcp,让外部 AI 经共享 API Key 调用原分辨率截图、批量键鼠、ATX/WOL 和虚拟介质八个工具。所有调用共用有界 FIFO 和全局限流,复用现有采集管线与硬件控制器。

移除内置模型调用、网页 Agent 面板及旧配置,设置页提供可持续查看的 Key、统一的地址/密钥复制样式。补齐 DEB 的 libudev1 运行依赖。

验证:583 项 Rust 库测试、前端生产构建、ARMv7 设备视觉操作与介质/WOL 实测,以及默认 Release 构建和 DEB 包校验通过。

Refs #319
2026-10-19 12:12:03 +08:00
mofeng-git
3f970ee834 feat(extensions): 使用 EasyFrp 替换 gostc
将扩展配置、接口和设置页切换为 EasyFrp,支持按行填写多个客户端连接 URL,通过 easyfrpc control 启动并校验协议与密钥路径。

旧 gostc 配置不自动启用 EasyFrp,升级后需重新配置;启动日志不再输出命令参数,避免暴露连接信息。

完整 Docker 镜像包含 easyfrpc、easytier-core、easytier-cli 和 frpc,通过独立下载脚本及版本锁定文件校验下载摘要和二进制架构。
2026-10-18 18:09:59 +08:00
mofeng-git
c4344e4a1c feat(web): 支持点击键盘状态灯切换锁定状态
为控制台普通和紧凑布局的 Caps、Num、Scroll 状态灯增加点击操作及中英文悬停提示,通过现有 HID 通道发送对应按键的按下和释放事件。

保留远端状态灯反馈,在 HID 未连接、按键已按住或发送过程中禁用操作,并在发送失败时显示提示。
2026-10-18 18:04:21 +08:00
mofeng-git
92a08fb2bc perf(video): 优化 MJPEG 传输和 JPEG 编码
按 HTTP 消费进度读取最新帧并合并积压通知,复用帧缓冲区所有权,减少 MJPEG 传输和截图的数据复制。

在 JPEG 数据后立即发送下一帧的完整 multipart 头,减少静态画面的浏览器显示等待,并保留保活和客户端释放逻辑。

缓存相同原始帧的 JPEG 编码结果,在输入或画质变化时重新编码;按像素格式分配转换缓冲区,减少闲置内存。

补充缓冲区寿命、慢客户端、序列号重置、编码缓存和保活回归测试。
2026-10-18 17:42:22 +08:00
mofeng-git
b657ef76bc feat(platform): 按平台能力控制功能入口与请求 2026-10-17 22:12:22 +08:00
mofeng-git
3a881ed0f7 docs: 更新 README.md 2026-10-13 22:31:56 +08:00
mofeng-git
d56665371f docs: 移除文档 2026-10-13 22:30:26 +08:00
mofeng-git
d7239ba15c fix(otg): 适配 OpenWrt netifd 网络桥接与事务恢复 2026-10-13 22:15:14 +08:00
mofeng-git
2b53c2982b fix(watchdog): 修复 musl 下 ioctl 请求参数类型不匹配 2026-10-13 22:11:55 +08:00
mofeng-git
1d40b34f1c feat: 支持从 GitHub Release 生成在线升级站点 2026-10-13 22:05:19 +08:00
mofeng-git
f926692e2c fix(web): 对齐侧边栏移动端判断与响应式断点 2026-10-13 21:56:21 +08:00
mofeng-git
2ebad622f0 feat: 增加 OpenWrt 系统管理入口
- 通过 ONE_KVM_OPENWRT=1 控制入口显示和跳转;
- 读取 uhttpd 当前监听配置,并跳转到 LuCI 地址。
2026-10-12 23:02:12 +08:00
mofeng-git
92f4203bc2 build: 启用显式 RKMPP 构建 2026-10-12 22:53:12 +08:00
raymond
03866aa495 fix(rustdesk): reduce mouse input latency 2026-09-02 09:46:55 +08:00
209 changed files with 16436 additions and 9339 deletions

1
.gitignore vendored
View File

@@ -42,4 +42,3 @@ CLAUDE.md
secrets.toml
.env
/docs/
web/package-lock.json

176
Cargo.lock generated
View File

@@ -719,6 +719,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -1063,7 +1064,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11"
dependencies = [
"darling",
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -1076,8 +1077,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core 0.24.1",
"darling_macro 0.24.1",
]
[[package]]
@@ -1094,17 +1105,41 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling_core"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.6",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"darling_core 0.20.11",
"quote",
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core 0.24.1",
"quote",
"syn 3.0.6",
]
[[package]]
name = "dary_heap"
version = "0.3.9"
@@ -1298,6 +1333,12 @@ dependencies = [
"x509-parser 0.16.0",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -2070,6 +2111,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@@ -2846,7 +2889,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "one-kvm"
version = "0.2.8"
version = "0.2.9"
dependencies = [
"alsa",
"anyhow",
@@ -2879,6 +2922,7 @@ dependencies = [
"rand 0.10.3",
"rcgen 0.14.10",
"reqwest",
"rmcp",
"rtp",
"rtsp-types",
"rust-embed",
@@ -3049,6 +3093,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pem"
version = "3.0.6"
@@ -3438,6 +3488,26 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -3574,6 +3644,51 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
[[package]]
name = "rmcp"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
dependencies = [
"async-trait",
"base64 0.23.1",
"bytes",
"chrono",
"futures",
"http",
"http-body",
"http-body-util",
"indexmap",
"pastey",
"pin-project-lite",
"rand 0.10.3",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
"sse-stream",
"thiserror 2.0.20",
"tokio",
"tokio-stream",
"tokio-util",
"tower-service",
"tracing",
"uuid",
]
[[package]]
name = "rmcp-macros"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40"
dependencies = [
"darling 0.24.1",
"proc-macro2",
"quote",
"serde_json",
"syn 3.0.6",
]
[[package]]
name = "rtcp"
version = "0.17.2"
@@ -3738,6 +3853,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.6",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -3839,6 +3980,17 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
@@ -4166,6 +4318,19 @@ dependencies = [
"url",
]
[[package]]
name = "sse-stream"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4"
dependencies = [
"bytes",
"futures-util",
"http-body",
"http-body-util",
"pin-project-lite",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4563,6 +4728,7 @@ dependencies = [
"futures-util",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tower",
"tower-layer",

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.2.8"
version = "0.2.9"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"
@@ -11,10 +11,12 @@ categories = ["embedded", "network-programming"]
[features]
default = ["desktop"]
rkmpp = ["hwcodec/rkmpp"]
desktop = [
"dep:tokio",
"dep:tokio-util",
"dep:axum",
"dep:rmcp",
"dep:axum-extra",
"dep:tower-http",
"dep:sqlx",
@@ -81,7 +83,9 @@ tokio-util = { version = "0.7", features = ["rt"], optional = true }
# Web framework
axum = { version = "0.8", features = ["ws", "multipart", "tokio"], optional = true }
axum-extra = { version = "0.12", features = ["cookie"], optional = true }
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"], optional = true }
tower-http = { version = "0.6", features = ["cors", "trace", "set-header", "limit"], optional = true }
rmcp = { version = "3.5.1", default-features = false, features = ["server", "macros", "schemars", "transport-streamable-http-server"], optional = true }
# Database - Use bundled SQLite for static linking
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled"], optional = true }

View File

@@ -38,16 +38,23 @@ For more information, see the [One-KVM Rust documentation](https://docs.one-kvm.
| Area | Capabilities |
|------|----------------|
| Video capture | HDMI USB / MIPI CSI / RK3588 HDMI IN; MJPEG and WebRTC (H.264 / H.265 / VP8 / VP9) |
| Video encoding | VAAPI / QSV / RKMPP / V4L2 M2M hardware paths, with software fallback |
| Keyboard & mouse | USB OTG HID or CH340 + CH9329 HID; absolute / relative mouse |
| Virtual media | USB mass storage; ISO/IMG mount and Ventoy-style virtual USB |
| ATX power | GPIO or USB relay; power control |
| Audio | ALSA capture + Opus (HTTP / WebRTC) |
| Video capture & streaming | USB HDMI capture cards, MIPI CSI, and RK3588 HDMI IN; MJPEG / WebRTC (H.264 / H.265 / VP8 / VP9) video streams |
| Video encoding | VAAPI / QSV / NVENC / AMF / RKMPP / V4L2 M2M hardware encoding, with software fallback |
| Keyboard & mouse | USB OTG HID, CH340 + CH9329 serial HID, and Bluetooth HID; absolute / relative mouse over USB or serial, relative mouse over Bluetooth |
| Virtual media | Mount ISO / IMG images through USB mass storage; virtual CD-ROM, virtual USB drive, and Ventoy multi-image boot |
| ATX power | GPIO, USB HID / serial relays for power on/off, forced shutdown, and reset; GPIO can sense power and disk activity LEDs |
| Wake-on-LAN | WOL with network interface selection and wake history |
| Audio streaming | Linux ALSA / Windows WASAPI capture with Opus encoding; stream audio from the controlled computer over WebSocket / WebRTC |
| USB microphone | Forward browser microphone audio to the controlled computer through USB OTG UAC |
| USB network sharing | NCM / ECM / RNDIS virtual adapters bridge the controlled computer to a wired network over USB |
| Remote client access | Built-in RustDesk and VNC services; RustDesk ID server / direct IP modes and VNC Tight JPEG / H.264 encoding |
| RTSP video | H.264 / H.265 RTSP streams with username and password authentication |
Feature availability depends on the platform, hardware, and drivers.
## Installation
Release artifacts are on [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases). Below are short paths for common setups. For **system requirements, hardware, Docker env vars, USB OTG**, and full troubleshooting, see the [One-KVM documentation](https://docs.one-kvm.cn/) (Chinese; use a translator if needed).
Release artifacts are on [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases). Below are brief steps for common installation methods. For full details on **system requirements, hardware preparation, Docker environment variables, and USB OTG**, see the [One-KVM Rust documentation](https://docs.one-kvm.cn/).
### Debian / Ubuntu (.deb)
@@ -102,7 +109,7 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
### Thanks
<details>
<summary><strong>Supporter list</strong></summary>
<summary><strong>Click to view the supporter list</strong></summary>
- 浩龙的电子嵌入式之路
@@ -226,12 +233,108 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
- 故人。
- WindowsHao
- 爱发电用户_d4f8b
- 爱发电用户_59f5a
- 爱发电用户_96f28
- 孤星赏月
- t316161070
- 爱发电用户_f5gW
- 爱发电用户_5fb5c
- 爱发电用户_86d54
- 爱发电用户_maRx
- nb
- 爱发电用户_0aa4a
- 爱发电用户_f75a7
- 爱发电用户_b4646
- n1k0
- Yeskf
- 魔力回旋彪
- 泡面
- 中看台的杰森
- LWF
- 爱发电用户_b38fb
- 丨拒绝啦丶
- 爱发电用户_rGYn
- 爱发电用户_XU9y
- 爱发电用户_ec19b
- 爱发电用户_A4dE
- 爱发电用户_eYRC
- Aleo
- 淼
- 爱发电用户_5d06d
- Ghost_chu
- 爱发电用户_1fa21
- 爱发电用户_a7072
- 爱发电用户_9HrC
- 飒歼外寒
- Resucdueff
- Gentisan
- 爱发电用户_MGAx
- 爱发电用户_bce93
- 爱发电用户_60f87
- Bottling3748
- 爱发电用户_7cbeb
- 爱发电用户_6c721
- 哦哦哦哦
- 盒子先生
- 似水流年
- chnxan
- ......
</details>
### Sponsors
This project is supported by the following sponsors:
**File hosting**
- **[Huang1111 public-interest program](https://pan.huang1111.cn/s/mxkx3T1)** — login-free downloads
@@ -240,12 +343,12 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
- **[林枫云](https://www.dkdun.cn)** — project server sponsorship
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)
<img height="128" alt="林枫云" src="https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png" />
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.
- **[Beta Network](https://my.beita.cc/?ref=github_onekvm)** — project server sponsorship
![BTBT](https://github.com/user-attachments/assets/c442d5f5-d72f-4a07-b9f4-400a6a0c3f1e)
<img height="128" alt="BTBT" src="https://github.com/user-attachments/assets/c442d5f5-d72f-4a07-b9f4-400a6a0c3f1e" />
Remote computers, consumer GPU servers, and dedicated physical machines with fully automated online delivery.
Remote computers, consumer GPU servers, and dedicated physical machines with fully automated online delivery.

117
README.md
View File

@@ -37,13 +37,20 @@
**核心功能**
| 功能 | 能力说明 |
|------|------|
| 视频采集 | HDMI USB /MIPI CSI/RK3588 HDMI IN 采集支持,提供 MJPEG / WebRTC(H.264/H.265/VP8/VP9) 视频流|
| 视频编码 | VAAPI/QSV/RKMPP/V4L2M2M 硬件编码支持,以及软件编码兜底 |
| 键鼠控制 | USB OTG HID 或 CH340 + CH9329 HID,支持绝对/相对鼠标模式 |
| 虚拟媒体 | USB Mass Storage,支持 ISO/IMG 镜像挂载和 Ventoy 虚拟U盘模式 |
| ATX 电源控制 | GPIO/USB 继电器,支持电源控制 |
| 音频传输 | ALSA 采集 + Opus 编码(HTTP/WebRTC) |
|------|----------|
| 视频采集与传输 | 支持 USB HDMI 采集卡、MIPI CSI 和 RK3588 HDMI IN,提供 MJPEG / WebRTC(H.264 / H.265 / VP8 / VP9)视频流 |
| 视频编码 | 支持 VAAPI / QSV / NVENC / AMF / RKMPP / V4L2 M2M 硬件编码,以及软件编码兜底 |
| 键鼠控制 | 支持 USB OTG HID、CH340 + CH9329 串口 HID 和蓝牙 HID;USB / 串口支持绝对与相对鼠标模式,蓝牙支持相对鼠标模式 |
| 虚拟媒体 | 通过 USB Mass Storage 挂载 ISO / IMG 镜像,支持虚拟光驱、虚拟 U 盘和 Ventoy 多镜像启动 |
| ATX 电源控制 | 支持 GPIO、USB HID / 串口继电器,实现开关机、强制关机和重启;GPIO 可检测电源与硬盘活动指示灯 |
| 网络唤醒 | 支持 Wake-on-LAN(WOL),可选择发送网卡并保存唤醒历史 |
| 音频传输 | 支持 Linux ALSA / Windows WASAPI 采集与 Opus 编码,通过 WebSocket / WebRTC 传输被控机音频 |
| USB 麦克风 | 通过 USB OTG UAC 将浏览器麦克风音频传入被控机 |
| USB 网络共享 | 支持 NCM / ECM / RNDIS 虚拟网卡,将被控机通过 USB 桥接到有线网络 |
| 远程客户端接入 | 内置 RustDesk 与 VNC 服务;RustDesk 支持 ID 服务与 IP 直连,VNC 支持 Tight JPEG / H.264 |
| RTSP 视频流 | 提供 H.264 / H.265 RTSP 视频流,支持用户名与密码认证 |
具体功能可用性取决于运行平台、硬件与驱动。
## 安装使用
@@ -220,6 +227,100 @@ One-KVM 已上架飞牛 **应用市场**,在 NAS 上直接搜索安装即可
- 故人。
- WindowsHao
- 爱发电用户_d4f8b
- 爱发电用户_59f5a
- 爱发电用户_96f28
- 孤星赏月
- t316161070
- 爱发电用户_f5gW
- 爱发电用户_5fb5c
- 爱发电用户_86d54
- 爱发电用户_maRx
- nb
- 爱发电用户_0aa4a
- 爱发电用户_f75a7
- 爱发电用户_b4646
- n1k0
- Yeskf
- 魔力回旋彪
- 泡面
- 中看台的杰森
- LWF
- 爱发电用户_b38fb
- 丨拒绝啦丶
- 爱发电用户_rGYn
- 爱发电用户_XU9y
- 爱发电用户_ec19b
- 爱发电用户_A4dE
- 爱发电用户_eYRC
- Aleo
- 淼
- 爱发电用户_5d06d
- Ghost_chu
- 爱发电用户_1fa21
- 爱发电用户_a7072
- 爱发电用户_9HrC
- 飒歼外寒
- Resucdueff
- Gentisan
- 爱发电用户_MGAx
- 爱发电用户_bce93
- 爱发电用户_60f87
- Bottling3748
- 爱发电用户_7cbeb
- 爱发电用户_6c721
- 哦哦哦哦
- 盒子先生
- 似水流年
- chnxan
- ......
</details>
@@ -243,4 +344,4 @@ One-KVM 已上架飞牛 **应用市场**,在 NAS 上直接搜索安装即可
<img height="128" alt="BTBT" src="https://github.com/user-attachments/assets/c442d5f5-d72f-4a07-b9f4-400a6a0c3f1e" />
远程电脑、消费级GPU服务器、独服物理机,全自动在线交付。
远程电脑、消费级GPU服务器、独服物理机,全自动在线交付。

View File

@@ -52,7 +52,7 @@ RUN if [ -n "$APT_MIRROR" ]; then \
COPY --chmod=755 init.sh /init.sh
# Copy binaries (these are placed by the build script)
COPY --chmod=755 one-kvm ttyd gostc easytier-core /usr/bin/
COPY --chmod=755 one-kvm ttyd easyfrpc easytier-core easytier-cli frpc /usr/bin/
# Copy ventoy resources if they exist
COPY ventoy/ /etc/one-kvm/ventoy/

View File

@@ -18,6 +18,10 @@ ARCH_MAP=(
# Build for specific architecture using cross
build_arch() {
local rust_target="$1"
local feature_args=()
case "$rust_target" in
aarch64-*|armv7-*) feature_args=(--features rkmpp) ;;
esac
case "${CHINAMIRRO:-}" in
1|true|TRUE|yes|YES|on|ON)
@@ -40,13 +44,13 @@ build_arch() {
CARGO_NET_GIT_FETCH_WITH_CLI=true \
RUSTUP_DIST_SERVER="$rust_dist" \
RUSTUP_UPDATE_ROOT="$rust_root" \
cross build --release --target "$rust_target"
cross build --release --target "$rust_target" "${feature_args[@]}"
return
;;
esac
echo "=== Building: $rust_target (via cross with custom Dockerfile) ==="
cross build --release --target "$rust_target"
cross build --release --target "$rust_target" "${feature_args[@]}"
}
# Main

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Fetch the pinned EasyFrp, EasyTier and frp clients for full Docker images."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import struct
import tarfile
import tempfile
import urllib.request
import zipfile
def install(platform, output):
architecture = {'linux/amd64': 'x86_64', 'linux/arm64': 'aarch64',
'linux/arm/v7': 'arm'}[platform]
lock = json.loads(Path(__file__).with_name('remote-access.lock.json').read_text())
output.mkdir(parents=True, exist_ok=True)
expected = {'arm': (1, 40), 'aarch64': (2, 183), 'x86_64': (2, 62)}[architecture]
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({'http': os.environ['CURL_PROXY'], 'https': os.environ['CURL_PROXY']})
) if os.environ.get('CURL_PROXY') else urllib.request.build_opener()
# Validate the entire download before replacing any staged executable.
with tempfile.TemporaryDirectory(prefix='one-kvm-remote-tools-') as temporary:
staging = Path(temporary)
for spec in lock['tools'].values():
asset = spec['assets'][architecture]
archive = staging / asset['archive']
prefix = os.environ.get('GHPROXY_PREFIX', '').rstrip('/')
url = prefix + '/' + asset['url'] if prefix else asset['url']
with opener.open(url, timeout=120) as response:
with archive.open('wb') as stream:
shutil.copyfileobj(response, stream)
if hashlib.sha256(archive.read_bytes()).hexdigest() != asset['sha256']:
raise ValueError('release checksum differs: ' + archive.name)
for name in spec['binaries']:
if zipfile.is_zipfile(archive):
with zipfile.ZipFile(archive) as source:
matches = [item for item in source.infolist()
if not item.is_dir() and PurePosixPath(item.filename).name == name]
if len(matches) != 1:
raise ValueError('expected one executable: ' + name)
data = source.read(matches[0])
else:
with tarfile.open(archive, 'r:gz') as source:
matches = [item for item in source if item.isfile() and PurePosixPath(item.name).name == name]
if len(matches) != 1:
raise ValueError('expected one executable: ' + name)
data = source.extractfile(matches[0]).read()
if (data[:4] != b'\x7fELF' or data[5] != 1
or (data[4], struct.unpack('<H', data[18:20])[0]) != expected):
raise ValueError('executable architecture differs: ' + name)
path = staging / name
path.write_bytes(data)
path.chmod(0o755)
for spec in lock['tools'].values():
for name in spec['binaries']:
shutil.copy2(staging / name, output / name)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--platform', required=True, choices=('linux/amd64', 'linux/arm64', 'linux/arm/v7'))
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
install(args.platform, args.output)

View File

@@ -125,8 +125,9 @@ EOF
chmod 755 "$PKG_DIR/DEBIAN/prerm"
# Create control file
BASE_DEPS="libc6 (>= 2.36), libgcc-s1, libstdc++6, libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4)"
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx-gen1.2 (>= 22.0) | libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14), i965-va-driver-shaders (>= 2.4), intel-media-va-driver-non-free (>= 21.1)"
BASE_DEPS="libc6 (>= 2.36), libgcc-s1, libstdc++6, libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4), libudev1"
# libmfx.so.1 is provided by libmfx1; libmfx-gen has a different SONAME.
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

@@ -118,7 +118,7 @@ while [[ $# -gt 0 ]]; do
echo " # Build for current platform and load locally"
echo " $0 --platform linux/arm64 --load"
echo ""
echo " # Build full image (includes gostc + easytier)"
echo " # Build full image (includes EasyFrp + EasyTier + frpc)"
echo " $0 --variant full --platform linux/arm64 --load"
echo ""
echo " # Build and push single platform"
@@ -184,21 +184,12 @@ get_tool_urls() {
case "$platform" in
"linux/amd64")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64"
GOSTC_URL="https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_amd64_v1.tar.gz"
EASYTIER_URL="https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-linux-x86_64-v2.4.5.zip"
EASYTIER_DIR="easytier-linux-x86_64"
;;
"linux/arm64")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.aarch64"
GOSTC_URL="https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_arm64_v8.0.tar.gz"
EASYTIER_URL="https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-linux-aarch64-v2.4.5.zip"
EASYTIER_DIR="easytier-linux-aarch64"
;;
"linux/arm/v7")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.armhf"
GOSTC_URL="https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_arm_7.tar.gz"
EASYTIER_URL="https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-linux-armv7hf-v2.4.5.zip"
EASYTIER_DIR="easytier-linux-armv7hf"
;;
esac
}
@@ -231,22 +222,9 @@ download_tools() {
fi
if [ "$INCLUDE_THIRD_PARTY" = true ]; then
# gostc
if [ ! -f "$staging/gostc" ]; then
dl "$GOSTC_URL" /tmp/gostc.tar.gz
tar -xzf /tmp/gostc.tar.gz -C "$staging"
chmod +x "$staging/gostc"
rm /tmp/gostc.tar.gz
fi
python3 "$PROJECT_ROOT/build/download-remote-tools.py" \
--platform "$platform" --output "$staging"
# easytier
if [ ! -f "$staging/easytier-core" ]; then
dl "$EASYTIER_URL" /tmp/easytier.zip
unzip -o /tmp/easytier.zip -d /tmp/easytier
cp "/tmp/easytier/$EASYTIER_DIR/easytier-core" "$staging/easytier-core"
chmod +x "$staging/easytier-core"
rm -rf /tmp/easytier.zip /tmp/easytier
fi
fi
}
@@ -268,14 +246,18 @@ build_for_platform() {
if [ "$BUILD_BINARY" = true ]; then
echo_info "Building binary with cross..."
cd "$PROJECT_ROOT"
cross build --release --target "$target"
local feature_args=()
case "$target" in
aarch64-*|armv7-*) feature_args=(--features rkmpp) ;;
esac
cross build --release --target "$target" "${feature_args[@]}"
fi
# Check binary exists
local binary="$PROJECT_ROOT/target/$target/release/one-kvm"
if [ ! -f "$binary" ]; then
echo_error "Binary not found: $binary"
echo_error "Build it first: cross build --release --target $target"
echo_error "Build it first: ./build/build-images.sh"
exit 1
fi

View File

@@ -0,0 +1,74 @@
{
"tools": {
"easyfrp": {
"version": "v3.1.1",
"binaries": [
"easyfrpc"
],
"assets": {
"arm": {
"archive": "easyfrpc_linux_arm_7.tar.gz",
"url": "https://github.com/SianHH/easyfrp/releases/download/v3.1.1/easyfrpc_linux_arm_7.tar.gz",
"sha256": "98fa5c95a01295b59043d2869251a63249d286813b66544389f80d6fa3b07ae6"
},
"aarch64": {
"archive": "easyfrpc_linux_arm64_v8.0.tar.gz",
"url": "https://github.com/SianHH/easyfrp/releases/download/v3.1.1/easyfrpc_linux_arm64_v8.0.tar.gz",
"sha256": "cbd6c21fcd4d1921ee86879a4e90f1abffeb49324cbf8b31b1fc4581523b34d9"
},
"x86_64": {
"archive": "easyfrpc_linux_amd64_v1.tar.gz",
"url": "https://github.com/SianHH/easyfrp/releases/download/v3.1.1/easyfrpc_linux_amd64_v1.tar.gz",
"sha256": "e744618a57305c3a6a866cc2e08ca09da22dad1cd1a4aa5216ab10ebf7ba16ec"
}
}
},
"easytier": {
"version": "v2.6.4",
"binaries": [
"easytier-core",
"easytier-cli"
],
"assets": {
"arm": {
"archive": "easytier-linux-armv7hf-v2.6.4.zip",
"url": "https://github.com/EasyTier/EasyTier/releases/download/v2.6.4/easytier-linux-armv7hf-v2.6.4.zip",
"sha256": "af0186ce95ffbe90b0e9dc8df0e9a01563f59e94ea52212651950c34dc35ac37"
},
"aarch64": {
"archive": "easytier-linux-aarch64-v2.6.4.zip",
"url": "https://github.com/EasyTier/EasyTier/releases/download/v2.6.4/easytier-linux-aarch64-v2.6.4.zip",
"sha256": "f533ec25a7ea714e09f645615012200278058525795cc3bb690ff011aec1a70f"
},
"x86_64": {
"archive": "easytier-linux-x86_64-v2.6.4.zip",
"url": "https://github.com/EasyTier/EasyTier/releases/download/v2.6.4/easytier-linux-x86_64-v2.6.4.zip",
"sha256": "61b659eaedba658fa66fe47d17e1426cdd77e5d02fa15fed447bb4357c09dfd6"
}
}
},
"frp": {
"version": "v0.71.1",
"binaries": [
"frpc"
],
"assets": {
"arm": {
"archive": "frp_0.71.1_linux_arm_hf.tar.gz",
"url": "https://github.com/fatedier/frp/releases/download/v0.71.1/frp_0.71.1_linux_arm_hf.tar.gz",
"sha256": "a40116f15f2a05b7001f985f1f1ccb2570e2aa41329b06de76a82e71495786db"
},
"aarch64": {
"archive": "frp_0.71.1_linux_arm64.tar.gz",
"url": "https://github.com/fatedier/frp/releases/download/v0.71.1/frp_0.71.1_linux_arm64.tar.gz",
"sha256": "8edf590b0c396fa42f9c147121a76af83cfc2002b08f5aef0ea1008c43482019"
},
"x86_64": {
"archive": "frp_0.71.1_linux_amd64.tar.gz",
"url": "https://github.com/fatedier/frp/releases/download/v0.71.1/frp_0.71.1_linux_amd64.tar.gz",
"sha256": "1d9875210972a5d1a62b2dc4c48df45ab0af05d3ca1b5e494a014428259df803"
}
}
}
}
}

View File

@@ -1,24 +0,0 @@
# macOS 拖拽兼容模式(实验性)
适用于 OTG 和 CH9329 后端的绝对鼠标输入。遇到目标 Mac 上拖拽只能移动一小段便停止时,可在设置页的 HID 配置中开启「macOS 拖拽兼容模式」。默认关闭。
配置字段为 `hid.mouse_macos_drag`,旧 PR 的 `ch9329_macos_drag` 字段仍可作为读取别名。OTG 必须同时启用相对鼠标和绝对鼠标接口,否则配置保存会被拒绝。CH9329 同时启用 Linux 兼容开关时,此模式优先。
## 报告行为
- 未按键时仍使用绝对坐标定位。
- 从绝对输入开始的拖拽:按下使用绝对报告,移动和滚轮使用相对报告,释放时先更新相对按钮状态,再发送绝对按钮释放。
- 按键报告的通道保持到所有按钮释放,不随拖拽期间的输入模式切换而改变。
- 从相对输入开始的点击、拖拽继续使用相对按钮报告;此选项不修复原生相对模式的点击兼容问题。
- 位移从原始 15 位输入坐标换算,累计小数余量,大位移按单包范围分包并保留总量及方向。
- 运行时使用当前视频采集尺寸,并在 HID 重载后恢复尺寸;视频尚未提供尺寸时使用配置尺寸。
## 验证范围与限制
本次 OTG 扩展及位移换算修改没有 macOS 实机验证。原 PR 作者对旧 CH9329 实现的实机反馈不能视为本版本已经验证。
本地自动化覆盖位移累计/分包、1080p/4K 换算、往返移动、滚轮、输入模式切换、复位、CH9329 命令队列、OTG 临时文件模拟端点及配置校验。临时文件测试仅验证写出的字节,不验证 USB 枚举或目标系统的事件解释。
相对位移受 macOS 鼠标速度、加速、显示缩放及多屏布局影响,无法保证与绝对定位逐像素一致。松手报告携带最后的客户端绝对坐标,仍可能出现落点偏移或光标跳位;大位移分包也可能增加低波特率串口延迟。
后续实机验证应分别覆盖两个后端:窗口/文件/文本拖拽,快速及慢速移动,滚轮和多按钮组合,1080p/4K 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。

View File

@@ -154,7 +154,7 @@ mod ffmpeg {
let include_dir = Path::new(&custom_lib_path).join("include");
let avcodec_lib = lib_dir.join("libavcodec.a");
if avcodec_lib.exists() {
if avcodec_lib.exists() && use_static {
println!("cargo:info=Using custom FFmpeg from {}", custom_lib_path);
println!("cargo:rustc-link-search=native={}", lib_dir.display());
builder.include(&include_dir);
@@ -171,7 +171,7 @@ mod ffmpeg {
println!("cargo:rustc-link-lib=va-drm");
println!("cargo:rustc-link-lib=va-x11"); // Required for vaGetDisplay
println!("cargo:rustc-link-lib=mfx");
} else {
} else if std::env::var_os("CARGO_FEATURE_RKMPP").is_some() {
for lib in ["rockchip_mpp", "rga"] {
if !lib_dir.join(format!("lib{lib}.a")).exists() {
panic!("missing static library: lib{lib}.a");
@@ -400,8 +400,7 @@ mod ffmpeg {
// RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support.
// Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist.
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
let enable_rkmpp = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp {
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
if enable_rkmpp {
@@ -459,8 +458,7 @@ mod ffmpeg {
.unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
let enable_rkmpp = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp {
// Include RGA headers for NV16->NV12 conversion (RGA im2d API)
let rga_sys_dirs = [

View File

@@ -9,12 +9,12 @@ use std::ffi::c_int;
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs"));
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
#[cfg(feature = "rkmpp")]
pub mod decode;
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder
// construction will fail (since the C++ RKMPP decoder isn't built/linked).
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")))]
#[cfg(not(feature = "rkmpp"))]
pub mod decode {
use crate::ffmpeg::AVPixelFormat;

View File

@@ -2,12 +2,12 @@
pub mod capture;
pub mod common;
pub mod ffmpeg;
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
#[cfg(feature = "rkmpp")]
pub mod ffmpeg_hw;
pub mod ffmpeg_ram;
#[cfg(all(
target_os = "linux",
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")
feature = "rkmpp"
))]
pub mod rkmpp_dmabuf;

View File

@@ -5,6 +5,7 @@
# <site_name>/v1/channels.json
# <site_name>/v1/releases.json
# <site_name>/v1/bin/<version>/one-kvm-<triple>
# 默认从 GitHub Release 下载 deb,提取二进制;也支持本地二进制输入。
#
set -euo pipefail
@@ -17,6 +18,11 @@ RELEASE_CHANNEL="stable"
STABLE_VERSION=""
BETA_VERSION=""
PUBLISHED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
PUBLISHED_AT_SET=0
GITHUB_REPO="mofeng-git/One-KVM"
GITHUB_RELEASE=""
GITHUB_PROXY="${GITHUB_PROXY:-}"
GITHUB_API_PROXY="${GITHUB_API_PROXY:-}"
ARTIFACTS_DIR=""
X86_64_BIN=""
AARCH64_BIN=""
@@ -32,10 +38,17 @@ declare -a NOTES=()
usage() {
cat <<'EOF'
Usage:
./scripts/build-update-site.sh --version <x.x.x> [options]
./scripts/build-update-site.sh [options]
Required:
--version <x.x.x> Release 版本号(如 0.1.10)
GitHub input (无本地输入时默认使用 latest Release):
--release <tag|url|latest> GitHub Release 标签或完整页面 URL
--github-repo <owner/repo> 默认 mofeng-git/One-KVM
--github-proxy <url> deb 下载代理前缀,如 https://ghproxy.com
代理失败后自动尝试 GitHub 直连
--github-api-proxy <url> 可选 GitHub API 代理前缀
也可设置 GITHUB_PROXY / GITHUB_API_PROXY
--version <x.x.x> GitHub 输入默认从 deb 文件名读取;本地输入必填
GitHub 输入指定时校验版本一致
Artifact input (二选一,可混用):
--artifacts-dir <dir> 自动扫描目录中的标准文件名:
@@ -50,8 +63,8 @@ Manifest options:
--release-channel <stable|beta> releases.json 里该版本所属渠道,默认 stable
--stable <x.x.x> channels.json 的 stable 指针,默认等于 --version
--beta <x.x.x> channels.json 的 beta 指针,默认等于 --version
--published-at <RFC3339> 发布时间,默认当前 UTC 时间
--note <text> 发布说明,可重复传入多次
--published-at <RFC3339> 发布时间,默认 GitHub 发布时间(本地输入为当前时间)
--note <text> 发布说明,可重复;指定后覆盖 GitHub 说明
Output options:
--site-name <name> 打包根目录名,默认 one-kvm-update
@@ -62,6 +75,12 @@ Other:
-h, --help 显示帮助
Example:
./scripts/build-update-site.sh --release v261001
./scripts/build-update-site.sh \
--release https://github.com/mofeng-git/One-KVM/releases/tag/v261001 \
--github-proxy https://ghproxy.com
./scripts/build-update-site.sh \
--version 0.1.10 \
--artifacts-dir ./target/release \
@@ -104,7 +123,26 @@ is_valid_channel() {
}
while [[ $# -gt 0 ]]; do
if [[ "$1" != "-h" && "$1" != "--help" ]]; then
[[ $# -ge 2 && -n "$2" && "$2" != --* ]] || fail "Missing value for $1"
fi
case "$1" in
--release | --github-tag)
GITHUB_RELEASE="$2"
shift 2
;;
--github-repo)
GITHUB_REPO="$2"
shift 2
;;
--github-proxy)
GITHUB_PROXY="$2"
shift 2
;;
--github-api-proxy)
GITHUB_API_PROXY="$2"
shift 2
;;
--version)
VERSION="${2:-}"
shift 2
@@ -123,6 +161,7 @@ while [[ $# -gt 0 ]]; do
;;
--published-at)
PUBLISHED_AT="${2:-}"
PUBLISHED_AT_SET=1
shift 2
;;
--note)
@@ -175,6 +214,128 @@ require_cmd stat
require_cmd tar
require_cmd mktemp
[[ "$SITE_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || fail "Invalid --site-name: ${SITE_NAME}"
BUILD_DIR="$(mktemp -d)"
trap 'rm -rf "$BUILD_DIR"' EXIT
download_github() {
local url="$1" dest="$2" proxy="$3"
local -a candidates=()
[[ -z "$proxy" ]] || candidates+=("${proxy%/}/${url}")
candidates+=("$url")
local candidate
for candidate in "${candidates[@]}"; do
echo "Downloading: $candidate" >&2
if curl --fail --location --show-error --connect-timeout 15 --max-time 300 \
--retry 2 --retry-delay 2 --output "${dest}.part" "$candidate"; then
# 一些代理返回 HTTP 200 的 HTML 页面,不能当作 deb 或 API 响应。
if [[ "$dest" == *.deb ]]; then
dpkg-deb --info "${dest}.part" >/dev/null 2>&1 || continue
else
python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); assert isinstance(r,dict) and isinstance(r.get("assets"),list)' \
"${dest}.part" >/dev/null 2>&1 || continue
fi
mv "${dest}.part" "$dest"
return 0
fi
echo "Download failed or invalid response; trying next source." >&2
done
fail "Unable to download $url"
}
if [[ -n "$GITHUB_RELEASE" || ( -z "$ARTIFACTS_DIR" && "$X86_64_SET" -eq 0 && "$AARCH64_SET" -eq 0 && "$ARMV7_SET" -eq 0 ) ]]; then
[[ -z "$ARTIFACTS_DIR" && "$X86_64_SET" -eq 0 && "$AARCH64_SET" -eq 0 && "$ARMV7_SET" -eq 0 ]] || \
fail "--release cannot be combined with local artifact input"
require_cmd curl
require_cmd python3
require_cmd dpkg-deb
GITHUB_RELEASE="${GITHUB_RELEASE:-latest}"
if [[ "$GITHUB_RELEASE" =~ ^https://github\.com/([^/]+/[^/]+)/releases/tag/([^/?#]+)/?$ ]]; then
GITHUB_REPO="${BASH_REMATCH[1]}"
GITHUB_RELEASE="${BASH_REMATCH[2]}"
fi
[[ "$GITHUB_REPO" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || fail "Invalid --github-repo"
for proxy in "$GITHUB_PROXY" "$GITHUB_API_PROXY"; do
[[ -z "$proxy" || "$proxy" =~ ^https?://[^[:space:]]+$ ]] || fail "Invalid GitHub proxy URL: $proxy"
done
release_path="latest"
if [[ "$GITHUB_RELEASE" != latest ]]; then
release_tag="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(urllib.parse.unquote(sys.argv[1]), safe=""))' "$GITHUB_RELEASE")"
release_path="tags/${release_tag}"
fi
release_json="${BUILD_DIR}/release.json"
download_github "https://api.github.com/repos/${GITHUB_REPO}/releases/${release_path}" "$release_json" "$GITHUB_API_PROXY"
python3 - "$release_json" "${BUILD_DIR}/assets.tsv" "${BUILD_DIR}/metadata.json" "$VERSION" <<'PY'
import json
import re
import sys
release = json.load(open(sys.argv[1], encoding="utf-8"))
assets = []
seen = set()
for asset in release["assets"]:
match = re.fullmatch(r"one-kvm_(\d+\.\d+\.\d+)_(amd64|arm64|armhf)\.deb", asset["name"])
if not match:
continue
version, arch = match.groups()
if arch in seen:
sys.exit(f"Error: Duplicate deb architecture: {arch}")
seen.add(arch)
url = asset["browser_download_url"]
if not url.startswith("https://github.com/") or any(c in url for c in "\t\r\n"):
sys.exit("Error: Invalid GitHub asset URL")
digest = asset.get("digest") or "-"
if digest != "-" and not re.fullmatch(r"sha256:[a-fA-F0-9]{64}", digest):
sys.exit("Error: Invalid GitHub asset digest")
assets.append((arch, version, url, digest, str(asset["size"])))
if not assets:
sys.exit("Error: Release has no supported One-KVM deb assets")
versions = {a[1] for a in assets}
if len(versions) != 1:
sys.exit("Error: Release deb versions differ")
version = versions.pop()
if sys.argv[4] and sys.argv[4] != version:
sys.exit(f"Error: --version {sys.argv[4]} differs from GitHub deb version {version}")
with open(sys.argv[2], "w", encoding="utf-8") as output:
for asset in assets:
output.write("\t".join(asset) + "\n")
with open(sys.argv[3], "w", encoding="utf-8") as output:
json.dump(dict(version=version, published_at=release["published_at"],
notes=release.get("body") or ""), output, ensure_ascii=False)
PY
VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "${BUILD_DIR}/metadata.json")"
if [[ "$PUBLISHED_AT_SET" -eq 0 ]]; then
PUBLISHED_AT="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["published_at"])' "${BUILD_DIR}/metadata.json")"
fi
if [[ ${#NOTES[@]} -eq 0 ]]; then
mapfile -t NOTES < <(python3 -c 'import json,re,sys; body=json.load(open(sys.argv[1]))["notes"]; print("\n".join(re.sub(r"^[-*+]\s+", "", line.strip()) for line in body.splitlines() if line.strip()), end="")' "${BUILD_DIR}/metadata.json")
fi
ARTIFACTS_DIR="${BUILD_DIR}/binaries"
mkdir -p "$ARTIFACTS_DIR"
while IFS=$'\t' read -r arch deb_version url digest expected_size; do
case "$arch" in
amd64) triple="x86_64-unknown-linux-gnu" ;;
arm64) triple="aarch64-unknown-linux-gnu" ;;
armhf) triple="armv7-unknown-linux-gnueabihf" ;;
esac
deb="${BUILD_DIR}/one-kvm_${deb_version}_${arch}.deb"
download_github "$url" "$deb" "$GITHUB_PROXY"
[[ "$(stat -c%s "$deb")" == "$expected_size" ]] || fail "deb size mismatch: $arch"
if [[ "$digest" != - ]]; then
actual_sha="$(sha256sum "$deb" | awk '{print $1}')"
[[ "$actual_sha" == "${digest#sha256:}" ]] || fail "deb SHA256 mismatch: $arch"
fi
[[ "$(dpkg-deb -f "$deb" Package)" == one-kvm ]] || fail "Unexpected deb package: $arch"
[[ "$(dpkg-deb -f "$deb" Version)" == "$VERSION" ]] || fail "deb version mismatch: $arch"
[[ "$(dpkg-deb -f "$deb" Architecture)" == "$arch" ]] || fail "deb architecture mismatch: $arch"
extract_dir="${BUILD_DIR}/extract-${arch}"
dpkg-deb -x "$deb" "$extract_dir"
binary="${extract_dir}/usr/bin/one-kvm"
[[ -f "$binary" && ! -L "$binary" && -s "$binary" ]] || fail "deb missing /usr/bin/one-kvm: $arch"
cp "$binary" "${ARTIFACTS_DIR}/one-kvm-${triple}"
done < "${BUILD_DIR}/assets.tsv"
fi
[[ -n "$VERSION" ]] || fail "--version is required"
is_valid_version "$VERSION" || fail "Invalid --version: ${VERSION} (expected x.x.x)"
is_valid_channel "$RELEASE_CHANNEL" || fail "Invalid --release-channel: ${RELEASE_CHANNEL}"
@@ -220,9 +381,6 @@ if [[ ${#SRC_BY_TRIPLE[@]} -eq 0 ]]; then
fail "No artifact found. Provide --artifacts-dir or at least one of --x86_64/--aarch64/--armv7."
fi
BUILD_DIR="$(mktemp -d)"
trap 'rm -rf "$BUILD_DIR"' EXIT
SITE_DIR="${BUILD_DIR}/${SITE_NAME}"
V1_DIR="${SITE_DIR}/v1"
BIN_DIR="${V1_DIR}/bin/${VERSION}"
@@ -268,7 +426,7 @@ RELEASES_FILE="${V1_DIR}/releases.json"
echo ' {'
echo " \"version\": \"${VERSION}\","
echo " \"channel\": \"${RELEASE_CHANNEL}\","
echo " \"published_at\": \"${PUBLISHED_AT}\","
echo " \"published_at\": \"$(json_escape "$PUBLISHED_AT")\","
if [[ ${#NOTES[@]} -eq 0 ]]; then
echo ' "notes": [],'

View File

@@ -1,23 +1,11 @@
#![allow(dead_code)]
use super::types::{AtxInputBinding, PowerStatus};
use crate::error::Result;
pub struct LedSensor {
config: AtxInputBinding,
}
pub struct LedSensor;
impl LedSensor {
pub fn new(config: AtxInputBinding) -> Self {
Self { config }
}
pub fn is_configured(&self) -> bool {
self.config.is_configured()
}
pub fn is_initialized(&self) -> bool {
false
pub fn new(_config: AtxInputBinding) -> Self {
Self
}
pub async fn init(&mut self) -> Result<()> {

View File

@@ -101,12 +101,6 @@ impl LedSensor {
}
}
impl Drop for LedSensor {
fn drop(&mut self) {
*self.handle.lock().unwrap() = None;
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

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

View File

@@ -9,6 +9,28 @@ use crate::error::{AppError, Result};
const MAGIC_PACKET_SIZE: usize = 102;
/// Validate and normalize the MAC formats accepted by the web and MCP APIs.
pub fn normalize_wol_mac_address(mac_address: &str) -> Result<String> {
let normalized = mac_address.trim().replace('-', ":");
let normalized = if normalized.len() == 12 && normalized.bytes().all(|b| b.is_ascii_hexdigit())
{
normalized
.as_bytes()
.chunks(2)
.map(|b| std::str::from_utf8(b).unwrap())
.collect::<Vec<_>>()
.join(":")
} else {
normalized
};
let mac = parse_mac_address(&normalized)?;
Ok(mac
.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<Vec<_>>()
.join(":"))
}
fn parse_mac_address(mac: &str) -> Result<[u8; 6]> {
let mac = mac.trim().to_uppercase();
let parts: Vec<&str> = if mac.contains(':') {
@@ -139,6 +161,17 @@ mod tests {
assert!(parse_mac_address("AA:BB:CC:DD:EE:GG").is_err());
}
#[test]
fn normalizes_web_and_mcp_mac_formats() {
for value in [" aa-bb-cc-dd-ee-ff ", "aabbccddeeff", "AA:BB:CC:DD:EE:FF"] {
assert_eq!(
normalize_wol_mac_address(value).unwrap(),
"AA:BB:CC:DD:EE:FF"
);
}
assert!(normalize_wol_mac_address("invalid").is_err());
}
#[test]
fn test_build_magic_packet() {
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];

View File

@@ -269,22 +269,6 @@ impl AudioController {
.map(|s| s.subscribe_opus())
}
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
{
let mut config = self.config.write().await;
config.enabled = enabled;
}
if !enabled && self.is_streaming().await {
self.stop_streaming_inner().await?;
}
info!("Audio enabled: {}", enabled);
Ok(())
}
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();

View File

@@ -92,13 +92,6 @@ impl SessionStore {
Ok(())
}
pub async fn cleanup_expired(&self) -> Result<u64> {
let mut guard = self.inner.write().await;
let before = guard.len();
guard.retain(|_, s| !s.is_expired());
Ok((before - guard.len()) as u64)
}
pub async fn delete_all(&self) -> Result<u64> {
let mut guard = self.inner.write().await;
let n = guard.len() as u64;

View File

@@ -1,218 +0,0 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComputerUseSessionStatus {
Idle,
WaitingScreenshot,
Thinking,
Executing,
Completed,
Failed,
Stopped,
}
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComputerUseButton {
Left,
Middle,
Right,
}
impl Default for ComputerUseButton {
fn default() -> Self {
Self::Left
}
}
// Kept out of typeshare because these internally tagged enums are consumed by
// manually maintained frontend types and changing serde shape would break WS/API.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ComputerUseAction {
Click {
x: u32,
y: u32,
#[serde(default)]
button: ComputerUseButton,
},
DoubleClick {
x: u32,
y: u32,
#[serde(default)]
button: ComputerUseButton,
},
Move {
x: u32,
y: u32,
},
Drag {
path: Vec<ComputerUsePoint>,
#[serde(default)]
button: ComputerUseButton,
},
Scroll {
x: u32,
y: u32,
#[serde(default)]
dx: i32,
#[serde(default)]
dy: i32,
},
Type {
text: String,
},
Keypress {
keys: Vec<String>,
},
Wait {
ms: u64,
},
Screenshot,
}
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ComputerUsePoint {
pub x: u32,
pub y: u32,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputerUseScreenshot {
pub data_url: String,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ComputerUseConversationMessage {
User { text: String },
Assistant { text: String },
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputerUseStartRequest {
pub prompt: String,
#[serde(default)]
pub continue_conversation: bool,
pub client_id: String,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputerUseConfigResponse {
pub enabled: bool,
pub base_url: String,
pub model: String,
pub api_key_configured: bool,
pub api_key_source: String,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputerUseConfigUpdate {
pub enabled: Option<bool>,
pub base_url: Option<String>,
pub model: Option<String>,
#[serde(alias = "openai_api_key")]
pub api_key: Option<String>,
#[serde(alias = "clear_openai_api_key")]
pub clear_api_key: Option<bool>,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputerUseSessionSummary {
pub id: Option<String>,
pub status: ComputerUseSessionStatus,
pub prompt: Option<String>,
pub step: u32,
pub last_error: Option<String>,
pub final_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ComputerUseWsClientMessage {
ScreenshotResult {
request_id: String,
screenshot: ComputerUseScreenshot,
},
ScreenshotError {
request_id: String,
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ComputerUseWsServerMessage {
SessionUpdated { session: ComputerUseSessionSummary },
ScreenshotRequested { request_id: String },
ScreenshotCaptured { screenshot: ComputerUseScreenshot },
StepStarted { step: u32 },
ReasoningDelta { delta: String },
ReasoningCompleted { failed: bool },
ActionsExecuted { actions: Vec<ComputerUseAction> },
Error { message: String },
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn computer_use_action_keeps_flat_tagged_json_shape() {
let action = ComputerUseAction::Click {
x: 10,
y: 20,
button: ComputerUseButton::Left,
};
assert_eq!(
serde_json::to_value(action).unwrap(),
json!({
"type": "click",
"x": 10,
"y": 20,
"button": "left"
})
);
}
#[test]
fn computer_use_ws_message_keeps_flat_tagged_json_shape() {
let message = ComputerUseWsServerMessage::ScreenshotRequested {
request_id: "req-1".to_string(),
};
assert_eq!(
serde_json::to_value(message).unwrap(),
json!({
"type": "screenshot_requested",
"request_id": "req-1"
})
);
}
#[test]
fn config_update_accepts_legacy_api_key_names() {
let update: ComputerUseConfigUpdate = serde_json::from_value(json!({
"openai_api_key": "legacy-key",
"clear_openai_api_key": true
}))
.unwrap();
assert_eq!(update.api_key.as_deref(), Some("legacy-key"));
assert_eq!(update.clear_api_key, Some(true));
}
}

View File

@@ -1,983 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket};
use futures::{SinkExt, StreamExt};
use tokio::sync::{broadcast, oneshot, watch, Mutex};
use tokio::task::JoinHandle;
use uuid::Uuid;
use super::actions::*;
use super::openai::{normalize_data_url, OpenAiComputerProvider};
use crate::config::ConfigStore;
use crate::error::{AppError, Result};
use crate::hid::{
CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEvent,
};
const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(10);
const KEY_DELAY: Duration = Duration::from_millis(35);
const ACTION_DELAY: Duration = Duration::from_millis(120);
const STOPPED_MESSAGE: &str = "Computer use task was stopped";
#[derive(Clone)]
pub struct ComputerUseManager {
config: ConfigStore,
hid: Arc<HidController>,
state: Arc<Mutex<ManagerState>>,
event_tx: broadcast::Sender<ComputerUseWsServerMessage>,
screenshot_tx: broadcast::Sender<ScreenshotRequest>,
}
struct ManagerState {
session: ComputerUseSessionSummary,
conversation: Vec<ComputerUseConversationMessage>,
screenshot_waiter: Option<ScreenshotWaiter>,
stop_tx: Option<oneshot::Sender<()>>,
cancel_tx: Option<watch::Sender<bool>>,
task: Option<JoinHandle<()>>,
}
struct ScreenshotWaiter {
request_id: String,
client_id: String,
tx: oneshot::Sender<Result<ComputerUseScreenshot>>,
}
#[derive(Debug, Clone)]
struct ScreenshotRequest {
request_id: String,
client_id: String,
}
impl ComputerUseManager {
pub fn new(config: ConfigStore, hid: Arc<HidController>) -> Arc<Self> {
let (event_tx, _) = broadcast::channel(128);
let (screenshot_tx, _) = broadcast::channel(8);
Arc::new(Self {
config,
hid,
state: Arc::new(Mutex::new(ManagerState {
session: empty_session(),
conversation: Vec::new(),
screenshot_waiter: None,
stop_tx: None,
cancel_tx: None,
task: None,
})),
event_tx,
screenshot_tx,
})
}
pub fn config_response(&self) -> ComputerUseConfigResponse {
let config = self.config.get();
let key_env = cua_api_key_env();
let key_db = config
.computer_use
.api_key
.as_ref()
.filter(|key| !key.is_empty());
ComputerUseConfigResponse {
enabled: config.computer_use.enabled,
base_url: cua_base_url_env().unwrap_or_else(|| config.computer_use.base_url.clone()),
model: config.computer_use.model.clone(),
api_key_configured: key_env.is_some() || key_db.is_some(),
api_key_source: if key_env.is_some() {
"env".to_string()
} else if key_db.is_some() {
"config".to_string()
} else {
"none".to_string()
},
}
}
pub async fn update_config(
&self,
req: ComputerUseConfigUpdate,
) -> Result<ComputerUseConfigResponse> {
if let Some(base_url) = req
.base_url
.as_ref()
.filter(|base_url| !base_url.trim().is_empty())
{
validate_endpoint_url(base_url)?;
}
self.config
.update(|config| {
if let Some(enabled) = req.enabled {
config.computer_use.enabled = enabled;
}
if let Some(model) = req.model.as_ref().filter(|model| !model.trim().is_empty()) {
config.computer_use.model = model.trim().to_string();
}
if let Some(base_url) = req
.base_url
.as_ref()
.filter(|base_url| !base_url.trim().is_empty())
{
config.computer_use.base_url = base_url.trim().to_string();
}
if req.clear_api_key.unwrap_or(false) {
config.computer_use.api_key = None;
}
if let Some(key) = req.api_key.as_ref() {
config.computer_use.api_key = if key.trim().is_empty() {
None
} else {
Some(key.trim().to_string())
};
}
})
.await?;
Ok(self.config_response())
}
pub async fn summary(&self) -> ComputerUseSessionSummary {
self.state.lock().await.session.clone()
}
pub async fn start(
self: &Arc<Self>,
req: ComputerUseStartRequest,
) -> Result<ComputerUseSessionSummary> {
let app_config = self.config.get();
let config = app_config.computer_use.clone();
if !config.enabled {
return Err(AppError::BadRequest("Computer use is disabled".to_string()));
}
if req.prompt.trim().is_empty() {
return Err(AppError::BadRequest("Task prompt is required".to_string()));
}
let client_id = req.client_id.trim();
if client_id.is_empty() {
return Err(AppError::BadRequest(
"Computer use client_id is required".to_string(),
));
}
let client_id = client_id.to_string();
let hid = self.hid.snapshot().await;
if !hid.initialized || !hid.supports_absolute_mouse {
return Err(AppError::BadRequest(
"Computer use requires an initialized absolute mouse HID backend".to_string(),
));
}
let api_key = cua_api_key_env()
.or(config.api_key.clone())
.ok_or_else(|| {
AppError::BadRequest("Computer Use API key is not configured".to_string())
})?;
let base_url = cua_base_url_env().unwrap_or_else(|| config.base_url.clone());
validate_endpoint_url(&base_url)?;
let mut state = self.state.lock().await;
if matches!(
state.session.status,
ComputerUseSessionStatus::WaitingScreenshot
| ComputerUseSessionStatus::Thinking
| ComputerUseSessionStatus::Executing
) {
return Err(AppError::BadRequest(
"A computer use session is already running".to_string(),
));
}
if let Some(handle) = state.task.take() {
handle.abort();
}
if !req.continue_conversation {
state.conversation.clear();
}
let conversation = state.conversation.clone();
state
.conversation
.push(ComputerUseConversationMessage::User {
text: req.prompt.trim().to_string(),
});
let (stop_tx, stop_rx) = oneshot::channel();
let (cancel_tx, cancel_rx) = watch::channel(false);
let session_id = Uuid::new_v4().to_string();
state.session = ComputerUseSessionSummary {
id: Some(session_id),
status: ComputerUseSessionStatus::Thinking,
prompt: Some(req.prompt.trim().to_string()),
step: 0,
last_error: None,
final_message: None,
};
state.stop_tx = Some(stop_tx);
state.cancel_tx = Some(cancel_tx);
let summary = state.session.clone();
drop(state);
self.publish_session().await;
let manager = self.clone();
let prompt = req.prompt.trim().to_string();
let model = config.model.clone();
let handle = tokio::spawn(async move {
manager
.run_loop(
prompt,
api_key,
base_url,
model,
conversation,
client_id,
cancel_rx,
stop_rx,
)
.await;
});
self.state.lock().await.task = Some(handle);
Ok(summary)
}
pub async fn stop(&self) -> Result<ComputerUseSessionSummary> {
let mut state = self.state.lock().await;
if let Some(tx) = state.stop_tx.take() {
let _ = tx.send(());
}
if let Some(tx) = state.cancel_tx.take() {
let _ = tx.send(true);
}
if let Some(waiter) = state.screenshot_waiter.take() {
drop(waiter.tx);
}
state.session.status = ComputerUseSessionStatus::Stopped;
drop(state);
let _ = self.hid.reset().await;
self.publish_session().await;
Ok(self.summary().await)
}
pub async fn submit_screenshot(
&self,
client_id: &str,
request_id: String,
mut screenshot: ComputerUseScreenshot,
) -> Result<()> {
if screenshot.width == 0 || screenshot.height == 0 {
return Err(AppError::BadRequest(
"Screenshot dimensions are invalid".to_string(),
));
}
screenshot.data_url = normalize_data_url(&screenshot.data_url)?;
let mut state = self.state.lock().await;
let Some(waiter) = state.screenshot_waiter.take() else {
return Ok(());
};
if waiter.request_id != request_id || waiter.client_id != client_id {
state.screenshot_waiter = Some(waiter);
return Ok(());
}
let _ = waiter.tx.send(Ok(screenshot));
Ok(())
}
async fn submit_screenshot_error(&self, client_id: &str, request_id: String, message: String) {
let mut state = self.state.lock().await;
let Some(waiter) = state.screenshot_waiter.take() else {
return;
};
if waiter.request_id != request_id || waiter.client_id != client_id {
state.screenshot_waiter = Some(waiter);
return;
}
let message: String = message.chars().take(300).collect();
let _ = waiter.tx.send(Err(AppError::ServiceUnavailable(format!(
"Screenshot capture failed: {}",
if message.trim().is_empty() {
"client did not provide an error"
} else {
message.trim()
}
))));
}
pub async fn handle_socket(self: Arc<Self>, socket: WebSocket, client_id: Option<String>) {
let (mut sender, mut receiver) = socket.split();
let mut event_rx = self.event_tx.subscribe();
let client_id = client_id
.as_deref()
.map(str::trim)
.filter(|client_id| !client_id.is_empty())
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
let mut screenshot_rx = self.screenshot_tx.subscribe();
let _ = sender
.send(Message::Text(
serde_json::to_string(&ComputerUseWsServerMessage::SessionUpdated {
session: self.summary().await,
})
.unwrap_or_default()
.into(),
))
.await;
loop {
tokio::select! {
Ok(event) = event_rx.recv() => {
if let Ok(text) = serde_json::to_string(&event) {
if sender.send(Message::Text(text.into())).await.is_err() {
break;
}
}
}
Ok(req) = screenshot_rx.recv() => {
if req.client_id != client_id {
continue;
}
let event = ComputerUseWsServerMessage::ScreenshotRequested { request_id: req.request_id };
if let Ok(text) = serde_json::to_string(&event) {
if sender.send(Message::Text(text.into())).await.is_err() {
break;
}
}
}
msg = receiver.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<ComputerUseWsClientMessage>(&text) {
Ok(ComputerUseWsClientMessage::ScreenshotResult { request_id, screenshot }) => {
let _ = self.submit_screenshot(&client_id, request_id, screenshot).await;
}
Ok(ComputerUseWsClientMessage::ScreenshotError { request_id, message }) => {
self.submit_screenshot_error(&client_id, request_id, message).await;
}
Err(_) => {}
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(_)) => break,
_ => {}
}
}
}
}
}
async fn run_loop(
&self,
prompt: String,
api_key: String,
base_url: String,
model: String,
conversation: Vec<ComputerUseConversationMessage>,
client_id: String,
cancel_rx: watch::Receiver<bool>,
mut stop_rx: oneshot::Receiver<()>,
) {
let provider = OpenAiComputerProvider::new(api_key, base_url, model);
let mut latest_screenshot: Option<ComputerUseScreenshot> = None;
let mut action_history: Vec<String> = Vec::new();
let mut step = 0_u32;
loop {
step = step.saturating_add(1);
self.set_status(ComputerUseSessionStatus::Thinking, step, None)
.await;
let response = tokio::select! {
_ = &mut stop_rx => {
let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningCompleted {
failed: true,
});
self.set_stopped().await;
return;
}
response = provider.next_actions(
&prompt,
&conversation,
&action_history,
latest_screenshot.as_ref(),
|delta| {
let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningDelta {
delta: delta.to_string(),
});
},
) => response,
};
let response = match response {
Ok(response) => {
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ReasoningCompleted { failed: false });
response
}
Err(err) => {
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ReasoningCompleted { failed: true });
self.fail(&err.to_string()).await;
return;
}
};
if *cancel_rx.borrow() {
self.set_stopped().await;
return;
}
if response.done {
self.complete(response.message).await;
return;
}
let executable = &response.actions[..response.actions.len().saturating_sub(1)];
action_history.push(format!(
"Step {step}: {}",
serde_json::to_string(&response.actions).unwrap_or_else(|_| "[]".to_string())
));
if !executable.is_empty() {
let Some(screenshot) = latest_screenshot.as_ref() else {
self.fail("Computer Use protocol error: actions require a screenshot")
.await;
return;
};
self.set_status(ComputerUseSessionStatus::Executing, step, None)
.await;
if let Err(err) = self
.execute_actions(
executable,
screenshot.width,
screenshot.height,
cancel_rx.clone(),
)
.await
{
if *cancel_rx.borrow() {
self.set_stopped().await;
} else {
self.fail(&err.to_string()).await;
}
return;
}
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ActionsExecuted {
actions: executable.to_vec(),
});
}
self.set_status(ComputerUseSessionStatus::WaitingScreenshot, step, None)
.await;
let screenshot = tokio::select! {
_ = &mut stop_rx => {
self.set_stopped().await;
return;
}
screenshot = self.request_screenshot(&client_id) => screenshot,
};
let screenshot = match screenshot {
Ok(screenshot) => screenshot,
Err(err) => {
self.fail(&err.to_string()).await;
return;
}
};
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ScreenshotCaptured {
screenshot: screenshot.clone(),
});
latest_screenshot = Some(screenshot);
}
}
async fn request_screenshot(&self, client_id: &str) -> Result<ComputerUseScreenshot> {
let request_id = Uuid::new_v4().to_string();
let (tx, rx) = oneshot::channel();
{
let mut state = self.state.lock().await;
state.screenshot_waiter = Some(ScreenshotWaiter {
request_id: request_id.clone(),
client_id: client_id.to_string(),
tx,
});
}
let _ = self.screenshot_tx.send(ScreenshotRequest {
request_id,
client_id: client_id.to_string(),
});
let reply = tokio::time::timeout(SCREENSHOT_TIMEOUT, rx)
.await
.map_err(|_| {
AppError::ServiceUnavailable("Timed out waiting for screenshot".to_string())
})?
.map_err(|_| {
AppError::ServiceUnavailable("Screenshot request was cancelled".to_string())
})?;
reply
}
async fn execute_actions(
&self,
actions: &[ComputerUseAction],
width: u32,
height: u32,
mut cancel_rx: watch::Receiver<bool>,
) -> Result<()> {
for action in actions {
if *cancel_rx.borrow() {
return Err(stopped_error());
}
match action {
ComputerUseAction::Click { x, y, button } => {
self.move_abs(*x, *y, width, height).await?;
self.mouse_button(*button, true).await?;
let click_result = sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await;
self.mouse_button(*button, false).await?;
click_result?;
}
ComputerUseAction::DoubleClick { x, y, button } => {
for _ in 0..2 {
self.move_abs(*x, *y, width, height).await?;
self.mouse_button(*button, true).await?;
let click_result = sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await;
self.mouse_button(*button, false).await?;
click_result?;
sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await?;
}
}
ComputerUseAction::Move { x, y } => self.move_abs(*x, *y, width, height).await?,
ComputerUseAction::Drag { path, button } => {
if let Some(first) = path.first() {
self.move_abs(first.x, first.y, width, height).await?;
self.mouse_button(*button, true).await?;
let drag_result = async {
for point in path.iter().skip(1) {
sleep_or_cancel(KEY_DELAY, &mut cancel_rx).await?;
self.move_abs(point.x, point.y, width, height).await?;
}
Result::<()>::Ok(())
}
.await;
self.mouse_button(*button, false).await?;
drag_result?;
}
}
ComputerUseAction::Scroll { x, y, dy, .. } => {
self.move_abs(*x, *y, width, height).await?;
let ticks = ((*dy).clamp(-1200, 1200) / 120).clamp(-10, 10);
let ticks = if ticks == 0 { dy.signum() } else { ticks };
for _ in 0..ticks.abs() {
if *cancel_rx.borrow() {
return Err(stopped_error());
}
self.hid
.send_mouse(MouseEvent::scroll(if ticks > 0 { 1 } else { -1 }))
.await?;
}
}
ComputerUseAction::Type { text } => self.type_text(text, &mut cancel_rx).await?,
ComputerUseAction::Keypress { keys } => self.keypress(keys, &mut cancel_rx).await?,
ComputerUseAction::Wait { ms } => {
sleep_or_cancel(Duration::from_millis((*ms).min(5000)), &mut cancel_rx).await?
}
ComputerUseAction::Screenshot => {}
}
sleep_or_cancel(ACTION_DELAY, &mut cancel_rx).await?;
}
Ok(())
}
async fn move_abs(&self, x: u32, y: u32, width: u32, height: u32) -> Result<()> {
let hid_x = ((x.min(width.saturating_sub(1)) as f64 / width.max(1) as f64) * 32767.0)
.round() as i32;
let hid_y = ((y.min(height.saturating_sub(1)) as f64 / height.max(1) as f64) * 32767.0)
.round() as i32;
self.hid
.send_mouse(MouseEvent::move_abs(hid_x, hid_y))
.await
}
async fn mouse_button(&self, button: ComputerUseButton, down: bool) -> Result<()> {
let button = match button {
ComputerUseButton::Left => MouseButton::Left,
ComputerUseButton::Middle => MouseButton::Middle,
ComputerUseButton::Right => MouseButton::Right,
};
let event = if down {
MouseEvent::button_down(button)
} else {
MouseEvent::button_up(button)
};
self.hid.send_mouse(event).await
}
async fn type_text(&self, text: &str, cancel_rx: &mut watch::Receiver<bool>) -> Result<()> {
for ch in text.chars() {
if *cancel_rx.borrow() {
return Err(stopped_error());
}
let (key, mods) = char_to_key(ch).ok_or_else(|| {
AppError::BadRequest(format!(
"Cannot type unsupported character {ch:?} through HID keyboard mapping"
))
})?;
self.key_down_up(key, mods, cancel_rx).await?;
}
Ok(())
}
async fn keypress(&self, keys: &[String], cancel_rx: &mut watch::Receiver<bool>) -> Result<()> {
let mut mods = KeyboardModifiers::default();
let mut key = None;
for item in keys {
match item.to_lowercase().as_str() {
"ctrl" | "control" | "controlleft" => mods.left_ctrl = true,
"shift" | "shiftleft" => mods.left_shift = true,
"alt" | "altleft" => mods.left_alt = true,
"meta" | "win" | "cmd" | "super" => mods.left_meta = true,
other => key = key_name_to_canonical(other),
}
}
if let Some(key) = key {
self.key_down_up(key, mods, cancel_rx).await?;
}
Ok(())
}
async fn key_down_up(
&self,
key: CanonicalKey,
mods: KeyboardModifiers,
cancel_rx: &mut watch::Receiver<bool>,
) -> Result<()> {
self.hid
.send_keyboard(KeyboardEvent {
event_type: KeyEventType::Down,
key,
modifiers: mods,
})
.await?;
let key_result = sleep_or_cancel(KEY_DELAY, cancel_rx).await;
self.hid
.send_keyboard(KeyboardEvent {
event_type: KeyEventType::Up,
key,
modifiers: KeyboardModifiers::default(),
})
.await?;
key_result
}
async fn publish_session(&self) {
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::SessionUpdated {
session: self.summary().await,
});
}
async fn set_status(&self, status: ComputerUseSessionStatus, step: u32, error: Option<String>) {
{
let mut state = self.state.lock().await;
state.session.status = status;
state.session.step = step;
state.session.last_error = error;
}
if matches!(status, ComputerUseSessionStatus::Thinking) {
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::StepStarted { step });
}
self.publish_session().await;
}
async fn complete(&self, message: Option<String>) {
{
let mut state = self.state.lock().await;
if let Some(message) = message.as_ref().filter(|message| !message.is_empty()) {
state
.conversation
.push(ComputerUseConversationMessage::Assistant {
text: message.clone(),
});
}
state.session.status = ComputerUseSessionStatus::Completed;
state.session.final_message = message;
state.stop_tx = None;
}
self.publish_session().await;
let _ = self.hid.reset().await;
}
async fn fail(&self, message: &str) {
{
let mut state = self.state.lock().await;
state.session.status = ComputerUseSessionStatus::Failed;
state.session.last_error = Some(message.to_string());
state.stop_tx = None;
}
let _ = self.event_tx.send(ComputerUseWsServerMessage::Error {
message: message.to_string(),
});
self.publish_session().await;
let _ = self.hid.reset().await;
}
async fn set_stopped(&self) {
{
let mut state = self.state.lock().await;
state.session.status = ComputerUseSessionStatus::Stopped;
state.stop_tx = None;
}
self.publish_session().await;
let _ = self.hid.reset().await;
}
}
async fn sleep_or_cancel(duration: Duration, cancel_rx: &mut watch::Receiver<bool>) -> Result<()> {
if *cancel_rx.borrow() {
return Err(stopped_error());
}
tokio::select! {
_ = tokio::time::sleep(duration) => Ok(()),
changed = cancel_rx.changed() => {
match changed {
Ok(()) if *cancel_rx.borrow() => {
Err(stopped_error())
}
Ok(()) => Ok(()),
Err(_) => Err(stopped_error()),
}
}
}
}
fn stopped_error() -> AppError {
AppError::BadRequest(STOPPED_MESSAGE.to_string())
}
fn empty_session() -> ComputerUseSessionSummary {
ComputerUseSessionSummary {
id: None,
status: ComputerUseSessionStatus::Idle,
prompt: None,
step: 0,
last_error: None,
final_message: None,
}
}
fn cua_api_key_env() -> Option<String> {
std::env::var("ONE_KVM_CUA_API_KEY")
.ok()
.filter(|key| !key.trim().is_empty())
.or_else(|| {
std::env::var("OPENAI_API_KEY")
.ok()
.filter(|key| !key.trim().is_empty())
})
}
fn cua_base_url_env() -> Option<String> {
std::env::var("ONE_KVM_CUA_BASE_URL")
.ok()
.filter(|url| !url.trim().is_empty())
.or_else(|| {
std::env::var("ONE_KVM_OPENAI_BASE_URL")
.ok()
.filter(|url| !url.trim().is_empty())
})
}
fn validate_endpoint_url(url: &str) -> Result<()> {
let trimmed = url.trim();
if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
return Err(AppError::BadRequest(
"API URL must be a complete http(s) endpoint".to_string(),
));
}
if trimmed.ends_with('/') {
return Err(AppError::BadRequest(
"API URL must include the full endpoint path without a trailing slash".to_string(),
));
}
if !trimmed.contains("/responses") && !trimmed.contains("/chat/completions") {
return Err(AppError::BadRequest(
"API URL must include /responses or /chat/completions".to_string(),
));
}
Ok(())
}
fn char_to_key(ch: char) -> Option<(CanonicalKey, KeyboardModifiers)> {
let mut mods = KeyboardModifiers::default();
let key = match ch {
'a'..='z' => key_name_to_canonical(&ch.to_string())?,
'A'..='Z' => {
mods.left_shift = true;
key_name_to_canonical(&ch.to_ascii_lowercase().to_string())?
}
'0' => CanonicalKey::Digit0,
'1' => CanonicalKey::Digit1,
'2' => CanonicalKey::Digit2,
'3' => CanonicalKey::Digit3,
'4' => CanonicalKey::Digit4,
'5' => CanonicalKey::Digit5,
'6' => CanonicalKey::Digit6,
'7' => CanonicalKey::Digit7,
'8' => CanonicalKey::Digit8,
'9' => CanonicalKey::Digit9,
' ' => CanonicalKey::Space,
'\n' => CanonicalKey::Enter,
'-' => CanonicalKey::Minus,
'_' => {
mods.left_shift = true;
CanonicalKey::Minus
}
'=' => CanonicalKey::Equal,
'+' => {
mods.left_shift = true;
CanonicalKey::Equal
}
'.' => CanonicalKey::Period,
',' => CanonicalKey::Comma,
'/' => CanonicalKey::Slash,
'?' => {
mods.left_shift = true;
CanonicalKey::Slash
}
';' => CanonicalKey::Semicolon,
':' => {
mods.left_shift = true;
CanonicalKey::Semicolon
}
'\'' => CanonicalKey::Quote,
'"' => {
mods.left_shift = true;
CanonicalKey::Quote
}
'[' => CanonicalKey::BracketLeft,
'{' => {
mods.left_shift = true;
CanonicalKey::BracketLeft
}
']' => CanonicalKey::BracketRight,
'}' => {
mods.left_shift = true;
CanonicalKey::BracketRight
}
'\\' => CanonicalKey::Backslash,
'|' => {
mods.left_shift = true;
CanonicalKey::Backslash
}
'`' => CanonicalKey::Backquote,
'~' => {
mods.left_shift = true;
CanonicalKey::Backquote
}
'!' => {
mods.left_shift = true;
CanonicalKey::Digit1
}
'@' => {
mods.left_shift = true;
CanonicalKey::Digit2
}
'#' => {
mods.left_shift = true;
CanonicalKey::Digit3
}
'$' => {
mods.left_shift = true;
CanonicalKey::Digit4
}
'%' => {
mods.left_shift = true;
CanonicalKey::Digit5
}
'^' => {
mods.left_shift = true;
CanonicalKey::Digit6
}
'&' => {
mods.left_shift = true;
CanonicalKey::Digit7
}
'*' => {
mods.left_shift = true;
CanonicalKey::Digit8
}
'(' => {
mods.left_shift = true;
CanonicalKey::Digit9
}
')' => {
mods.left_shift = true;
CanonicalKey::Digit0
}
_ => return None,
};
Some((key, mods))
}
fn key_name_to_canonical(name: &str) -> Option<CanonicalKey> {
match name.trim().to_lowercase().as_str() {
"a" => Some(CanonicalKey::KeyA),
"b" => Some(CanonicalKey::KeyB),
"c" => Some(CanonicalKey::KeyC),
"d" => Some(CanonicalKey::KeyD),
"e" => Some(CanonicalKey::KeyE),
"f" => Some(CanonicalKey::KeyF),
"g" => Some(CanonicalKey::KeyG),
"h" => Some(CanonicalKey::KeyH),
"i" => Some(CanonicalKey::KeyI),
"j" => Some(CanonicalKey::KeyJ),
"k" => Some(CanonicalKey::KeyK),
"l" => Some(CanonicalKey::KeyL),
"m" => Some(CanonicalKey::KeyM),
"n" => Some(CanonicalKey::KeyN),
"o" => Some(CanonicalKey::KeyO),
"p" => Some(CanonicalKey::KeyP),
"q" => Some(CanonicalKey::KeyQ),
"r" => Some(CanonicalKey::KeyR),
"s" => Some(CanonicalKey::KeyS),
"t" => Some(CanonicalKey::KeyT),
"u" => Some(CanonicalKey::KeyU),
"v" => Some(CanonicalKey::KeyV),
"w" => Some(CanonicalKey::KeyW),
"x" => Some(CanonicalKey::KeyX),
"y" => Some(CanonicalKey::KeyY),
"z" => Some(CanonicalKey::KeyZ),
"enter" | "return" => Some(CanonicalKey::Enter),
"escape" | "esc" => Some(CanonicalKey::Escape),
"backspace" => Some(CanonicalKey::Backspace),
"tab" => Some(CanonicalKey::Tab),
"space" => Some(CanonicalKey::Space),
"delete" | "del" => Some(CanonicalKey::Delete),
"arrowup" | "up" => Some(CanonicalKey::ArrowUp),
"arrowdown" | "down" => Some(CanonicalKey::ArrowDown),
"arrowleft" | "left" => Some(CanonicalKey::ArrowLeft),
"arrowright" | "right" => Some(CanonicalKey::ArrowRight),
"home" => Some(CanonicalKey::Home),
"end" => Some(CanonicalKey::End),
"pageup" => Some(CanonicalKey::PageUp),
"pagedown" => Some(CanonicalKey::PageDown),
"f1" => Some(CanonicalKey::F1),
"f2" => Some(CanonicalKey::F2),
"f3" => Some(CanonicalKey::F3),
"f4" => Some(CanonicalKey::F4),
"f5" => Some(CanonicalKey::F5),
"f6" => Some(CanonicalKey::F6),
"f7" => Some(CanonicalKey::F7),
"f8" => Some(CanonicalKey::F8),
"f9" => Some(CanonicalKey::F9),
"f10" => Some(CanonicalKey::F10),
"f11" => Some(CanonicalKey::F11),
"f12" => Some(CanonicalKey::F12),
_ => None,
}
}

View File

@@ -1,6 +0,0 @@
mod actions;
mod manager;
mod openai;
pub use actions::*;
pub use manager::*;

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ComputerUseConfig {
pub enabled: bool,
pub base_url: String,
pub model: String,
#[typeshare(skip)]
#[serde(alias = "openai_api_key")]
pub api_key: Option<String>,
}
impl Default for ComputerUseConfig {
fn default() -> Self {
Self {
enabled: false,
base_url: "https://api.openai.com/v1/responses".to_string(),
model: "gpt-5.5".to_string(),
api_key: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_openai_api_key_migrates_to_generic_key() {
let config: ComputerUseConfig = serde_json::from_value(serde_json::json!({
"enabled": true,
"provider": "openai",
"base_url": "https://example.test/v1/chat/completions",
"model": "vision-model",
"openai_api_key": "legacy-key",
"max_steps": 30,
"timeout_seconds": 600
}))
.unwrap();
assert_eq!(config.api_key.as_deref(), Some("legacy-key"));
assert_eq!(config.model, "vision-model");
}
}

View File

@@ -56,11 +56,37 @@ impl BluetoothHidConfig {
pub enum HidBackend {
Otg,
Ch9329,
#[serde(rename = "ch9329_otg")]
Ch9329Otg,
Bluetooth,
#[default]
None,
}
impl HidBackend {
pub fn uses_ch9329(&self) -> bool {
matches!(self, Self::Ch9329 | Self::Ch9329Otg)
}
pub fn uses_otg_hid(&self) -> bool {
matches!(self, Self::Otg)
}
pub fn supports_otg_functions(&self) -> bool {
matches!(self, Self::Otg | Self::Ch9329Otg)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Otg => "otg",
Self::Ch9329 => "ch9329",
Self::Ch9329Otg => "ch9329_otg",
Self::Bluetooth => "bluetooth",
Self::None => "none",
}
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OtgDescriptorConfig {
@@ -141,6 +167,15 @@ pub struct OtgHidFunctions {
}
impl OtgHidFunctions {
pub fn disabled() -> Self {
Self {
keyboard: false,
mouse_relative: false,
mouse_absolute: false,
consumer: false,
}
}
pub fn full() -> Self {
Self {
keyboard: true,
@@ -211,10 +246,39 @@ impl OtgHidProfile {
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct MouseJigglerConfig {
pub enabled: bool,
pub interval_secs: u32,
}
impl Default for MouseJigglerConfig {
fn default() -> Self {
Self {
enabled: false,
interval_secs: 60,
}
}
}
impl MouseJigglerConfig {
pub fn validate(&self) -> crate::error::Result<()> {
if !(10..=3600).contains(&self.interval_secs) {
return Err(crate::error::AppError::BadRequest(
"Mouse jiggler interval must be an integer between 10 and 3600 seconds".into(),
));
}
Ok(())
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct HidConfig {
pub jiggler: MouseJigglerConfig,
pub bluetooth: BluetoothHidConfig,
pub backend: HidBackend,
pub otg_udc: Option<String>,
@@ -241,6 +305,7 @@ pub struct HidConfig {
impl Default for HidConfig {
fn default() -> Self {
Self {
jiggler: MouseJigglerConfig::default(),
backend: HidBackend::None,
bluetooth: BluetoothHidConfig::default(),
otg_udc: None,
@@ -259,20 +324,66 @@ impl Default for HidConfig {
}
impl HidConfig {
pub fn has_mouse(&self) -> bool {
match self.backend {
HidBackend::None => false,
HidBackend::Otg => {
let functions = self.effective_otg_functions();
functions.mouse_relative || functions.mouse_absolute
}
_ => true,
}
}
pub fn effective_otg_functions(&self) -> OtgHidFunctions {
self.otg_profile.resolve_functions(&self.otg_functions)
}
pub fn effective_otg_keyboard_leds(&self) -> bool {
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
self.otg_keyboard_leds && self.constrained_otg_functions().keyboard
}
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
self.effective_otg_functions()
if self.backend.uses_otg_hid() {
self.effective_otg_functions()
} else {
OtgHidFunctions::disabled()
}
}
pub fn validate_platform(&self) -> crate::error::Result<()> {
self.jiggler.validate()?;
if self.backend == HidBackend::Ch9329Otg && !cfg!(target_os = "linux") {
return Err(crate::error::AppError::BadRequest(
"CH9329 + OTG requires Linux with a USB device controller".into(),
));
}
Ok(())
}
pub fn validate_composite_selection(&self) -> crate::error::Result<()> {
if self.backend != HidBackend::Ch9329Otg {
return Ok(());
}
if self.ch9329_port.trim().is_empty()
|| ![9600, 19200, 38400, 57600, 115200].contains(&self.ch9329_baudrate)
{
return Err(crate::error::AppError::BadRequest(
"CH9329 + OTG requires a serial port and a supported baudrate".into(),
));
}
if !self.otg_udc.as_ref().is_some_and(|udc| {
!udc.trim().is_empty() && !udc.contains('/') && udc != "." && udc != ".."
}) {
return Err(crate::error::AppError::BadRequest(
"CH9329 + OTG requires an explicit USB device controller".into(),
));
}
Ok(())
}
pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
if self.backend != HidBackend::Otg {
if !self.backend.uses_otg_hid() {
return Ok(());
}
@@ -293,7 +404,7 @@ impl HidConfig {
#[inline]
pub fn resolved_otg_udc(&self) -> Option<String> {
if self.backend != HidBackend::Otg {
if !self.backend.supports_otg_functions() {
return None;
}
self.otg_udc
@@ -301,6 +412,9 @@ impl HidConfig {
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
if !self.backend.uses_otg_hid() {
return None;
}
#[cfg(unix)]
{
crate::otg::OtgGadgetManager::find_udc()
@@ -316,6 +430,53 @@ impl HidConfig {
#[cfg(test)]
mod bluetooth_tests {
use super::*;
#[test]
fn composite_mode_preserves_usb_functions_and_saved_otg_profile() {
let mut config = crate::config::AppConfig::default();
config.hid.backend = HidBackend::Ch9329Otg;
config.hid.otg_profile = OtgHidProfile::LegacyKeyboard;
config.hid.otg_keyboard_leds = true;
config.hid.mouse_macos_drag = true;
config.msd.enabled = true;
config.otg_network.enabled = true;
config.uac.enabled = true;
config.enforce_invariants();
assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled);
assert!(config.hid.constrained_otg_functions().is_empty());
assert!(!config.hid.effective_otg_keyboard_leds());
assert!(config.hid.validate_otg_functions().is_ok());
let saved = serde_json::to_value(&config.hid).unwrap();
assert_eq!(saved["backend"], "ch9329_otg");
assert_eq!(saved["otg_profile"], "legacy_keyboard");
config.hid = serde_json::from_value(saved).unwrap();
config.hid.backend = HidBackend::Otg;
assert!(config.hid.constrained_otg_functions().keyboard);
assert!(config.hid.effective_otg_keyboard_leds());
config.hid.backend = HidBackend::Ch9329;
config.enforce_invariants();
assert!(!config.msd.enabled && !config.otg_network.enabled && !config.uac.enabled);
}
#[test]
fn composite_selection_requires_both_devices_without_auto_udc_fallback() {
let mut config = HidConfig {
backend: HidBackend::Ch9329Otg,
..Default::default()
};
assert!(config.validate_composite_selection().is_err());
assert!(config.resolved_otg_udc().is_none());
config.otg_udc = Some("test.udc".into());
assert!(config.validate_composite_selection().is_ok());
assert_eq!(config.resolved_otg_udc().as_deref(), Some("test.udc"));
for udc in ["", "../other", "/sys/class/udc/test", ".."] {
config.otg_udc = Some(udc.into());
assert!(config.validate_composite_selection().is_err());
}
config.otg_udc = Some("test.udc".into());
config.ch9329_port.clear();
assert!(config.validate_composite_selection().is_err());
}
#[test]
fn mouse_compatibility_defaults_alias_and_otg_validation() {
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();

43
src/config/schema/mcp.rs Normal file
View File

@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct McpConfig {
pub enabled: bool,
pub requests_per_second: u32,
#[typeshare(skip)]
pub api_key: Option<String>,
#[typeshare(skip)]
pub api_key_hash: Option<String>,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
enabled: false,
requests_per_second: 5,
api_key: None,
api_key_hash: None,
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn mcp_defaults_are_disabled_and_have_no_model_credentials() {
let config: super::super::AppConfig = serde_json::from_value(
serde_json::json!({"computer_use":{"enabled":true,"api_key":"old-model-key"}}),
)
.unwrap();
assert!(!config.mcp.enabled);
assert!(config.mcp.api_key_hash.is_none());
assert!(config.mcp.api_key.is_none());
assert_eq!(config.mcp.requests_per_second, 5);
assert!(serde_json::to_value(&config)
.unwrap()
.get("computer_use")
.is_none());
}
}

View File

@@ -6,8 +6,8 @@ pub use crate::rustdesk::config::RustDeskConfig;
mod atx;
mod common;
mod computer_use;
mod hid;
mod mcp;
mod otg_network;
mod stream;
mod uac;
@@ -16,8 +16,8 @@ mod web;
pub use atx::*;
pub use common::*;
pub use computer_use::*;
pub use hid::*;
pub use mcp::*;
pub use otg_network::*;
pub use stream::*;
pub use uac::*;
@@ -39,7 +39,7 @@ pub struct AppConfig {
pub audio: AudioConfig,
pub stream: StreamConfig,
pub web: WebConfig,
pub computer_use: ComputerUseConfig,
pub mcp: McpConfig,
pub extensions: ExtensionsConfig,
pub rustdesk: RustDeskConfig,
pub vnc: VncConfig,
@@ -51,7 +51,7 @@ pub struct AppConfig {
impl AppConfig {
pub fn enforce_invariants(&mut self) {
if self.hid.backend != HidBackend::Otg {
if !self.hid.backend.supports_otg_functions() {
self.msd.enabled = false;
self.otg_network.enabled = false;
self.uac.enabled = false;
@@ -60,6 +60,7 @@ impl AppConfig {
self.hid.mouse_absolute = false;
}
self.atx.normalize();
self.mcp.requests_per_second = self.mcp.requests_per_second.clamp(1, 100);
}
pub fn apply_platform_defaults(&mut self) {

View File

@@ -27,9 +27,9 @@ impl ConfigStore {
}
pub async fn load(&self) -> Result<()> {
let (mut config, removed_legacy_totp) = Self::load_config(&self.pool).await?;
let (mut config, removed_legacy_fields) = Self::load_config(&self.pool).await?;
config.enforce_invariants();
if removed_legacy_totp {
if removed_legacy_fields {
Self::save_config_to_db(&self.pool, &config).await?;
}
self.cache.store(Arc::new(config));
@@ -46,7 +46,9 @@ impl ConfigStore {
Some((json,)) => {
let mut value: serde_json::Value =
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))?;
let mut removed = false;
let mut removed = value
.as_object_mut()
.is_some_and(|object| object.remove("computer_use").is_some());
if let Some(auth) = value
.get_mut("auth")
.and_then(|value| value.as_object_mut())
@@ -142,6 +144,32 @@ mod tests {
use crate::db::DatabasePool;
use tempfile::tempdir;
#[tokio::test]
async fn jiggler_defaults_and_preferences_survive_restart() {
let defaults: crate::config::HidConfig =
serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
assert!(!defaults.jiggler.enabled);
assert_eq!(defaults.jiggler.interval_secs, 60);
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("jiggler.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let store = ConfigStore::new(db.clone_pool());
store.load().await.unwrap();
store
.update(|config| {
config.hid.jiggler.enabled = true;
config.hid.jiggler.interval_secs = 120;
})
.await
.unwrap();
let reloaded = ConfigStore::new(db.clone_pool());
reloaded.load().await.unwrap();
assert!(reloaded.get().hid.jiggler.enabled);
assert_eq!(reloaded.get().hid.jiggler.interval_secs, 120);
}
#[tokio::test]
async fn test_config_store() {
let dir = tempdir().unwrap();
@@ -175,6 +203,60 @@ mod tests {
assert_eq!(config.web.http_port, 9000);
}
#[tokio::test]
async fn mcp_key_remains_readable_after_config_reload() {
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("mcp-key.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let store = ConfigStore::new(db.clone_pool());
store.load().await.unwrap();
let key = crate::mcp::generate_key();
store
.update(|config| {
config.mcp.enabled = true;
config.mcp.api_key_hash = Some(crate::mcp::hash_key(&key));
config.mcp.api_key = Some(key.clone());
})
.await
.unwrap();
let reloaded = ConfigStore::new(db.clone_pool());
reloaded.load().await.unwrap();
assert_eq!(reloaded.get().mcp.api_key.as_deref(), Some(key.as_str()));
assert_eq!(
reloaded.get().mcp.api_key_hash,
store.get().mcp.api_key_hash
);
}
#[tokio::test]
async fn loading_legacy_agent_cleans_persisted_model_credentials() {
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("legacy-agent.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let value =
serde_json::json!({"computer_use":{"enabled":true,"api_key":"legacy-model-secret"}});
sqlx::query("INSERT INTO config (key, value) VALUES ('app_config', ?1)")
.bind(value.to_string())
.execute(db.pool())
.await
.unwrap();
let store = ConfigStore::new(db.clone_pool());
store.load().await.unwrap();
assert!(!store.get().mcp.enabled);
assert!(store.get().mcp.api_key_hash.is_none());
let (persisted,): (String,) =
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
.fetch_one(db.pool())
.await
.unwrap();
assert!(!persisted.contains("legacy-model-secret"));
assert!(!persisted.contains("computer_use"));
}
#[tokio::test]
async fn failed_watchdog_persistence_does_not_update_cache() {
let dir = tempdir().unwrap();

143
src/diagnostics/host.rs Normal file
View File

@@ -0,0 +1,143 @@
//! Read-only host metadata and filesystem identity for the About page.
use super::{get_disk_space, DiskSpaceInfo};
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize)]
pub struct HostInfo {
pub operating_system: String,
pub architecture: &'static str,
pub kernel: Option<String>,
pub cpu_count: usize,
pub storage: StorageInfo,
}
#[derive(Debug, Serialize)]
pub struct StorageInfo {
pub system: Option<DiskSpaceInfo>,
pub msd: Option<DiskSpaceInfo>,
/// Unknown identity must never be interpreted as a shared filesystem.
pub shared_partition: Option<bool>,
}
pub fn get_host_info(msd_path: &Path) -> HostInfo {
let operating_system = std::fs::read_to_string("/etc/os-release")
.ok()
.and_then(|text| os_release_name(&text))
.unwrap_or_else(|| std::env::consts::OS.to_string());
let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
HostInfo {
operating_system,
architecture: std::env::consts::ARCH,
kernel,
cpu_count: std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1),
storage: storage_info(system_root().as_path(), msd_path),
}
}
fn system_root() -> PathBuf {
#[cfg(windows)]
{
PathBuf::from(std::env::var_os("SystemDrive").unwrap_or_else(|| "C:".into())).join("\\")
}
#[cfg(not(windows))]
{
PathBuf::from("/")
}
}
fn os_release_name(text: &str) -> Option<String> {
text.lines().find_map(|line| {
let value = line
.strip_prefix("PRETTY_NAME=")?
.trim()
.trim_matches(['\'', '"']);
(!value.is_empty()).then(|| value.to_string())
})
}
// MSD directories may not have been created yet. Read their containing filesystem
// without creating directories or changing permissions.
fn existing_ancestor(path: &Path) -> Option<PathBuf> {
if path.as_os_str().is_empty() {
return None;
}
let mut candidate = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
loop {
match std::fs::metadata(&candidate) {
Ok(_) => return Some(candidate),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if !candidate.pop() {
return None;
}
}
Err(_) => return None,
}
}
}
fn storage_info(system: &Path, msd: &Path) -> StorageInfo {
let system = existing_ancestor(system);
let msd = existing_ancestor(msd);
StorageInfo {
system: system.as_deref().and_then(|path| get_disk_space(path).ok()),
msd: msd.as_deref().and_then(|path| get_disk_space(path).ok()),
shared_partition: system
.as_deref()
.zip(msd.as_deref())
.and_then(|(a, b)| same_filesystem(a, b)),
}
}
#[cfg(unix)]
fn same_filesystem(a: &Path, b: &Path) -> Option<bool> {
use std::os::unix::fs::MetadataExt;
Some(std::fs::metadata(a).ok()?.dev() == std::fs::metadata(b).ok()?.dev())
}
#[cfg(not(unix))]
fn same_filesystem(_a: &Path, _b: &Path) -> Option<bool> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distribution_name_handles_quotes_and_missing_values() {
assert_eq!(
os_release_name("NAME=Linux\nPRETTY_NAME=\"OpenWrt 24.10\"\n"),
Some("OpenWrt 24.10".into())
);
assert_eq!(os_release_name("PRETTY_NAME=\n"), None);
assert_eq!(os_release_name("NAME=Linux\n"), None);
}
#[cfg(unix)]
#[test]
fn nested_msd_directory_shares_its_existing_parent_filesystem() {
let parent = tempfile::tempdir().unwrap();
let missing = parent.path().join("not-created/msd");
let storage = storage_info(parent.path(), &missing);
assert_eq!(storage.shared_partition, Some(true));
assert!(storage.system.is_some() && storage.msd.is_some());
assert!(!missing.exists());
}
#[test]
fn missing_msd_is_not_a_duplicate_system_partition() {
let storage = storage_info(&system_root(), Path::new(""));
assert!(storage.msd.is_none());
assert_eq!(storage.shared_partition, None);
}
}

View File

@@ -9,7 +9,7 @@ pub fn get_disk_space(path: &std::path::Path) -> Result<DiskSpaceInfo> {
let block_size = stat.block_size() as u64;
let total = stat.blocks() as u64 * block_size;
let available = stat.blocks_available() as u64 * block_size;
let used = total - available;
let used = total.saturating_sub(stat.blocks_free() as u64 * block_size);
Ok(DiskSpaceInfo {
total,

View File

@@ -4,6 +4,9 @@ use serde::Serialize;
use crate::error::Result;
mod host;
pub use host::{get_host_info, HostInfo};
#[derive(Debug, Clone, Serialize)]
pub struct DeviceInfo {
pub hostname: String,

View File

@@ -9,7 +9,7 @@ use tokio::sync::RwLock;
use super::protected_config::ProtectedConfigFile;
use super::types::*;
use super::validation::{validate_easytier_config, validate_frpc_config, validate_gostc_config};
use super::validation::{validate_easyfrp_config, validate_easytier_config, validate_frpc_config};
use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200;
@@ -82,8 +82,8 @@ impl ExtensionManager {
fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> bool {
match id {
ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => {
config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok()
ExtensionId::Easyfrp => {
config.easyfrp.enabled && validate_easyfrp_config(&config.easyfrp).is_ok()
}
ExtensionId::Easytier => {
config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok()
@@ -144,12 +144,7 @@ impl ExtensionManager {
let launch = self.build_launch(id, config).await?;
tracing::info!(
"Starting extension {}: {} {}",
id,
id.binary_path().display(),
launch.args.join(" ")
);
tracing::info!("Starting extension {}: {}", id, id.binary_path().display());
let mut child = Command::new(id.binary_path())
.args(&launch.args)
@@ -272,17 +267,12 @@ impl ExtensionManager {
args
}
ExtensionId::Gostc => {
let c = &config.gostc;
validate_gostc_config(c)?;
// gostc defaults to TLS, so disabling it must be explicit.
let mut args = vec![format!("--tls={}", c.tls)];
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
args.extend(["-key".to_string(), c.key.clone()]);
ExtensionId::Easyfrp => {
let c = &config.easyfrp;
validate_easyfrp_config(c)?;
let mut args = vec!["control".to_string()];
args.extend(c.control_urls.iter().map(|url| url.trim().to_string()));
args
}
@@ -580,6 +570,27 @@ impl ExtensionManager {
mod tests {
use super::*;
#[tokio::test]
async fn easyfrp_launch_uses_control_urls_as_separate_arguments() {
let mut config = ExtensionsConfig::default();
config.easyfrp.control_urls = vec![
"wss://example.com/key-one".to_string(),
"txt://example.net/key-two".to_string(),
];
let launch = ExtensionManager::new()
.build_launch(ExtensionId::Easyfrp, &config)
.await
.unwrap();
assert_eq!(
launch.args,
[
"control",
"wss://example.com/key-one",
"txt://example.net/key-two"
]
);
}
#[tokio::test]
async fn easytier_launch_revalidates_full_configuration() {
let config = EasytierConfig {

View File

@@ -11,6 +11,6 @@ pub use manager::TTYD_SOCKET_PATH;
pub use manager::TTYD_TCP_ADDR;
pub use types::*;
pub(crate) use validation::{
validate_easytier_config, validate_extension_config, validate_frpc_config,
validate_gostc_config,
validate_easyfrp_config, validate_easytier_config, validate_extension_config,
validate_frpc_config,
};

View File

@@ -5,7 +5,7 @@ use super::ExtensionId;
pub fn default_binary_path(id: ExtensionId) -> &'static str {
match id {
ExtensionId::Ttyd => "/usr/bin/ttyd",
ExtensionId::Gostc => "/usr/bin/gostc",
ExtensionId::Easyfrp => "/usr/bin/easyfrpc",
ExtensionId::Easytier => "/usr/bin/easytier-core",
ExtensionId::Frpc => "/usr/bin/frpc",
}

View File

@@ -5,7 +5,7 @@ use super::ExtensionId;
pub fn default_binary_path(id: ExtensionId) -> &'static str {
match id {
ExtensionId::Ttyd => "ttyd.win32.exe",
ExtensionId::Gostc => "gostc.exe",
ExtensionId::Easyfrp => "easyfrpc.exe",
ExtensionId::Easytier => "easytier-core.exe",
ExtensionId::Frpc => "frpc.exe",
}

View File

@@ -8,7 +8,7 @@ use super::software;
#[serde(rename_all = "lowercase")]
pub enum ExtensionId {
Ttyd,
Gostc,
Easyfrp,
Easytier,
Frpc,
}
@@ -19,7 +19,7 @@ impl ExtensionId {
}
pub fn all() -> &'static [ExtensionId] {
&[Self::Ttyd, Self::Gostc, Self::Easytier, Self::Frpc]
&[Self::Ttyd, Self::Easyfrp, Self::Easytier, Self::Frpc]
}
}
@@ -27,7 +27,7 @@ impl std::fmt::Display for ExtensionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ttyd => write!(f, "ttyd"),
Self::Gostc => write!(f, "gostc"),
Self::Easyfrp => write!(f, "easyfrp"),
Self::Easytier => write!(f, "easytier"),
Self::Frpc => write!(f, "frpc"),
}
@@ -40,7 +40,7 @@ impl std::str::FromStr for ExtensionId {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"ttyd" => Ok(Self::Ttyd),
"gostc" => Ok(Self::Gostc),
"easyfrp" => Ok(Self::Easyfrp),
"easytier" => Ok(Self::Easytier),
"frpc" => Ok(Self::Frpc),
_ => Err(format!("Unknown extension: {}", s)),
@@ -81,25 +81,11 @@ impl Default for TtydConfig {
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct GostcConfig {
pub struct EasyfrpConfig {
pub enabled: bool,
pub addr: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub key: String,
pub tls: bool,
}
impl Default for GostcConfig {
fn default() -> Self {
Self {
enabled: false,
addr: String::new(),
key: String::new(),
tls: true,
}
}
pub control_urls: Vec<String>,
}
#[typeshare]
@@ -217,7 +203,7 @@ impl Default for FrpcConfig {
#[serde(default)]
pub struct ExtensionsConfig {
pub ttyd: TtydConfig,
pub gostc: GostcConfig,
pub easyfrp: EasyfrpConfig,
pub easytier: EasytierConfig,
pub frpc: FrpcConfig,
}
@@ -239,10 +225,10 @@ pub struct TtydInfo {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GostcInfo {
pub struct EasyfrpInfo {
pub available: bool,
pub status: ExtensionStatus,
pub config: GostcConfig,
pub config: EasyfrpConfig,
}
#[typeshare]
@@ -265,7 +251,7 @@ pub struct FrpcInfo {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionsStatus {
pub ttyd: TtydInfo,
pub gostc: GostcInfo,
pub easyfrp: EasyfrpInfo,
pub easytier: EasytierInfo,
pub frpc: FrpcInfo,
}
@@ -279,7 +265,17 @@ pub struct ExtensionLogs {
#[cfg(test)]
mod tests {
use super::{EasytierConfig, EasytierConfigMode};
use super::{EasytierConfig, EasytierConfigMode, ExtensionsConfig};
#[test]
fn replacing_legacy_gostc_requires_new_easyfrp_configuration() {
let config: ExtensionsConfig = serde_json::from_str(
r#"{"gostc":{"enabled":true,"addr":"old.example.com","key":"old-key","tls":true}}"#,
)
.unwrap();
assert!(!config.easyfrp.enabled);
assert!(config.easyfrp.control_urls.is_empty());
}
#[test]
fn legacy_easytier_config_defaults_to_quick_mode() {

View File

@@ -1,8 +1,8 @@
use toml_edit::DocumentMut;
use super::types::{
EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig,
FrpcConfigMode, GostcConfig,
EasyfrpConfig, EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType,
FrpcConfig, FrpcConfigMode,
};
pub(crate) fn validate_extension_config(
@@ -11,15 +11,36 @@ pub(crate) fn validate_extension_config(
) -> Result<(), String> {
match id {
ExtensionId::Ttyd => Ok(()),
ExtensionId::Gostc => validate_gostc_config(&config.gostc),
ExtensionId::Easyfrp => validate_easyfrp_config(&config.easyfrp),
ExtensionId::Easytier => validate_easytier_config(&config.easytier),
ExtensionId::Frpc => validate_frpc_config(&config.frpc),
}
}
pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> {
require_non_empty(config.addr.trim(), "GOSTC server address is required")?;
require_non_empty(config.key.as_str(), "GOSTC client key is required")
pub(crate) fn validate_easyfrp_config(config: &EasyfrpConfig) -> Result<(), String> {
if config.control_urls.is_empty() {
return Err("EasyFrp control URL is required".to_string());
}
for value in &config.control_urls {
let value = value.trim();
if value.chars().any(char::is_whitespace) {
return Err("EasyFrp control URL must not contain whitespace".to_string());
}
let url =
reqwest::Url::parse(value).map_err(|_| "EasyFrp control URL is invalid".to_string())?;
if !matches!(url.scheme(), "ws" | "wss" | "http" | "https" | "txt")
|| url.host_str().is_none()
|| url.path().trim_matches('/').is_empty()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err(
"EasyFrp requires a ws/wss/http/https/txt URL with a client key path".to_string(),
);
}
}
Ok(())
}
pub(crate) fn validate_easytier_config(config: &EasytierConfig) -> Result<(), String> {
@@ -80,6 +101,36 @@ fn validate_full_toml(extension_name: &str, config: &str) -> Result<(), String>
mod tests {
use super::*;
#[test]
fn easyfrp_accepts_documented_control_protocols() {
for scheme in ["ws", "wss", "http", "https", "txt"] {
let config = EasyfrpConfig {
enabled: true,
control_urls: vec![format!("{scheme}://example.com/client-key")],
};
assert!(validate_easyfrp_config(&config).is_ok());
}
}
#[test]
fn easyfrp_rejects_missing_keys_and_non_connection_arguments() {
for urls in [
vec![],
vec![""],
vec!["--help"],
vec!["wss://example.com/"],
vec!["file:///tmp/config"],
vec!["wss://example.com/key with spaces"],
vec!["wss://example.com/key", "invalid"],
] {
let config = EasyfrpConfig {
enabled: true,
control_urls: urls.into_iter().map(str::to_string).collect(),
};
assert!(validate_easyfrp_config(&config).is_err());
}
}
#[test]
fn validates_easytier_full_configuration() {
let mut config = EasytierConfig {

View File

@@ -37,6 +37,27 @@ pub enum HidBackendType {
None,
}
impl From<&crate::config::HidConfig> for HidBackendType {
fn from(config: &crate::config::HidConfig) -> Self {
use crate::config::HidBackend;
match config.backend {
HidBackend::Otg => Self::Otg {
macos_drag: config.mouse_macos_drag,
},
HidBackend::Ch9329 | HidBackend::Ch9329Otg => Self::Ch9329 {
port: config.ch9329_port.clone(),
baud_rate: config.ch9329_baudrate,
hybrid_mouse: config.ch9329_hybrid_mouse,
macos_drag: config.mouse_macos_drag,
},
HidBackend::Bluetooth => Self::Bluetooth {
config: config.bluetooth.clone(),
},
HidBackend::None => Self::None,
}
}
}
impl HidBackendType {
pub fn name_str(&self) -> &str {
match self {

500
src/hid/jiggler.rs Normal file
View File

@@ -0,0 +1,500 @@
//! Device-side mouse jiggler. Real input invalidates every remaining movement.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use crate::config::{HidBackend, HidConfig, MouseJigglerConfig};
use super::{CanonicalKey, KeyEventType, KeyboardEvent, MouseEvent, MouseEventType};
const STEP_DELAY: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Relative,
Absolute,
}
pub(super) struct JigglerState {
config: MouseJigglerConfig,
relative: bool,
absolute: bool,
mode: Mode,
ready: bool,
activity: Instant,
keys: HashSet<CanonicalKey>,
modifiers: u8,
buttons: u8,
consumer: u16,
last_absolute: Option<(i32, i32)>,
return_move: Option<(MouseEvent, Instant)>,
generation: u64,
}
impl Default for JigglerState {
fn default() -> Self {
Self {
config: MouseJigglerConfig::default(),
relative: false,
absolute: false,
mode: Mode::Relative,
ready: false,
activity: Instant::now(),
keys: HashSet::new(),
modifiers: 0,
buttons: 0,
consumer: 0,
last_absolute: None,
return_move: None,
generation: 0,
}
}
}
impl JigglerState {
pub fn configure(&mut self, hid: &HidConfig, now: Instant) {
let (relative, absolute) = match hid.backend {
HidBackend::None => (false, false),
HidBackend::Bluetooth => (true, false),
HidBackend::Otg => {
let functions = hid.effective_otg_functions();
(functions.mouse_relative, functions.mouse_absolute)
}
_ => (true, true),
};
let mode = if absolute && (hid.mouse_absolute || !relative) {
Mode::Absolute
} else {
Mode::Relative
};
if self.config != hid.jiggler
|| (self.relative, self.absolute, self.mode) != (relative, absolute, mode)
{
self.config = hid.jiggler.clone();
self.relative = relative;
self.absolute = absolute;
self.mode = mode;
self.activity(now);
}
}
pub fn activity(&mut self, now: Instant) {
self.activity = now;
self.return_move = None;
self.generation = self.generation.wrapping_add(1);
}
pub fn keyboard(&mut self, event: &KeyboardEvent, now: Instant) {
self.activity(now);
match event.event_type {
KeyEventType::Down => {
self.keys.insert(event.key);
}
KeyEventType::Up => {
self.keys.remove(&event.key);
}
}
self.modifiers = event.modifiers.to_hid_byte();
}
pub fn mouse(&mut self, event: &MouseEvent, now: Instant) {
self.activity(now);
match event.event_type {
MouseEventType::Move if self.relative => self.mode = Mode::Relative,
MouseEventType::MoveAbs if self.absolute => {
self.mode = Mode::Absolute;
self.last_absolute = Some((event.x.clamp(0, 32767), event.y.clamp(0, 32767)));
}
MouseEventType::Down => {
if let Some(button) = event.button {
self.buttons |= button.to_hid_bit();
}
}
MouseEventType::Up => {
if let Some(button) = event.button {
self.buttons &= !button.to_hid_bit();
}
}
_ => {}
}
}
pub fn consumer(&mut self, usage: u16, now: Instant) {
self.activity(now);
self.consumer = usage;
}
pub fn reset(&mut self, now: Instant) {
self.activity(now);
self.keys.clear();
self.modifiers = 0;
self.buttons = 0;
self.consumer = 0;
}
pub fn suspend(&mut self) {
self.ready = false;
self.last_absolute = None;
self.reset(Instant::now());
}
pub fn delay(&self) -> Option<Duration> {
(self.config.enabled && (self.relative || self.absolute)).then_some(
if self.return_move.is_some() {
STEP_DELAY
} else {
Duration::from_secs(1)
},
)
}
pub fn valid(&self, generation: u64) -> bool {
self.config.enabled && self.ready && self.generation == generation
}
pub fn tick(
&mut self,
ready: bool,
resolution: (u32, u32),
now: Instant,
) -> Option<(MouseEvent, u64)> {
if ready != self.ready {
self.ready = ready;
if ready {
// Input may already have arrived since reconnecting. Preserve
// held keys/buttons when starting the new idle period.
self.activity(now);
} else {
self.reset(now);
self.last_absolute = None;
}
}
if !self.ready
|| !self.config.enabled
|| (!self.relative && !self.absolute)
|| !self.keys.is_empty()
|| self.modifiers != 0
|| self.buttons != 0
|| self.consumer != 0
{
return None;
}
if let Some((_, deadline)) = &self.return_move {
if now < *deadline {
return None;
}
let (event, _) = self.return_move.take().unwrap();
self.activity = now;
return Some((event, self.generation));
}
if now.duration_since(self.activity) < Duration::from_secs(self.config.interval_secs.into())
{
return None;
}
let (event, back) = if self.mode == Mode::Absolute
&& self.absolute
&& (self.last_absolute.is_some() || !self.relative)
{
// An absolute-only device has no way to query the host cursor position.
let (x, y) = self.last_absolute.unwrap_or((16384, 16384));
let offset = |position: i32, extent: u32| {
let delta = (65534 / extent.max(1)).clamp(1, 512) as i32;
if position + delta <= 32767 {
position + delta
} else {
position - delta
}
};
(
MouseEvent::move_abs(offset(x, resolution.0), offset(y, resolution.1)),
MouseEvent::move_abs(x, y),
)
} else {
(MouseEvent::move_rel(2, 0), MouseEvent::move_rel(-2, 0))
};
self.return_move = Some((back, now + STEP_DELAY));
Some((event, self.generation))
}
pub fn failed(&mut self, generation: u64, now: Instant) {
if self.generation == generation {
self.activity(now);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::OtgHidProfile;
use crate::hid::{KeyboardModifiers, MouseButton};
fn configured(now: Instant) -> (JigglerState, HidConfig) {
let mut config = HidConfig::default();
config.backend = HidBackend::Otg;
config.jiggler.enabled = true;
config.jiggler.interval_secs = 10;
let mut state = JigglerState::default();
state.configure(&config, now);
assert!(state.tick(true, (1920, 1080), now).is_none());
(state, config)
}
#[test]
fn jiggler_waits_then_moves_out_and_back_without_counting_synthetic_input() {
let now = Instant::now();
let (mut state, _) = configured(now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(9))
.is_none());
let due = now + Duration::from_secs(10);
let (out, generation) = state.tick(true, (1920, 1080), due).unwrap();
assert_eq!((out.event_type, out.x, out.y), (MouseEventType::Move, 2, 0));
assert!(state.valid(generation));
assert!(state
.tick(true, (1920, 1080), due + STEP_DELAY / 2)
.is_none());
let (back, _) = state.tick(true, (1920, 1080), due + STEP_DELAY).unwrap();
assert_eq!((back.x, back.y), (-2, 0));
assert!(state
.tick(true, (1920, 1080), due + Duration::from_secs(10))
.is_none());
assert!(state
.tick(
true,
(1920, 1080),
due + STEP_DELAY + Duration::from_secs(10)
)
.is_some());
}
#[test]
fn jiggler_real_input_cancels_return_and_invalidates_dispatched_movement() {
let now = Instant::now();
let (mut state, _) = configured(now);
state.mouse(&MouseEvent::move_abs(32767, 0), now);
let due = now + Duration::from_secs(10);
let (out, generation) = state.tick(true, (1920, 1080), due).unwrap();
assert!(out.x < 32767 && out.y > 0);
let input = due + Duration::from_millis(50);
state.mouse(&MouseEvent::move_abs(12000, 24000), input);
assert!(!state.valid(generation));
assert!(state.tick(true, (1920, 1080), due + STEP_DELAY).is_none());
let (next, _) = state
.tick(true, (1920, 1080), input + Duration::from_secs(10))
.unwrap();
assert!(next.x > 12000 && next.x < 12100 && next.y > 24000 && next.y < 24100);
}
#[test]
fn jiggler_pauses_for_held_keys_modifiers_buttons_and_consumer_controls() {
let now = Instant::now();
let (mut state, _) = configured(now);
let down = KeyboardEvent::key_down(CanonicalKey::KeyA, KeyboardModifiers::default());
state.keyboard(&down, now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.keyboard(
&KeyboardEvent::key_up(
CanonicalKey::KeyA,
KeyboardModifiers {
left_ctrl: true,
..Default::default()
},
),
now,
);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.reset(now);
state.mouse(&MouseEvent::button_down(MouseButton::Left), now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.mouse(&MouseEvent::button_up(MouseButton::Left), now);
state.consumer(0xe9, now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.consumer(0, now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.is_some());
}
#[test]
fn jiggler_config_changes_disconnects_and_shutdown_cancel_and_restart_the_wait() {
let now = Instant::now();
let (mut state, mut config) = configured(now);
let due = now + Duration::from_secs(10);
let (_, generation) = state.tick(true, (1920, 1080), due).unwrap();
config.jiggler.enabled = false;
state.configure(&config, due);
assert!(!state.valid(generation));
assert!(state.delay().is_none());
config.jiggler.enabled = true;
config.jiggler.interval_secs = 60;
state.configure(&config, due);
assert!(state
.tick(true, (1920, 1080), due + Duration::from_secs(59))
.is_none());
assert!(state
.tick(false, (1920, 1080), due + Duration::from_secs(60))
.is_none());
let reconnect = due + Duration::from_secs(100);
assert!(state.tick(true, (1920, 1080), reconnect).is_none());
assert!(state
.tick(true, (1920, 1080), reconnect + Duration::from_secs(60))
.is_some());
state.suspend();
assert!(!state.valid(state.generation));
}
#[test]
fn jiggler_handles_bluetooth_relative_only_absolute_only_and_keyboard_only() {
let now = Instant::now();
let (mut state, mut config) = configured(now);
config.backend = HidBackend::Bluetooth;
state.configure(&config, now);
assert_eq!(
state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.unwrap()
.0
.event_type,
MouseEventType::Move
);
config.backend = HidBackend::Otg;
config.otg_profile = OtgHidProfile::Custom;
config.otg_functions.mouse_relative = false;
state.configure(&config, now);
assert_eq!(
state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.unwrap()
.0
.event_type,
MouseEventType::MoveAbs
);
config.otg_profile = OtgHidProfile::LegacyKeyboard;
state.configure(&config, now);
assert!(state.delay().is_none());
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
}
#[test]
fn jiggler_failed_moves_wait_a_full_interval_before_retrying() {
let now = Instant::now();
let (mut state, _) = configured(now);
let due = now + Duration::from_secs(10);
let (_, generation) = state.tick(true, (1920, 1080), due).unwrap();
state.failed(generation, due);
assert!(state.tick(true, (1920, 1080), due + STEP_DELAY).is_none());
assert!(state
.tick(true, (1920, 1080), due + Duration::from_secs(10))
.is_some());
}
#[test]
fn jiggler_reconnect_does_not_forget_input_received_before_the_first_tick() {
let now = Instant::now();
let (mut state, _) = configured(now);
state.suspend();
state.keyboard(
&KeyboardEvent::key_down(CanonicalKey::KeyA, KeyboardModifiers::default()),
now,
);
assert!(state.tick(true, (1920, 1080), now).is_none());
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.keyboard(
&KeyboardEvent::key_up(CanonicalKey::KeyA, KeyboardModifiers::default()),
now,
);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.is_some());
}
struct RecordingBackend {
mouse: tokio::sync::mpsc::UnboundedSender<MouseEvent>,
runtime: tokio::sync::watch::Sender<()>,
}
#[async_trait::async_trait]
impl crate::hid::HidBackend for RecordingBackend {
async fn init(&self) -> crate::error::Result<()> {
Ok(())
}
async fn send_keyboard(&self, _: KeyboardEvent) -> crate::error::Result<()> {
Ok(())
}
async fn send_mouse(&self, event: MouseEvent) -> crate::error::Result<()> {
self.mouse.send(event).unwrap();
Ok(())
}
async fn reset(&self) -> crate::error::Result<()> {
Ok(())
}
async fn shutdown(&self) -> crate::error::Result<()> {
Ok(())
}
fn runtime_snapshot(&self) -> crate::hid::HidBackendRuntimeSnapshot {
crate::hid::HidBackendRuntimeSnapshot {
initialized: true,
online: true,
..Default::default()
}
}
fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> {
self.runtime.subscribe()
}
}
#[tokio::test]
async fn jiggler_worker_runs_without_clients_and_confirmed_input_cancels_return() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let backend = std::sync::Arc::new(RecordingBackend {
mouse: tx,
runtime: tokio::sync::watch::channel(()).0,
});
let controller = crate::hid::test_controller(backend).await;
let (_, config) = configured(Instant::now());
controller.configure_jiggler(&config);
{
let mut state = controller.jiggler.lock();
state.ready = true;
state.activity(Instant::now() - Duration::from_secs(9));
}
// Bluetooth publishes status periodically, even with no user input.
let changed = controller.jiggler_changed.clone();
let notifications = tokio::spawn(async move {
loop {
changed.notify_one();
tokio::time::sleep(Duration::from_millis(20)).await;
}
});
let out = tokio::time::timeout(Duration::from_secs(3), rx.recv())
.await
.unwrap()
.unwrap();
notifications.abort();
assert_eq!(out.x, 2);
controller
.send_keyboard_confirmed(KeyboardEvent::key_down(
CanonicalKey::KeyA,
KeyboardModifiers::default(),
))
.await
.unwrap();
assert!(tokio::time::timeout(Duration::from_millis(300), rx.recv())
.await
.is_err());
controller.shutdown().await.unwrap();
}
}

View File

@@ -8,6 +8,7 @@ mod ch9329_proto;
pub mod consumer;
pub mod datachannel;
mod factory;
mod jiggler;
pub mod keyboard;
mod macos_drag;
#[cfg(unix)]
@@ -120,6 +121,10 @@ enum QueuedHidEvent {
Mouse(MouseEvent),
Consumer(ConsumerEvent),
Reset,
Confirmed(
Box<QueuedHidEvent>,
tokio::sync::oneshot::Sender<Result<()>>,
),
}
pub struct HidController {
@@ -137,6 +142,8 @@ pub struct HidController {
backend_available: Arc<AtomicBool>,
reset_requested: Arc<AtomicBool>,
screen_resolution: parking_lot::RwLock<(u32, u32)>,
jiggler: Arc<parking_lot::Mutex<jiggler::JigglerState>>,
jiggler_changed: Arc<tokio::sync::Notify>,
}
impl HidController {
@@ -160,6 +167,8 @@ impl HidController {
backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)),
jiggler: Arc::new(parking_lot::Mutex::new(jiggler::JigglerState::default())),
jiggler_changed: Arc::new(tokio::sync::Notify::new()),
}
}
@@ -183,6 +192,8 @@ impl HidController {
backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)),
jiggler: Arc::new(parking_lot::Mutex::new(jiggler::JigglerState::default())),
jiggler_changed: Arc::new(tokio::sync::Notify::new()),
}
}
@@ -231,6 +242,9 @@ impl HidController {
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller");
self.backend_available.store(false, Ordering::Release);
self.jiggler.lock().suspend();
self.jiggler_changed.notify_one();
self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() {
@@ -261,6 +275,7 @@ impl HidController {
info!("Preparing OTG HID backend for gadget rebuild");
self.backend_available.store(false, Ordering::Release);
self.jiggler.lock().suspend();
self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() {
@@ -313,6 +328,36 @@ impl HidController {
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
}
/// MCP uses acknowledged events so timing starts after the hardware send.
pub async fn send_keyboard_confirmed(&self, event: KeyboardEvent) -> Result<()> {
self.send_confirmed(QueuedHidEvent::Keyboard(event)).await
}
pub async fn send_mouse_confirmed(&self, event: MouseEvent) -> Result<()> {
self.send_confirmed(QueuedHidEvent::Mouse(event)).await
}
pub async fn reset_confirmed(&self) -> Result<()> {
self.send_confirmed(QueuedHidEvent::Reset).await
}
async fn send_confirmed(&self, event: QueuedHidEvent) -> Result<()> {
if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::ServiceUnavailable(
"HID backend not available".into(),
));
}
let (tx, rx) = tokio::sync::oneshot::channel();
self.enqueue_event(QueuedHidEvent::Confirmed(Box::new(event), tx))
.await?;
tokio::time::timeout(Duration::from_secs(3), rx)
.await
.map_err(|_| AppError::ServiceUnavailable("HID hardware send timed out".into()))?
.map_err(|_| {
AppError::ServiceUnavailable("HID event discarded before sending".into())
})?
}
pub async fn bluetooth_status(&self) -> Result<serde_json::Value> {
let backend = self
.backend
@@ -369,6 +414,14 @@ impl HidController {
self.runtime_state.read().await.clone()
}
/// Jiggler preferences are independent of USB descriptors and device reloads.
pub fn configure_jiggler(&self, config: &crate::config::HidConfig) {
self.jiggler
.lock()
.configure(config, std::time::Instant::now());
self.jiggler_changed.notify_one();
}
async fn ch9329_backend(&self) -> Result<Arc<dyn HidBackend>> {
if !matches!(
*self.backend_type.read().await,
@@ -402,6 +455,7 @@ impl HidController {
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.jiggler.lock().suspend();
self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() {
@@ -510,13 +564,53 @@ impl HidController {
let pending_move = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone();
let reset_requested = self.reset_requested.clone();
let jiggler = self.jiggler.clone();
let jiggler_changed = self.jiggler_changed.clone();
let backend_available = self.backend_available.clone();
let handle = tokio::spawn(async move {
let mut rx = rx;
let mut next_poll = tokio::time::Instant::now();
loop {
let event = match rx.recv().await {
Some(ev) => ev,
None => break,
let delay = jiggler.lock().delay();
let event = tokio::select! {
biased;
event = rx.recv() => match event {
Some(event) => event,
None => break,
},
_ = jiggler_changed.notified() => continue,
_ = tokio::time::sleep_until(next_poll), if delay.is_some() => {
// Synthetic motion is sent by this same worker, outside the
// coalescing queue, so opposite movements cannot cancel out.
let active_backend = backend.read().await.clone();
let snapshot = active_backend.as_ref().map(|backend| backend.runtime_snapshot());
let ready = backend_available.load(Ordering::Acquire)
&& snapshot.as_ref().is_some_and(|state| state.initialized && state.online);
let resolution = snapshot.and_then(|state| state.screen_resolution).unwrap_or((1920, 1080));
let movement = jiggler.lock().tick(ready, resolution, std::time::Instant::now());
if let (Some((event, generation)), Some(active_backend)) = (movement, active_backend) {
let state = jiggler.clone();
let available = backend_available.clone();
let result = tokio::task::spawn_blocking(move || {
// Check again after dispatch to the blocking pool:
// real input, shutdown or a config change may win.
if !available.load(Ordering::Acquire) || !state.lock().valid(generation) {
return Ok(());
}
futures::executor::block_on(active_backend.send_mouse(event))
}).await;
if !matches!(result, Ok(Ok(()))) {
jiggler.lock().failed(generation, std::time::Instant::now());
warn!("Mouse jiggler movement failed: {:?}", result);
}
}
// Keep an absolute deadline: periodic backend status
// notifications must not restart the timer indefinitely.
next_poll = tokio::time::Instant::now()
+ jiggler.lock().delay().unwrap_or(Duration::from_secs(1));
continue;
}
};
if reset_requested.swap(false, Ordering::AcqRel) {
@@ -525,15 +619,16 @@ impl HidController {
while rx.try_recv().is_ok() {}
*pending_move.lock() = None;
pending_move_flag.store(false, Ordering::Release);
process_hid_event(QueuedHidEvent::Reset, &backend).await;
process_hid_event(QueuedHidEvent::Reset, &backend, &jiggler).await;
continue;
}
process_hid_event(event, &backend).await;
process_hid_event(event, &backend, &jiggler).await;
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(QueuedHidEvent::Mouse(move_event), &backend).await;
process_hid_event(QueuedHidEvent::Mouse(move_event), &backend, &jiggler)
.await;
}
}
}
@@ -555,6 +650,8 @@ impl HidController {
let events = self.events.clone();
let backend_available = self.backend_available.clone();
let backend_type = self.backend_type.clone();
let jiggler = self.jiggler.clone();
let jiggler_changed = self.jiggler_changed.clone();
let handle = tokio::spawn(async move {
loop {
@@ -570,6 +667,11 @@ impl HidController {
Some(backend.as_ref()),
)
.await;
let state = runtime_state.read().await;
if !state.initialized || !state.online {
jiggler.lock().suspend();
}
jiggler_changed.notify_one();
}
});
@@ -583,6 +685,7 @@ impl HidController {
}
fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
self.jiggler.lock().activity(std::time::Instant::now());
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => {
@@ -597,6 +700,8 @@ impl HidController {
}
async fn enqueue_event(&self, event: QueuedHidEvent) -> Result<()> {
self.jiggler.lock().activity(std::time::Instant::now());
self.jiggler_changed.notify_one();
match self.hid_tx.try_send(event) {
Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(ev)) => {
@@ -625,6 +730,85 @@ impl HidController {
}
}
#[cfg(test)]
pub(crate) async fn test_controller(backend: Arc<dyn HidBackend>) -> Arc<HidController> {
#[cfg(unix)]
let controller = Arc::new(HidController::new(HidBackendType::None, None));
#[cfg(not(unix))]
let controller = Arc::new(HidController::new(HidBackendType::None));
*controller.backend.write().await = Some(backend);
controller.sync_runtime_state_from_backend().await;
controller.start_event_worker().await;
controller
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
pub(crate) struct RecordingBackend {
pub events: parking_lot::Mutex<Vec<MouseEvent>>,
changed: tokio::sync::Notify,
runtime: tokio::sync::watch::Sender<()>,
}
impl RecordingBackend {
pub fn new() -> Arc<Self> {
Arc::new(Self {
events: parking_lot::Mutex::new(Vec::new()),
changed: tokio::sync::Notify::new(),
runtime: tokio::sync::watch::channel(()).0,
})
}
pub async fn wait_for_events(&self, count: usize) {
tokio::time::timeout(Duration::from_secs(2), async {
loop {
let changed = self.changed.notified();
if self.events.lock().len() >= count {
return;
}
changed.await;
}
})
.await
.expect("HID worker should deliver mouse events");
}
}
#[async_trait::async_trait]
impl HidBackend for RecordingBackend {
async fn init(&self) -> Result<()> {
Ok(())
}
async fn send_keyboard(&self, _: KeyboardEvent) -> Result<()> {
Ok(())
}
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
self.events.lock().push(event);
self.changed.notify_one();
Ok(())
}
async fn reset(&self) -> Result<()> {
Ok(())
}
async fn shutdown(&self) -> Result<()> {
Ok(())
}
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
HidBackendRuntimeSnapshot {
initialized: true,
online: true,
supports_absolute_mouse: true,
..Default::default()
}
}
fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> {
self.runtime.subscribe()
}
}
}
async fn apply_backend_runtime_state(
backend_type: &Arc<RwLock<HidBackendType>>,
runtime_state: &Arc<RwLock<HidRuntimeState>>,
@@ -644,35 +828,74 @@ async fn apply_backend_runtime_state(
async fn process_hid_event(
event: QueuedHidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
jiggler: &Arc<parking_lot::Mutex<jiggler::JigglerState>>,
) {
let backend_opt = backend.read().await.clone();
let backend = match backend_opt {
Some(b) => b,
None => return,
let (event, reply) = match event {
QueuedHidEvent::Confirmed(event, reply) => (*event, Some(reply)),
event => (event, None),
};
let backend_for_send = backend.clone();
let result = tokio::task::spawn_blocking(move || {
futures::executor::block_on(async move {
match event {
QueuedHidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await,
QueuedHidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await,
QueuedHidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await,
QueuedHidEvent::Reset => backend_for_send.reset().await,
}
{
let mut state = jiggler.lock();
let now = std::time::Instant::now();
match &event {
QueuedHidEvent::Keyboard(event) => state.keyboard(event, now),
QueuedHidEvent::Mouse(event) => state.mouse(event, now),
QueuedHidEvent::Consumer(event) => state.consumer(event.usage, now),
QueuedHidEvent::Reset => state.reset(now),
QueuedHidEvent::Confirmed(..) => unreachable!("nested confirmed HID event"),
}
}
let backend = backend.read().await.clone();
let result = match backend {
Some(backend) => tokio::task::spawn_blocking(move || {
futures::executor::block_on(async move {
match event {
QueuedHidEvent::Keyboard(ev) => backend.send_keyboard(ev).await,
QueuedHidEvent::Mouse(ev) => send_mouse_to_backend(backend.as_ref(), ev).await,
QueuedHidEvent::Consumer(ev) => backend.send_consumer(ev).await,
QueuedHidEvent::Reset => backend.reset().await,
QueuedHidEvent::Confirmed(..) => unreachable!("nested confirmed HID event"),
}
})
})
})
.await;
let result = match result {
Ok(r) => r,
Err(_) => return,
.await
.unwrap_or_else(|_| Err(AppError::ServiceUnavailable("HID worker failed".into()))),
None => Err(AppError::ServiceUnavailable(
"HID backend not available".into(),
)),
};
if let Err(error) = &result {
warn!("HID event processing failed: {}", error);
}
if let Some(reply) = reply {
let _ = reply.send(result);
}
}
match result {
Ok(_) => {}
Err(e) => {
warn!("HID event processing failed: {}", e);
async fn send_mouse_to_backend(backend: &dyn HidBackend, event: MouseEvent) -> Result<()> {
if event.event_type != MouseEventType::Move {
return backend.send_mouse(event).await;
}
// Split after queue coalescing, otherwise a full queue could merge small
// reports back into a delta that OTG/CH9329 would truncate to signed 8-bit.
// Match the pending queue's bound to keep work finite for arbitrary callers.
let mut x = event.x.clamp(-32767, 32767);
let mut y = event.y.clamp(-32767, 32767);
loop {
let dx = x.clamp(-127, 127);
let dy = y.clamp(-127, 127);
backend
.send_mouse(MouseEvent {
x: dx,
y: dy,
..event.clone()
})
.await?;
x -= dx;
y -= dy;
if x == 0 && y == 0 {
return Ok(());
}
}
}
@@ -722,6 +945,140 @@ fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
#[cfg(test)]
mod queue_tests {
use super::*;
#[tokio::test]
async fn large_relative_motion_is_split_at_the_backend() {
let backend = test_support::RecordingBackend::new();
send_mouse_to_backend(backend.as_ref(), MouseEvent::move_rel(300, -260))
.await
.unwrap();
let events = backend.events.lock();
let deltas: Vec<_> = events.iter().map(|event| (event.x, event.y)).collect();
assert_eq!(deltas, [(127, -127), (127, -127), (46, -6)]);
}
#[tokio::test]
async fn backend_split_is_bounded_and_preserves_non_relative_events() {
let backend = test_support::RecordingBackend::new();
send_mouse_to_backend(backend.as_ref(), MouseEvent::move_rel(i32::MAX, i32::MIN))
.await
.unwrap();
{
let events = backend.events.lock();
assert_eq!(events.len(), 259);
assert_eq!(events.iter().map(|event| event.x).sum::<i32>(), 32767);
assert_eq!(events.iter().map(|event| event.y).sum::<i32>(), -32767);
}
backend.events.lock().clear();
send_mouse_to_backend(backend.as_ref(), MouseEvent::move_abs(30000, 20000))
.await
.unwrap();
send_mouse_to_backend(backend.as_ref(), MouseEvent::scroll(-2))
.await
.unwrap();
let events = backend.events.lock();
assert_eq!(events.len(), 2);
assert_eq!((events[0].x, events[0].y), (30000, 20000));
assert_eq!(events[0].event_type, MouseEventType::MoveAbs);
assert_eq!(events[1].scroll, -2);
}
#[tokio::test]
async fn congested_queue_splits_accumulated_relative_motion_without_loss() {
#[cfg(unix)]
let controller = HidController::new(HidBackendType::None, None);
#[cfg(not(unix))]
let controller = HidController::new(HidBackendType::None);
let backend = test_support::RecordingBackend::new();
*controller.backend.write().await = Some(backend.clone());
controller.sync_runtime_state_from_backend().await;
// Keep the worker stopped so saturation does not depend on scheduling.
for _ in 0..HID_EVENT_QUEUE_CAPACITY {
controller
.send_mouse(MouseEvent::move_rel(0, 0))
.await
.unwrap();
}
controller
.send_mouse(MouseEvent::move_rel(100, -20))
.await
.unwrap();
controller
.send_mouse(MouseEvent::move_rel(80, 30))
.await
.unwrap();
controller.start_event_worker().await;
controller
.send_mouse_confirmed(MouseEvent::scroll(1))
.await
.unwrap();
let events = backend.events.lock();
let moves: Vec<_> = events
.iter()
.filter(|event| event.event_type == MouseEventType::Move)
.collect();
assert!(moves
.iter()
.all(|event| event.x.abs() <= 127 && event.y.abs() <= 127));
assert_eq!(moves.iter().map(|event| event.x).sum::<i32>(), 180);
assert_eq!(moves.iter().map(|event| event.y).sum::<i32>(), 10);
assert_eq!(events.last().unwrap().event_type, MouseEventType::Scroll);
}
#[cfg(unix)]
#[tokio::test]
async fn composite_otg_updates_and_rollback_preserve_hid_instance() {
let mut old = crate::config::AppConfig::default();
old.hid.backend = crate::config::HidBackend::Ch9329;
old.enforce_invariants();
let otg = Arc::new(OtgService::new());
let controller = Arc::new(HidController::new(
HidBackendType::from(&old.hid),
Some(otg.clone()),
));
let pressed = Arc::new(AtomicBool::new(true));
let (runtime, _) = tokio::sync::watch::channel(());
let backend: Arc<dyn HidBackend> = Arc::new(TestBackend {
pressed: pressed.clone(),
reset_done: Arc::new(tokio::sync::Notify::new()),
runtime,
});
*controller.backend.write().await = Some(backend.clone());
let data = tempfile::tempdir().unwrap();
let coordinator = crate::runtime::UsbCoordinator::new(
controller.clone(),
otg,
Arc::new(RwLock::new(None)),
Arc::new(RwLock::new(None)),
Arc::new(EventBus::new()),
data.path().into(),
);
let mut new = old.clone();
new.hid.backend = crate::config::HidBackend::Ch9329Otg;
new.hid.otg_udc = Some("test.udc".into());
new.hid.otg_descriptor.product = "Advanced USB".into();
new.hid.otg_keyboard_leds = true;
coordinator.apply_config(&old, &new).await.unwrap();
assert!(Arc::ptr_eq(
controller.backend.read().await.as_ref().unwrap(),
&backend
));
coordinator.apply_config(&new, &old).await.unwrap();
assert!(Arc::ptr_eq(
controller.backend.read().await.as_ref().unwrap(),
&backend
));
assert!(pressed.load(Ordering::Acquire));
// Invalid OTG configuration must not touch the healthy input backend.
new.uac.enabled = true;
new.uac.sample_rate = 8000;
assert!(coordinator.apply_config(&old, &new).await.is_err());
assert!(Arc::ptr_eq(
controller.backend.read().await.as_ref().unwrap(),
&backend
));
}
#[tokio::test]
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
#[cfg(unix)]

View File

@@ -10,8 +10,6 @@ pub mod audio;
#[cfg(feature = "desktop")]
pub mod auth;
#[cfg(feature = "desktop")]
pub mod computer_use;
#[cfg(feature = "desktop")]
pub mod config;
#[cfg(feature = "desktop")]
pub mod db;
@@ -25,6 +23,8 @@ pub mod events;
pub mod extensions;
#[cfg(feature = "desktop")]
pub mod hid;
#[cfg(feature = "desktop")]
pub mod mcp;
#[cfg(all(unix, feature = "desktop"))]
pub mod msd;
#[cfg(all(unix, feature = "desktop"))]

332
src/mcp/actions.rs Normal file
View File

@@ -0,0 +1,332 @@
use super::keyboard::{char_to_key, key_name_to_canonical};
use crate::hid::{
CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEvent,
};
use rmcp::schemars::{self, JsonSchema};
use serde::Deserialize;
use std::time::Duration;
#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum Button {
#[default]
Left,
Middle,
Right,
}
impl From<Button> for MouseButton {
fn from(b: Button) -> Self {
match b {
Button::Left => Self::Left,
Button::Middle => Self::Middle,
Button::Right => Self::Right,
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Action {
Click {
x: f64,
y: f64,
#[serde(default)]
button: Button,
},
DoubleClick {
x: f64,
y: f64,
#[serde(default)]
button: Button,
},
Move {
x: f64,
y: f64,
},
Drag {
path: Vec<Point>,
#[serde(default)]
button: Button,
},
Scroll {
x: f64,
y: f64,
vertical_steps: i32,
},
Type {
text: String,
},
Keypress {
/// Modifiers (ctrl, shift, alt, meta) plus one key: a-z, 0-9, enter, esc, tab, space, backspace, delete, arrows, home/end, pageup/pagedown, f1-f12.
keys: Vec<String>,
#[serde(default = "default_hold")]
hold_ms: u64,
},
Wait {
ms: u64,
},
}
fn default_hold() -> u64 {
35
}
#[derive(Debug)]
pub(super) enum Step {
Mouse(MouseEvent),
Keyboard(KeyboardEvent),
Pause(u64),
}
#[derive(Debug)]
pub(super) struct PreparedActions {
pub actions: Vec<Vec<Step>>,
pub needs_absolute_mouse: bool,
}
fn position(x: f64, y: f64) -> Result<Step, String> {
if !x.is_finite() || !y.is_finite() || !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
return Err("Mouse coordinates must be finite numbers in [0,1]".into());
}
Ok(Step::Mouse(MouseEvent::move_abs(
(x * 32767.0).round() as i32,
(y * 32767.0).round() as i32,
)))
}
fn key_steps(out: &mut Vec<Step>, key: CanonicalKey, modifiers: KeyboardModifiers, hold: u64) {
out.push(Step::Keyboard(KeyboardEvent {
event_type: KeyEventType::Down,
key,
modifiers,
}));
out.push(Step::Pause(hold));
out.push(Step::Keyboard(KeyboardEvent {
event_type: KeyEventType::Up,
key,
modifiers: KeyboardModifiers::default(),
}));
}
fn chord(keys: &[String]) -> Result<(CanonicalKey, KeyboardModifiers), String> {
if keys.is_empty() || keys.len() > 5 {
return Err("keypress requires modifiers and one ordinary key".into());
}
let mut mods = KeyboardModifiers::default();
let mut key = None;
let mut modifier_key = None;
for name in keys {
let modifier = match name.trim().to_lowercase().as_str() {
"ctrl" | "control" | "controlleft" => {
mods.left_ctrl = true;
Some(CanonicalKey::ControlLeft)
}
"shift" | "shiftleft" => {
mods.left_shift = true;
Some(CanonicalKey::ShiftLeft)
}
"alt" | "altleft" => {
mods.left_alt = true;
Some(CanonicalKey::AltLeft)
}
"meta" | "win" | "cmd" | "super" | "metaleft" => {
mods.left_meta = true;
Some(CanonicalKey::MetaLeft)
}
"controlright" => {
mods.right_ctrl = true;
Some(CanonicalKey::ControlRight)
}
"shiftright" => {
mods.right_shift = true;
Some(CanonicalKey::ShiftRight)
}
"altright" | "altgr" => {
mods.right_alt = true;
Some(CanonicalKey::AltRight)
}
"metaright" => {
mods.right_meta = true;
Some(CanonicalKey::MetaRight)
}
_ => None,
};
if let Some(modifier) = modifier {
modifier_key = Some(modifier);
continue;
}
let parsed = key_name_to_canonical(name)
.or_else(|| {
let mut chars = name.chars();
let ch = chars.next()?;
if chars.next().is_some() {
return None;
}
let (key, implied) = char_to_key(ch)?;
mods.left_shift |= implied.left_shift;
Some(key)
})
.ok_or("Unsupported key name")?;
if key.replace(parsed).is_some() {
return Err("Only one ordinary key per keypress is supported".into());
}
}
Ok((key.or(modifier_key).ok_or("No key provided")?, mods))
}
pub(super) fn prepare(actions: Vec<Action>) -> Result<PreparedActions, String> {
if actions.is_empty() || actions.len() > 32 {
return Err("Provide between 1 and 32 actions".into());
}
let mut prepared = PreparedActions {
actions: Vec::new(),
needs_absolute_mouse: false,
};
let mut characters = 0;
let mut duration = 0u64;
for action in actions {
let mut steps = Vec::new();
match action {
Action::Click { x, y, button } | Action::DoubleClick { x, y, button } => {
let count = if matches!(action, Action::DoubleClick { .. }) {
2
} else {
1
};
steps.push(position(x, y)?);
for _ in 0..count {
steps.push(Step::Mouse(MouseEvent::button_down(button.into())));
steps.push(Step::Pause(35));
steps.push(Step::Mouse(MouseEvent::button_up(button.into())));
steps.push(Step::Pause(35));
}
prepared.needs_absolute_mouse = true;
}
Action::Move { x, y } => {
steps.push(position(x, y)?);
prepared.needs_absolute_mouse = true;
}
Action::Drag { path, button } => {
if path.len() < 2 || path.len() > 128 {
return Err("Drag path needs 2 to 128 points".into());
}
for (i, p) in path.into_iter().enumerate() {
steps.push(position(p.x, p.y)?);
if i == 0 {
steps.push(Step::Mouse(MouseEvent::button_down(button.into())));
}
steps.push(Step::Pause(35));
}
steps.push(Step::Mouse(MouseEvent::button_up(button.into())));
prepared.needs_absolute_mouse = true;
}
Action::Scroll {
x,
y,
vertical_steps,
} => {
if !(-100..=100).contains(&vertical_steps) {
return Err("vertical_steps must be between -100 and 100".into());
}
steps.push(position(x, y)?);
for _ in 0..vertical_steps.unsigned_abs() {
// The device wheel convention is positive up; public MCP is positive down.
steps.push(Step::Mouse(MouseEvent::scroll(
-vertical_steps.signum() as i8
)));
steps.push(Step::Pause(35));
}
prepared.needs_absolute_mouse = true;
}
Action::Type { text } => {
characters += text.chars().count();
if characters > 256 {
return Err("At most 256 characters per batch".into());
}
for ch in text.chars() {
let (key, mods) = char_to_key(ch).ok_or(
"Text must use the US keyboard ASCII mapping (including newline and tab)",
)?;
key_steps(&mut steps, key, mods, 35);
}
}
Action::Keypress { keys, hold_ms } => {
if hold_ms > 5000 {
return Err("hold_ms must not exceed 5000".into());
}
let (key, mods) = chord(&keys)?;
key_steps(&mut steps, key, mods, hold_ms);
}
Action::Wait { ms } => {
if ms > 5000 {
return Err("wait ms must not exceed 5000".into());
}
steps.push(Step::Pause(ms));
}
}
steps.push(Step::Pause(120));
duration += steps
.iter()
.map(|s| match s {
Step::Pause(ms) => *ms,
_ => 1,
})
.sum::<u64>();
if duration > 25_000 {
return Err("Batch duration exceeds 25 seconds before hardware delays".into());
}
prepared.actions.push(steps);
}
Ok(prepared)
}
pub(super) async fn execute(hid: &HidController, steps: Vec<Step>) -> crate::Result<()> {
for step in steps {
match step {
Step::Mouse(event) => hid.send_mouse_confirmed(event).await?,
Step::Keyboard(event) => hid.send_keyboard_confirmed(event).await?,
Step::Pause(ms) => tokio::time::sleep(Duration::from_millis(ms)).await,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_entire_batch_before_any_hid_input() {
assert!(prepare(vec![
Action::Type {
text: "valid".into()
},
Action::Type {
text: "中文".into()
}
])
.is_err());
assert!(prepare(vec![Action::Move {
x: f64::NAN,
y: 0.5
}])
.is_err());
assert!(prepare(vec![Action::Move { x: 1.01, y: 0.5 }]).is_err());
assert!(chord(&["ctrl".into(), "unknown".into()]).is_err());
assert!(chord(&["a".into(), "b".into()]).is_err());
assert!(chord(&["ctrl".into(), "alt".into(), "delete".into()]).is_ok());
}
#[test]
fn uses_full_hid_range_and_bounded_waits() {
let actions = prepare(vec![Action::Move { x: 1.0, y: 0.0 }]).unwrap();
match &actions.actions[0][0] {
Step::Mouse(event) => assert_eq!((event.x, event.y), (32767, 0)),
_ => panic!(),
}
assert!(prepare((0..6).map(|_| Action::Wait { ms: 5000 }).collect()).is_err());
assert!(prepare(vec![Action::Type {
text: "x".repeat(257)
}])
.is_err());
}
}

430
src/mcp/executor.rs Normal file
View File

@@ -0,0 +1,430 @@
use super::actions::{self, PreparedActions};
use super::hardware::{HardwareCommand, McpHardware};
use crate::{
config::ConfigStore,
hid::HidController,
video::{snapshot::Snapshot, VideoStreamManager},
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use parking_lot::Mutex;
use rmcp::model::{CallToolResult, ContentBlock};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, oneshot};
pub(super) enum Command {
Status,
Screenshot(Option<u8>),
Actions(PreparedActions, bool),
Hardware(HardwareCommand),
}
struct Job {
command: Command,
enqueued: Instant,
reply: oneshot::Sender<CallToolResult>,
}
struct Bucket {
tokens: f64,
updated: Instant,
rate: u32,
}
impl Bucket {
fn take(&mut self, rate: u32, now: Instant) -> bool {
let rate = rate.clamp(1, 100);
self.tokens = (self.tokens
+ now.duration_since(self.updated).as_secs_f64() * self.rate as f64)
.min(rate as f64);
self.updated = now;
self.rate = rate;
if self.tokens < 1.0 {
false
} else {
self.tokens -= 1.0;
true
}
}
}
pub struct McpService {
config: ConfigStore,
tx: mpsc::Sender<Job>,
bucket: Mutex<Bucket>,
}
impl McpService {
pub(crate) fn new(
config: ConfigStore,
hid: Arc<HidController>,
video: Arc<VideoStreamManager>,
hardware: McpHardware,
) -> Arc<Self> {
let (tx, mut rx) = mpsc::channel::<Job>(8);
let rate = config.get().mcp.requests_per_second.clamp(1, 100);
let service = Arc::new(Self {
config,
tx,
bucket: Mutex::new(Bucket {
tokens: rate as f64,
updated: Instant::now(),
rate,
}),
});
tokio::spawn(async move {
while let Some(job) = rx.recv().await {
// Accepted jobs are independent of the caller's transport lifetime.
let result = if job.enqueued.elapsed() > Duration::from_secs(30) {
failure(
"queue_timeout",
"Request expired while waiting; no action executed",
0,
)
} else {
match job.command {
Command::Status => {
let h = hid.snapshot().await;
let v = video.stats().await;
let devices = hardware.status().await;
output(
json!({ "version": env!("CARGO_PKG_VERSION"), "video": {"state": v.state, "resolution": v.resolution, "format": v.format}, "hid": {"initialized": h.initialized, "online": h.online, "backend": h.backend, "supports_absolute_mouse": h.supports_absolute_mouse}, "atx": devices["atx"], "media": devices["media"], "queue_length": rx.len() }),
false,
)
}
Command::Screenshot(quality) => match video.capture_snapshot(quality).await
{
Ok(snapshot) => {
with_snapshot(output(json!({"success": true}), false), snapshot)
}
Err(error) => failure("screenshot_failed", &error.to_string(), 0),
},
Command::Actions(prepared, screenshot) => {
run_actions(&hid, &video, prepared, screenshot).await
}
Command::Hardware(command) => hardware.execute(command).await,
}
};
let _ = job.reply.send(result);
}
});
service
}
pub(super) async fn call(&self, command: Command) -> CallToolResult {
let (reply, rx) = oneshot::channel();
{
// One lock defines admission order and a single service-wide quota.
let mut bucket = self.bucket.lock();
if !bucket.take(self.config.get().mcp.requests_per_second, Instant::now()) {
return failure(
"rate_limited",
"Global MCP rate limit exceeded; retry after 1 second",
0,
);
}
if self
.tx
.try_send(Job {
command,
enqueued: Instant::now(),
reply,
})
.is_err()
{
return failure("busy", "MCP queue is full; retry later", 0);
}
}
rx.await
.unwrap_or_else(|_| failure("unavailable", "MCP worker unavailable", 0))
}
}
async fn run_actions(
hid: &HidController,
video: &VideoStreamManager,
prepared: PreparedActions,
screenshot: bool,
) -> CallToolResult {
let state = hid.snapshot().await;
if !state.initialized || (prepared.needs_absolute_mouse && !state.supports_absolute_mouse) {
return failure("hid_unavailable", "Actions require an initialized HID backend; pointer actions require absolute mouse support", 0);
}
let mut completed = 0;
let result = tokio::time::timeout(Duration::from_secs(30), async {
for action in prepared.actions {
actions::execute(hid, action).await?;
completed += 1;
}
Ok::<(), crate::AppError>(())
})
.await;
// Successful actions already release their keys/buttons. A backend reset
// also moves an absolute pointer to (0,0), so reserve it for failed or
// timed-out batches that may have left input held.
let cleanup = if matches!(&result, Ok(Ok(()))) {
Ok(())
} else {
hid.reset_confirmed().await
};
let mut response = match result {
Ok(Ok(())) => output(
json!({"success": true, "completed_actions": completed}),
false,
),
Ok(Err(error)) => failure("execution_failed", &error.to_string(), completed),
Err(_) => failure(
"execution_timeout",
"Execution time limit exceeded; last action may be partially executed",
completed,
),
};
if let Err(error) = cleanup {
response.is_error = Some(true);
if let Some(Value::Object(value)) = response.structured_content.as_mut() {
value.insert("success".into(), json!(false));
value.insert("cleanup_error".into(), json!(error.to_string()));
}
refresh_text(&mut response);
return response;
}
if screenshot {
match video.capture_snapshot(None).await {
Ok(snapshot) => response = with_snapshot(response, snapshot),
Err(error) => {
if let Some(Value::Object(value)) = response.structured_content.as_mut() {
value.insert("screenshot_error".into(), json!(error.to_string()));
}
refresh_text(&mut response);
}
}
}
response
}
pub(super) fn output(value: Value, failed: bool) -> CallToolResult {
let mut result = CallToolResult::success(vec![ContentBlock::text(value.to_string())]);
result.structured_content = Some(value);
result.is_error = Some(failed);
result
}
pub(super) fn failure(code: &str, message: &str, completed: usize) -> CallToolResult {
output(
json!({"success": false, "error": code, "message": message, "completed_actions": completed, "failed_action_index": completed}),
true,
)
}
fn refresh_text(result: &mut CallToolResult) {
if let Some(value) = &result.structured_content {
result.content[0] = ContentBlock::text(value.to_string());
}
}
fn with_snapshot(mut result: CallToolResult, snapshot: Snapshot) -> CallToolResult {
if let Some(Value::Object(value)) = result.structured_content.as_mut() {
value.insert(
"screenshot".into(),
json!({"width": snapshot.width, "height": snapshot.height, "age_ms": snapshot.age_ms}),
);
}
refresh_text(&mut result);
result.content.push(ContentBlock::image(
STANDARD.encode(snapshot.jpeg),
"image/jpeg",
));
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn global_bucket_has_bounded_burst_and_recovers() {
let now = Instant::now();
let mut bucket = Bucket {
tokens: 2.0,
updated: now,
rate: 2,
};
assert!(bucket.take(2, now));
assert!(bucket.take(2, now));
assert!(!bucket.take(2, now));
assert!(bucket.take(2, now + Duration::from_millis(500)));
assert!(!bucket.take(2, now + Duration::from_millis(500)));
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use crate::hid::{
backend::{HidBackend, HidBackendRuntimeSnapshot},
CanonicalKey, KeyEventType, KeyboardEvent, MouseEvent,
};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::{watch, Notify};
struct Backend {
events: Mutex<Vec<String>>,
entered: Notify,
proceed: Notify,
pressed: AtomicBool,
runtime: watch::Sender<()>,
}
#[async_trait::async_trait]
impl HidBackend for Backend {
async fn init(&self) -> crate::Result<()> {
Ok(())
}
async fn send_keyboard(&self, event: KeyboardEvent) -> crate::Result<()> {
self.events
.lock()
.push(format!("{:?}:{:?}", event.key, event.event_type));
self.pressed.store(
matches!(event.event_type, KeyEventType::Down),
Ordering::SeqCst,
);
if event.key == CanonicalKey::KeyA && matches!(event.event_type, KeyEventType::Down) {
self.entered.notify_one();
self.proceed.notified().await;
}
if event.key == CanonicalKey::KeyC && matches!(event.event_type, KeyEventType::Down) {
return Err(crate::AppError::ServiceUnavailable(
"Simulated hardware failure".into(),
));
}
Ok(())
}
async fn send_mouse(&self, _: MouseEvent) -> crate::Result<()> {
Ok(())
}
async fn reset(&self) -> crate::Result<()> {
self.events.lock().push("reset".into());
self.pressed.store(false, Ordering::SeqCst);
Ok(())
}
async fn shutdown(&self) -> crate::Result<()> {
Ok(())
}
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
HidBackendRuntimeSnapshot {
initialized: true,
online: true,
supports_absolute_mouse: true,
..Default::default()
}
}
fn subscribe_runtime(&self) -> watch::Receiver<()> {
self.runtime.subscribe()
}
}
#[tokio::test]
async fn fifo_batches_survive_caller_disconnect_and_release_input() {
let dir = tempfile::tempdir().unwrap();
let db = crate::db::DatabasePool::new(&dir.path().join("mcp.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let config = ConfigStore::new(db.clone_pool());
config.load().await.unwrap();
config
.update(|c| c.mcp.requests_per_second = 100)
.await
.unwrap();
let backend = Arc::new(Backend {
events: Mutex::new(Vec::new()),
entered: Notify::new(),
proceed: Notify::new(),
pressed: AtomicBool::new(false),
runtime: watch::channel(()).0,
});
let hid = crate::hid::test_controller(backend.clone()).await;
let video = VideoStreamManager::with_webrtc_streamer(
crate::video::streamer::Streamer::new(),
crate::webrtc::WebRtcStreamer::with_config(Default::default()),
);
let hardware = McpHardware::for_test(config.clone(), &db);
let service = McpService::new(config, hid.clone(), video.clone(), hardware);
let call = |text: &str| {
Command::Actions(
actions::prepare(vec![actions::Action::Type { text: text.into() }]).unwrap(),
false,
)
};
let s = service.clone();
let first = call("A");
let caller = tokio::spawn(async move { s.call(first).await });
tokio::time::timeout(Duration::from_secs(2), backend.entered.notified())
.await
.unwrap();
assert!(backend.pressed.load(Ordering::SeqCst));
caller.abort();
let s = service.clone();
let second = call("B");
let second = tokio::spawn(async move { s.call(second).await });
// Wait for admission, then fill the remaining bounded queue with status reads.
tokio::time::timeout(Duration::from_secs(2), async {
while service.tx.capacity() != 7 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let mut pending = Vec::new();
for _ in 0..7 {
let s = service.clone();
pending.push(tokio::spawn(async move { s.call(Command::Status).await }));
}
tokio::time::timeout(Duration::from_secs(2), async {
while service.tx.capacity() != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let busy = service.call(Command::Status).await;
assert_eq!(busy.structured_content.unwrap()["error"], "busy");
backend.proceed.notify_one();
let result = tokio::time::timeout(Duration::from_secs(3), second)
.await
.unwrap()
.unwrap();
assert_eq!(result.structured_content.unwrap()["completed_actions"], 1);
for job in pending {
assert_eq!(job.await.unwrap().is_error, Some(false));
}
assert!(!backend.pressed.load(Ordering::SeqCst));
assert_eq!(
*backend.events.lock(),
["KeyA:Down", "KeyA:Up", "KeyB:Down", "KeyB:Up"]
);
let partial = service
.call(Command::Actions(
actions::prepare(vec![
actions::Action::Type { text: "B".into() },
actions::Action::Type { text: "C".into() },
])
.unwrap(),
false,
))
.await;
assert_eq!(partial.is_error, Some(true));
let partial = partial.structured_content.unwrap();
assert_eq!(partial["completed_actions"], 1);
assert_eq!(partial["failed_action_index"], 1);
assert!(!backend.pressed.load(Ordering::SeqCst));
assert_eq!(backend.events.lock().last().unwrap(), "reset");
// Hold a snapshot request so capture deterministically fails as busy,
// independently of video hardware present on the build machine.
let pending_snapshot = video.streamer().snapshot_slot.request().unwrap();
let screenshot_failure = service
.call(Command::Actions(
actions::prepare(vec![actions::Action::Type { text: "B".into() }]).unwrap(),
true,
))
.await;
assert_eq!(screenshot_failure.is_error, Some(false));
let value = screenshot_failure.structured_content.unwrap();
assert_eq!(value["success"], true);
assert_eq!(value["completed_actions"], 1);
assert!(value.get("screenshot_error").is_some());
assert_eq!(backend.events.lock().last().unwrap(), "KeyB:Up");
drop(pending_snapshot);
hid.shutdown().await.unwrap();
}
}

328
src/mcp/hardware.rs Normal file
View File

@@ -0,0 +1,328 @@
//! Device operations share the MCP worker and existing controller locks.
use super::executor::output;
use crate::{
atx::{AtxController, AtxState},
config::ConfigStore,
db::WolHistoryStore,
error::{AppError, Result},
};
use rmcp::{
model::CallToolResult,
schemars::{self, JsonSchema},
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(super) enum PowerAction {
PressShort,
PressLong,
Reset,
}
pub(super) enum HardwareCommand {
Power(PowerAction),
Wake(String),
ListMedia,
MountMedia {
image_id: String,
cdrom: bool,
read_only: bool,
},
UnmountMedia(String),
}
pub(crate) struct McpHardware {
pub config: ConfigStore,
pub atx: Arc<RwLock<Option<AtxController>>>,
pub wol_history: WolHistoryStore,
#[cfg(unix)]
pub msd: Arc<RwLock<Option<crate::msd::MsdController>>>,
#[cfg(unix)]
pub otg_lock: Arc<tokio::sync::Mutex<()>>,
}
fn atx_json(state: AtxState) -> Value {
json!({
"available": state.available, "backend": state.driver,
"power_configured": state.power_configured, "reset_configured": state.reset_configured,
"power_status": state.power_status, "led_supported": state.led_supported,
"hdd_status": state.hdd_status, "hdd_supported": state.hdd_supported,
})
}
pub(super) fn valid_image_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 16 && id.bytes().all(|b| b.is_ascii_hexdigit())
}
impl McpHardware {
pub(super) async fn status(&self) -> Value {
let atx = self.atx.read().await;
let atx = match atx.as_ref() {
Some(controller) => controller.state().await,
None => AtxState::default(),
};
json!({"atx": atx_json(atx), "media": self.media_status().await})
}
#[cfg(unix)]
async fn media_status(&self) -> Value {
let guard = self.msd.read().await;
let state = match guard.as_ref() {
Some(controller) => controller.state().await,
None => Default::default(),
};
json!({"supported": true, "available": state.available,
"disk_mode": state.disk_mode, "slot_capacity": state.disk_mode.capacity(),
"mounted_count": state.mounted_media.len(), "usb_reenumerating": state.usb_reenumerating})
}
#[cfg(not(unix))]
async fn media_status(&self) -> Value {
json!({"supported": false, "available": false})
}
pub(super) async fn execute(&self, command: HardwareCommand) -> CallToolResult {
match self.run(command).await {
Ok(value) => output(value, false),
Err(error) => {
let code = match &error {
AppError::Msd(msd) => msd.code().as_str(),
AppError::ServiceUnavailable(_) => "unavailable",
AppError::BadRequest(_) => "invalid_arguments",
AppError::Conflict(_) => "busy",
_ => "hardware_error",
};
output(
json!({"success": false, "error": code, "message": error.to_string()}),
true,
)
}
}
}
async fn run(&self, command: HardwareCommand) -> Result<Value> {
match command {
HardwareCommand::Power(action) => {
let guard = self.atx.read().await;
let controller = guard.as_ref().ok_or_else(|| {
AppError::ServiceUnavailable("ATX controller is not configured".into())
})?;
let state = controller.state().await;
let configured = match action {
PowerAction::Reset => state.reset_configured,
_ => state.power_configured,
};
if !state.available || !configured {
return Err(AppError::ServiceUnavailable(
"Requested ATX button is not configured".into(),
));
}
match action {
PowerAction::PressShort => controller.power_short().await?,
PowerAction::PressLong => controller.power_long().await?,
PowerAction::Reset => controller.reset().await?,
}
Ok(
json!({"success": true, "action_sent": true, "action": action,
"atx": atx_json(controller.state().await)}),
)
}
HardwareCommand::Wake(mac_address) => {
let interface = self.config.get().atx.wol_interface.clone();
let target = mac_address.clone();
tokio::task::spawn_blocking(move || {
crate::atx::send_wol(
&target,
(!interface.is_empty()).then_some(interface.as_str()),
)
})
.await
.map_err(|e| AppError::Internal(format!("WOL worker failed: {e}")))??;
let mut result =
json!({"success": true, "packet_sent": true, "mac_address": mac_address});
// Sending succeeded even if recording history fails. Never invite a write retry.
if let Err(error) = self.wol_history.record(&mac_address).await {
tracing::warn!(%error, "Failed to persist MCP WOL history");
result["history_saved"] = json!(false);
} else {
result["history_saved"] = json!(true);
}
Ok(result)
}
HardwareCommand::ListMedia => self.list_media().await,
command => self.change_media(command).await,
}
}
#[cfg(unix)]
async fn list_media(&self) -> Result<Value> {
let guard = self.msd.read().await;
let state = match guard.as_ref() {
Some(controller) => controller.state().await,
None => Default::default(),
};
let path = self.config.get().msd.images_dir();
let images =
tokio::task::spawn_blocking(move || crate::msd::ImageManager::new(path).list())
.await
.map_err(|e| AppError::Internal(format!("Media listing failed: {e}")))??;
Ok(json!({"success": true, "available": state.available,
"disk_mode": state.disk_mode, "slot_capacity": state.disk_mode.capacity(),
"mounted_count": state.mounted_media.len(), "mounted_media": state.mounted_media,
"usb_reenumerating": state.usb_reenumerating, "images": images}))
}
#[cfg(unix)]
async fn change_media(&self, command: HardwareCommand) -> Result<Value> {
use crate::error::MsdErrorCode;
// Same lock as USB settings and disk-mode changes, followed by the controller lock.
let _otg = self
.otg_lock
.try_lock()
.map_err(|_| AppError::from(MsdErrorCode::MsdOperationInProgress))?;
let guard = self.msd.write().await;
let controller = guard
.as_ref()
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
match command {
HardwareCommand::MountMedia {
image_id,
cdrom,
read_only,
} => {
let path = self.config.get().msd.images_dir();
let id = image_id.clone();
let image = tokio::task::spawn_blocking(move || {
crate::msd::ImageManager::new(path).get(&id)
})
.await
.map_err(|e| AppError::Internal(format!("Media lookup failed: {e}")))??;
controller.mount_image(&image, cdrom, read_only).await?;
Ok(
json!({"success": true, "image_id": image_id, "mounted": true,
"cdrom": cdrom, "read_only": cdrom || read_only}),
)
}
HardwareCommand::UnmountMedia(image_id) => {
// Idempotent: an absent image is already unmounted. Never eject other media.
controller.unmount_image(&image_id).await?;
Ok(json!({"success": true, "image_id": image_id, "mounted": false}))
}
_ => unreachable!("only media mutations reach change_media"),
}
}
#[cfg(not(unix))]
async fn list_media(&self) -> Result<Value> {
Err(AppError::ServiceUnavailable(
"Virtual media is unsupported on this platform".into(),
))
}
#[cfg(not(unix))]
async fn change_media(&self, _: HardwareCommand) -> Result<Value> {
self.list_media().await
}
#[cfg(test)]
pub(super) fn for_test(config: ConfigStore, db: &crate::db::DatabasePool) -> Self {
Self {
config,
atx: Arc::new(RwLock::new(None)),
wol_history: db.wol_history(),
#[cfg(unix)]
msd: Arc::new(RwLock::new(None)),
#[cfg(unix)]
otg_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unavailable_power_is_unknown_and_media_is_serialized_with_usb_settings() {
let dir = tempfile::tempdir().unwrap();
let db = crate::db::DatabasePool::new(&dir.path().join("hardware.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let config = ConfigStore::new(db.clone_pool());
config.load().await.unwrap();
config
.update(|c| c.msd.msd_dir = dir.path().join("msd").to_string_lossy().into())
.await
.unwrap();
let hardware = McpHardware::for_test(config, &db);
let status = hardware.status().await;
assert_eq!(status["atx"]["available"], false);
assert_eq!(status["atx"]["power_status"], "unknown");
let power = hardware
.execute(HardwareCommand::Power(PowerAction::PressShort))
.await;
assert_eq!(power.is_error, Some(true));
assert_eq!(power.structured_content.unwrap()["error"], "unavailable");
#[cfg(unix)]
{
let lock = hardware.otg_lock.lock().await;
let mount = hardware
.execute(HardwareCommand::MountMedia {
image_id: "abc".into(),
cdrom: true,
read_only: true,
})
.await;
assert_eq!(
mount.structured_content.unwrap()["error"],
"MSD_OPERATION_IN_PROGRESS"
);
drop(lock);
let missing = hardware
.execute(HardwareCommand::UnmountMedia("abc".into()))
.await;
assert_eq!(
missing.structured_content.unwrap()["error"],
"MSD_UNAVAILABLE"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn media_listing_returns_ids_and_capacity_without_host_paths() {
let dir = tempfile::tempdir().unwrap();
let db = crate::db::DatabasePool::new(&dir.path().join("hardware.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let config = ConfigStore::new(db.clone_pool());
config.load().await.unwrap();
config
.update(|c| c.msd.msd_dir = dir.path().join("msd").to_string_lossy().into())
.await
.unwrap();
let images = config.get().msd.images_dir();
std::fs::create_dir_all(&images).unwrap();
std::fs::write(images.join("mcp-test.iso"), [0u8; 2048]).unwrap();
let hardware = McpHardware::for_test(config, &db);
let result = hardware.execute(HardwareCommand::ListMedia).await;
assert_eq!(result.is_error, Some(false));
let value = result.structured_content.unwrap();
assert_eq!(value["slot_capacity"], 1);
assert_eq!(value["mounted_count"], 0);
let image = &value["images"][0];
assert_eq!(image["name"], "mcp-test.iso");
assert_eq!(image["size"], 2048);
assert!(valid_image_id(image["id"].as_str().unwrap()));
assert!(image.get("path").is_none());
assert!(!value
.to_string()
.contains(&dir.path().to_string_lossy().to_string()));
}
}

185
src/mcp/keyboard.rs Normal file
View File

@@ -0,0 +1,185 @@
use crate::hid::{CanonicalKey, KeyboardModifiers};
pub(super) fn char_to_key(ch: char) -> Option<(CanonicalKey, KeyboardModifiers)> {
let mut mods = KeyboardModifiers::default();
let key = match ch {
'a'..='z' => key_name_to_canonical(&ch.to_string())?,
'A'..='Z' => {
mods.left_shift = true;
key_name_to_canonical(&ch.to_ascii_lowercase().to_string())?
}
'0' => CanonicalKey::Digit0,
'1' => CanonicalKey::Digit1,
'2' => CanonicalKey::Digit2,
'3' => CanonicalKey::Digit3,
'4' => CanonicalKey::Digit4,
'5' => CanonicalKey::Digit5,
'6' => CanonicalKey::Digit6,
'7' => CanonicalKey::Digit7,
'8' => CanonicalKey::Digit8,
'9' => CanonicalKey::Digit9,
' ' => CanonicalKey::Space,
'\n' => CanonicalKey::Enter,
'\t' => CanonicalKey::Tab,
'-' => CanonicalKey::Minus,
'_' => {
mods.left_shift = true;
CanonicalKey::Minus
}
'=' => CanonicalKey::Equal,
'+' => {
mods.left_shift = true;
CanonicalKey::Equal
}
'.' => CanonicalKey::Period,
',' => CanonicalKey::Comma,
'/' => CanonicalKey::Slash,
'?' => {
mods.left_shift = true;
CanonicalKey::Slash
}
';' => CanonicalKey::Semicolon,
':' => {
mods.left_shift = true;
CanonicalKey::Semicolon
}
'\'' => CanonicalKey::Quote,
'"' => {
mods.left_shift = true;
CanonicalKey::Quote
}
'[' => CanonicalKey::BracketLeft,
'{' => {
mods.left_shift = true;
CanonicalKey::BracketLeft
}
']' => CanonicalKey::BracketRight,
'}' => {
mods.left_shift = true;
CanonicalKey::BracketRight
}
'\\' => CanonicalKey::Backslash,
'|' => {
mods.left_shift = true;
CanonicalKey::Backslash
}
'`' => CanonicalKey::Backquote,
'~' => {
mods.left_shift = true;
CanonicalKey::Backquote
}
'!' => {
mods.left_shift = true;
CanonicalKey::Digit1
}
'@' => {
mods.left_shift = true;
CanonicalKey::Digit2
}
'#' => {
mods.left_shift = true;
CanonicalKey::Digit3
}
'$' => {
mods.left_shift = true;
CanonicalKey::Digit4
}
'%' => {
mods.left_shift = true;
CanonicalKey::Digit5
}
'^' => {
mods.left_shift = true;
CanonicalKey::Digit6
}
'&' => {
mods.left_shift = true;
CanonicalKey::Digit7
}
'*' => {
mods.left_shift = true;
CanonicalKey::Digit8
}
'(' => {
mods.left_shift = true;
CanonicalKey::Digit9
}
')' => {
mods.left_shift = true;
CanonicalKey::Digit0
}
_ => return None,
};
Some((key, mods))
}
pub(super) fn key_name_to_canonical(name: &str) -> Option<CanonicalKey> {
match name.trim().to_lowercase().as_str() {
"a" => Some(CanonicalKey::KeyA),
"b" => Some(CanonicalKey::KeyB),
"c" => Some(CanonicalKey::KeyC),
"d" => Some(CanonicalKey::KeyD),
"e" => Some(CanonicalKey::KeyE),
"f" => Some(CanonicalKey::KeyF),
"g" => Some(CanonicalKey::KeyG),
"h" => Some(CanonicalKey::KeyH),
"i" => Some(CanonicalKey::KeyI),
"j" => Some(CanonicalKey::KeyJ),
"k" => Some(CanonicalKey::KeyK),
"l" => Some(CanonicalKey::KeyL),
"m" => Some(CanonicalKey::KeyM),
"n" => Some(CanonicalKey::KeyN),
"o" => Some(CanonicalKey::KeyO),
"p" => Some(CanonicalKey::KeyP),
"q" => Some(CanonicalKey::KeyQ),
"r" => Some(CanonicalKey::KeyR),
"s" => Some(CanonicalKey::KeyS),
"t" => Some(CanonicalKey::KeyT),
"u" => Some(CanonicalKey::KeyU),
"v" => Some(CanonicalKey::KeyV),
"w" => Some(CanonicalKey::KeyW),
"x" => Some(CanonicalKey::KeyX),
"y" => Some(CanonicalKey::KeyY),
"z" => Some(CanonicalKey::KeyZ),
"0" => Some(CanonicalKey::Digit0),
"1" => Some(CanonicalKey::Digit1),
"2" => Some(CanonicalKey::Digit2),
"3" => Some(CanonicalKey::Digit3),
"4" => Some(CanonicalKey::Digit4),
"5" => Some(CanonicalKey::Digit5),
"6" => Some(CanonicalKey::Digit6),
"7" => Some(CanonicalKey::Digit7),
"8" => Some(CanonicalKey::Digit8),
"9" => Some(CanonicalKey::Digit9),
"insert" => Some(CanonicalKey::Insert),
"capslock" => Some(CanonicalKey::CapsLock),
"printscreen" => Some(CanonicalKey::PrintScreen),
"enter" | "return" => Some(CanonicalKey::Enter),
"escape" | "esc" => Some(CanonicalKey::Escape),
"backspace" => Some(CanonicalKey::Backspace),
"tab" => Some(CanonicalKey::Tab),
"space" => Some(CanonicalKey::Space),
"delete" | "del" => Some(CanonicalKey::Delete),
"arrowup" | "up" => Some(CanonicalKey::ArrowUp),
"arrowdown" | "down" => Some(CanonicalKey::ArrowDown),
"arrowleft" | "left" => Some(CanonicalKey::ArrowLeft),
"arrowright" | "right" => Some(CanonicalKey::ArrowRight),
"home" => Some(CanonicalKey::Home),
"end" => Some(CanonicalKey::End),
"pageup" => Some(CanonicalKey::PageUp),
"pagedown" => Some(CanonicalKey::PageDown),
"f1" => Some(CanonicalKey::F1),
"f2" => Some(CanonicalKey::F2),
"f3" => Some(CanonicalKey::F3),
"f4" => Some(CanonicalKey::F4),
"f5" => Some(CanonicalKey::F5),
"f6" => Some(CanonicalKey::F6),
"f7" => Some(CanonicalKey::F7),
"f8" => Some(CanonicalKey::F8),
"f9" => Some(CanonicalKey::F9),
"f10" => Some(CanonicalKey::F10),
"f11" => Some(CanonicalKey::F11),
"f12" => Some(CanonicalKey::F12),
_ => None,
}
}

395
src/mcp/mod.rs Normal file
View File

@@ -0,0 +1,395 @@
//! Native MCP service: one FIFO and one shared API key.
mod actions;
mod executor;
mod hardware;
mod keyboard;
mod tools;
pub use executor::McpService;
pub(crate) use hardware::McpHardware;
use crate::config::ConfigStore;
use axum::{
extract::{Request, State},
http::{header, StatusCode, Uri},
middleware::{self, Next},
response::{IntoResponse, Response},
Router,
};
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub(crate) fn hash_key(key: &str) -> String {
Sha256::digest(key.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub(crate) fn generate_key() -> String {
format!(
"okvm_{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
fn key_matches(key: &str, expected: Option<&str>) -> bool {
expected.is_some_and(|expected| {
expected.len() == 64 && openssl::memcmp::eq(hash_key(key).as_bytes(), expected.as_bytes())
})
}
fn valid_origin(request: &Request) -> bool {
let Some(origin) = request.headers().get(header::ORIGIN) else {
return true;
};
let Some(origin) = origin.to_str().ok().and_then(|s| s.parse::<Uri>().ok()) else {
return false;
};
matches!(origin.scheme_str(), Some("http" | "https"))
&& origin.authority().is_some_and(|authority| {
request
.headers()
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.is_some_and(|host| host.eq_ignore_ascii_case(authority.as_str()))
})
&& origin.path_and_query().is_none_or(|p| p.as_str() == "/")
}
async fn authorize(State(config): State<ConfigStore>, request: Request, next: Next) -> Response {
if !valid_origin(&request) {
return (StatusCode::FORBIDDEN, "Origin not allowed").into_response();
}
let config = config.get();
if !config.mcp.enabled {
return (StatusCode::SERVICE_UNAVAILABLE, "MCP is disabled").into_response();
}
let supplied = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
if !supplied.is_some_and(|key| key_matches(key, config.mcp.api_key_hash.as_deref())) {
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
"Invalid MCP API key",
)
.into_response();
}
drop(config);
next.run(request).await
}
pub fn router<S: Clone + Send + Sync + 'static>(
service: Arc<McpService>,
config: ConfigStore,
) -> Router<S> {
let http = StreamableHttpService::new(
move || Ok(tools::McpTools::new(service.clone())),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default()
// One-KVM is served on device IPs and user-provided hostnames.
// Shared-key authentication and same-origin checks run above the SDK.
.disable_allowed_hosts()
.with_legacy_session_mode(false)
.with_json_response(true),
);
Router::new()
.route_service("/mcp", http)
.layer(middleware::from_fn_with_state(config, authorize))
.layer(tower_http::limit::RequestBodyLimitLayer::new(64 * 1024))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_key_and_origin_validation() {
let key = generate_key();
let hash = hash_key(&key);
assert!(key_matches(&key, Some(&hash)));
assert!(!key_matches("wrong", Some(&hash)));
assert!(!key_matches(&key, None));
assert!(!key_matches(&key, Some("malformed")));
let build = |origin: &str| {
Request::builder()
.header("host", "kvm.local:8080")
.header("origin", origin)
.body(axum::body::Body::empty())
.unwrap()
};
assert!(valid_origin(&build("http://kvm.local:8080")));
assert!(!valid_origin(&build("https://attacker.example")));
assert!(!valid_origin(&build("null")));
assert!(!valid_origin(&build("http://kvm.local:8080/extra")));
}
}
#[cfg(test)]
mod http_tests {
use super::*;
use crate::{
db::DatabasePool,
hid::{HidBackendType, HidController},
video::{streamer::Streamer, VideoStreamManager},
webrtc::WebRtcStreamer,
};
use serde_json::{json, Value};
#[tokio::test]
async fn http_auth_discovery_native_schema_and_shared_rate_limit() {
let dir = tempfile::tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("mcp-http.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let config = ConfigStore::new(db.clone_pool());
config.load().await.unwrap();
#[cfg(unix)]
let hid = Arc::new(HidController::new(HidBackendType::None, None));
#[cfg(not(unix))]
let hid = Arc::new(HidController::new(HidBackendType::None));
let video = VideoStreamManager::with_webrtc_streamer(
Streamer::new(),
WebRtcStreamer::with_config(Default::default()),
);
config
.update(|c| c.msd.msd_dir = dir.path().join("msd").to_string_lossy().into())
.await
.unwrap();
let hardware = McpHardware::for_test(config.clone(), &db);
let atx = hardware.atx.clone();
let service = McpService::new(config.clone(), hid, video, hardware);
let app = router::<()>(service, config.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let url = format!("http://{address}/mcp");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
assert_eq!(
client.post(&url).send().await.unwrap().status(),
StatusCode::SERVICE_UNAVAILABLE
);
let key = generate_key();
config
.update(|c| {
c.mcp.enabled = true;
c.mcp.api_key_hash = Some(hash_key(&key));
c.mcp.requests_per_second = 100;
})
.await
.unwrap();
assert_eq!(
client.post(&url).send().await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
client
.post(&url)
.bearer_auth(&key)
.header("Origin", "https://other.invalid")
.send()
.await
.unwrap()
.status(),
StatusCode::FORBIDDEN
);
let rpc = |method: &str, params: Value| {
client
.post(&url)
.bearer_auth(&key)
.header("Host", "192.168.1.15:8080")
.header("Origin", "http://192.168.1.15:8080")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2025-11-25")
.json(&json!({"jsonrpc":"2.0", "id":1, "method":method, "params":params}))
};
let init: Value = rpc("initialize", json!({"protocolVersion":"2025-11-25", "clientInfo":{"name":"mcp-test","version":"1"}, "capabilities":{}})).send().await.unwrap().json().await.unwrap();
assert!(init.get("result").is_some(), "{init}");
assert_eq!(init["result"]["serverInfo"]["name"], "one-kvm");
assert_eq!(
init["result"]["serverInfo"]["version"],
env!("CARGO_PKG_VERSION")
);
let list: Value = rpc("tools/list", json!({}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let tools = list["result"]["tools"].as_array().unwrap();
let names = tools
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(names.len(), 8);
assert!(names.contains(&"one_kvm_screenshot"));
assert!(names.contains(&"one_kvm_execute_actions"));
assert!(names.contains(&"one_kvm_get_status"));
for name in [
"one_kvm_power",
"one_kvm_wake_on_lan",
"one_kvm_list_media",
"one_kvm_mount_media",
"one_kvm_unmount_media",
] {
assert!(names.contains(&name));
}
let screenshot = tools
.iter()
.find(|t| t["name"] == "one_kvm_screenshot")
.unwrap();
assert!(screenshot["inputSchema"]["properties"]
.get("max_width")
.is_none());
assert!(screenshot["inputSchema"]["properties"]
.get("max_height")
.is_none());
assert_eq!(screenshot["inputSchema"]["additionalProperties"], false);
let status: Value = rpc(
"tools/call",
json!({"name":"one_kvm_get_status", "arguments":{}}),
)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(status["result"]["isError"], false, "{status}");
assert!(status["result"]["structuredContent"]["hid"]["initialized"] == false);
assert_eq!(
status["result"]["structuredContent"]["atx"]["power_status"],
"unknown"
);
let power: Value = rpc(
"tools/call",
json!({"name":"one_kvm_power", "arguments":{"action":"press_short"}}),
)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(power["result"]["isError"], true);
let media: Value = rpc(
"tools/call",
json!({"name":"one_kvm_list_media", "arguments":{}}),
)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
#[cfg(unix)]
assert_eq!(media["result"]["structuredContent"]["images"], json!([]));
#[cfg(not(unix))]
assert_eq!(media["result"]["isError"], true);
let invalid_id: Value = rpc(
"tools/call",
json!({"name":"one_kvm_mount_media", "arguments":{"image_id":"/etc/passwd"}}),
)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
invalid_id["result"]["structuredContent"]["error"],
"invalid_arguments"
);
// A slow controller must retain its HTTP response while waiting in the FIFO.
for _ in 0..6 {
let guard = atx.write().await;
let request = rpc(
"tools/call",
json!({"name":"one_kvm_get_status", "arguments":{}}),
);
let response = tokio::spawn(async move {
request.send().await.unwrap().json::<Value>().await.unwrap()
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!response.is_finished());
drop(guard);
assert_eq!(response.await.unwrap()["result"]["isError"], false);
}
// Current MCP revision: per-request metadata, without a transport session.
let modern: Value = client
.post(&url)
.bearer_auth(&key)
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", "tools/call")
.header("Mcp-Name", "one_kvm_get_status")
.json(
&json!({"jsonrpc":"2.0", "id":2, "method":"tools/call", "params":{
"name":"one_kvm_get_status", "arguments":{},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"mcp-test", "version":"1"},
"io.modelcontextprotocol/clientCapabilities":{}
}
}}),
)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(modern["result"]["isError"], false, "{modern}");
let invalid: Value = rpc("tools/call", json!({"name":"one_kvm_execute_actions", "arguments":{"actions":[{"type":"type", "text":"中文"}]}})).send().await.unwrap().json().await.unwrap();
assert_eq!(invalid["result"]["isError"], true, "{invalid}");
let oversize = rpc("tools/list", json!({"padding":"x".repeat(70_000)}))
.send()
.await
.unwrap();
assert_eq!(oversize.status(), StatusCode::PAYLOAD_TOO_LARGE);
// All independent HTTP connections use the same service bucket.
config
.update(|c| c.mcp.requests_per_second = 1)
.await
.unwrap();
let a: Value = rpc(
"tools/call",
json!({"name":"one_kvm_get_status", "arguments":{}}),
)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let b: Value = rpc(
"tools/call",
json!({"name":"one_kvm_list_media", "arguments":{}}),
)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(
a["result"]["structuredContent"]["error"] == "rate_limited"
|| b["result"]["structuredContent"]["error"] == "rate_limited"
);
let rotated = generate_key();
config
.update(|c| c.mcp.api_key_hash = Some(hash_key(&rotated)))
.await
.unwrap();
assert_eq!(
rpc("tools/list", json!({})).send().await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
server.abort();
}
}

246
src/mcp/tools.rs Normal file
View File

@@ -0,0 +1,246 @@
use super::{
actions::{self, Action},
executor::{failure, Command},
hardware::{valid_image_id, HardwareCommand, PowerAction},
McpService,
};
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ServerCapabilities, ServerConfig},
schemars::{self, JsonSchema},
tool, tool_handler, tool_router, ServerHandler,
};
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScreenshotParams {
/// Optional JPEG quality, 1 to 100. Native width and height are always preserved.
pub quality: Option<u8>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ActionsParams {
/// One to 32 actions. Pointer x/y values are normalized to [0,1] over the whole screen.
pub actions: Vec<Action>,
/// Return a fresh native-resolution JPEG after executing the batch.
#[serde(default)]
pub return_screenshot: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PowerParams {
/// Physical ATX button: short power press, long power press (force off), or reset.
action: PowerAction,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WakeParams {
/// Target MAC address, e.g. AA:BB:CC:DD:EE:FF. Uses the configured WOL interface.
mac_address: String,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MountMediaParams {
/// Image ID from one_kvm_list_media, never a filesystem path or URL.
image_id: String,
/// Present as a CD-ROM. Defaults to true.
#[serde(default = "default_true")]
cdrom: bool,
/// Read-only access. Defaults to true; CD-ROM is always read-only.
#[serde(default = "default_true")]
read_only: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UnmountMediaParams {
/// Image ID from one_kvm_list_media. Does not unmount other images or the virtual drive.
image_id: String,
}
#[derive(Clone)]
pub struct McpTools {
service: Arc<McpService>,
tool_router: ToolRouter<Self>,
}
impl McpTools {
pub fn new(service: Arc<McpService>) -> Self {
Self {
service,
tool_router: Self::tool_router(),
}
}
}
#[tool_router]
impl McpTools {
#[tool(
name = "one_kvm_get_status",
description = "Get One-KVM video signal, current resolution, HID capabilities and global queue length."
)]
async fn status(&self) -> CallToolResult {
self.service.call(Command::Status).await
}
#[tool(
name = "one_kvm_screenshot",
description = "Capture a fresh JPEG at the current source resolution, without resizing or cropping. No browser is needed. Coordinates for pointer actions are normalized to [0,1] over this whole image."
)]
async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
if params.quality.is_some_and(|q| !(1..=100).contains(&q)) {
return failure("invalid_arguments", "quality must be between 1 and 100", 0);
}
self.service.call(Command::Screenshot(params.quality)).await
}
#[tool(
name = "one_kvm_execute_actions",
description = "Execute a bounded batch of physical HID actions in global FIFO order. Pointer coordinates are normalized: (0,0) top-left, (1,1) bottom-right. Positive vertical_steps scrolls down. Text uses the target's US keyboard layout (ASCII, newline, tab); no clipboard or arbitrary Unicode. All parameters are checked before input. No client ownership, tasks or cancellation. On connection failure, inspect the screen before retrying: actions may already have executed."
)]
async fn actions(&self, Parameters(params): Parameters<ActionsParams>) -> CallToolResult {
match actions::prepare(params.actions) {
Ok(actions) => {
self.service
.call(Command::Actions(actions, params.return_screenshot))
.await
}
Err(message) => failure("invalid_arguments", &message, 0),
}
}
#[tool(
name = "one_kvm_power",
description = "Press a configured physical ATX button: press_short, press_long (force off), or reset. Inspect ATX availability with one_kvm_get_status first. Success means the button pulse was sent, not that the target OS completed startup/shutdown. LED state is unknown when not wired. Uses the global FIFO and rate limit; never automatically repeat power operations."
)]
async fn power(&self, Parameters(params): Parameters<PowerParams>) -> CallToolResult {
self.service
.call(Command::Hardware(HardwareCommand::Power(params.action)))
.await
}
#[tool(
name = "one_kvm_wake_on_lan",
description = "Send Wake-on-LAN magic packets to a MAC address, using One-KVM's configured network interface. Reports packet transmission, not successful startup. Does not require ATX wiring. Shares the global FIFO and rate limit."
)]
async fn wake(&self, Parameters(params): Parameters<WakeParams>) -> CallToolResult {
match crate::atx::normalize_wol_mac_address(&params.mac_address) {
Ok(mac) => {
self.service
.call(Command::Hardware(HardwareCommand::Wake(mac)))
.await
}
Err(error) => failure("invalid_arguments", &error.to_string(), 0),
}
}
#[tool(
name = "one_kvm_list_media",
description = "List uploaded ISO/IMG images, their image IDs and sizes, currently mounted media, and USB media slot capacity. No upload or file paths. Virtual media requires a supported USB gadget backend. Uses the global FIFO and rate limit.",
annotations(read_only_hint = true)
)]
async fn list_media(&self) -> CallToolResult {
self.service
.call(Command::Hardware(HardwareCommand::ListMedia))
.await
}
#[tool(
name = "one_kvm_mount_media",
description = "Mount an uploaded image by its image_id from one_kvm_list_media into a free USB media slot. Defaults to CD-ROM and read-only; CD-ROM always forces read-only. To mount a disk, explicitly set cdrom=false. Does not upload, download, replace existing media, change USB configuration, or initialize/format a drive. Shares global FIFO/rate limits and existing USB operation locks."
)]
async fn mount_media(
&self,
Parameters(params): Parameters<MountMediaParams>,
) -> CallToolResult {
if !valid_image_id(&params.image_id) {
return failure(
"invalid_arguments",
"image_id must be an image ID returned by one_kvm_list_media",
0,
);
}
self.service
.call(Command::Hardware(HardwareCommand::MountMedia {
image_id: params.image_id,
cdrom: params.cdrom,
read_only: params.read_only,
}))
.await
}
#[tool(
name = "one_kvm_unmount_media",
description = "Unmount only the uploaded image identified by image_id, releasing its USB media slot. An already unmounted image succeeds. Does not unmount the virtual drive or other images, delete files, or force removal when the target prevents ejection. Shares the global FIFO/rate limit and existing USB operation locks."
)]
async fn unmount_media(
&self,
Parameters(params): Parameters<UnmountMediaParams>,
) -> CallToolResult {
if !valid_image_id(&params.image_id) {
return failure(
"invalid_arguments",
"image_id must be an image ID returned by one_kvm_list_media",
0,
);
}
self.service
.call(Command::Hardware(HardwareCommand::UnmountMedia(
params.image_id,
)))
.await
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for McpTools {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new("one-kvm", env!("CARGO_PKG_VERSION")))
.with_instructions("One-KVM controls a physical computer using video and HID. First inspect the screen, then execute short batches and observe again. The service has one global FIFO and rate limit; coordinate multiple operators externally.")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn screenshot_accepts_native_resolution_only() {
assert!(serde_json::from_value::<ScreenshotParams>(serde_json::json!({})).is_ok());
assert!(
serde_json::from_value::<ScreenshotParams>(serde_json::json!({"max_width": 1920}))
.is_err()
);
assert!(serde_json::from_value::<ScreenshotParams>(
serde_json::json!({"max_height": 1080})
)
.is_err());
assert!(serde_json::from_value::<ActionsParams>(
serde_json::json!({"actions": [{"type": "wait", "ms": 1}], "control_id": "unneeded"})
)
.is_err());
}
#[test]
fn hardware_params_have_safe_defaults_and_require_ids() {
let mount: MountMediaParams =
serde_json::from_value(serde_json::json!({"image_id":"abcdef"})).unwrap();
assert!(mount.cdrom && mount.read_only);
assert!(valid_image_id(&mount.image_id));
for id in [
"",
"drive",
"/etc/passwd",
"../image.iso",
"https://host/disk.iso",
"0123456789abcdef0",
] {
assert!(!valid_image_id(id), "{id}");
}
assert!(serde_json::from_value::<PowerParams>(serde_json::json!({"action":"on"})).is_err());
assert!(serde_json::from_value::<MountMediaParams>(
serde_json::json!({"image_id":"abc", "path":"/tmp/a"})
)
.is_err());
}
}

View File

@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use uuid::Uuid;
use super::netifd_bridge;
use super::network_backend::{current_backend, NetworkBackend};
use crate::error::{AppError, Result};
const BRIDGE_IF: &str = "okvm-br0";
@@ -104,11 +106,24 @@ impl TransactionProfiles {
#[derive(Debug, Clone)]
pub struct NetworkBridgeRuntime {
journal: BridgeJournal,
journal: RuntimeJournal,
}
#[derive(Debug, Clone)]
enum RuntimeJournal {
NetworkManager(BridgeJournal),
Netifd(netifd_bridge::Journal),
}
impl NetworkBridgeRuntime {
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> {
if current_backend().map_err(AppError::Config)? == NetworkBackend::Netifd {
let interfaces = list_netifd_interfaces()?;
let selected = select_bridge_candidate(&interfaces, requested)?;
return netifd_bridge::activate(selected, usb_interface).map(|journal| Self {
journal: RuntimeJournal::Netifd(journal),
});
}
ensure_command("nmcli")?;
ensure_command("ip")?;
@@ -321,16 +336,23 @@ impl NetworkBridgeRuntime {
})();
match result {
Ok(_address) => Ok(Self { journal }),
Ok(_address) => Ok(Self {
journal: RuntimeJournal::NetworkManager(journal),
}),
Err(error) => Err(restore_or_combine(&journal, error)),
}
}
pub fn deactivate(&self) -> Result<()> {
restore_from_journal(&self.journal)
// Use the backend recorded at activation, even if the environment changes.
match &self.journal {
RuntimeJournal::NetworkManager(journal) => restore_from_journal(journal),
RuntimeJournal::Netifd(journal) => netifd_bridge::deactivate(journal),
}
}
pub fn recover_stale_transaction() -> Result<()> {
netifd_bridge::recover()?;
let value = match fs::read_to_string(JOURNAL_PATH) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
@@ -340,6 +362,7 @@ impl NetworkBridgeRuntime {
)))
}
};
ensure_command("nmcli")?;
match serde_json::from_str::<BridgeJournal>(&value) {
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
Ok(journal) => Err(AppError::Config(format!(
@@ -354,10 +377,92 @@ impl NetworkBridgeRuntime {
}
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
if current_backend().map_err(AppError::Config)? == NetworkBackend::Netifd {
return list_netifd_interfaces();
}
let devices = enumerate_network_manager_devices()?;
Ok(bridge_candidates(devices, is_physical_network_interface))
}
fn list_netifd_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
let output = run_command("ubus", &["call", "network.interface", "dump"])?;
let value: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|error| AppError::Internal(format!("Invalid netifd interface dump: {error}")))?;
parse_netifd_interfaces(&value)
}
fn parse_netifd_interfaces(value: &serde_json::Value) -> Result<Vec<NetworkInterfaceInfo>> {
parse_netifd_interfaces_with_check(value, netifd_bridge::bridgeable_device)
}
fn parse_netifd_interfaces_with_check(
value: &serde_json::Value,
bridgeable: impl Fn(&str) -> bool,
) -> Result<Vec<NetworkInterfaceInfo>> {
let interfaces = value
.get("interface")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| AppError::Internal("netifd dump is missing interface array".to_string()))?;
Ok(interfaces
.iter()
.filter_map(|interface| {
let logical_name = interface.get("interface")?.as_str()?;
let name = interface
.get("l3_device")
.or_else(|| interface.get("device"))?
.as_str()?;
if name == "lo" {
return None;
}
let up = interface
.get("up")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let addresses: Vec<String> = interface
.get("ipv4-address")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|address| {
let ip = address.get("address")?.as_str()?;
let mask = address.get("mask")?.as_u64()?;
Some(format!("{ip}/{mask}"))
})
.collect();
let has_default_route = interface
.get("route")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.any(|route| {
route.get("target").and_then(serde_json::Value::as_str) == Some("0.0.0.0")
&& route.get("mask").and_then(serde_json::Value::as_u64) == Some(0)
});
let reason = if !up {
Some("Network interface is disconnected".to_string())
} else if !matches!(interface.get("proto").and_then(serde_json::Value::as_str), Some("dhcp" | "static")) {
Some("OTG bridging supports DHCP and static Ethernet uplinks".to_string())
} else if !bridgeable(name) {
Some("OTG uplink must be Ethernet or an existing bridge; ordinary Wi-Fi clients cannot be bridged".to_string())
} else if addresses.is_empty() {
Some("OTG bridging requires an IPv4 uplink".to_string())
} else {
None
};
Some(NetworkInterfaceInfo {
name: name.to_string(),
interface_type: "netifd".to_string(),
state: if up { "connected" } else { "disconnected" }.to_string(),
connection: logical_name.to_string(),
addresses,
has_default_route,
bridge_supported: reason.is_none(),
reason,
})
})
.collect())
}
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
ensure_command("nmcli")?;
let output = run_command(
@@ -440,10 +545,11 @@ fn select_bridge_candidate<'a>(
if requested.trim().is_empty() {
return interfaces
.iter()
.filter(|item| item.bridge_supported)
.max_by_key(|item| item.has_default_route)
.ok_or_else(|| {
AppError::Config(
"No connected physical NetworkManager Ethernet interface is available for OTG bridging"
"No connected Ethernet interface or supported bridge is available for OTG bridging"
.to_string(),
)
});
@@ -451,10 +557,15 @@ fn select_bridge_candidate<'a>(
interfaces
.iter()
.find(|item| item.name == requested)
.find(|item| item.name == requested && item.bridge_supported)
.ok_or_else(|| {
AppError::Config(format!(
"Network interface {requested} is not a connected physical NetworkManager Ethernet interface"
"Network interface {requested} does not support OTG bridging: {}",
interfaces
.iter()
.find(|item| item.name == requested)
.and_then(|item| item.reason.as_deref())
.unwrap_or("interface unavailable")
))
})
}
@@ -759,7 +870,7 @@ fn connection_value(connection: &str, property: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn first_ipv4_address(interface: &str) -> Option<String> {
pub(super) fn first_ipv4_address(interface: &str) -> Option<String> {
ipv4_addresses(interface).into_iter().next()
}
@@ -783,7 +894,7 @@ fn ipv4_addresses(interface: &str) -> Vec<String> {
.collect()
}
fn default_route(interface: &str) -> Option<String> {
pub(super) fn default_route(interface: &str) -> Option<String> {
let output = Command::new("ip")
.args(["-4", "route", "show", "default", "dev", interface])
.output()
@@ -801,7 +912,7 @@ fn gateway_from_route(route: &str) -> Option<&str> {
.find_map(|part| (part[0] == "via").then_some(part[1]))
}
fn ensure_command(name: &str) -> Result<()> {
pub(super) fn ensure_command(name: &str) -> Result<()> {
let status = Command::new(name).arg("--version").output();
if status.is_err() {
return Err(AppError::BadRequest(format!(
@@ -815,7 +926,7 @@ fn run_nmcli(args: &[&str]) -> Result<Output> {
run_command("nmcli", args)
}
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
pub(super) fn run_command(command: &str, args: &[&str]) -> Result<Output> {
let output = Command::new(command)
.env("LC_ALL", "C")
.args(args)
@@ -842,6 +953,49 @@ fn run_command(command: &str, args: &[&str]) -> Result<Output> {
mod tests {
use super::*;
#[test]
fn netifd_dump_preserves_logical_name_addresses_and_default_route() {
let dump = serde_json::json!({"interface": [
{"interface": "loopback", "l3_device": "lo", "up": true},
{"interface": "lan", "l3_device": "br-lan", "up": true, "proto": "dhcp",
"ipv4-address": [{"address": "192.0.2.5", "mask": 24}],
"route": [{"target": "0.0.0.0", "mask": 0}]},
{"interface": "offline", "device": "eth1", "up": false}
]});
let result = parse_netifd_interfaces_with_check(&dump, |_| true).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "br-lan");
assert_eq!(result[0].connection, "lan");
assert_eq!(result[0].addresses, vec!["192.0.2.5/24"]);
assert!(result[0].has_default_route);
assert!(result[0].bridge_supported);
assert!(result[0].reason.is_none());
assert_eq!(result[1].state, "disconnected");
assert!(!result[1].has_default_route);
}
#[test]
fn malformed_netifd_dump_fails_and_incomplete_entries_are_skipped() {
assert!(parse_netifd_interfaces(&serde_json::json!({})).is_err());
assert!(parse_netifd_interfaces(&serde_json::json!({"interface": {}})).is_err());
let dump = serde_json::json!({"interface": [{"interface": "pending"}, {}]});
assert!(parse_netifd_interfaces(&dump).unwrap().is_empty());
}
#[test]
fn netifd_selection_skips_wifi_ppp_and_ipv6_only_uplinks() {
let dump = serde_json::json!({"interface": [
{"interface":"wifi", "l3_device":"wlan0", "up":true, "proto":"dhcp", "ipv4-address":[{"address":"192.0.2.6","mask":24}], "route":[{"target":"0.0.0.0","mask":0}]},
{"interface":"wan", "l3_device":"pppoe-wan", "up":true, "proto":"pppoe"},
{"interface":"lan6", "l3_device":"eth1", "up":true, "proto":"static"},
{"interface":"lan", "l3_device":"eth0", "up":true, "proto":"dhcp", "ipv4-address":[{"address":"192.0.2.5","mask":24}]}
]});
let interfaces = parse_netifd_interfaces_with_check(&dump, |name| name.starts_with("eth")).unwrap();
assert_eq!(select_bridge_candidate(&interfaces, "").unwrap().name, "eth0");
assert!(select_bridge_candidate(&interfaces, "wlan0").is_err());
assert!(select_bridge_candidate(&interfaces, "eth1").is_err());
}
fn device(
name: &str,
interface_type: &str,
@@ -994,6 +1148,6 @@ mod tests {
let error = select_bridge_candidate(&[], "").unwrap_err();
assert!(matches!(error, AppError::Config(_)));
assert!(error.to_string().contains("connected physical"));
assert!(error.to_string().contains("connected Ethernet"));
}
}

View File

@@ -14,6 +14,10 @@ pub mod manager;
pub mod msd;
#[cfg(unix)]
pub mod network;
#[cfg(unix)]
mod network_backend;
#[cfg(unix)]
mod netifd_bridge;
pub mod report_desc;
pub mod self_check;
#[cfg(unix)]

826
src/otg/netifd_bridge.rs Normal file
View File

@@ -0,0 +1,826 @@
//! OpenWrt bridge transactions. Preserve logical interfaces and their L3 settings.
//! The recovery journal lives in /etc so committed UCI changes survive a reboot safely.
use super::bridge::{
default_route, ensure_command, first_ipv4_address, run_command, NetworkInterfaceInfo,
};
use crate::error::{AppError, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::{
collections::BTreeMap,
fs,
io::Write,
os::unix::fs::PermissionsExt,
path::Path,
thread,
time::{Duration, Instant},
};
use uuid::Uuid;
const JOURNAL_PATH: &str = "/etc/one-kvm/otg-netifd-bridge.json";
const BRIDGE: &str = "okvm-br0";
const VERSION: u8 = 1;
type Config = BTreeMap<String, Map<String, Value>>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct Journal {
version: u8,
uplink: String,
usb: String,
bridge: String,
had_default_route: bool,
interface_devices: BTreeMap<String, String>,
created_section: Option<String>,
bridge_section: String,
// Exact owned section options; unrelated user edits are preserved on recovery.
created_options: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq)]
enum Edit {
Set(String, String),
Delete(String),
AddList(String, String),
DelList(String, String),
}
fn invalid(message: impl Into<String>) -> AppError {
AppError::Config(message.into())
}
fn io_error(error: impl std::fmt::Display) -> AppError {
AppError::Internal(format!("OpenWrt bridge: {error}"))
}
fn identifier(name: &str) -> bool {
!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
fn device_name(name: &str) -> bool {
!name.is_empty()
&& name.len() < 16
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
}
fn option<'a>(section: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
section.get(key)?.as_str()
}
fn ports(section: &Map<String, Value>) -> Result<Vec<String>> {
match section.get("ports") {
None => Ok(Vec::new()),
Some(Value::Array(values)) => values
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| invalid("Invalid UCI bridge port list"))
})
.collect(),
Some(Value::String(value)) => Ok(value.split_whitespace().map(str::to_owned).collect()),
_ => Err(invalid("Invalid UCI bridge ports")),
}
}
pub(super) fn bridgeable_device(name: &str) -> bool {
if !device_name(name) {
return false;
}
let path = Path::new("/sys/class/net").join(name);
path.join("bridge").is_dir()
|| (path.join("device").exists()
&& fs::read_to_string(path.join("type")).is_ok_and(|value| value.trim() == "1")
&& !path.join("wireless").exists()
&& !path.join("phy80211").exists())
}
fn plan(
config: &Config,
selected: &NetworkInterfaceInfo,
usb: &str,
mac: &str,
existing_bridge: bool,
) -> Result<(Journal, Vec<Edit>)> {
if !device_name(&selected.name)
|| !device_name(usb)
|| selected.name == usb
|| !identifier(&selected.connection)
{
return Err(invalid(
"Invalid or identical OpenWrt uplink and USB interfaces",
));
}
let interface = config
.get(&selected.connection)
.ok_or_else(|| invalid("Selected UCI interface no longer exists"))?;
if option(interface, ".type") != Some("interface")
|| option(interface, "device") != Some(selected.name.as_str())
|| interface.contains_key("ifname")
|| interface.contains_key("type")
{
return Err(invalid("OTG bridging requires a current UCI interface with a device option; legacy ifname/type configurations are unsupported"));
}
if !matches!(option(interface, "proto"), Some("dhcp" | "static")) {
return Err(invalid(
"OTG bridging supports DHCP and static Ethernet uplinks",
));
}
for section in config.values() {
if option(section, ".type") == Some("interface")
&& option(section, "ifname")
.is_some_and(|value| value.split_whitespace().any(|name| name == selected.name))
{
return Err(invalid("Another UCI interface uses the uplink through legacy ifname; migrate it to a device option first"));
}
if option(section, "device") == Some(usb)
|| option(section, "ifname").is_some_and(|v| v.split_whitespace().any(|p| p == usb))
|| (option(section, ".type") == Some("device")
&& ports(section)?.iter().any(|p| p == usb))
{
return Err(invalid(
"USB interface already belongs to an OpenWrt network configuration",
));
}
}
let mut journal = Journal {
version: VERSION,
uplink: selected.name.clone(),
usb: usb.to_owned(),
bridge: selected.name.clone(),
had_default_route: selected.has_default_route,
interface_devices: BTreeMap::new(),
created_section: None,
bridge_section: String::new(),
created_options: Map::new(),
};
let mut edits = Vec::new();
if existing_bridge {
let matches: Vec<_> = config
.iter()
.filter(|(_, s)| {
option(s, ".type") == Some("device")
&& option(s, "name") == Some(selected.name.as_str())
})
.collect();
if matches.len() != 1 || option(matches[0].1, "type") != Some("bridge") {
return Err(invalid("Existing bridge must have one UCI device section"));
}
let (name, section) = matches[0];
if !identifier(name)
|| matches!(
option(section, "vlan_filtering"),
Some("1" | "true" | "yes" | "on")
)
|| config.values().any(|s| {
option(s, ".type") == Some("bridge-vlan")
&& option(s, "device") == Some(selected.name.as_str())
})
{
return Err(invalid("VLAN-filtered bridges require an explicit USB VLAN configuration and are unsupported"));
}
// Preserve list semantics; a scalar ports option is not safe to edit with add_list.
if section.get("ports").is_some_and(|v| !v.is_array()) {
return Err(invalid("Bridge ports must use UCI list entries"));
}
journal.bridge_section = name.clone();
edits.push(Edit::AddList(format!("{name}.ports"), usb.to_owned()));
} else {
if config.values().any(|s| option(s, "name") == Some(BRIDGE)) {
return Err(invalid(
"okvm-br0 already exists; refusing to replace another bridge",
));
}
if config.values().any(|s| {
option(s, ".type") == Some("device")
&& ports(s).is_ok_and(|p| p.iter().any(|p| p == &selected.name))
}) {
return Err(invalid(
"Selected uplink already belongs to a configured bridge",
));
}
let section = format!("one_kvm_otg_{}", Uuid::new_v4().simple());
journal.bridge = BRIDGE.to_owned();
journal.bridge_section = section.clone();
journal.created_section = Some(section.clone());
journal.created_options = json!({".type":"device", "name":BRIDGE, "type":"bridge", "stp":"0", "macaddr":mac, "ports":[selected.name, usb]}).as_object().unwrap().clone();
edits.push(Edit::Set(section.clone(), "device".to_owned()));
for key in ["name", "type", "stp", "macaddr"] {
edits.push(Edit::Set(
format!("{section}.{key}"),
option(&journal.created_options, key).unwrap().to_owned(),
));
}
for port in [selected.name.as_str(), usb] {
edits.push(Edit::AddList(format!("{section}.ports"), port.to_owned()));
}
for (name, section) in config {
if option(section, ".type") != Some("interface")
|| option(section, "device") != Some(selected.name.as_str())
{
continue;
}
if !identifier(name)
|| section.contains_key("ifname")
|| section.contains_key("type")
|| !matches!(
option(section, "proto"),
Some("dhcp" | "static" | "dhcpv6" | "none")
)
{
return Err(invalid("An additional uplink interface has an unsupported protocol or legacy configuration"));
}
journal
.interface_devices
.insert(name.clone(), selected.name.clone());
edits.push(Edit::Set(format!("{name}.device"), BRIDGE.to_owned()));
}
}
Ok((journal, edits))
}
fn restore_plan(config: &Config, journal: &Journal) -> Result<Vec<Edit>> {
if journal.version != VERSION
|| !identifier(&journal.bridge_section)
|| !device_name(&journal.uplink)
|| !device_name(&journal.usb)
|| !device_name(&journal.bridge)
{
return Err(invalid("Invalid OpenWrt bridge recovery journal"));
}
let mut edits = Vec::new();
for (name, original) in &journal.interface_devices {
if !identifier(name) {
return Err(invalid("Invalid recovery interface name"));
}
let section = config.get(name).ok_or_else(|| {
invalid(format!(
"Recovery interface {name} was removed; journal retained"
))
})?;
match option(section, "device") {
Some(value) if value == original => {},
Some(value) if value == journal.bridge => edits.push(Edit::Set(format!("{name}.device"), original.clone())),
_ => return Err(invalid(format!("Interface {name} was changed externally; refusing to overwrite it; journal retained"))),
}
}
if let Some(name) = &journal.created_section {
if name != &journal.bridge_section || !name.starts_with("one_kvm_otg_") {
return Err(invalid("Invalid owned bridge section"));
}
if let Some(section) = config.get(name) {
let actual: Map<_, _> = section
.iter()
.filter(|(key, _)| !key.starts_with('.') || key.as_str() == ".type")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if actual != journal.created_options {
return Err(invalid(
"Owned bridge was changed externally; refusing to delete it; journal retained",
));
}
if config.iter().any(|(name, s)| {
!journal.interface_devices.contains_key(name)
&& option(s, "device") == Some(journal.bridge.as_str())
}) {
return Err(invalid(
"Another interface now uses the owned bridge; journal retained",
));
}
edits.push(Edit::Delete(name.clone()));
}
} else {
let section = config
.get(&journal.bridge_section)
.ok_or_else(|| invalid("Existing bridge was removed; journal retained"))?;
if option(section, "name") != Some(journal.bridge.as_str())
|| option(section, "type") != Some("bridge")
{
return Err(invalid("Existing bridge was replaced; journal retained"));
}
if ports(section)?.contains(&journal.usb) {
edits.push(Edit::DelList(
format!("{}.ports", journal.bridge_section),
journal.usb.clone(),
));
}
}
Ok(edits)
}
trait Control {
fn config(&mut self) -> Result<Config>;
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()>;
fn reload(&mut self) -> Result<()>;
fn wait(&mut self, journal: &Journal, active: bool) -> Result<()>;
fn save(&mut self, journal: &Journal) -> Result<()>;
fn clear(&mut self) -> Result<()>;
}
fn activate_with(
control: &mut impl Control,
selected: &NetworkInterfaceInfo,
usb: &str,
mac: &str,
existing_bridge: bool,
) -> Result<Journal> {
let config = control.config()?;
let (journal, edits) = plan(&config, selected, usb, mac, existing_bridge)?;
control.save(&journal)?; // Durable recovery precedes every configuration change.
let result = control
.apply(&config, &edits)
.and_then(|()| control.reload())
.and_then(|()| control.wait(&journal, true));
if let Err(primary) = result {
return Err(match restore_with(control, &journal) {
Ok(()) => primary,
Err(rollback) => invalid(format!(
"{primary}; OpenWrt bridge rollback failed: {rollback}"
)),
});
}
Ok(journal)
}
fn restore_with(control: &mut impl Control, journal: &Journal) -> Result<()> {
let config = control.config()?;
let edits = restore_plan(&config, journal)?;
if !edits.is_empty() {
control.apply(&config, &edits)?;
}
control.reload()?;
control.wait(journal, false)?;
control.clear()
}
struct System;
impl Control for System {
fn config(&mut self) -> Result<Config> {
if Path::new("/var/run/uci/network").exists() {
return Err(invalid("A runtime network UCI override is active; OTG bridge configuration cannot safely replace it"));
}
let pending = run_command("uci", &["-q", "changes", "network"])?;
if !pending.stdout.is_empty() {
return Err(invalid(
"Save or discard pending network UCI changes before changing the OTG bridge",
));
}
let output = run_command("ubus", &["call", "uci", "get", r#"{"config":"network"}"#])?;
let value: Value = serde_json::from_slice(&output.stdout).map_err(io_error)?;
serde_json::from_value(value.get("values").cloned().ok_or_else(|| {
invalid("Invalid ubus UCI network response; rpcd UCI service is required")
})?)
.map_err(io_error)
}
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()> {
if &self.config()? != original {
return Err(invalid(
"Network configuration changed concurrently; retry the bridge operation",
));
}
let path = Path::new("/etc/config/network");
if path.is_symlink() {
return Err(invalid("Symlinked network configuration is unsupported"));
}
let before = fs::read(path).map_err(io_error)?;
let directory = tempfile::tempdir().map_err(io_error)?;
let copy = directory.path().join("network");
fs::write(&copy, &before).map_err(io_error)?;
let delta = directory.path().join("delta");
fs::create_dir(&delta).map_err(io_error)?;
let confdir = directory
.path()
.to_str()
.ok_or_else(|| invalid("Invalid UCI temporary directory"))?;
let savedir = delta
.to_str()
.ok_or_else(|| invalid("Invalid UCI delta directory"))?;
for edit in edits {
let (command, argument) = match edit {
Edit::Set(key, value) => ("set", format!("network.{key}={value}")),
Edit::Delete(key) => ("delete", format!("network.{key}")),
Edit::AddList(key, value) => ("add_list", format!("network.{key}={value}")),
Edit::DelList(key, value) => ("del_list", format!("network.{key}={value}")),
};
run_command(
"uci",
&["-q", "-c", confdir, "-t", savedir, command, &argument],
)?;
}
run_command(
"uci",
&["-q", "-c", confdir, "-t", savedir, "commit", "network"],
)?;
let mut replacement =
tempfile::NamedTempFile::new_in(path.parent().unwrap()).map_err(io_error)?;
replacement
.as_file()
.set_permissions(fs::metadata(path).map_err(io_error)?.permissions())
.map_err(io_error)?;
replacement
.write_all(&fs::read(&copy).map_err(io_error)?)
.map_err(io_error)?;
replacement.as_file().sync_all().map_err(io_error)?;
if fs::read(path).map_err(io_error)? != before || &self.config()? != original {
return Err(invalid(
"Network configuration changed concurrently; original configuration was preserved",
));
}
replacement.persist(path).map_err(io_error)?;
fs::File::open(path.parent().unwrap())
.and_then(|f| f.sync_all())
.map_err(io_error)
}
fn reload(&mut self) -> Result<()> {
run_command("ubus", &["call", "network", "reload"]).map(|_| ())
}
fn wait(&mut self, journal: &Journal, active: bool) -> Result<()> {
let interface = if active {
&journal.bridge
} else {
&journal.uplink
};
let deadline = Instant::now() + Duration::from_secs(35);
while Instant::now() < deadline {
let members = !active
|| (Path::new("/sys/class/net")
.join(interface)
.join("brif")
.join(&journal.usb)
.exists()
&& (journal.created_section.is_none()
|| Path::new("/sys/class/net")
.join(interface)
.join("brif")
.join(&journal.uplink)
.exists()));
if members
&& first_ipv4_address(interface).is_some()
&& (!journal.had_default_route || default_route(interface).is_some())
{
return Ok(());
}
thread::sleep(Duration::from_secs(1));
}
Err(invalid(format!("OpenWrt interface {interface} did not restore its IPv4 address/default route or bridge ports in time")))
}
fn save(&mut self, journal: &Journal) -> Result<()> {
let path = Path::new(JOURNAL_PATH);
if path.exists() {
return Err(invalid("An OpenWrt bridge recovery journal already exists; recover it before activating another bridge"));
}
fs::create_dir_all(path.parent().unwrap()).map_err(io_error)?;
let mut file = tempfile::NamedTempFile::new_in(path.parent().unwrap()).map_err(io_error)?;
file.as_file()
.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(io_error)?;
file.write_all(&serde_json::to_vec(journal).map_err(io_error)?)
.map_err(io_error)?;
file.as_file().sync_all().map_err(io_error)?;
file.persist_noclobber(path).map_err(io_error)?;
fs::File::open(path.parent().unwrap())
.and_then(|f| f.sync_all())
.map_err(io_error)
}
fn clear(&mut self) -> Result<()> {
match fs::remove_file(JOURNAL_PATH) {
Ok(()) => fs::File::open(Path::new(JOURNAL_PATH).parent().unwrap())
.and_then(|f| f.sync_all())
.map_err(io_error),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_error(error)),
}
}
}
pub(super) fn activate(selected: &NetworkInterfaceInfo, usb: &str) -> Result<Journal> {
for name in ["uci", "ubus", "ip"] {
ensure_command(name)?;
}
if !bridgeable_device(&selected.name) {
return Err(invalid("OpenWrt bridge uplink must be Ethernet or an existing bridge; ordinary Wi-Fi clients cannot be bridged"));
}
let existing_bridge = Path::new("/sys/class/net")
.join(&selected.name)
.join("bridge")
.is_dir();
if existing_bridge
&& fs::read_to_string(
Path::new("/sys/class/net")
.join(&selected.name)
.join("bridge/vlan_filtering"),
)
.is_ok_and(|value| value.trim() == "1")
{
return Err(invalid(
"VLAN-filtered bridges require an explicit USB VLAN configuration and are unsupported",
));
}
if !existing_bridge && Path::new("/sys/class/net").join(BRIDGE).exists() {
return Err(invalid(
"okvm-br0 already exists; refusing to replace another bridge",
));
}
let usb_path = Path::new("/sys/class/net").join(usb);
if !device_name(usb) || !usb_path.is_dir() || usb_path.join("master").exists() {
return Err(invalid(
"USB network interface is unavailable or already enslaved",
));
}
let mac = fs::read_to_string(
Path::new("/sys/class/net")
.join(&selected.name)
.join("address"),
)
.map_err(io_error)?;
activate_with(&mut System, selected, usb, mac.trim(), existing_bridge)
}
pub(super) fn deactivate(journal: &Journal) -> Result<()> {
restore_with(&mut System, journal)
}
pub(super) fn recover() -> Result<()> {
let contents = match fs::read(JOURNAL_PATH) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(io_error(error)),
};
let journal: Journal = serde_json::from_slice(&contents).map_err(io_error)?;
restore_with(&mut System, &journal)
}
#[cfg(test)]
mod tests {
use super::*;
struct Fake {
config: Config,
journal: Option<Journal>,
reload_failures: usize,
fail_active_wait: bool,
saved_before_apply: bool,
}
impl Control for Fake {
fn config(&mut self) -> Result<Config> {
Ok(self.config.clone())
}
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()> {
assert_eq!(original, &self.config);
assert!(self.journal.is_some());
self.saved_before_apply = true;
for edit in edits {
match edit {
Edit::Set(key, value) => match key.split_once('.') {
Some((name, key)) => {
self.config
.get_mut(name)
.unwrap()
.insert(key.to_owned(), json!(value));
}
None => {
self.config.insert(
key.clone(),
json!({".type":value}).as_object().unwrap().clone(),
);
}
},
Edit::Delete(key) => {
self.config.remove(key);
}
Edit::AddList(key, value) => {
let (name, key) = key.split_once('.').unwrap();
self.config
.get_mut(name)
.unwrap()
.entry(key.to_owned())
.or_insert(json!([]))
.as_array_mut()
.unwrap()
.push(json!(value));
}
Edit::DelList(key, value) => {
let (name, key) = key.split_once('.').unwrap();
self.config
.get_mut(name)
.unwrap()
.get_mut(key)
.unwrap()
.as_array_mut()
.unwrap()
.retain(|v| v != value);
}
}
}
Ok(())
}
fn reload(&mut self) -> Result<()> {
if self.reload_failures > 0 {
self.reload_failures -= 1;
return Err(invalid("simulated reload failure"));
}
Ok(())
}
fn wait(&mut self, _: &Journal, active: bool) -> Result<()> {
if active && self.fail_active_wait {
Err(invalid("simulated DHCP timeout"))
} else {
Ok(())
}
}
fn save(&mut self, journal: &Journal) -> Result<()> {
assert!(self.journal.is_none());
self.journal = Some(journal.clone());
Ok(())
}
fn clear(&mut self) -> Result<()> {
self.journal = None;
Ok(())
}
}
fn fixture(proto: &str) -> (Fake, NetworkInterfaceInfo) {
let config: Config = serde_json::from_value(json!({
"lan":{".type":"interface", "device":"eth0", "proto":proto, "ipaddr":"192.0.2.5", "dns":["192.0.2.1"], "gateway":"192.0.2.1"},
"lan6":{".type":"interface", "device":"eth0", "proto":"dhcpv6"},
"wan":{".type":"interface", "device":"eth1", "proto":"dhcp"}
})).unwrap();
let selected = NetworkInterfaceInfo {
name: "eth0".into(),
connection: "lan".into(),
interface_type: "netifd".into(),
state: "connected".into(),
addresses: vec!["192.0.2.5/24".into()],
has_default_route: true,
bridge_supported: true,
reason: None,
};
(
Fake {
config,
journal: None,
reload_failures: 0,
fail_active_wait: false,
saved_before_apply: false,
},
selected,
)
}
#[test]
fn ethernet_transaction_keeps_static_ip_dns_routes_ipv6_and_other_networks() {
let (mut fake, selected) = fixture("static");
let original = fake.config.clone();
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
assert!(fake.saved_before_apply);
for name in ["lan", "lan6"] {
let mut expected = original[name].clone();
expected.insert("device".into(), json!(BRIDGE));
assert_eq!(fake.config[name], expected);
}
assert_eq!(fake.config["wan"], original["wan"]);
assert_eq!(
fake.config[&journal.bridge_section]["macaddr"],
"02:00:00:00:00:01"
);
fake.config
.get_mut("wan")
.unwrap()
.insert("metric".into(), json!("50"));
restore_with(&mut fake, &journal).unwrap();
assert_eq!(fake.config["lan"], original["lan"]);
assert_eq!(fake.config["lan6"], original["lan6"]);
assert_eq!(fake.config["wan"]["metric"], "50");
assert!(fake.journal.is_none());
assert!(!fake.config.contains_key(&journal.bridge_section));
}
#[test]
fn existing_bridge_only_adds_and_removes_usb_preserving_new_ports() {
let (mut fake, mut selected) = fixture("dhcp");
selected.name = "br-lan".into();
fake.config
.get_mut("lan")
.unwrap()
.insert("device".into(), json!("br-lan"));
fake.config.insert("cfg123".into(), json!({".type":"device", "name":"br-lan", "type":"bridge", "ports":["eth0"], "stp":"1"}).as_object().unwrap().clone());
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", true).unwrap();
assert!(journal.created_section.is_none());
assert_eq!(fake.config["cfg123"]["ports"], json!(["eth0", "usb0"]));
fake.config
.get_mut("cfg123")
.unwrap()
.get_mut("ports")
.unwrap()
.as_array_mut()
.unwrap()
.push(json!("eth2"));
restore_with(&mut fake, &journal).unwrap();
assert_eq!(fake.config["cfg123"]["ports"], json!(["eth0", "eth2"]));
assert_eq!(fake.config["cfg123"]["stp"], "1");
assert_eq!(fake.config["lan"]["device"], "br-lan");
}
#[test]
fn reload_failure_and_dhcp_timeout_restore_original_before_returning_error() {
for reload_failure in [true, false] {
let (mut fake, selected) = fixture("dhcp");
let original = fake.config.clone();
fake.reload_failures = usize::from(reload_failure);
fake.fail_active_wait = !reload_failure;
assert!(
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).is_err()
);
assert_eq!(fake.config, original);
assert!(fake.journal.is_none());
}
}
#[test]
fn failed_rollback_keeps_journal_and_can_be_retried() {
let (mut fake, selected) = fixture("dhcp");
let original = fake.config.clone();
fake.reload_failures = 2;
let error =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap_err();
assert!(error.to_string().contains("rollback failed"));
let journal = fake.journal.clone().unwrap();
restore_with(&mut fake, &journal).unwrap();
assert_eq!(fake.config, original);
assert!(fake.journal.is_none());
}
#[test]
fn reboot_recovery_accepts_serialized_journal_and_already_restored_config() {
let (mut fake, selected) = fixture("dhcp");
let original = fake.config.clone();
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
let recovered: Journal =
serde_json::from_slice(&serde_json::to_vec(&journal).unwrap()).unwrap();
restore_with(&mut fake, &recovered).unwrap();
fake.journal = Some(recovered.clone());
restore_with(&mut fake, &recovered).unwrap();
assert_eq!(fake.config, original);
}
#[test]
fn externally_changed_interface_or_owned_bridge_is_never_overwritten() {
for change_interface in [true, false] {
let (mut fake, selected) = fixture("dhcp");
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
let (name, key, value) = if change_interface {
("lan", "device", "eth2")
} else {
(journal.bridge_section.as_str(), "stp", "1")
};
fake.config
.get_mut(name)
.unwrap()
.insert(key.into(), json!(value));
let before = fake.config.clone();
assert!(restore_with(&mut fake, &journal).is_err());
assert_eq!(fake.config, before);
assert!(fake.journal.is_some());
}
}
#[test]
fn rejects_ppp_legacy_and_busy_usb_before_writing_journal() {
for kind in 0..3 {
let (mut fake, selected) = fixture(if kind == 0 { "pppoe" } else { "dhcp" });
if kind == 1 {
fake.config
.get_mut("lan")
.unwrap()
.insert("ifname".into(), json!("eth0"));
}
if kind == 2 {
fake.config
.get_mut("wan")
.unwrap()
.insert("device".into(), json!("usb0"));
}
let before = fake.config.clone();
assert!(
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).is_err()
);
assert_eq!(fake.config, before);
assert!(fake.journal.is_none());
}
}
#[test]
fn rejects_vlan_filtered_bridge_without_mutations() {
let (mut fake, mut selected) = fixture("dhcp");
selected.name = "br-lan".into();
fake.config
.get_mut("lan")
.unwrap()
.insert("device".into(), json!("br-lan"));
fake.config.insert(
"bridge".into(),
json!({".type":"device", "name":"br-lan", "type":"bridge", "ports":["eth0"]})
.as_object()
.unwrap()
.clone(),
);
fake.config.insert(
"vlan".into(),
json!({".type":"bridge-vlan", "device":"br-lan", "vlan":"1"})
.as_object()
.unwrap()
.clone(),
);
assert!(activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", true).is_err());
assert!(fake.journal.is_none());
}
}

View File

@@ -0,0 +1,74 @@
//! Select OpenWrt netifd or a running NetworkManager, with an explicit override.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NetworkBackend {
NetworkManager,
Netifd,
}
pub(crate) fn select_backend(
requested: Option<&str>,
openwrt_release_present: bool,
) -> Result<NetworkBackend, String> {
match requested.unwrap_or("auto") {
"auto" if openwrt_release_present => Ok(NetworkBackend::Netifd),
"auto" | "networkmanager" => Ok(NetworkBackend::NetworkManager),
"netifd" => Ok(NetworkBackend::Netifd),
value => Err(format!(
"Unknown ONE_KVM_NETWORK_BACKEND={value}; expected auto, networkmanager or netifd"
)),
}
}
pub(crate) fn current_backend() -> Result<NetworkBackend, String> {
let requested = std::env::var("ONE_KVM_NETWORK_BACKEND").ok();
let backend = select_backend(
requested.as_deref(),
std::path::Path::new("/etc/openwrt_release").is_file(),
)?;
if backend == NetworkBackend::NetworkManager {
let status = std::process::Command::new("nmcli")
.env("LC_ALL", "C")
.args(["-t", "-f", "RUNNING", "general"])
.output()
.map_err(|_| {
"OTG bridging requires NetworkManager and nmcli on this system".to_string()
})?;
if !status.status.success() || String::from_utf8_lossy(&status.stdout).trim() != "running" {
return Err("NetworkManager is not running; OTG bridging cannot manage this system's interfaces".to_string());
}
}
Ok(backend)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_selection_respects_openwrt_marker() {
assert_eq!(select_backend(None, true), Ok(NetworkBackend::Netifd));
assert_eq!(
select_backend(Some("auto"), false),
Ok(NetworkBackend::NetworkManager)
);
}
#[test]
fn explicit_selection_is_respected() {
assert_eq!(
select_backend(Some("networkmanager"), true),
Ok(NetworkBackend::NetworkManager)
);
assert_eq!(
select_backend(Some("netifd"), false),
Ok(NetworkBackend::Netifd)
);
}
#[test]
fn unknown_selection_is_not_silently_ignored() {
assert!(select_backend(Some("typo"), true).is_err());
assert!(select_backend(Some(""), false).is_err());
}
}

View File

@@ -149,8 +149,11 @@ fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool {
/// OTG self-check status for troubleshooting USB gadget issues
pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg);
let gadget_expected = hid_backend_is_otg || config.msd.enabled || config.otg_network.enabled;
let hid_backend_is_otg = config.hid.backend.uses_otg_hid();
let gadget_expected = hid_backend_is_otg
|| config.msd.enabled
|| config.otg_network.enabled
|| config.uac.enabled;
let mut checks = Vec::new();
let build_response = |checks: Vec<OtgSelfCheckItem>,
@@ -173,7 +176,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
overall_ok: error_count == 0,
error_count,
warning_count,
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
hid_backend: config.hid.backend.as_str().to_string(),
selected_udc,
bound_udc,
udc_state,
@@ -415,7 +418,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
OtgSelfCheckLevel::Warn
},
"Check one-kvm gadget presence",
Some("Enable OTG HID, MSD, or USB Ethernet to create the one-kvm gadget"),
Some("Enable OTG HID, MSD, USB Ethernet, or USB audio to create the one-kvm gadget"),
Some(one_kvm_path.display().to_string()),
);
}
@@ -459,7 +462,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
false,
OtgSelfCheckLevel::Warn,
"Check one-kvm UDC binding",
Some("Ensure HID/MSD is enabled and initialized successfully"),
Some("Ensure an OTG function is enabled and initialized successfully"),
Some(one_kvm_udc_path.display().to_string()),
);
} else {
@@ -494,18 +497,19 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
})
.cloned()
.collect::<Vec<_>>();
if hid_functions.is_empty() {
let hid_functions_ok = hid_backend_is_otg != hid_functions.is_empty();
if !hid_functions_ok {
push_otg_check(
&mut checks,
"hid_functions_present",
false,
if hid_backend_is_otg {
OtgSelfCheckLevel::Error
OtgSelfCheckLevel::Error,
"Check OTG HID function selection",
Some(if hid_backend_is_otg {
"Check OTG HID config and enable at least one HID function"
} else {
OtgSelfCheckLevel::Warn
},
"Check HID function creation",
Some("Check OTG HID config and enable at least one HID function"),
"OTG HID must be disabled when input is handled by another backend"
}),
Some(functions_path.display().to_string()),
);
} else {
@@ -514,7 +518,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
"hid_functions_present",
true,
OtgSelfCheckLevel::Info,
"Check HID function creation",
"Check OTG HID function selection",
None::<String>,
Some(functions_path.display().to_string()),
);
@@ -569,7 +573,13 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
.map(|name| name.to_string_lossy().to_string())
})
.is_some_and(|name| {
name == "okvm-br0" || name == config.otg_network.bridge_interface
name == "okvm-br0"
|| name == config.otg_network.bridge_interface
|| (config.otg_network.bridge_interface.is_empty()
&& std::path::Path::new("/sys/class/net")
.join(name)
.join("bridge")
.is_dir())
});
push_otg_check(
&mut checks,
@@ -841,7 +851,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
overall_ok: error_count == 0,
error_count,
warning_count,
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(),
hid_backend: config.hid.backend.as_str().to_string(),
selected_udc,
bound_udc,
udc_state,

View File

@@ -8,8 +8,7 @@ use super::bridge::NetworkBridgeRuntime;
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
use crate::config::{
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
UacConfig,
HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, UacConfig,
};
use crate::error::{AppError, Result};
@@ -40,6 +39,17 @@ pub struct OtgNetworkStatus {
pub error: Option<String>,
}
#[typeshare]
#[derive(Debug, Clone, serde::Serialize)]
pub struct OtgRuntimeStatus {
pub enabled: bool,
pub health: OtgRuntimeHealth,
pub gadget_active: bool,
pub udc: Option<String>,
pub udc_state: Option<String>,
pub error: Option<String>,
}
impl HidDevicePaths {
pub fn existing_paths(&self) -> Vec<PathBuf> {
[
@@ -93,7 +103,7 @@ impl OtgDesiredState {
network.validate()?;
uac.validate()?;
msd.validate()?;
let hid_functions = if hid.backend == HidBackend::Otg {
let hid_functions = if hid.backend.uses_otg_hid() {
let functions = hid.constrained_otg_functions();
Some(functions)
} else {
@@ -107,7 +117,13 @@ impl OtgDesiredState {
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(OtgGadgetManager::find_udc)
.or_else(|| {
if hid.backend.uses_ch9329() {
None
} else {
OtgGadgetManager::find_udc()
}
})
} else {
None
};
@@ -249,6 +265,25 @@ impl OtgService {
}
}
pub async fn runtime_status(&self, enabled: bool) -> OtgRuntimeStatus {
let state = self.state.read().await;
let udc_state = state.configured_udc.as_ref().and_then(|udc| {
// Only UDC names, never a path supplied through a configuration file.
if udc.contains('/') || udc == "." || udc == ".." {
return None;
}
crate::utils::read_trimmed(&PathBuf::from(format!("/sys/class/udc/{udc}/state")))
});
OtgRuntimeStatus {
enabled,
health: state.health,
gadget_active: state.gadget_active,
udc: state.configured_udc.clone(),
udc_state,
error: state.error.clone(),
}
}
pub async fn apply_config(
&self,
hid: &HidConfig,
@@ -687,6 +722,38 @@ impl From<&OtgDescriptorConfig> for GadgetDescriptor {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::HidBackend;
#[test]
fn composite_gadget_supports_every_advanced_function_combination_without_hid() {
let hid = HidConfig {
backend: HidBackend::Ch9329Otg,
otg_udc: Some("test.udc".into()),
otg_keyboard_leds: true,
..Default::default()
};
for mask in 0..8 {
let msd = MsdConfig {
enabled: mask & 1 != 0,
..Default::default()
};
let network = OtgNetworkConfig {
enabled: mask & 2 != 0,
..Default::default()
};
let uac = UacConfig {
enabled: mask & 4 != 0,
..Default::default()
};
let desired = OtgDesiredState::from_config(&hid, &msd, &network, &uac).unwrap();
assert!(!desired.hid_enabled());
assert!(!desired.keyboard_leds);
assert_eq!(desired.msd_enabled, msd.enabled);
assert_eq!(desired.network_enabled(), network.enabled);
assert_eq!(desired.uac.enabled, uac.enabled);
assert_eq!(desired.udc.is_some(), mask != 0);
}
}
#[test]
fn service_new_and_availability_probe() {

View File

@@ -10,7 +10,7 @@ pub fn capabilities() -> PlatformCapabilities {
encoder: FeatureCapability::available([
"software", "vaapi", "nvenc", "qsv", "amf", "rkmpp", "v4l2m2m",
]),
hid: FeatureCapability::available(["otg", "ch9329", "none"]),
hid: FeatureCapability::available(["otg", "ch9329", "ch9329_otg", "bluetooth", "none"]),
atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]),
msd: FeatureCapability::available(["configfs"]),
otg: FeatureCapability::available(["configfs"]),

View File

@@ -33,14 +33,6 @@ impl Status {
}
}
pub fn enabled_health(health: &str) -> Self {
Self {
state: "Enabled".to_string(),
health: health.to_string(),
health_rollup: None,
}
}
pub fn disabled_ok() -> Self {
Self {
state: "Disabled".to_string(),
@@ -48,14 +40,6 @@ impl Status {
health_rollup: None,
}
}
pub fn offline_ok() -> Self {
Self {
state: "Offline".to_string(),
health: "OK".to_string(),
health_rollup: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
@@ -626,16 +610,6 @@ impl RedfishError {
}
}
pub fn property_missing(property: &str) -> Self {
Self {
error: RedfishErrorBody {
code: "Base.1.18.PropertyMissing".to_string(),
message: format!("Property '{}' is required", property),
extended_info: vec![],
},
}
}
pub fn service_unavailable(msg: &str) -> Self {
Self {
error: RedfishErrorBody {

View File

@@ -6,8 +6,7 @@ use tokio::sync::broadcast;
use crate::atx::AtxController;
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
use crate::auth::{SessionStore, TwoFactorService, UserStore};
use crate::computer_use::ComputerUseManager;
use crate::config::{self, AppConfig, ConfigStore};
use crate::config::{AppConfig, ConfigStore};
use crate::db::{open_database_pool, DatabasePool};
use crate::events::EventBus;
use crate::extensions::ExtensionManager;
@@ -82,6 +81,7 @@ impl RuntimeBuilder {
web_overrides,
} = self;
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
config.hid.validate_platform()?;
web_overrides.apply(&mut config);
let sessions = SessionStore::new(config.auth.session_timeout_secs as i64);
@@ -107,6 +107,7 @@ impl RuntimeBuilder {
#[cfg(target_os = "linux")]
hid.set_bond_store(config_store.hid_bonds());
hid.set_event_bus(events.clone()).await;
hid.configure_jiggler(&config.hid);
hid.set_screen_resolution(video_resolution.width, video_resolution.height)
.await;
if let Err(error) = hid.init().await {
@@ -152,7 +153,6 @@ impl RuntimeBuilder {
);
}
let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone());
let state = AppState::new(
db,
config_store.clone(),
@@ -164,7 +164,6 @@ impl RuntimeBuilder {
stream_manager,
webrtc,
hid,
computer_use,
#[cfg(unix)]
msd,
atx,
@@ -403,26 +402,13 @@ async fn build_otg(config: &AppConfig) -> Arc<OtgService> {
.await
{
tracing::warn!("Failed to apply OTG config: {}", error);
service.mark_degraded(error.to_string()).await;
}
service
}
fn hid_backend_type(config: &AppConfig) -> HidBackendType {
match config.hid.backend {
config::HidBackend::Otg => HidBackendType::Otg {
macos_drag: config.hid.mouse_macos_drag,
},
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
port: config.hid.ch9329_port.clone(),
baud_rate: config.hid.ch9329_baudrate,
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
macos_drag: config.hid.mouse_macos_drag,
},
config::HidBackend::None => HidBackendType::None,
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
config: config.hid.bluetooth.clone(),
},
}
HidBackendType::from(&config.hid)
}
#[cfg(unix)]

View File

@@ -2,7 +2,10 @@ use std::sync::Arc;
use tokio::sync::{Mutex, OwnedMutexGuard};
use crate::config::{AtxConfig, AudioConfig, StreamConfig, VideoConfig};
use crate::error::{AppError, Result};
use crate::state::AppState;
use crate::stream_encoder::encoder_type_to_backend;
#[derive(Debug, Clone, Copy, Default)]
pub struct ConfigApplyOptions {
@@ -12,14 +15,6 @@ pub struct ConfigApplyOptions {
}
impl ConfigApplyOptions {
pub const fn forced() -> Self {
Self {
force: true,
preserve_service_state: false,
runtime_only: false,
}
}
pub const fn preserving_service_state() -> Self {
Self {
force: false,
@@ -42,3 +37,148 @@ pub fn try_apply_lock(lock: &Arc<Mutex<()>>, domain: &str) -> Result<OwnedMutexG
AppError::ServiceUnavailable(format!("{domain} configuration is already applying"))
})
}
pub(crate) async fn apply_video_config(state: &AppState, new_config: &VideoConfig) -> Result<()> {
tracing::info!("Applying video config changes...");
let device = new_config
.device
.as_deref()
.ok_or_else(|| AppError::BadRequest("video_device is required".to_string()))?;
let format = new_config
.format
.as_ref()
.and_then(|f| {
serde_json::from_value::<crate::video::format::PixelFormat>(serde_json::Value::String(
f.clone(),
))
.ok()
})
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
state
.stream_manager
.apply_video_config(device, format, resolution, new_config.fps)
.await
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
tracing::info!("Video config applied successfully");
Ok(())
}
pub(crate) async fn apply_stream_config(
state: &AppState,
old_config: &StreamConfig,
new_config: &StreamConfig,
options: ConfigApplyOptions,
) -> Result<()> {
tracing::info!("Applying stream config changes...");
if options.force || old_config.encoder != new_config.encoder {
let encoder_backend = encoder_type_to_backend(new_config.encoder.clone());
tracing::info!(
"Updating encoder backend to: {:?} (from config: {:?})",
encoder_backend,
new_config.encoder
);
state.webrtc.update_encoder_backend(encoder_backend).await;
}
if options.force || old_config.bitrate_preset != new_config.bitrate_preset {
state
.stream_manager
.set_bitrate_preset(new_config.bitrate_preset)
.await?;
}
let ice_changed = old_config.stun_server != new_config.stun_server
|| old_config.turn_server != new_config.turn_server
|| old_config.turn_username != new_config.turn_username
|| old_config.turn_password != new_config.turn_password;
if options.force || ice_changed {
tracing::info!(
"Updating ICE config: STUN={:?}, TURN={:?}",
new_config.stun_server,
new_config.turn_server
);
state
.webrtc
.update_ice_config(
new_config.stun_server.clone(),
new_config.turn_server.clone(),
new_config.turn_username.clone(),
new_config.turn_password.clone(),
)
.await;
}
tracing::info!(
"Stream config applied: encoder={:?}, bitrate={}",
new_config.encoder,
new_config.bitrate_preset
);
Ok(())
}
pub(crate) async fn apply_atx_config(state: &AppState, new_config: &AtxConfig) -> Result<()> {
tracing::info!("Applying ATX config changes...");
let controller_config = new_config.to_controller_config();
let atx_guard = state.atx.read().await;
if let Some(atx) = atx_guard.as_ref() {
if let Err(e) = atx.reload(controller_config).await {
tracing::error!("ATX reload failed: {}", e);
return Err(AppError::Config(format!("ATX reload failed: {}", e)));
}
tracing::info!("ATX controller reloaded successfully");
} else {
drop(atx_guard);
if new_config.enabled {
tracing::info!("ATX enabled in config, initializing...");
let atx = crate::atx::AtxController::new(controller_config);
atx.init()
.await
.map_err(|e| AppError::Config(format!("ATX initialization failed: {}", e)))?;
*state.atx.write().await = Some(atx);
tracing::info!("ATX controller initialized successfully");
}
}
Ok(())
}
pub(crate) async fn apply_audio_config(state: &AppState, new_config: &AudioConfig) -> Result<()> {
tracing::info!("Applying audio config changes...");
let audio_config = crate::audio::AudioControllerConfig {
enabled: new_config.enabled,
device: new_config.device.clone(),
quality: new_config.quality.parse::<crate::audio::AudioQuality>()?,
};
state.audio.update_config(audio_config).await?;
tracing::info!(
"Audio config applied: enabled={}, device={}",
new_config.enabled,
new_config.device
);
state
.stream_manager
.set_webrtc_audio_enabled(new_config.enabled)
.await?;
tracing::debug!("WebRTC audio enabled: {}", new_config.enabled);
if new_config.enabled {
state.stream_manager.reconnect_webrtc_audio_sources().await;
}
Ok(())
}

View File

@@ -5,6 +5,9 @@ mod supervisor;
mod usb;
pub use builder::{ApplicationRuntime, RuntimeBuilder, WebConfigOverrides};
pub(crate) use config_apply::{
apply_atx_config, apply_audio_config, apply_stream_config, apply_video_config,
};
pub use config_apply::{try_apply_lock, ConfigApplyOptions};
pub use remote_access::{RemoteAccessCoordinator, RustDeskRuntimeStatus};
pub use usb::UsbCoordinator;

View File

@@ -219,7 +219,7 @@ impl RemoteAccessCoordinator {
tracing::info!("RustDesk service stopped");
}
*self.rustdesk.write().await = None;
} else if !options.preserve_service_state && new_config.enabled {
} else if !options.preserve_service_state {
match current {
None => {
tracing::info!("Initializing RustDesk service...");
@@ -258,7 +258,7 @@ impl RemoteAccessCoordinator {
credentials_to_save = service.save_credentials();
}
}
} else if options.preserve_service_state && need_restart {
} else if need_restart {
if let Some(service) = current {
let mut runtime_config = new_config.clone();
runtime_config.enabled = true;
@@ -336,7 +336,7 @@ impl RemoteAccessCoordinator {
service.stop().await?;
}
*self.vnc.write().await = None;
} else if !options.preserve_service_state && new_config.enabled {
} else if !options.preserve_service_state {
match current {
None => {
let service = Arc::new(VncService::new(
@@ -360,7 +360,7 @@ impl RemoteAccessCoordinator {
}
}
}
} else if options.preserve_service_state && need_restart {
} else if need_restart {
if let Some(service) = current {
let mut runtime_config = new_config.clone();
runtime_config.enabled = true;
@@ -399,7 +399,7 @@ impl RemoteAccessCoordinator {
})?;
}
*self.rtsp.write().await = None;
} else if !options.preserve_service_state && new_config.enabled {
} else if !options.preserve_service_state {
match current {
None => {
let service = Arc::new(RtspService::new(
@@ -422,7 +422,7 @@ impl RemoteAccessCoordinator {
}
}
}
} else if options.preserve_service_state && need_restart {
} else if need_restart {
if let Some(service) = current {
let mut runtime_config = new_config.clone();
runtime_config.enabled = true;

View File

@@ -51,24 +51,33 @@ impl UsbCoordinator {
}
pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> {
new_config.hid.validate_platform()?;
// Also runs for config-only updates and rollback; never forces a USB reload.
self.hid.configure_jiggler(&new_config.hid);
#[cfg(unix)]
{
let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg
&& new_config.hid.backend != HidBackend::Otg;
let hid_unchanged = old_config.hid == new_config.hid;
let gadget_rebuilt = old_config.msd != new_config.msd
|| old_config.otg_network != new_config.otg_network
|| old_config.uac != new_config.uac
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|| old_config.hid.backend != new_config.hid.backend
|| old_config.hid.constrained_otg_functions()
!= new_config.hid.constrained_otg_functions()
|| old_config.hid.effective_otg_keyboard_leds()
!= new_config.hid.effective_otg_keyboard_leds();
let hid_unchanged = !hid_requires_reload(&old_config.hid, &new_config.hid);
let gadget_rebuilt = crate::otg::service::OtgDesiredState::from_config(
&old_config.hid,
&old_config.msd,
&old_config.otg_network,
&old_config.uac,
)? != crate::otg::service::OtgDesiredState::from_config(
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
)?;
let restart_uac =
old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt);
if gadget_rebuilt {
// This is a no-op for CH9329, including the composite mode.
self.hid.prepare_otg_rebuild().await?;
}
if restart_uac {
let playback = self.uac_playback.write().await.take();
if let Some(playback) = playback {
@@ -167,29 +176,11 @@ impl UsbCoordinator {
uac_config: &UacConfig,
options: ConfigApplyOptions,
) -> Result<()> {
new_config.validate_platform()?;
new_config.validate_otg_functions()?;
new_config.bluetooth.validate()?;
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let hid_functions_changed =
old_config.constrained_otg_functions() != new_config.constrained_otg_functions();
let keyboard_leds_changed =
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
let mouse_compatibility_changed = old_config.ch9329_hybrid_mouse
!= new_config.ch9329_hybrid_mouse
|| old_config.mouse_macos_drag != new_config.mouse_macos_drag;
if old_config.backend == new_config.backend
&& old_config.ch9329_port == new_config.ch9329_port
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
&& old_config.bluetooth == new_config.bluetooth
&& !mouse_compatibility_changed
&& old_config.otg_udc == new_config.otg_udc
&& !descriptor_changed
&& !hid_functions_changed
&& !keyboard_leds_changed
&& !options.force
{
if !hid_requires_reload(old_config, new_config) && !options.force {
tracing::info!("HID config unchanged, skipping reload");
return Ok(());
}
@@ -256,7 +247,7 @@ impl UsbCoordinator {
options: ConfigApplyOptions,
) -> Result<()> {
let old_enabled = old_config.enabled;
let new_enabled = new_config.enabled && hid_config.backend == HidBackend::Otg;
let new_enabled = new_config.enabled && hid_config.backend.supports_otg_functions();
let directory_changed = old_config.msd_dir != new_config.msd_dir;
let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string
|| old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string;
@@ -309,28 +300,98 @@ impl UsbCoordinator {
}
fn hid_backend_type(config: &HidConfig) -> HidBackendType {
match config.backend {
HidBackend::Otg => HidBackendType::Otg {
macos_drag: config.mouse_macos_drag,
},
HidBackend::Ch9329 => HidBackendType::Ch9329 {
port: config.ch9329_port.clone(),
baud_rate: config.ch9329_baudrate,
hybrid_mouse: config.ch9329_hybrid_mouse,
macos_drag: config.mouse_macos_drag,
},
HidBackend::None => HidBackendType::None,
HidBackend::Bluetooth => HidBackendType::Bluetooth {
config: config.bluetooth.clone(),
},
}
HidBackendType::from(config)
}
fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool {
old_config.backend == HidBackend::Otg
|| new_config.backend == HidBackend::Otg
|| old_config.otg_udc != new_config.otg_udc
|| old_config.otg_descriptor != new_config.otg_descriptor
|| old_config.constrained_otg_functions() != new_config.constrained_otg_functions()
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds()
fn hid_requires_reload(old: &HidConfig, new: &HidConfig) -> bool {
HidBackendType::from(old) != HidBackendType::from(new)
|| (new.backend.uses_otg_hid() && hid_otg_config_changed(old, new))
}
fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool {
old.backend.uses_otg_hid() != new.backend.uses_otg_hid()
|| old.otg_udc != new.otg_udc
|| old.otg_descriptor != new.otg_descriptor
|| old.constrained_otg_functions() != new.constrained_otg_functions()
|| old.effective_otg_keyboard_leds() != new.effective_otg_keyboard_leds()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jiggler_preferences_do_not_reload_hid_or_change_usb_descriptors() {
let old = AppConfig::default();
let mut new = old.clone();
new.hid.jiggler.enabled = true;
new.hid.jiggler.interval_secs = 120;
assert!(!hid_requires_reload(&old.hid, &new.hid));
#[cfg(unix)]
assert_eq!(
crate::otg::service::OtgDesiredState::from_config(
&old.hid,
&old.msd,
&old.otg_network,
&old.uac
)
.unwrap(),
crate::otg::service::OtgDesiredState::from_config(
&new.hid,
&new.msd,
&new.otg_network,
&new.uac
)
.unwrap()
);
}
#[test]
fn composite_otg_preferences_do_not_reload_ch9329_but_input_changes_do() {
let old = HidConfig {
backend: HidBackend::Ch9329Otg,
otg_udc: Some("first.udc".into()),
..Default::default()
};
let mut new = old.clone();
new.otg_udc = Some("second.udc".into());
new.otg_descriptor.product = "Advanced USB".into();
new.otg_keyboard_leds = !old.otg_keyboard_leds;
new.otg_profile = crate::config::OtgHidProfile::LegacyKeyboard;
assert!(!hid_requires_reload(&old, &new));
assert!(!hid_requires_reload(&new, &old));
let input_changes: [fn(&mut HidConfig); 4] = [
|hid: &mut HidConfig| hid.ch9329_port.push('1'),
|hid: &mut HidConfig| hid.ch9329_baudrate = 115200,
|hid: &mut HidConfig| hid.ch9329_hybrid_mouse = !hid.ch9329_hybrid_mouse,
|hid: &mut HidConfig| hid.mouse_macos_drag = !hid.mouse_macos_drag,
];
for change in input_changes {
let mut changed = old.clone();
change(&mut changed);
assert!(hid_requires_reload(&old, &changed));
assert!(hid_requires_reload(&changed, &old));
}
}
#[test]
fn composite_mode_switch_only_reloads_when_input_driver_changes() {
let composite = HidConfig {
backend: HidBackend::Ch9329Otg,
otg_udc: Some("test.udc".into()),
..Default::default()
};
for (backend, reload) in [
(HidBackend::Ch9329, false),
(HidBackend::Otg, true),
(HidBackend::Bluetooth, true),
(HidBackend::None, true),
] {
let mut other = composite.clone();
other.backend = backend;
assert_eq!(hid_requires_reload(&composite, &other), reload);
assert_eq!(hid_requires_reload(&other, &composite), reload);
}
}
}

View File

@@ -1,59 +1,26 @@
//! Variable-length TCP framing (RustDesk wire format).
use bytes::{Buf, BufMut, Bytes, BytesMut};
use bytes::BytesMut;
use std::io::{self, IoSlice};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
const MAX_PACKET_LENGTH: usize = 0x3FFFFFFF;
pub fn encode_frame(data: &[u8]) -> io::Result<Vec<u8>> {
let len = data.len();
let mut buf = Vec::with_capacity(len + 4);
if len <= 0x3F {
buf.push((len << 2) as u8);
} else if len <= 0x3FFF {
let h = ((len << 2) as u16) | 0x1;
buf.extend_from_slice(&h.to_le_bytes());
} else if len <= 0x3FFFFF {
let h = ((len << 2) as u32) | 0x2;
buf.push((h & 0xFF) as u8);
buf.push(((h >> 8) & 0xFF) as u8);
buf.push(((h >> 16) & 0xFF) as u8);
} else if len <= MAX_PACKET_LENGTH {
let h = ((len << 2) as u32) | 0x3;
buf.extend_from_slice(&h.to_le_bytes());
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Message too large",
));
}
buf.extend_from_slice(data);
Ok(buf)
}
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.is_empty() {
n |= (header_bytes[0] as usize) << 8;
}
if head_len > 2 && header_bytes.len() >= 2 {
n |= (header_bytes[1] as usize) << 16;
}
if head_len > 3 && header_bytes.len() >= 3 {
n |= (header_bytes[2] as usize) << 24;
}
let msg_len = n >> 2;
(head_len, msg_len)
}
pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<BytesMut> {
read_frame_with_limit(reader, MAX_PACKET_LENGTH).await
fn encode_header(len: usize) -> io::Result<([u8; 4], usize)> {
let header_len = match len {
0..=0x3F => 1,
0x40..=0x3FFF => 2,
0x4000..=0x3FFFFF => 3,
0x400000..=MAX_PACKET_LENGTH => 4,
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Message too large",
));
}
};
let header = (((len as u32) << 2) | (header_len as u32 - 1)).to_le_bytes();
Ok((header, header_len))
}
/// Read one framed message while enforcing a caller-selected allocation limit.
@@ -65,17 +32,11 @@ pub async fn read_frame_with_limit<R: AsyncRead + Unpin>(
reader: &mut R,
max_packet_length: usize,
) -> io::Result<BytesMut> {
let mut first_byte = [0u8; 1];
reader.read_exact(&mut first_byte).await?;
let head_len = ((first_byte[0] & 0x3) + 1) as usize;
let mut header_rest = [0u8; 3];
if head_len > 1 {
reader.read_exact(&mut header_rest[..head_len - 1]).await?;
}
let (_, msg_len) = decode_header(first_byte[0], &header_rest);
let mut header = [0u8; 4];
reader.read_exact(&mut header[..1]).await?;
let header_len = ((header[0] & 0x3) + 1) as usize;
reader.read_exact(&mut header[1..header_len]).await?;
let msg_len = (u32::from_le_bytes(header) >> 2) as usize;
if msg_len > max_packet_length {
return Err(io::Error::new(
@@ -92,8 +53,7 @@ pub async fn read_frame_with_limit<R: AsyncRead + Unpin>(
}
pub async fn write_frame<W: AsyncWrite + Unpin>(writer: &mut W, data: &[u8]) -> io::Result<()> {
let frame = encode_frame(data)?;
writer.write_all(&frame).await?;
write_frame_vectored(writer, data).await?;
writer.flush().await?;
Ok(())
}
@@ -104,29 +64,7 @@ pub async fn write_frame_vectored<W: AsyncWrite + Unpin>(
writer: &mut W,
data: &[u8],
) -> io::Result<()> {
let len = data.len();
let mut header = [0u8; 4];
let header_len = if len <= 0x3F {
header[0] = (len << 2) as u8;
1
} else if len <= 0x3FFF {
header[..2].copy_from_slice(&(((len << 2) as u16) | 0x1).to_le_bytes());
2
} else if len <= 0x3FFFFF {
let value = ((len << 2) as u32) | 0x2;
header[0] = (value & 0xFF) as u8;
header[1] = ((value >> 8) & 0xFF) as u8;
header[2] = ((value >> 16) & 0xFF) as u8;
3
} else if len <= MAX_PACKET_LENGTH {
header.copy_from_slice(&(((len << 2) as u32) | 0x3).to_le_bytes());
4
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Message too large",
));
};
let (header, header_len) = encode_header(data.len())?;
let slices = [IoSlice::new(&header[..header_len]), IoSlice::new(data)];
let written = writer.write_vectored(&slices).await?;
@@ -145,214 +83,148 @@ pub async fn write_frame_vectored<W: AsyncWrite + Unpin>(
Ok(())
}
pub async fn write_frame_buffered<W: AsyncWrite + Unpin>(
writer: &mut W,
data: &[u8],
buf: &mut BytesMut,
) -> io::Result<()> {
buf.clear();
encode_frame_into(data, buf)?;
writer.write_all(buf).await?;
writer.flush().await?;
Ok(())
}
pub fn encode_frame_into(data: &[u8], buf: &mut BytesMut) -> io::Result<()> {
let len = data.len();
buf.reserve(4 + len);
if len <= 0x3F {
buf.put_u8((len << 2) as u8);
} else if len <= 0x3FFF {
buf.put_u16_le(((len << 2) as u16) | 0x1);
} else if len <= 0x3FFFFF {
let h = ((len << 2) as u32) | 0x2;
buf.put_u8((h & 0xFF) as u8);
buf.put_u8(((h >> 8) & 0xFF) as u8);
buf.put_u8(((h >> 16) & 0xFF) as u8);
} else if len <= MAX_PACKET_LENGTH {
buf.put_u32_le(((len << 2) as u32) | 0x3);
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Message too large",
));
}
buf.extend_from_slice(data);
Ok(())
}
/// Stateful decoder for `Framed`.
#[derive(Debug, Clone, Copy)]
pub struct BytesCodec {
state: DecodeState,
max_packet_length: usize,
}
#[derive(Debug, Clone, Copy)]
enum DecodeState {
Head,
Data(usize),
}
impl Default for BytesCodec {
fn default() -> Self {
Self::new()
}
}
impl BytesCodec {
pub fn new() -> Self {
Self {
state: DecodeState::Head,
max_packet_length: MAX_PACKET_LENGTH,
}
}
pub fn set_max_packet_length(&mut self, n: usize) {
self.max_packet_length = n;
}
pub fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<BytesMut>> {
let n = match self.state {
DecodeState::Head => match self.decode_head(src)? {
Some(n) => {
self.state = DecodeState::Data(n);
n
}
None => return Ok(None),
},
DecodeState::Data(n) => n,
};
match self.decode_data(n, src)? {
Some(data) => {
self.state = DecodeState::Head;
Ok(Some(data))
}
None => Ok(None),
}
}
fn decode_head(&mut self, src: &mut BytesMut) -> io::Result<Option<usize>> {
if src.is_empty() {
return Ok(None);
}
let head_len = ((src[0] & 0x3) + 1) as usize;
if src.len() < head_len {
return Ok(None);
}
let mut n = src[0] as usize;
if head_len > 1 {
n |= (src[1] as usize) << 8;
}
if head_len > 2 {
n |= (src[2] as usize) << 16;
}
if head_len > 3 {
n |= (src[3] as usize) << 24;
}
n >>= 2;
if n > self.max_packet_length {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Message too large",
));
}
src.advance(head_len);
Ok(Some(n))
}
fn decode_data(&self, n: usize, src: &mut BytesMut) -> io::Result<Option<BytesMut>> {
if src.len() < n {
return Ok(None);
}
Ok(Some(src.split_to(n)))
}
pub fn encode(&mut self, data: Bytes, buf: &mut BytesMut) -> io::Result<()> {
let len = data.len();
if len <= 0x3F {
buf.put_u8((len << 2) as u8);
} else if len <= 0x3FFF {
buf.put_u16_le(((len << 2) as u16) | 0x1);
} else if len <= 0x3FFFFF {
let h = ((len << 2) as u32) | 0x2;
buf.put_u16_le((h & 0xFFFF) as u16);
buf.put_u8((h >> 16) as u8);
} else if len <= MAX_PACKET_LENGTH {
buf.put_u32_le(((len << 2) as u32) | 0x3);
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Message too large",
));
}
buf.extend(data);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::pin::Pin;
use std::task::{Context, Poll};
#[test]
fn test_encode_decode_small() {
let data = vec![1u8; 63];
let encoded = encode_frame(&data).unwrap();
assert_eq!(encoded.len(), 63 + 1);
struct ShortWriter {
bytes: Vec<u8>,
max_write: usize,
}
let mut codec = BytesCodec::new();
let mut buf = BytesMut::from(&encoded[..]);
let decoded = codec.decode(&mut buf).unwrap().unwrap();
assert_eq!(decoded.len(), 63);
impl AsyncWrite for ShortWriter {
fn poll_write(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let len = buf.len().min(self.max_write);
self.bytes.extend_from_slice(&buf[..len]);
Poll::Ready(Ok(len))
}
fn poll_write_vectored(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<io::Result<usize>> {
let mut written = 0;
for buf in bufs {
let len = buf.len().min(self.max_write - written);
self.bytes.extend_from_slice(&buf[..len]);
written += len;
}
Poll::Ready(Ok(written))
}
fn is_write_vectored(&self) -> bool {
true
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[test]
fn test_encode_decode_medium() {
let data = vec![2u8; 1000];
let encoded = encode_frame(&data).unwrap();
assert_eq!(encoded.len(), 1000 + 2);
let mut codec = BytesCodec::new();
let mut buf = BytesMut::from(&encoded[..]);
let decoded = codec.decode(&mut buf).unwrap().unwrap();
assert_eq!(decoded.len(), 1000);
fn header_boundaries_match_wire_format() {
for (len, expected) in [
(0, &[0x00][..]),
(63, &[0xfc][..]),
(64, &[0x01, 0x01][..]),
(16383, &[0xfd, 0xff][..]),
(16384, &[0x02, 0x00, 0x01][..]),
(4194303, &[0xfe, 0xff, 0xff][..]),
(4194304, &[0x03, 0x00, 0x00, 0x01][..]),
(MAX_PACKET_LENGTH, &[0xff, 0xff, 0xff, 0xff][..]),
] {
let (header, header_len) = encode_header(len).unwrap();
assert_eq!(&header[..header_len], expected, "payload length {len}");
}
assert_eq!(
encode_header(MAX_PACKET_LENGTH + 1).unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn test_encode_decode_large() {
let data = vec![3u8; 100000];
let encoded = encode_frame(&data).unwrap();
assert_eq!(encoded.len(), 100000 + 3);
let mut codec = BytesCodec::new();
let mut buf = BytesMut::from(&encoded[..]);
let decoded = codec.decode(&mut buf).unwrap().unwrap();
assert_eq!(decoded.len(), 100000);
#[tokio::test]
async fn frame_round_trips_at_header_boundaries() {
for len in [0, 63, 64, 16383, 16384, 4194303, 4194304] {
let payload = vec![0x5a; len];
let mut encoded = Vec::new();
write_frame(&mut encoded, &payload).await.unwrap();
let mut reader = encoded.as_slice();
let decoded = read_frame_with_limit(&mut reader, len).await.unwrap();
assert_eq!(decoded.as_ref(), payload.as_slice());
assert!(reader.is_empty());
}
}
#[tokio::test]
async fn read_limit_rejects_length_before_allocating_payload() {
let encoded = encode_frame(&vec![0u8; 1024]).unwrap();
let (mut writer, mut reader) = tokio::io::duplex(encoded.len());
tokio::spawn(async move {
writer.write_all(&encoded).await.unwrap();
});
// A 1024-byte length header with no payload must fail the limit check,
// rather than attempt to read the missing payload and return EOF.
let mut reader = &[0x01, 0x10][..];
let error = read_frame_with_limit(&mut reader, 128).await.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn truncated_frames_return_unexpected_eof() {
for bytes in [
&[0x03][..],
&[0x03, 0x00][..],
&[0x03, 0x00, 0x00][..],
&[0x08, 0x5a][..],
] {
let mut reader = bytes;
let error = read_frame_with_limit(&mut reader, 1024).await.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
}
}
#[tokio::test]
async fn consecutive_frames_preserve_message_boundaries() {
let mut reader = &[0x08, 0x11, 0x22, 0x00, 0x04, 0x33][..];
for expected in [&[0x11, 0x22][..], &[][..], &[0x33][..]] {
let frame = read_frame_with_limit(&mut reader, 2).await.unwrap();
assert_eq!(frame.as_ref(), expected);
}
assert!(reader.is_empty());
}
#[tokio::test]
async fn vectored_writer_completes_partial_header_and_payload_writes() {
let payload = vec![0x5a; 16384];
for max_write in [1, 2, 3, 4, 1024] {
let mut writer = ShortWriter {
bytes: Vec::new(),
max_write,
};
write_frame_vectored(&mut writer, &payload).await.unwrap();
assert_eq!(&writer.bytes[..3], &[0x02, 0x00, 0x01]);
assert_eq!(&writer.bytes[3..], payload.as_slice());
}
}
#[tokio::test]
async fn vectored_writer_reports_zero_writes() {
let mut writer = ShortWriter {
bytes: Vec::new(),
max_write: 0,
};
let error = write_frame_vectored(&mut writer, b"payload")
.await
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::WriteZero);
}
#[tokio::test]
async fn vectored_writer_round_trips() {
let payload = vec![0x5a; 100_000];

View File

@@ -103,12 +103,6 @@ impl RustDeskConfig {
(*new_uuid.as_bytes(), true)
}
pub fn get_uuid_bytes(&self) -> Option<[u8; 16]> {
self.uuid
.as_ref()
.and_then(|s| uuid::Uuid::parse_str(s).ok().map(|u| *u.as_bytes()))
}
pub fn rendezvous_addr(&self) -> String {
let server = &self.rendezvous_server;
if server.is_empty() {

View File

@@ -26,7 +26,7 @@ use super::bytes_codec::{read_frame_with_limit, write_frame_vectored};
use super::config::RustDeskConfig;
use super::crypto::{self, KeyPair, SigningKeyPair};
use super::frame_adapters::{AudioFrameAdapter, VideoCodec, VideoFrameAdapter};
use super::hid_adapter::{convert_key_events_in_code_space, convert_mouse_event, mouse_type};
use super::hid_adapter::{convert_key_events_in_code_space, mouse_type, MouseEventAdapter};
use super::keyboard_mapping::KeyboardCodeSpace;
use super::protocol::hbb::message::key_event as ke_union;
use super::protocol::{
@@ -43,6 +43,8 @@ const DEFAULT_SCREEN_HEIGHT: u32 = 1080;
/// Default mouse event throttle interval (16ms ≈ 60Hz)
const DEFAULT_MOUSE_THROTTLE_MS: u64 = 16;
/// Bound accumulated relative input so one flush cannot create an excessive HID burst.
const MAX_PENDING_RELATIVE_DELTA: i64 = 4096;
/// Limit work retained for unauthenticated peers.
const MAX_CONNECTIONS: usize = 8;
@@ -203,6 +205,8 @@ pub struct Connection {
negotiated_codec: Option<VideoEncoderType>,
/// Input event throttler to prevent HID device EAGAIN errors
input_throttler: InputThrottler,
/// Fractional trackpad wheel state belongs to this session only.
mouse_event_adapter: MouseEventAdapter,
/// Last measured round-trip delay in milliseconds (for TestDelay responses)
last_delay: u32,
/// Time when we last sent a TestDelay to the client (for RTT calculation)
@@ -211,7 +215,7 @@ pub struct Connection {
last_caps_lock: bool,
/// Whether relative mouse mode is currently active for this connection
relative_mouse_active: bool,
/// Latest throttled move; flushed on the next HID interval.
/// Latest absolute position or accumulated relative movement, flushed on the next HID interval.
pending_mouse_move: Option<MouseEvent>,
/// Server-configured RustDesk video codec.
configured_codec: VideoEncoderType,
@@ -289,6 +293,7 @@ impl Connection {
dec_seqnum: 0,
negotiated_codec: None,
input_throttler: InputThrottler::new(),
mouse_event_adapter: MouseEventAdapter::default(),
last_delay: 0,
last_test_delay_sent: None,
last_caps_lock: false,
@@ -423,10 +428,7 @@ impl Connection {
}
_ = mouse_flush_interval.tick(), if self.pending_mouse_move.is_some() => {
if let Some(mouse_event) = self.pending_mouse_move.take() {
self.send_mouse_event_to_hid(&mouse_event).await;
self.input_throttler.mark_mouse_sent();
}
self.flush_pending_mouse_move().await;
}
_ = &mut handshake_deadline, if self.state() != ConnectionState::Active => {
@@ -1403,44 +1405,76 @@ impl Connection {
// Check if this is a pure move event (no button/scroll)
let is_pure_move = event_type == mouse_type::MOVE || is_relative_move;
// For pure move events, apply throttling
if is_pure_move && !self.input_throttler.should_send_mouse_move() {
// Coalesce moves instead of losing the final pointer position.
self.pending_mouse_move = Some(me.clone());
if is_pure_move {
self.queue_mouse_move(me);
if self.input_throttler.should_send_mouse_move() {
self.flush_pending_mouse_move().await;
}
return Ok(());
}
// Preserve input ordering when a button/scroll event follows a move
// that was waiting for the next HID poll interval.
if !is_pure_move {
if let Some(pending) = self.pending_mouse_move.take() {
self.send_mouse_event_to_hid(&pending).await;
}
}
self.flush_pending_mouse_move().await;
debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask);
self.send_mouse_event_to_hid(me).await;
if self.hid.is_some() {
// Mark that we sent a mouse event (for non-move events)
if !is_pure_move {
self.input_throttler.mark_mouse_sent();
}
let attempted = self.send_mouse_event_to_hid(me).await;
// Fractional trackpad packets that emit no HID report must not delay moves.
// Keep the existing throttling behavior for other event types.
if self.hid.is_some() && (event_type != mouse_type::TRACKPAD || attempted) {
self.input_throttler.mark_mouse_sent();
}
Ok(())
}
async fn send_mouse_event_to_hid(&self, event: &MouseEvent) {
let mouse_events = convert_mouse_event(event, self.screen_width, self.screen_height);
fn queue_mouse_move(&mut self, event: &MouseEvent) {
let mut next = event.clone();
if event.mask & 0x07 == mouse_type::MOVE_RELATIVE {
let previous = self
.pending_mouse_move
.as_ref()
.filter(|pending| pending.mask & 0x07 == mouse_type::MOVE_RELATIVE);
let (x, y) = previous
.map(|pending| (pending.x, pending.y))
.unwrap_or_default();
next.x = (i64::from(x) + i64::from(event.x))
.clamp(-MAX_PENDING_RELATIVE_DELTA, MAX_PENDING_RELATIVE_DELTA)
as i32;
next.y = (i64::from(y) + i64::from(event.y))
.clamp(-MAX_PENDING_RELATIVE_DELTA, MAX_PENDING_RELATIVE_DELTA)
as i32;
}
// Absolute coordinates replace the previous sample. Changing modes also
// replaces it: absolute coordinates and relative deltas cannot be added.
self.pending_mouse_move = Some(next);
}
async fn flush_pending_mouse_move(&mut self) {
if let Some(pending) = self.pending_mouse_move.take() {
if self.send_mouse_event_to_hid(&pending).await {
self.input_throttler.mark_mouse_sent();
}
}
}
/// Returns whether at least one HID report was attempted, including failed sends.
async fn send_mouse_event_to_hid(&mut self, event: &MouseEvent) -> bool {
let mouse_events =
self.mouse_event_adapter
.convert(event, self.screen_width, self.screen_height);
let has_events = !mouse_events.is_empty();
if let Some(ref hid) = self.hid {
for mouse_event in mouse_events {
if let Err(error) = hid.send_mouse(mouse_event).await {
warn!("Failed to send mouse event: {}", error);
}
}
has_events
} else {
debug!("HID controller not available, skipping mouse event");
false
}
}
@@ -2095,6 +2129,150 @@ async fn run_audio_streaming(
#[cfg(test)]
mod tests {
use super::*;
use crate::hid::test_support::RecordingBackend;
fn mouse_event(kind: i32, x: i32, y: i32) -> MouseEvent {
let mut event = MouseEvent::new();
event.mask = kind;
event.x = x;
event.y = y;
event
}
#[test]
fn pending_mouse_motion_accumulates_relative_and_replaces_absolute() {
let mut connection = connection(ConnectionMode::DirectIp);
connection.queue_mouse_move(&mouse_event(mouse_type::MOVE_RELATIVE, 100, -20));
connection.queue_mouse_move(&mouse_event(mouse_type::MOVE_RELATIVE, 80, 30));
let pending = connection.pending_mouse_move.as_ref().unwrap();
assert_eq!((pending.x, pending.y), (180, 10));
connection.queue_mouse_move(&mouse_event(mouse_type::MOVE, 320, 240));
connection.queue_mouse_move(&mouse_event(mouse_type::MOVE, 640, 480));
let pending = connection.pending_mouse_move.as_ref().unwrap();
assert_eq!((pending.x, pending.y), (640, 480));
connection.queue_mouse_move(&mouse_event(mouse_type::MOVE_RELATIVE, 12, -8));
let pending = connection.pending_mouse_move.as_ref().unwrap();
assert_eq!((pending.x, pending.y), (12, -8));
connection.queue_mouse_move(&mouse_event(mouse_type::MOVE_RELATIVE, i32::MAX, i32::MIN));
let pending = connection.pending_mouse_move.as_ref().unwrap();
assert_eq!((pending.x, pending.y), (4096, -4096));
}
#[tokio::test]
async fn immediate_relative_move_includes_pending_delta_and_clears_it() {
let backend = RecordingBackend::new();
let mut connection = connection(ConnectionMode::DirectIp);
connection.hid = Some(crate::hid::test_controller(backend.clone()).await);
connection.queue_mouse_move(&mouse_event(mouse_type::MOVE_RELATIVE, 30, -10));
connection
.handle_mouse_event(&mouse_event(mouse_type::MOVE_RELATIVE, 20, 5))
.await
.unwrap();
assert!(connection.pending_mouse_move.is_none());
backend.wait_for_events(1).await;
let events = backend.events.lock();
assert_eq!(events.len(), 1);
assert_eq!((events[0].x, events[0].y), (50, -5));
}
#[tokio::test]
async fn pending_relative_motion_is_delivered_before_mouse_buttons() {
let backend = RecordingBackend::new();
let mut connection = connection(ConnectionMode::DirectIp);
connection.hid = Some(crate::hid::test_controller(backend.clone()).await);
connection.input_throttler.mouse_interval = Duration::from_secs(60);
connection.input_throttler.mark_mouse_sent();
connection
.handle_mouse_event(&mouse_event(mouse_type::MOVE_RELATIVE, 300, -260))
.await
.unwrap();
assert!(connection.pending_mouse_move.is_some());
for kind in [mouse_type::DOWN, mouse_type::UP] {
connection
.handle_mouse_event(&mouse_event(
kind | (super::super::hid_adapter::mouse_button::LEFT << 3),
0,
0,
))
.await
.unwrap();
}
assert!(connection.pending_mouse_move.is_none());
backend.wait_for_events(5).await;
let events = backend.events.lock();
assert_eq!(events[..3].iter().map(|event| event.x).sum::<i32>(), 300);
assert_eq!(events[..3].iter().map(|event| event.y).sum::<i32>(), -260);
assert!(events[..3]
.iter()
.all(|event| event.event_type == crate::hid::MouseEventType::Move));
assert_eq!(events[3].event_type, crate::hid::MouseEventType::Down);
assert_eq!(events[4].event_type, crate::hid::MouseEventType::Up);
}
#[tokio::test]
async fn fractional_trackpad_input_does_not_restart_mouse_throttle() {
let backend = RecordingBackend::new();
let mut connection = connection(ConnectionMode::DirectIp);
connection.hid = Some(crate::hid::test_controller(backend.clone()).await);
let previous = Instant::now() - Duration::from_millis(10);
connection.input_throttler.last_mouse_time = previous;
connection
.handle_mouse_event(&mouse_event(mouse_type::TRACKPAD, 0, 60))
.await
.unwrap();
assert_eq!(connection.input_throttler.last_mouse_time, previous);
connection
.handle_mouse_event(&mouse_event(mouse_type::TRACKPAD, 0, 60))
.await
.unwrap();
assert!(connection.input_throttler.last_mouse_time > previous);
backend.wait_for_events(1).await;
assert_eq!(backend.events.lock()[0].scroll, 1);
}
#[tokio::test]
async fn connection_timer_delivers_final_relative_move_without_more_input() {
let backend = RecordingBackend::new();
let mut connection = connection(ConnectionMode::DirectIp);
connection.hid = Some(crate::hid::test_controller(backend.clone()).await);
connection.input_throttler.mouse_interval = Duration::from_secs(60);
connection.input_throttler.mark_mouse_sent();
let state = connection.state.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let client = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() });
let (stream, peer) = listener.accept().await.unwrap();
let mut client = client.await.unwrap();
let server = tokio::spawn(async move { connection.handle_tcp(stream, peer).await });
read_frame_with_limit(&mut client, MAX_UNAUTHENTICATED_PACKET_LENGTH)
.await
.unwrap();
// Authentication is covered separately; exercise the real socket input
// loop and flush timer without starting unrelated media pipelines.
*state.write() = ConnectionState::Active;
let mut message = HbbMessage::new();
message.union = Some(message::Union::MouseEvent(mouse_event(
mouse_type::MOVE_RELATIVE,
10,
-5,
)));
super::super::bytes_codec::write_frame(&mut client, &message.write_to_bytes().unwrap())
.await
.unwrap();
// No subsequent packet or button event is needed to deliver this move.
backend.wait_for_events(1).await;
let events = backend.events.lock().clone();
assert_eq!(events.len(), 1);
assert_eq!((events[0].x, events[0].y), (10, -5));
drop(client);
tokio::time::timeout(Duration::from_secs(2), server)
.await
.unwrap()
.unwrap()
.unwrap();
}
fn connection(mode: ConnectionMode) -> Connection {
crypto::init().expect("crypto should initialize");

View File

@@ -10,16 +10,10 @@ use thiserror::Error;
pub enum CryptoError {
#[error("Failed to initialize sodiumoxide")]
InitError,
#[error("Encryption failed")]
EncryptionFailed,
#[error("Decryption failed")]
DecryptionFailed,
#[error("Invalid key length")]
InvalidKeyLength,
#[error("Invalid nonce")]
InvalidNonce,
#[error("Signature verification failed")]
SignatureVerificationFailed,
#[error("Key conversion failed")]
KeyConversionFailed,
}
@@ -103,39 +97,6 @@ pub fn decrypt_box(
.map_err(|_| CryptoError::DecryptionFailed)
}
pub fn encrypt_with_key(data: &[u8], key: &secretbox::Key) -> (secretbox::Nonce, Vec<u8>) {
let nonce = secretbox::gen_nonce();
let ciphertext = secretbox::seal(data, &nonce, key);
(nonce, ciphertext)
}
pub fn decrypt_with_key(
ciphertext: &[u8],
nonce: &secretbox::Nonce,
key: &secretbox::Key,
) -> Result<Vec<u8>, CryptoError> {
secretbox::open(ciphertext, nonce, key).map_err(|_| CryptoError::DecryptionFailed)
}
pub fn precompute_key(
their_public_key: &PublicKey,
our_secret_key: &SecretKey,
) -> box_::PrecomputedKey {
box_::precompute(their_public_key, our_secret_key)
}
pub fn symmetric_key_from_slice(key: &[u8]) -> Result<secretbox::Key, CryptoError> {
secretbox::Key::from_slice(key).ok_or(CryptoError::InvalidKeyLength)
}
pub fn nonce_from_slice(bytes: &[u8]) -> Result<Nonce, CryptoError> {
Nonce::from_slice(bytes).ok_or(CryptoError::InvalidNonce)
}
pub fn public_key_from_slice(bytes: &[u8]) -> Result<PublicKey, CryptoError> {
PublicKey::from_slice(bytes).ok_or(CryptoError::InvalidKeyLength)
}
pub fn hash_password(password: &str, salt: &str) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
@@ -182,26 +143,6 @@ pub fn decrypt_symmetric_key(
secretbox::Key::from_slice(&key_bytes).ok_or(CryptoError::InvalidKeyLength)
}
pub fn encrypt_message(data: &[u8], key: &secretbox::Key, nonce_counter: u64) -> Vec<u8> {
let mut nonce_bytes = [0u8; secretbox::NONCEBYTES];
nonce_bytes[..8].copy_from_slice(&nonce_counter.to_le_bytes());
let nonce = secretbox::Nonce(nonce_bytes);
secretbox::seal(data, &nonce, key)
}
pub fn decrypt_message(
ciphertext: &[u8],
key: &secretbox::Key,
nonce_counter: u64,
) -> Result<Vec<u8>, CryptoError> {
let mut nonce_bytes = [0u8; secretbox::NONCEBYTES];
nonce_bytes[..8].copy_from_slice(&nonce_counter.to_le_bytes());
let nonce = secretbox::Nonce(nonce_bytes);
secretbox::open(ciphertext, &nonce, key).map_err(|_| CryptoError::DecryptionFailed)
}
#[derive(Clone)]
pub struct SigningKeyPair {
pub public_key: sign::PublicKey,
@@ -273,13 +214,6 @@ impl SigningKeyPair {
}
}
pub fn verify_signed(
signed_message: &[u8],
public_key: &sign::PublicKey,
) -> Result<Vec<u8>, CryptoError> {
sign::verify(signed_message, public_key).map_err(|_| CryptoError::SignatureVerificationFailed)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -3,7 +3,7 @@ use protobuf::Message as ProtobufMessage;
use super::protocol::hbb::message::{
message as msg_union, misc as misc_union, video_frame as vf_union, AudioFormat, AudioFrame,
CursorData, CursorPosition, EncodedVideoFrame, EncodedVideoFrames, Message, Misc, VideoFrame,
CursorData, EncodedVideoFrame, EncodedVideoFrames, Message, Misc, VideoFrame,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -15,18 +15,6 @@ pub enum VideoCodec {
AV1,
}
impl VideoCodec {
pub fn to_codec_id(self) -> i32 {
match self {
VideoCodec::H264 => 0,
VideoCodec::H265 => 1,
VideoCodec::VP8 => 2,
VideoCodec::VP9 => 3,
VideoCodec::AV1 => 4,
}
}
}
pub struct VideoFrameAdapter {
codec: VideoCodec,
seq: u32,
@@ -42,10 +30,6 @@ impl VideoFrameAdapter {
}
}
pub fn set_codec(&mut self, codec: VideoCodec) {
self.codec = codec;
}
pub fn encode_frame_from_bytes(
&mut self,
data: Bytes,
@@ -95,15 +79,6 @@ impl VideoFrameAdapter {
Bytes::from(msg.write_to_bytes().unwrap_or_default())
}
pub fn encode_frame_bytes(
&mut self,
data: &[u8],
is_keyframe: bool,
timestamp_ms: u64,
) -> Bytes {
self.encode_frame_bytes_zero_copy(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms)
}
pub fn seq(&self) -> u32 {
self.seq
}
@@ -185,16 +160,6 @@ impl CursorAdapter {
msg.union = Some(msg_union::Union::CursorData(cursor));
msg
}
pub fn encode_position(x: i32, y: i32) -> Message {
let mut pos = CursorPosition::new();
pos.x = x;
pos.y = y;
let mut msg = Message::new();
msg.union = Some(msg_union::Union::CursorPosition(pos));
msg
}
}
#[cfg(test)]

View File

@@ -5,6 +5,7 @@ use crate::hid::{
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEvent as OneKvmMouseEvent, MouseEventType,
};
use std::time::{Duration, Instant};
use tracing::debug;
pub mod mouse_type {
@@ -24,6 +25,68 @@ pub mod mouse_button {
pub const FORWARD: i32 = 0x10;
}
const TRACKPAD_UNITS_PER_WHEEL_STEP: i64 = 120;
const TRACKPAD_IDLE_RESET: Duration = Duration::from_millis(250);
/// Per-session conversion of fractional RustDesk trackpad deltas to USB wheel steps.
#[derive(Default)]
pub(super) struct MouseEventAdapter {
trackpad_remainder: i64,
last_vertical_event: Option<Instant>,
}
impl MouseEventAdapter {
pub(super) fn convert(
&mut self,
event: &MouseEvent,
screen_width: u32,
screen_height: u32,
) -> Vec<OneKvmMouseEvent> {
self.convert_at(event, screen_width, screen_height, Instant::now())
}
fn convert_at(
&mut self,
event: &MouseEvent,
screen_width: u32,
screen_height: u32,
now: Instant,
) -> Vec<OneKvmMouseEvent> {
if event.mask & 0x07 != mouse_type::TRACKPAD {
return convert_mouse_event(event, screen_width, screen_height);
}
// The USB mouse descriptor supports vertical wheel reports only.
if event.y == 0 {
return Vec::new();
}
let delta = i64::from(event.y);
let idle = self
.last_vertical_event
.is_some_and(|last| now.saturating_duration_since(last) >= TRACKPAD_IDLE_RESET);
if idle
|| (self.trackpad_remainder != 0 && self.trackpad_remainder.signum() != delta.signum())
{
self.trackpad_remainder = 0;
}
self.last_vertical_event = Some(now);
// One-KVM advertises Windows compatibility. RustDesk sends TRACKPAD in
// Windows wheel units (120 per notch), whereas WHEEL already uses steps.
// Preserve the direction: the client has already applied its reverse setting.
let total = self.trackpad_remainder + delta;
let steps = total / TRACKPAD_UNITS_PER_WHEEL_STEP;
self.trackpad_remainder = total % TRACKPAD_UNITS_PER_WHEEL_STEP;
if steps == 0 {
Vec::new()
} else {
// One bounded HID report; discard pathological excess instead of
// retaining a backlog that would scroll long after the gesture ends.
vec![OneKvmMouseEvent::scroll(steps.clamp(-127, 127) as i8)]
}
}
}
pub fn convert_mouse_event(
event: &MouseEvent,
screen_width: u32,
@@ -157,10 +220,6 @@ pub(super) fn convert_key_events_in_code_space(
}
}
pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
convert_key_events(event).into_iter().next()
}
#[derive(Debug, Clone, Copy)]
struct KeyMapping {
key: CanonicalKey,
@@ -320,6 +379,147 @@ mod tests {
key_event(KeyboardMode::Map, ke_union::Union::Chr(code), down)
}
fn scroll_event(event_type: i32, x: i32, y: i32) -> MouseEvent {
let mut event = MouseEvent::new();
event.mask = event_type;
event.x = x;
event.y = y;
event
}
fn trackpad_steps(adapter: &mut MouseEventAdapter, y: i32, now: Instant) -> i32 {
adapter
.convert_at(&scroll_event(mouse_type::TRACKPAD, 0, y), 640, 480, now)
.into_iter()
.map(|event| {
assert_eq!(event.event_type, MouseEventType::Scroll);
i32::from(event.scroll)
})
.sum()
}
#[test]
fn trackpad_accumulates_fractional_steps_independently_of_packet_size() {
let now = Instant::now();
for direction in [-1, 1] {
let mut split = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut split, 60 * direction, now), 0);
assert_eq!(trackpad_steps(&mut split, 60 * direction, now), direction);
let mut whole = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut whole, 120 * direction, now), direction);
assert_eq!(
trackpad_steps(&mut whole, 360 * direction, now),
3 * direction
);
let mut tiny = MouseEventAdapter::default();
let total: i32 = (0..360)
.map(|_| trackpad_steps(&mut tiny, direction, now))
.sum();
assert_eq!(total, 3 * direction);
}
}
#[test]
fn trackpad_horizontal_and_zero_scroll_do_not_generate_vertical_reports() {
let now = Instant::now();
let mut adapter = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut adapter, 60, now), 0);
for x in [0, 120, -120] {
assert!(adapter
.convert_at(&scroll_event(mouse_type::TRACKPAD, x, 0), 640, 480, now)
.is_empty());
}
assert_eq!(trackpad_steps(&mut adapter, 60, now), 1);
}
#[test]
fn trackpad_remainder_does_not_cross_sessions_or_idle_gaps() {
let now = Instant::now();
let mut first = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut first, 60, now), 0);
let mut second = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut second, 60, now), 0);
assert_eq!(trackpad_steps(&mut first, 60, now), 1);
// A new connection gets a fresh adapter, including when reconnecting.
first = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut first, 60, now), 0);
assert_eq!(
trackpad_steps(&mut first, 60, now + Duration::from_millis(250)),
0
);
assert_eq!(
trackpad_steps(&mut first, 60, now + Duration::from_millis(280)),
1
);
}
#[test]
fn trackpad_direction_change_discards_old_fraction() {
let now = Instant::now();
let mut adapter = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut adapter, 119, now), 0);
assert_eq!(trackpad_steps(&mut adapter, -60, now), 0);
assert_eq!(trackpad_steps(&mut adapter, -60, now), -1);
assert_eq!(trackpad_steps(&mut adapter, -119, now), 0);
assert_eq!(trackpad_steps(&mut adapter, 120, now), 1);
}
#[test]
fn trackpad_extreme_deltas_are_bounded_without_a_future_backlog() {
let now = Instant::now();
for delta in [i32::MIN, i32::MAX] {
let mut adapter = MouseEventAdapter::default();
assert_eq!(
trackpad_steps(&mut adapter, delta, now),
127 * delta.signum()
);
assert!(adapter.trackpad_remainder.abs() < 120);
assert_eq!(trackpad_steps(&mut adapter, delta.signum(), now), 0);
}
}
#[test]
fn wheel_steps_remain_immediate_and_do_not_use_trackpad_remainder() {
let now = Instant::now();
let mut adapter = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut adapter, 60, now), 0);
for y in [-120, -1, 1, 120] {
let events = adapter.convert_at(&scroll_event(mouse_type::WHEEL, 0, y), 640, 480, now);
assert_eq!(events.len(), 1);
assert_eq!(events[0].scroll, y.signum() as i8);
}
assert_eq!(trackpad_steps(&mut adapter, 60, now), 1);
}
#[test]
fn trackpad_accumulation_does_not_change_pointer_or_button_events() {
let now = Instant::now();
let mut adapter = MouseEventAdapter::default();
assert_eq!(trackpad_steps(&mut adapter, 60, now), 0);
let events = adapter.convert(&scroll_event(mouse_type::MOVE, 320, 240), 640, 480);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, MouseEventType::MoveAbs);
assert_eq!((events[0].x, events[0].y), (16383, 16383));
let events = adapter.convert(&scroll_event(mouse_type::MOVE_RELATIVE, -12, 8), 640, 480);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, MouseEventType::Move);
assert_eq!((events[0].x, events[0].y), (-12, 8));
for (kind, expected) in [
(mouse_type::DOWN, MouseEventType::Down),
(mouse_type::UP, MouseEventType::Up),
] {
let event = scroll_event((mouse_button::LEFT << 3) | kind, 0, 0);
let events = adapter.convert(&event, 640, 480);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, expected);
assert_eq!(events[0].button, Some(MouseButton::Left));
}
assert_eq!(trackpad_steps(&mut adapter, 60, now), 1);
}
#[test]
fn mouse_events_keep_existing_semantics() {
let mut event = MouseEvent::new();

View File

@@ -520,11 +520,6 @@ impl RustDeskService {
}
None
}
#[deprecated(note = "Use save_credentials instead")]
pub fn save_keypair(&self) {
let _ = self.save_credentials();
}
}
async fn wait_for_service_task(mut handle: JoinHandle<()>, task_name: &str) {

View File

@@ -4,7 +4,6 @@ use tokio::sync::{broadcast, watch, Mutex, RwLock};
use crate::atx::AtxController;
use crate::audio::AudioController;
use crate::auth::{SessionStore, TwoFactorService, UserStore};
use crate::computer_use::ComputerUseManager;
use crate::config::ConfigStore;
use crate::db::DatabasePool;
#[cfg(unix)]
@@ -15,6 +14,7 @@ use crate::events::{
};
use crate::extensions::{ExtensionId, ExtensionManager};
use crate::hid::HidController;
use crate::mcp::{McpHardware, McpService};
#[cfg(unix)]
use crate::msd::MsdController;
#[cfg(unix)]
@@ -72,7 +72,7 @@ pub struct AppState {
pub stream_manager: Arc<VideoStreamManager>,
pub webrtc: Arc<WebRtcStreamer>,
pub hid: Arc<HidController>,
pub computer_use: Arc<ComputerUseManager>,
pub mcp: Arc<McpService>,
#[cfg(unix)]
pub msd: Arc<RwLock<Option<MsdController>>>,
pub atx: Arc<RwLock<Option<AtxController>>>,
@@ -104,7 +104,6 @@ impl AppState {
stream_manager: Arc<VideoStreamManager>,
webrtc: Arc<WebRtcStreamer>,
hid: Arc<HidController>,
computer_use: Arc<ComputerUseManager>,
#[cfg(unix)] msd: Option<MsdController>,
atx: Option<AtxController>,
audio: Arc<AudioController>,
@@ -138,6 +137,23 @@ impl AppState {
data_dir.clone(),
);
let atx = Arc::new(RwLock::new(atx));
let config_apply_locks = ConfigApplyLocks::new();
let hardware = McpHardware {
config: config.clone(),
atx: atx.clone(),
wol_history: db.wol_history(),
#[cfg(unix)]
msd: msd.clone(),
#[cfg(unix)]
otg_lock: config_apply_locks.otg.clone(),
};
let mcp = McpService::new(
config.clone(),
hid.clone(),
stream_manager.clone(),
hardware,
);
Arc::new(Self {
db,
config,
@@ -149,10 +165,10 @@ impl AppState {
stream_manager,
webrtc,
hid,
computer_use,
mcp,
#[cfg(unix)]
msd,
atx: Arc::new(RwLock::new(atx)),
atx,
audio,
usb,
remote_access,
@@ -163,7 +179,7 @@ impl AppState {
watchdog: Arc::new(WatchdogController::new()),
shutdown_tx,
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
config_apply_locks: ConfigApplyLocks::new(),
config_apply_locks,
data_dir,
#[cfg(unix)]
uac_playback,

View File

@@ -24,6 +24,25 @@ use crate::video::VideoFrame;
pub type ClientId = String;
#[cfg(feature = "desktop")]
#[derive(Clone, Copy, PartialEq, Eq)]
struct RawFrameFingerprint {
resolution: crate::video::format::Resolution,
format: PixelFormat,
stride: u32,
online: bool,
len: usize,
hash: u64,
quality: u32,
}
#[cfg(feature = "desktop")]
struct JpegEncoderState {
encoder: JpegEncoder,
last_input: Option<RawFrameFingerprint>,
last_frame: Option<VideoFrame>,
}
#[derive(Debug, Clone)]
pub struct ClientSession {
pub id: ClientId,
@@ -115,7 +134,7 @@ pub struct MjpegStreamHandler {
dropped_same_frames: AtomicU64,
max_drop_same_frames: AtomicU64,
#[cfg(feature = "desktop")]
jpeg_encoder: ParkingMutex<Option<JpegEncoder>>,
jpeg_encoder: ParkingMutex<Option<JpegEncoderState>>,
jpeg_quality: AtomicU64,
}
@@ -219,24 +238,43 @@ impl MjpegStreamHandler {
let resolution = frame.resolution;
let sequence = self.sequence.load(Ordering::Relaxed);
let desired_quality = self.jpeg_quality.load(Ordering::Relaxed) as u32;
// Keep only a fingerprint and the encoded output, not a second raw
// frame. Unchanged input can reuse JPEG bytes even for forced refreshes.
let fingerprint = RawFrameFingerprint {
resolution,
format: frame.format,
stride: frame.stride,
online: frame.online,
len: frame.len(),
hash: frame.get_hash(),
quality: desired_quality,
};
let mut encoder_guard = self.jpeg_encoder.lock();
let encoder = encoder_guard.get_or_insert_with(|| {
if encoder_guard.is_none() {
let config = EncoderConfig::jpeg(resolution, desired_quality);
match JpegEncoder::new(config) {
Ok(enc) => {
debug!(
"Created JPEG encoder for MJPEG stream: {}x{} (q={})",
resolution.width, resolution.height, desired_quality
);
enc
}
Err(e) => {
warn!("Failed to create JPEG encoder: {}", e);
panic!("Failed to create JPEG encoder");
}
let encoder = JpegEncoder::new(config)
.map_err(|e| format!("Failed to create JPEG encoder: {}", e))?;
debug!(
"Created JPEG encoder for MJPEG stream: {}x{} (q={})",
resolution.width, resolution.height, desired_quality
);
*encoder_guard = Some(JpegEncoderState {
encoder,
last_input: None,
last_frame: None,
});
}
let state = encoder_guard.as_mut().expect("JPEG encoder initialized");
if state.last_input == Some(fingerprint) {
if let Some(mut cached) = state.last_frame.clone() {
cached.sequence = sequence;
cached.capture_ts = frame.capture_ts;
cached.online = frame.online;
return Ok(cached);
}
});
}
let encoder = &mut state.encoder;
if encoder.config().resolution != resolution {
debug!(
@@ -285,13 +323,12 @@ impl MjpegStreamHandler {
}
};
Ok(VideoFrame::new(
encoded.data,
resolution,
PixelFormat::Mjpeg,
0,
sequence,
))
let mut jpeg = VideoFrame::new(encoded.data, resolution, PixelFormat::Mjpeg, 0, sequence);
jpeg.capture_ts = frame.capture_ts;
jpeg.online = frame.online;
state.last_input = Some(fingerprint);
state.last_frame = Some(jpeg.clone());
Ok(jpeg)
}
pub fn set_offline(&self) {
@@ -377,14 +414,6 @@ impl MjpegStreamHandler {
self.auto_pause_config.read().clone()
}
pub fn set_auto_pause_config(&self, config: AutoPauseConfig) {
info!(
"Auto-pause config updated: enabled={}, delay={}s, timeout={}s",
config.enabled, config.shutdown_delay_secs, config.client_timeout_secs
);
*self.auto_pause_config.write() = config;
}
pub fn current_frame(&self) -> Option<VideoFrame> {
(**self.current_frame.load()).clone()
}
@@ -393,19 +422,11 @@ impl MjpegStreamHandler {
self.frame_notify.subscribe()
}
pub fn disconnect_all_clients(&self) {
self.disconnect_clients_matching(|_| true);
}
pub fn disconnect_non_vnc_clients(&self) {
self.disconnect_clients_matching(|id| !id.starts_with("vnc-"));
}
fn disconnect_clients_matching(&self, should_disconnect: impl Fn(&str) -> bool) {
let count = {
let mut clients = self.clients.write();
let before = clients.len();
clients.retain(|id, _| !should_disconnect(id));
clients.retain(|id, _| id.starts_with("vnc-"));
before - clients.len()
};
let remaining = self.client_count();
@@ -550,6 +571,91 @@ mod tests {
use crate::video::{format::Resolution, PixelFormat};
use bytes::Bytes;
#[cfg(feature = "desktop")]
fn raw_frame(resolution: Resolution, sequence: u64) -> VideoFrame {
VideoFrame::new(
Bytes::from(vec![
128;
resolution.width as usize
* resolution.height as usize
* 3
/ 2
]),
resolution,
PixelFormat::Nv12,
resolution.width,
sequence,
)
}
#[cfg(feature = "desktop")]
#[test]
fn raw_cache_reuses_bytes_but_invalidates_on_input_and_quality_changes() {
let handler = Arc::new(MjpegStreamHandler::with_drop_limit(0));
let _guard = ClientGuard::new("raw-test".into(), handler.clone());
let resolution = Resolution::new(64, 32);
handler.update_frame(raw_frame(resolution, 1));
let first = handler.current_frame().unwrap();
let next_input = raw_frame(resolution, 2);
let capture_ts = next_input.capture_ts;
handler.update_frame(next_input);
let repeated = handler.current_frame().unwrap();
assert_eq!(first.data().as_ptr(), repeated.data().as_ptr());
assert_ne!(first.sequence, repeated.sequence);
assert_eq!(repeated.capture_ts, capture_ts);
handler.set_jpeg_quality(40);
handler.update_frame(raw_frame(resolution, 3));
let changed_quality = handler.current_frame().unwrap();
assert_ne!(changed_quality.data().as_ptr(), repeated.data().as_ptr());
let mut data = raw_frame(resolution, 4).data().to_vec();
data[321] = 255; // A small change away from the fast sample positions.
handler.update_frame(VideoFrame::new(
Bytes::from(data),
resolution,
PixelFormat::Nv12,
resolution.width,
4,
));
let changed_pixels = handler.current_frame().unwrap();
assert_ne!(
changed_pixels.data().as_ptr(),
changed_quality.data().as_ptr()
);
let resized = Resolution::new(32, 64);
handler.update_frame(raw_frame(resized, 5));
let changed_resolution = handler.current_frame().unwrap();
assert_eq!(changed_resolution.resolution, resized);
assert_ne!(
changed_resolution.data().as_ptr(),
changed_pixels.data().as_ptr()
);
}
#[cfg(feature = "desktop")]
#[test]
fn raw_duplicates_skip_publication_but_forced_refresh_reuses_jpeg() {
let handler = Arc::new(MjpegStreamHandler::new());
let _guard = ClientGuard::new("raw-refresh-test".into(), handler.clone());
let mut notifications = handler.subscribe();
let resolution = Resolution::new(64, 32);
handler.update_frame(raw_frame(resolution, 1));
let first = handler.current_frame().unwrap();
notifications.try_recv().unwrap();
handler.update_frame(raw_frame(resolution, 2));
assert!(matches!(
notifications.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
*handler.last_frame_ts.write() = Some(Instant::now() - Duration::from_secs(2));
handler.update_frame(raw_frame(resolution, 3));
notifications.try_recv().unwrap();
let refresh = handler.current_frame().unwrap();
assert_ne!(refresh.sequence, first.sequence);
assert_eq!(refresh.data().as_ptr(), first.data().as_ptr());
}
#[tokio::test]
async fn test_stream_handler() {
let handler = MjpegStreamHandler::new();

184
src/stream/mjpeg_http.rs Normal file
View File

@@ -0,0 +1,184 @@
//! Pull the latest JPEG directly when the HTTP consumer is ready.
use std::io;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use futures::Stream;
use tokio::sync::broadcast::error::{RecvError, TryRecvError};
use super::{ClientGuard, MjpegStreamHandler};
const PART_HEADER: &[u8] = b"--frame\r\nContent-Type: image/jpeg\r\n\r\n";
// Chromium commits the previous image after parsing the next part's complete
// headers. Send these immediately after the JPEG, even when the scene stops.
const PART_TRAILER: &[u8] = b"\r\n--frame\r\nContent-Type: image/jpeg\r\n\r\n";
pub(crate) fn body_stream(
handler: Arc<MjpegStreamHandler>,
guard: ClientGuard,
) -> impl Stream<Item = Result<Bytes, io::Error>> + Send {
stream_with_timeout(handler, guard, Duration::from_secs(5))
}
fn stream_with_timeout(
handler: Arc<MjpegStreamHandler>,
guard: ClientGuard,
timeout: Duration,
) -> impl Stream<Item = Result<Bytes, io::Error>> + Send {
// Subscribe before reading the initial frame so an update cannot be missed.
let mut notifications = handler.subscribe();
async_stream::stream! {
let guard = guard;
let mut last_sequence = None;
let mut timeout_count = 0;
let mut keepalive = false;
yield Ok(Bytes::from_static(PART_HEADER));
loop {
// Notifications contain no frame data. Coalesce the backlog, then
// fetch the latest frame rather than queueing JPEGs for slow peers.
loop {
match notifications.try_recv() {
Ok(()) | Err(TryRecvError::Lagged(_)) => timeout_count = 0,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Closed) => return,
}
}
if !handler.is_online() {
break;
}
if let Some(frame) = handler.current_frame() {
if (keepalive || last_sequence != Some(frame.sequence)) && frame.is_valid_jpeg() {
last_sequence = Some(frame.sequence);
// This retains the pooled owner; it does not copy the JPEG.
let data = frame.owned_data_bytes();
drop(frame);
handler.record_frame_sent(guard.id());
yield Ok(data);
yield Ok(Bytes::from_static(PART_TRAILER));
}
}
keepalive = false;
match tokio::time::timeout(timeout, notifications.recv()).await {
Ok(Ok(())) | Ok(Err(RecvError::Lagged(_))) => timeout_count = 0,
Ok(Err(RecvError::Closed)) => break,
Err(_) => {
timeout_count += 1;
if timeout_count > 6 {
break;
}
keepalive = true;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use futures::StreamExt;
fn jpeg(sequence: u64) -> VideoFrame {
let mut data = vec![sequence as u8; 128];
data[..2].copy_from_slice(&[0xff, 0xd8]);
data[126..].copy_from_slice(&[0xff, 0xd9]);
VideoFrame::new(
Bytes::from(data),
Resolution::VGA,
PixelFormat::Mjpeg,
0,
sequence,
)
}
#[tokio::test]
async fn sends_complete_pooled_frame_without_copying_or_waiting_for_next_frame() {
let handler = Arc::new(MjpegStreamHandler::with_drop_limit(0));
let pool = Arc::new(FrameBufferPool::new(1));
let data = jpeg(0).data().to_vec();
let original = data.as_ptr();
handler.update_frame(VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(data, Some(pool.clone()))),
Resolution::VGA,
PixelFormat::Mjpeg,
0,
0,
));
let guard = ClientGuard::new("http-test".into(), handler.clone());
let mut stream = Box::pin(body_stream(handler.clone(), guard));
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_HEADER);
let payload = stream.next().await.unwrap().unwrap();
assert_eq!(payload.as_ptr(), original);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
assert!(
tokio::time::timeout(Duration::from_millis(20), stream.next())
.await
.is_err()
);
// Exceed the broadcast capacity while the HTTP consumer is stalled.
for sequence in 1..=40 {
handler.update_frame(jpeg(sequence));
}
let latest = stream.next().await.unwrap().unwrap();
assert_eq!(latest[2], 40);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
// The previous pooled frame remains valid until its HTTP owner drops.
assert_eq!(payload.as_ptr(), original);
assert_eq!(payload[2], 0);
drop(payload);
let recycled = pool.take(128);
assert_eq!(recycled.as_ptr(), original);
// Capture sequence numbers may reset after reopening the source.
handler.update_frame(jpeg(0));
assert_eq!(stream.next().await.unwrap().unwrap()[2], 0);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
assert_eq!(handler.get_clients_stat()["http-test"].fps, 3);
drop(stream);
assert_eq!(handler.client_count(), 0);
}
#[tokio::test]
async fn keepalive_and_offline_close_release_the_client() {
let handler = Arc::new(MjpegStreamHandler::new());
handler.update_frame(jpeg(1));
let guard = ClientGuard::new("keepalive-test".into(), handler.clone());
let mut stream = Box::pin(stream_with_timeout(
handler.clone(),
guard,
Duration::from_millis(10),
));
for _ in 0..3 {
stream.next().await.unwrap().unwrap();
}
let repeated = tokio::time::timeout(Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(repeated[2], 1);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
handler.set_offline();
assert!(stream.next().await.is_none());
drop(stream);
assert_eq!(handler.client_count(), 0);
}
#[test]
fn dropping_an_unpolled_response_releases_the_client() {
let handler = Arc::new(MjpegStreamHandler::new());
let guard = ClientGuard::new("unpolled-test".into(), handler.clone());
let stream = body_stream(handler.clone(), guard);
assert_eq!(handler.client_count(), 1);
drop(stream);
assert_eq!(handler.client_count(), 0);
}
}

View File

@@ -1,9 +1,6 @@
//! MJPEG multipart streaming and WebSocket HID (for MJPEG mode).
//! MJPEG multipart streaming.
pub mod mjpeg;
#[cfg(feature = "desktop")]
pub mod ws_hid;
pub(crate) mod mjpeg_http;
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
#[cfg(feature = "desktop")]
pub use ws_hid::WsHidHandler;

View File

@@ -1,237 +0,0 @@
//! WebSocket HID for MJPEG mode; binary messages per `crate::hid::datachannel`.
use axum::extract::ws::{Message, WebSocket};
use futures::{SinkExt, StreamExt};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::hid::datachannel::{parse_hid_message, HidChannelEvent};
use crate::hid::HidController;
pub type ClientId = String;
#[derive(Debug)]
pub struct WsHidClient {
pub id: ClientId,
pub connected_at: Instant,
pub events_processed: AtomicU64,
shutdown_tx: mpsc::Sender<()>,
}
impl WsHidClient {
pub fn events_count(&self) -> u64 {
self.events_processed.load(Ordering::Relaxed)
}
pub fn connected_secs(&self) -> u64 {
self.connected_at.elapsed().as_secs()
}
}
pub struct WsHidHandler {
hid_controller: RwLock<Option<Arc<HidController>>>,
clients: RwLock<HashMap<ClientId, Arc<WsHidClient>>>,
running: AtomicBool,
total_events: AtomicU64,
}
impl WsHidHandler {
pub fn new() -> Arc<Self> {
Arc::new(Self {
hid_controller: RwLock::new(None),
clients: RwLock::new(HashMap::new()),
running: AtomicBool::new(true),
total_events: AtomicU64::new(0),
})
}
pub fn set_hid_controller(&self, hid: Arc<HidController>) {
*self.hid_controller.write() = Some(hid);
info!("WsHidHandler: HID controller set");
}
pub fn hid_controller(&self) -> Option<Arc<HidController>> {
self.hid_controller.read().clone()
}
pub fn is_hid_available(&self) -> bool {
self.hid_controller.read().is_some()
}
pub fn client_count(&self) -> usize {
self.clients.read().len()
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
let clients = self.clients.read();
for client in clients.values() {
let _ = client.shutdown_tx.try_send(());
}
}
pub fn total_events(&self) -> u64 {
self.total_events.load(Ordering::Relaxed)
}
pub async fn add_client(self: &Arc<Self>, client_id: ClientId, socket: WebSocket) {
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
let client = Arc::new(WsHidClient {
id: client_id.clone(),
connected_at: Instant::now(),
events_processed: AtomicU64::new(0),
shutdown_tx,
});
self.clients
.write()
.insert(client_id.clone(), client.clone());
info!(
"WsHidHandler: Client {} connected (total: {})",
client_id,
self.client_count()
);
let handler = self.clone();
tokio::spawn(async move {
handler
.handle_client(client_id.clone(), socket, client, shutdown_rx)
.await;
handler.remove_client(&client_id);
});
}
pub fn remove_client(&self, client_id: &str) {
if let Some(client) = self.clients.write().remove(client_id) {
info!(
"WsHidHandler: Client {} disconnected after {}s ({} events)",
client_id,
client.connected_secs(),
client.events_count()
);
}
}
async fn handle_client(
&self,
client_id: ClientId,
socket: WebSocket,
client: Arc<WsHidClient>,
mut shutdown_rx: mpsc::Receiver<()>,
) {
let (mut sender, mut receiver) = socket.split();
let status_byte = if self.is_hid_available() {
0x00u8
} else {
0x01u8
};
let _ = sender.send(Message::Binary(vec![status_byte].into())).await;
loop {
tokio::select! {
biased;
_ = shutdown_rx.recv() => {
debug!("WsHidHandler: Client {} received shutdown signal", client_id);
break;
}
msg = receiver.next() => {
match msg {
Some(Ok(Message::Binary(data))) => {
if let Err(e) = self.handle_binary_message(&data, &client).await {
warn!("WsHidHandler: Failed to handle binary message: {}", e);
}
}
Some(Ok(Message::Ping(data))) => {
let _ = sender.send(Message::Pong(data)).await;
}
Some(Ok(Message::Close(_))) => {
debug!("WsHidHandler: Client {} closed connection", client_id);
break;
}
Some(Err(e)) => {
error!("WsHidHandler: WebSocket error for client {}: {}", client_id, e);
break;
}
None => {
debug!("WsHidHandler: Client {} stream ended", client_id);
break;
}
Some(Ok(Message::Text(_))) => {
warn!("WsHidHandler: Ignoring text message from client {} (binary protocol only)", client_id);
}
_ => {}
}
}
}
}
let hid = self.hid_controller.read().clone();
if let Some(hid) = hid {
if let Err(e) = hid.reset().await {
warn!(
"WsHidHandler: Failed to reset HID on client {} disconnect: {}",
client_id, e
);
} else {
debug!("WsHidHandler: HID reset on client {} disconnect", client_id);
}
}
}
async fn handle_binary_message(&self, data: &[u8], client: &WsHidClient) -> Result<(), String> {
let hid = self
.hid_controller
.read()
.clone()
.ok_or("HID controller not available")?;
let event = parse_hid_message(data).ok_or("Invalid binary HID message")?;
match event {
HidChannelEvent::Keyboard(kb_event) => {
hid.send_keyboard(kb_event)
.await
.map_err(|e| e.to_string())?;
}
HidChannelEvent::Mouse(ms_event) => {
hid.send_mouse(ms_event).await.map_err(|e| e.to_string())?;
}
HidChannelEvent::Consumer(consumer_event) => {
hid.send_consumer(consumer_event)
.await
.map_err(|e| e.to_string())?;
}
}
client.events_processed.fetch_add(1, Ordering::Relaxed);
self.total_events.fetch_add(1, Ordering::Relaxed);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ws_hid_handler_creation() {
let handler = WsHidHandler::new();
assert!(handler.is_running());
assert_eq!(handler.client_count(), 0);
assert!(!handler.is_hid_available());
}
}

View File

@@ -3,7 +3,7 @@
use std::fs::File;
use std::io;
use std::os::fd::AsFd;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
use std::os::fd::OwnedFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
@@ -31,7 +31,7 @@ use crate::video::device::VideoControlMode;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::signal::SignalStatus;
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
#[path = "dmabuf_layout.rs"]
mod dmabuf_layout;
@@ -79,7 +79,7 @@ pub struct CaptureStream {
bridge_kind: Option<CsiBridgeKind>,
native_hdmirx_state: Option<NativeHdmirxState>,
native_hdmirx_next_state_check: Option<Instant>,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
dma_layout_bytes: Option<usize>,
}
@@ -333,7 +333,7 @@ impl CaptureStream {
mappings.push(plane_maps);
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
let dma_layout_bytes = PixelFormat::from_v4l2r(actual_fmt.pixelformat).and_then(|format| {
dmabuf_layout::DmaCaptureLayout {
native_hdmi: is_native_hdmirx,
@@ -364,7 +364,7 @@ impl CaptureStream {
bridge_kind: bridge.kind,
native_hdmirx_state,
native_hdmirx_next_state_check,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
dma_layout_bytes,
};
@@ -491,7 +491,7 @@ impl CaptureStream {
/// Native HDMI NV12/BGR24 and single-planar USB UVC YUYV/NV12/RGB24/MJPEG.
/// Actual EXPBUF/import support is probed separately; failure retains copy.
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(crate) fn supports_rkmpp_dmabuf(&self) -> bool {
self.dma_layout_bytes.is_some_and(|minimum| {
(2..=16).contains(&self.mappings.len())
@@ -502,7 +502,7 @@ impl CaptureStream {
})
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(crate) fn export_dmabufs(&self) -> io::Result<Vec<(OwnedFd, usize)>> {
if !self.supports_rkmpp_dmabuf() {
return Err(io::Error::new(
@@ -524,7 +524,7 @@ impl CaptureStream {
/// Run a synchronous consumer while a buffer is dequeued. QBUF occurs only
/// after the callback returns, including its error path. Consumers must end
/// hardware access before returning; see hwcodec::rkmpp_dmabuf::encode.
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(crate) fn with_next_dmabuf<T>(
&mut self,
consume: impl FnOnce(usize, usize, Option<OwnedFd>) -> T,
@@ -843,17 +843,17 @@ impl CaptureStream {
}
}
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
struct BufferReturn<F: FnOnce() -> io::Result<()>>(Option<F>);
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
impl<F: FnOnce() -> io::Result<()>> BufferReturn<F> {
fn finish(mut self) -> io::Result<()> {
self.0.take().expect("capture lease already returned")()
}
}
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
impl<F: FnOnce() -> io::Result<()>> Drop for BufferReturn<F> {
fn drop(&mut self) {
if let Some(return_buffer) = self.0.take() {
@@ -940,7 +940,6 @@ impl Drop for CaptureStream {
struct DvTimingsMode {
width: u32,
height: u32,
#[allow(dead_code)]
fps: Option<f64>,
signature: Option<DvTimingsSignature>,
}

View File

@@ -4,14 +4,14 @@ use std::io;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
all(target_os = "linux", feature = "rkmpp")
))]
use crate::video::device::VideoControlMode;
use crate::video::signal::SignalStatus;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
all(target_os = "linux", feature = "rkmpp")
))]
pub(crate) fn capture_recovery_status(
control_mode: VideoControlMode,

View File

@@ -33,14 +33,6 @@ pub struct JpegEncoder {
impl JpegEncoder {
/// Create a new JPEG encoder
pub fn new(config: EncoderConfig) -> Result<Self> {
let resolution = config.resolution;
let width = resolution.width as usize;
let height = resolution.height as usize;
// I420: Y = width*height, U = width*height/4, V = width*height/4
let i420_size = width * height * 3 / 2;
let max_uv_plane_size = width * height;
let bgra_size = width * height * 4;
let mut compressor = turbojpeg::Compressor::new().map_err(|e| {
AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e))
})?;
@@ -52,12 +44,18 @@ impl JpegEncoder {
Ok(Self {
config,
compressor,
i420_buffer: vec![0u8; i420_size],
uv_split_buffer: vec![0u8; max_uv_plane_size * 2],
bgra_buffer: vec![0u8; bgra_size],
i420_buffer: Vec::new(),
uv_split_buffer: Vec::new(),
bgra_buffer: Vec::new(),
})
}
fn ensure_i420_buffer(&mut self) {
let size =
self.config.resolution.width as usize * self.config.resolution.height as usize * 3 / 2;
self.i420_buffer.resize(size, 0);
}
/// Create with specific quality
pub fn with_quality(resolution: Resolution, quality: u32) -> Result<Self> {
let config = EncoderConfig::jpeg(resolution, quality);
@@ -146,6 +144,7 @@ impl JpegEncoder {
}
// Convert YUYV to I420 using libyuv (SIMD accelerated)
self.ensure_i420_buffer();
libyuv::yuy2_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv YUYV→I420 failed: {}", e)))?;
@@ -166,6 +165,7 @@ impl JpegEncoder {
)));
}
self.ensure_i420_buffer();
// Reuse bgra_buffer as scratch for the swapped YUYV data.
if self.bgra_buffer.len() < expected_size {
self.bgra_buffer.resize(expected_size, 0);
@@ -202,6 +202,7 @@ impl JpegEncoder {
}
// Convert NV12 to I420 using libyuv (SIMD accelerated)
self.ensure_i420_buffer();
libyuv::nv12_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv NV12→I420 failed: {}", e)))?;
@@ -224,6 +225,8 @@ impl JpegEncoder {
)));
}
self.ensure_i420_buffer();
self.uv_split_buffer.resize(y_size, 0);
let src_uv = &data[y_size..expected_size];
let chroma_plane_size = y_size / 2;
let (u_plane_422, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
@@ -273,6 +276,8 @@ impl JpegEncoder {
)));
}
self.uv_split_buffer.resize(y_size * 2, 0);
self.bgra_buffer.resize(y_size * 4, 0);
let src_uv = &data[y_size..expected_size];
let chroma_plane_size = y_size;
let (u_plane_444, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
@@ -318,6 +323,7 @@ impl JpegEncoder {
}
// Convert RGB24 to I420 using libyuv (SIMD accelerated)
self.ensure_i420_buffer();
libyuv::rgb24_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv RGB24→I420 failed: {}", e)))?;
@@ -340,6 +346,7 @@ impl JpegEncoder {
// Convert BGR24 to I420 using libyuv (SIMD accelerated)
// Note: libyuv's RAWToI420 is BGR24 → I420
self.ensure_i420_buffer();
libyuv::bgr24_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv BGR24→I420 failed: {}", e)))?;
@@ -395,10 +402,31 @@ mod tests {
use super::*;
#[test]
fn test_i420_buffer_size() {
// 1920x1080 I420 = 1920*1080 + 960*540 + 960*540 = 3110400 bytes
let config = EncoderConfig::jpeg(Resolution::HD1080, 80);
let encoder = JpegEncoder::new(config).unwrap();
assert_eq!(encoder.i420_buffer.len(), 1920 * 1080 * 3 / 2);
fn scratch_buffers_are_allocated_only_for_the_formats_that_use_them() {
let resolution = Resolution::new(64, 32);
let pixels = 64 * 32;
let mut encoder = JpegEncoder::new(EncoderConfig::jpeg(resolution, 80)).unwrap();
assert!(encoder.i420_buffer.is_empty());
assert!(encoder.uv_split_buffer.is_empty());
assert!(encoder.bgra_buffer.is_empty());
encoder.encode_yuyv(&vec![128; pixels * 2], 0).unwrap();
encoder.encode_nv12(&vec![128; pixels * 3 / 2], 1).unwrap();
encoder.encode_rgb(&vec![128; pixels * 3], 2).unwrap();
encoder.encode_bgr(&vec![128; pixels * 3], 3).unwrap();
assert_eq!(encoder.i420_buffer.len(), pixels * 3 / 2);
assert!(encoder.uv_split_buffer.is_empty());
assert!(encoder.bgra_buffer.is_empty());
encoder.encode_yvyu(&vec![128; pixels * 2], 4).unwrap();
assert_eq!(encoder.bgra_buffer.len(), pixels * 2);
encoder.encode_nv16(&vec![128; pixels * 2], 5).unwrap();
assert_eq!(encoder.uv_split_buffer.len(), pixels);
encoder.encode_nv24(&vec![128; pixels * 3], 6).unwrap();
assert_eq!(encoder.uv_split_buffer.len(), pixels * 2);
assert_eq!(encoder.bgra_buffer.len(), pixels * 4);
let mut nv24 = JpegEncoder::new(EncoderConfig::jpeg(resolution, 80)).unwrap();
nv24.encode_nv24(&vec![128; pixels * 3], 0).unwrap();
assert!(nv24.i420_buffer.is_empty());
}
}

View File

@@ -17,7 +17,7 @@ pub mod video_codec;
pub mod vp8;
pub mod vp9;
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
#[cfg(all(feature = "desktop", feature = "rkmpp"))]
pub mod mjpeg_rkmpp;
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};

View File

@@ -36,10 +36,6 @@ impl ThirdPartyCodecLock {
ThirdPartyCodecLock::Mjpeg => "mjpeg",
}
}
fn compatible_with(self, other: Self) -> bool {
self == other
}
}
#[derive(Debug, Clone, Copy)]
@@ -152,13 +148,6 @@ pub fn rtsp_codec_to_video_codec(codec: RtspCodec) -> VideoCodecType {
}
}
pub fn vnc_encoding_to_video_codec(encoding: VncEncoding) -> Option<VideoCodecType> {
match encoding {
VncEncoding::TightJpeg => None,
VncEncoding::H264 => Some(VideoCodecType::H264),
}
}
fn rustdesk_lock(config: &AppConfig) -> Option<ThirdPartySourceLock> {
if config.rustdesk.enabled {
return Some(ThirdPartySourceLock {
@@ -204,7 +193,7 @@ pub fn validate_third_party_codec_compatibility(config: &AppConfig) -> Result<()
let locks = third_party_locks(config);
if let Some(first) = locks.first() {
for item in locks.iter().skip(1) {
if !first.lock.compatible_with(item.lock) {
if first.lock != item.lock {
return Err(AppError::BadRequest(format!(
"{} codec {} conflicts with {} codec {}; choose a compatible codec or stop the running service first",
item.source,
@@ -290,15 +279,6 @@ pub fn encoder_codec_to_id(codec: VideoEncoderType) -> &'static str {
}
}
pub fn video_codec_to_encoder_codec(codec: VideoCodecType) -> VideoEncoderType {
match codec {
VideoCodecType::H264 => VideoEncoderType::H264,
VideoCodecType::H265 => VideoEncoderType::H265,
VideoCodecType::VP8 => VideoEncoderType::VP8,
VideoCodecType::VP9 => VideoEncoderType::VP9,
}
}
pub fn encoder_codec_to_video_codec(codec: VideoEncoderType) -> VideoCodecType {
match codec {
VideoEncoderType::H264 => VideoCodecType::H264,

View File

@@ -13,6 +13,8 @@ pub mod pipeline;
pub mod recovery;
pub mod signal;
#[cfg(feature = "desktop")]
pub mod snapshot;
#[cfg(feature = "desktop")]
pub mod stream_manager;
#[cfg(feature = "desktop")]
pub mod streamer;

View File

@@ -256,6 +256,22 @@ fn run(
}
let capture = active.as_mut().expect("opened capture");
if pipeline.snapshot_slot.pending() {
let mut raw = Vec::new();
if let Ok(meta) = capture.stream.next_into(&mut raw) {
raw.truncate(meta.bytes_used);
if meta.bytes_used >= MIN_CAPTURE_FRAME_SIZE {
pipeline.snapshot_slot.publish(&VideoFrame::from_vec(
raw,
capture.stream.resolution(),
capture.stream.format(),
capture.stream.stride(),
meta.sequence,
));
}
}
continue;
}
let reservations = pipeline.reserve_outputs();
let result = if reservations.as_ref().is_none_or(Vec::is_empty) {
match capture.encoder.as_mut().expect("active encoder") {

View File

@@ -8,7 +8,7 @@ use crate::video::codec::vp8::{VP8Config, VP8Encoder};
use crate::video::codec::vp9::{VP9Config, VP9Encoder};
use crate::video::format::{PixelFormat, Resolution};
use bytes::Bytes;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
use hwcodec::ffmpeg_hw::{
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
};
@@ -22,9 +22,9 @@ pub(super) struct EncoderThreadState {
pub(super) nv12_converter: Option<Nv12Converter>,
pub(super) yuv420p_converter: Option<PixelConverter>,
pub(super) encoder_needs_yuv420p: bool,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(super) ffmpeg_hw_enabled: bool,
pub(super) fps: u32,
pub(super) codec: VideoEncoderType,
@@ -347,9 +347,9 @@ pub(super) fn build_encoder_state(
}
};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
if needs_mjpeg_decode
&& is_rkmpp_encoder
&& matches!(
@@ -389,9 +389,9 @@ pub(super) fn build_encoder_state(
nv12_converter: None,
yuv420p_converter: None,
encoder_needs_yuv420p: false,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: Some(pipeline),
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: true,
fps: config.fps,
codec: config.output_codec,
@@ -573,9 +573,9 @@ pub(super) fn build_encoder_state(
nv12_converter,
yuv420p_converter,
encoder_needs_yuv420p: needs_yuv420p,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: None,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: false,
fps: config.fps,
codec: config.output_codec,

View File

@@ -7,6 +7,7 @@ mod mjpeg_admission;
mod shared;
pub use delivery::EncodedVideoFrameReceiver;
pub(crate) use shared::compact_strided_frame_for_encoder;
pub use shared::{
EncodedVideoFrame, PipelineAppliedConfig, PipelineLifecycle, PipelineStateNotification,
SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,

View File

@@ -35,14 +35,11 @@ use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, En
use super::frame_mailbox::{FrameMailbox, PublishResult};
use super::mjpeg_admission::{MjpegAdmissionPolicy, MjpegAdmissionStage, MjpegDropReason};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
#[cfg(all(target_os = "linux", feature = "rkmpp"))]
#[path = "dmabuf.rs"]
mod dmabuf;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
#[cfg(any(test, all(target_os = "linux", feature = "rkmpp")))]
fn rkmpp_dma_eligible(
backend: Option<EncoderBackend>,
codec: VideoEncoderType,
@@ -269,7 +266,7 @@ fn spawn_mjpeg_decode_workers(
senders
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
/// Encoded video frame for distribution
@@ -524,6 +521,7 @@ struct CachedH26xParameterSets {
/// Universal shared video pipeline
pub struct SharedVideoPipeline {
pub(crate) snapshot_slot: crate::video::snapshot::SnapshotSlot,
config: RwLock<SharedVideoPipelineConfig>,
subscribers: ParkingRwLock<Vec<Arc<VideoSubscriber>>>,
stats: ParkingMutex<SharedVideoPipelineStats>,
@@ -588,6 +586,7 @@ impl SharedVideoPipeline {
let (h264_profile_tx, h264_profile_rx) = watch::channel(None);
let pipeline = Arc::new(Self {
snapshot_slot: Default::default(),
config: RwLock::new(config),
subscribers: ParkingRwLock::new(Vec::new()),
stats: ParkingMutex::new(SharedVideoPipelineStats::default()),
@@ -726,7 +725,7 @@ impl SharedVideoPipeline {
match cmd {
PipelineCmd::SetBitrate { preset } => {
let bitrate_kbps = preset.bitrate_kbps();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
if state.ffmpeg_hw_enabled {
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
pipeline
@@ -1154,7 +1153,7 @@ impl SharedVideoPipeline {
Err(e) => return Err(e),
};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
#[cfg(all(target_os = "linux", feature = "rkmpp"))]
if dmabuf::eligible(&config) {
if let Some(stream) = preopened.as_ref().filter(|s| s.supports_rkmpp_dmabuf()) {
match dmabuf::prepare(stream, &config) {
@@ -1698,6 +1697,15 @@ impl SharedVideoPipeline {
owned.truncate(frame_size);
capture_sequence = capture_sequence.wrapping_add(1);
if pipeline.snapshot_slot.pending() {
pipeline.snapshot_slot.publish(&VideoFrame::from_vec(
owned.clone(),
resolution,
pixel_format,
stride,
capture_sequence,
));
}
if parallel_mjpeg_decode
&& pixel_format == PixelFormat::Mjpeg
@@ -1817,7 +1825,7 @@ impl SharedVideoPipeline {
let pts_ms = self.pts_ms();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
if state.ffmpeg_hw_enabled {
if input_format != PixelFormat::Mjpeg {
return Err(AppError::VideoError(
@@ -2079,7 +2087,10 @@ impl SharedVideoPipeline {
}
}
fn compact_strided_frame_for_encoder(frame: &VideoFrame, data: &[u8]) -> Result<Option<Vec<u8>>> {
pub(crate) fn compact_strided_frame_for_encoder(
frame: &VideoFrame,
data: &[u8],
) -> Result<Option<Vec<u8>>> {
let width = frame.resolution.width as usize;
let height = frame.resolution.height as usize;
let stride = frame.stride as usize;
@@ -2527,9 +2538,9 @@ mod tests {
nv12_converter: None,
yuv420p_converter: None,
encoder_needs_yuv420p: false,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: None,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: false,
fps: 30,
codec: VideoEncoderType::H264,

271
src/video/snapshot.rs Normal file
View File

@@ -0,0 +1,271 @@
//! One-shot capture at the current source resolution. No resize or browser dependency.
use super::codec::{Encoder, EncoderConfig, JpegEncoder, PixelConverter};
use super::{format::PixelFormat, frame::VideoFrame, VideoStreamManager};
use crate::config::StreamMode;
use crate::error::{AppError, Result};
use bytes::Bytes;
use parking_lot::Mutex;
use std::time::Duration;
use tokio::sync::oneshot;
#[derive(Default)]
pub(crate) struct SnapshotSlot(Mutex<Option<oneshot::Sender<VideoFrame>>>);
impl SnapshotSlot {
pub fn request(&self) -> Result<oneshot::Receiver<VideoFrame>> {
let mut slot = self.0.lock();
if slot.as_ref().is_some_and(|tx| !tx.is_closed()) {
return Err(AppError::ServiceUnavailable(
"Snapshot already pending".into(),
));
}
let (tx, rx) = oneshot::channel();
*slot = Some(tx);
Ok(rx)
}
pub fn pending(&self) -> bool {
self.0.lock().as_ref().is_some_and(|tx| !tx.is_closed())
}
pub fn publish(&self, frame: &VideoFrame) {
let mut slot = self.0.lock();
if !slot.as_ref().is_some_and(|tx| !tx.is_closed()) {
*slot = None;
return;
}
// UVC devices can start with both partial JPEGs and structurally valid
// but noisy JPEGs. Skip the initial capture frames and keep waiting for
// a valid frame within the existing timeout, without reopening capture.
if frame.online
&& frame.format.is_compressed()
&& (frame.sequence < 3 || turbojpeg::read_header(frame.data()).is_err())
{
return;
}
if let Some(tx) = slot.take() {
let _ = tx.send(frame.clone());
}
}
}
pub struct Snapshot {
pub jpeg: Bytes,
pub width: u32,
pub height: u32,
pub age_ms: u64,
}
impl VideoStreamManager {
pub async fn capture_snapshot(&self, quality: Option<u8>) -> Result<Snapshot> {
if self.is_switching() || self.is_config_changing() {
return Err(AppError::ServiceUnavailable(
"Video configuration is changing; retry later".into(),
));
}
if quality.is_some_and(|q| !(1..=100).contains(&q)) {
return Err(AppError::BadRequest(
"quality must be between 1 and 100".into(),
));
}
let capture = async {
let frame = match self.current_mode().await {
StreamMode::Mjpeg => {
let streamer = self.streamer();
let handler = self.mjpeg_handler();
let _guard = crate::stream::ClientGuard::new(
format!("snapshot-{}", uuid::Uuid::new_v4()),
handler,
);
let rx = streamer.snapshot_slot.request()?;
streamer.start().await?;
rx.await.map_err(|_| {
AppError::ServiceUnavailable("Capture stopped before snapshot".into())
})?
}
StreamMode::WebRTC => {
let pipeline = self.snapshot_pipeline().await?;
// Keeps the shared capture pipeline alive without opening another video device.
let _consumer = pipeline.subscribe();
let rx = pipeline.snapshot_slot.request()?;
rx.await.map_err(|_| {
AppError::ServiceUnavailable("Capture stopped before snapshot".into())
})?
}
};
tokio::task::spawn_blocking(move || encode_snapshot(frame, quality))
.await
.map_err(|_| AppError::VideoError("Snapshot encoder failed".into()))?
};
tokio::time::timeout(Duration::from_secs(10), capture)
.await
.map_err(|_| {
AppError::ServiceUnavailable("Timed out waiting for a fresh video frame".into())
})?
}
}
fn encode_snapshot(frame: VideoFrame, quality: Option<u8>) -> Result<Snapshot> {
if !frame.online {
return Err(AppError::ServiceUnavailable("No video signal".into()));
}
let resolution = frame.resolution;
let age = frame.capture_ts;
let jpeg = if frame.format.is_compressed() {
let header = turbojpeg::read_header(frame.data()).map_err(jpeg_error)?;
// Use dimensions from the actual image, including source-following resolution changes.
let jpeg = if let Some(quality) = quality {
let image = turbojpeg::decompress(frame.data(), turbojpeg::PixelFormat::RGB)
.map_err(jpeg_error)?;
Bytes::from(
turbojpeg::compress(image.as_deref(), quality as i32, turbojpeg::Subsamp::Sub2x2)
.map_err(jpeg_error)?
.to_vec(),
)
} else {
frame.owned_data_bytes()
};
return Ok(Snapshot {
jpeg,
width: header.width as u32,
height: header.height as u32,
age_ms: age.elapsed().as_millis() as u64,
});
} else {
let compact = super::pipeline::compact_strided_frame_for_encoder(&frame, frame.data())?;
let data = compact.as_deref().unwrap_or(frame.data());
let quality = quality.unwrap_or(80) as u32;
let mut config = EncoderConfig::jpeg(resolution, quality);
config.input_format = frame.format;
let mut encoder = JpegEncoder::new(config)?;
if encoder.supports_format(frame.format) {
encoder.encode(data, frame.sequence)?.data
} else {
let mut converter = match frame.format {
PixelFormat::Uyvy => Some(PixelConverter::uyvy_to_yuv420p(resolution)),
PixelFormat::Nv21 => Some(PixelConverter::nv21_to_yuv420p(resolution)),
PixelFormat::Yvu420 => Some(PixelConverter::yvu420_to_yuv420p(resolution)),
PixelFormat::Yuv420 => None,
other => {
return Err(AppError::VideoError(format!(
"Snapshot format unsupported: {other}"
)))
}
};
let i420 = match converter.as_mut() {
Some(c) => c.convert(data)?,
None => data,
};
Bytes::from(
turbojpeg::compress_yuv(
turbojpeg::YuvImage {
pixels: i420,
width: resolution.width as usize,
height: resolution.height as usize,
align: 1,
subsamp: turbojpeg::Subsamp::Sub2x2,
},
quality as i32,
)
.map_err(jpeg_error)?
.to_vec(),
)
}
};
Ok(Snapshot {
jpeg,
width: resolution.width,
height: resolution.height,
age_ms: age.elapsed().as_millis() as u64,
})
}
fn jpeg_error(error: turbojpeg::Error) -> AppError {
AppError::VideoError(format!("Snapshot JPEG: {error}"))
}
#[cfg(test)]
mod tests {
use super::super::format::Resolution;
use super::*;
#[test]
fn snapshot_preserves_native_resolution_and_padded_rows() {
let width = 2048;
let height = 4;
let stride = width * 3 + 32;
let frame = VideoFrame::from_vec(
vec![90; stride * height],
Resolution::new(width as u32, height as u32),
PixelFormat::Rgb24,
stride as u32,
1,
);
let snapshot = encode_snapshot(frame, Some(80)).unwrap();
let header = turbojpeg::read_header(&snapshot.jpeg).unwrap();
assert_eq!((header.width, header.height), (width, height));
assert_eq!(
(snapshot.width, snapshot.height),
(width as u32, height as u32)
);
}
#[test]
fn jpeg_passthrough_and_quality_change_preserve_dimensions() {
let resolution = Resolution::new(2048, 4);
let raw = VideoFrame::from_vec(
vec![80; 2048 * 4 * 3],
resolution,
PixelFormat::Rgb24,
2048 * 3,
1,
);
let first = encode_snapshot(raw, Some(80)).unwrap();
let source = VideoFrame::new(first.jpeg.clone(), resolution, PixelFormat::Mjpeg, 0, 2);
let copied = encode_snapshot(source.clone(), None).unwrap();
assert_eq!(copied.jpeg, first.jpeg);
let changed = encode_snapshot(source, Some(60)).unwrap();
let header = turbojpeg::read_header(&changed.jpeg).unwrap();
assert_eq!((header.width, header.height), (2048, 4));
}
#[tokio::test]
async fn slot_delivers_only_a_requested_new_frame() {
let slot = SnapshotSlot::default();
assert!(!slot.pending());
let rx = slot.request().unwrap();
assert!(slot.request().is_err());
let frame = VideoFrame::from_vec(
vec![0; 24],
Resolution::new(4, 2),
PixelFormat::Rgb24,
12,
7,
);
slot.publish(&frame);
assert_eq!(rx.await.unwrap().sequence, 7);
assert!(!slot.pending());
let abandoned = slot.request().unwrap();
drop(abandoned);
assert!(slot.request().is_ok());
}
#[tokio::test]
async fn snapshot_waits_past_invalid_and_unsettled_startup_jpeg() {
let slot = SnapshotSlot::default();
let mut rx = slot.request().unwrap();
let resolution = Resolution::new(4, 2);
let partial = VideoFrame::from_vec(vec![0; 128], resolution, PixelFormat::Mjpeg, 0, 3);
slot.publish(&partial);
assert!(matches!(
rx.try_recv(),
Err(oneshot::error::TryRecvError::Empty)
));
assert!(slot.pending());
let raw = VideoFrame::from_vec(vec![80; 24], resolution, PixelFormat::Rgb24, 12, 2);
let jpeg = encode_snapshot(raw, None).unwrap();
let startup = VideoFrame::new(jpeg.jpeg.clone(), resolution, PixelFormat::Mjpeg, 0, 2);
slot.publish(&startup);
assert!(matches!(
rx.try_recv(),
Err(oneshot::error::TryRecvError::Empty)
));
let complete = VideoFrame::new(jpeg.jpeg, resolution, PixelFormat::Mjpeg, 0, 3);
slot.publish(&complete);
assert_eq!(rx.await.unwrap().sequence, 3);
assert!(!slot.pending());
}
}

View File

@@ -211,19 +211,6 @@ impl VideoStreamManager {
Ok(())
}
/// Switch streaming mode
///
/// This will:
/// 1. Acquire switching lock (prevent concurrent switches)
/// 2. Notify clients of the mode change
/// 3. Stop the current mode
/// 4. Start the new mode (ensuring video capture runs for WebRTC)
/// 5. Update configuration
pub async fn switch_mode(self: &Arc<Self>, new_mode: StreamMode) -> Result<()> {
let _ = self.switch_mode_transaction(new_mode).await?;
Ok(())
}
/// Switch streaming mode with a transaction ID for correlating events
///
/// If a switch is already in progress, returns `accepted=false` with the
@@ -614,16 +601,6 @@ impl VideoStreamManager {
}
}
/// Get MJPEG client count
pub fn mjpeg_client_count(&self) -> u64 {
self.streamer.mjpeg_handler().client_count()
}
/// Get WebRTC session count
pub async fn webrtc_session_count(&self) -> usize {
self.webrtc_streamer.session_count().await
}
/// Set HID controller for WebRTC DataChannel
pub async fn set_hid_controller(&self, hid: Arc<HidController>) {
self.webrtc_streamer.set_hid_controller(hid).await;
@@ -634,11 +611,6 @@ impl VideoStreamManager {
self.webrtc_streamer.set_audio_enabled(enabled).await
}
/// Check if WebRTC audio is enabled
pub async fn is_webrtc_audio_enabled(&self) -> bool {
self.webrtc_streamer.is_audio_enabled().await
}
/// Reconnect audio sources for all WebRTC sessions
/// Call this after audio controller restarts (e.g., quality change)
pub async fn reconnect_webrtc_audio_sources(&self) {
@@ -732,6 +704,22 @@ impl VideoStreamManager {
}
}
pub(crate) async fn snapshot_pipeline(
&self,
) -> Result<Arc<crate::video::pipeline::SharedVideoPipeline>> {
// Preserve a running pipeline's negotiated/source-following geometry.
// Syncing the MJPEG configuration on every snapshot could restart WebRTC viewers.
if self.webrtc_streamer.get_pipeline_config().await.is_none() {
if self.streamer.state().await == StreamerState::Uninitialized {
self.streamer.init_auto().await?;
}
self.sync_webrtc_capture_source("for snapshot").await;
}
self.webrtc_streamer
.ensure_video_pipeline_for_external()
.await
}
/// Get the current video encoding configuration from the shared pipeline
pub async fn get_encoding_config(
&self,

View File

@@ -165,6 +165,7 @@ impl StreamerState {
pub struct Streamer {
config: RwLock<StreamerConfig>,
mjpeg_handler: Arc<MjpegStreamHandler>,
pub(crate) snapshot_slot: super::snapshot::SnapshotSlot,
current_device: RwLock<Option<VideoDeviceInfo>>,
state: RwLock<StreamerState>,
start_lock: tokio::sync::Mutex<()>,
@@ -197,6 +198,7 @@ impl Streamer {
Arc::new(Self {
config: RwLock::new(StreamerConfig::default()),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
snapshot_slot: Default::default(),
current_device: RwLock::new(None),
state: RwLock::new(StreamerState::Uninitialized),
start_lock: tokio::sync::Mutex::new(()),
@@ -221,6 +223,7 @@ impl Streamer {
Arc::new(Self {
config: RwLock::new(config),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
snapshot_slot: Default::default(),
current_device: RwLock::new(None),
state: RwLock::new(StreamerState::Uninitialized),
start_lock: tokio::sync::Mutex::new(()),
@@ -260,10 +263,6 @@ impl Streamer {
}
}
pub fn set_next_retry_ms(&self, ms: u64) {
self.next_retry_ms.store(ms, Ordering::Relaxed);
}
/// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
@@ -291,12 +290,6 @@ impl Streamer {
self.current_device.read().await.clone()
}
/// Get current video configuration (format, resolution, fps)
pub async fn current_video_config(&self) -> (PixelFormat, Resolution, u32) {
let config = self.config.read().await;
(config.format, config.resolution, config.fps)
}
/// Get current capture configuration for direct pipelines
pub async fn current_capture_config(
&self,
@@ -1236,6 +1229,7 @@ impl Streamer {
});
}
self.snapshot_slot.publish(&frame);
self.mjpeg_handler.update_frame(frame);
fps_frame_count += 1;
@@ -1534,6 +1528,7 @@ impl Default for Streamer {
Self {
config: RwLock::new(StreamerConfig::default()),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
snapshot_slot: Default::default(),
current_device: RwLock::new(None),
state: RwLock::new(StreamerState::Uninitialized),
start_lock: tokio::sync::Mutex::new(()),

View File

@@ -14,6 +14,8 @@ mod platform {
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf};
// Request arguments use c_ulong on glibc and c_int on musl. Cast at the
// call sites to preserve the kernel command's low 32 bits on either libc.
const WDIOC_GETSUPPORT: libc::c_ulong = 0x8028_5700;
const WDIOC_SETOPTIONS: libc::c_ulong = 0x8004_5704;
const WDIOC_KEEPALIVE: libc::c_ulong = 0x8004_5705;
@@ -54,7 +56,7 @@ mod platform {
let supports_magic_close = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&file),
WDIOC_GETSUPPORT,
WDIOC_GETSUPPORT as _,
&mut info,
) == 0
&& info.options & WDIOF_MAGICCLOSE != 0
@@ -109,7 +111,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_KEEPALIVE,
WDIOC_KEEPALIVE as _,
0,
)
};
@@ -125,7 +127,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_GETTIMEOUT,
WDIOC_GETTIMEOUT as _,
&mut timeout,
)
};
@@ -152,7 +154,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_SETOPTIONS,
WDIOC_SETOPTIONS as _,
&mut option,
)
};

View File

@@ -135,30 +135,12 @@ pub struct WolHistoryResponse {
pub history: Vec<WolHistoryEntry>,
}
fn normalize_wol_mac_address(mac_address: &str) -> String {
let normalized = mac_address.trim().to_uppercase().replace('-', ":");
if normalized.len() == 12 && normalized.chars().all(|c| c.is_ascii_hexdigit()) {
let mut mac_with_separator = String::with_capacity(17);
for (index, chunk) in normalized.as_bytes().chunks(2).enumerate() {
if index > 0 {
mac_with_separator.push(':');
}
mac_with_separator.push(chunk[0] as char);
mac_with_separator.push(chunk[1] as char);
}
mac_with_separator
} else {
normalized
}
}
/// Send Wake-on-LAN magic packet
pub async fn atx_wol(
State(state): State<Arc<AppState>>,
Json(req): Json<WolRequest>,
) -> Result<Json<LoginResponse>> {
let mac_address = normalize_wol_mac_address(&req.mac_address);
let mac_address = crate::atx::normalize_wol_mac_address(&req.mac_address)?;
// Get WOL interface from config
let config = state.config.get();

View File

@@ -1,64 +0,0 @@
use axum::{
extract::{ws::WebSocketUpgrade, Query, State},
response::Response,
Json,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::computer_use::{
ComputerUseConfigResponse, ComputerUseConfigUpdate, ComputerUseSessionSummary,
ComputerUseStartRequest,
};
use crate::error::Result;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct ComputerUseWsQuery {
client_id: Option<String>,
}
pub async fn computer_use_config(
State(state): State<Arc<AppState>>,
) -> Json<ComputerUseConfigResponse> {
Json(state.computer_use.config_response())
}
pub async fn computer_use_update_config(
State(state): State<Arc<AppState>>,
Json(req): Json<ComputerUseConfigUpdate>,
) -> Result<Json<ComputerUseConfigResponse>> {
Ok(Json(state.computer_use.update_config(req).await?))
}
pub async fn computer_use_session(
State(state): State<Arc<AppState>>,
) -> Json<ComputerUseSessionSummary> {
Json(state.computer_use.summary().await)
}
pub async fn computer_use_start(
State(state): State<Arc<AppState>>,
Json(req): Json<ComputerUseStartRequest>,
) -> Result<Json<ComputerUseSessionSummary>> {
Ok(Json(state.computer_use.start(req).await?))
}
pub async fn computer_use_stop(
State(state): State<Arc<AppState>>,
) -> Result<Json<ComputerUseSessionSummary>> {
Ok(Json(state.computer_use.stop().await?))
}
pub async fn computer_use_ws(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Query(query): Query<ComputerUseWsQuery>,
) -> Response {
ws.on_upgrade(move |socket| {
state
.computer_use
.clone()
.handle_socket(socket, query.client_id)
})
}

View File

@@ -1,170 +0,0 @@
use std::sync::Arc;
use crate::config::*;
use crate::error::{AppError, Result};
pub use crate::runtime::{try_apply_lock, ConfigApplyOptions};
use crate::state::AppState;
use crate::stream_encoder::encoder_type_to_backend;
pub async fn apply_video_config(
state: &Arc<AppState>,
old_config: &VideoConfig,
new_config: &VideoConfig,
options: ConfigApplyOptions,
) -> Result<()> {
if old_config == new_config && !options.force {
tracing::info!("Video config unchanged, skipping reload");
return Ok(());
}
tracing::info!("Applying video config changes...");
let device = new_config
.device
.clone()
.ok_or_else(|| AppError::BadRequest("video_device is required".to_string()))?;
let format = new_config
.format
.as_ref()
.and_then(|f| {
serde_json::from_value::<crate::video::format::PixelFormat>(serde_json::Value::String(
f.clone(),
))
.ok()
})
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
state
.stream_manager
.apply_video_config(&device, format, resolution, new_config.fps)
.await
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
tracing::info!("Video config applied successfully");
Ok(())
}
pub async fn apply_stream_config(
state: &Arc<AppState>,
old_config: &StreamConfig,
new_config: &StreamConfig,
options: ConfigApplyOptions,
) -> Result<()> {
tracing::info!("Applying stream config changes...");
if options.force || old_config.encoder != new_config.encoder {
let encoder_backend = encoder_type_to_backend(new_config.encoder.clone());
tracing::info!(
"Updating encoder backend to: {:?} (from config: {:?})",
encoder_backend,
new_config.encoder
);
state.webrtc.update_encoder_backend(encoder_backend).await;
}
if options.force || old_config.bitrate_preset != new_config.bitrate_preset {
state
.stream_manager
.set_bitrate_preset(new_config.bitrate_preset)
.await?;
}
let ice_changed = old_config.stun_server != new_config.stun_server
|| old_config.turn_server != new_config.turn_server
|| old_config.turn_username != new_config.turn_username
|| old_config.turn_password != new_config.turn_password;
if options.force || ice_changed {
tracing::info!(
"Updating ICE config: STUN={:?}, TURN={:?}",
new_config.stun_server,
new_config.turn_server
);
state
.webrtc
.update_ice_config(
new_config.stun_server.clone(),
new_config.turn_server.clone(),
new_config.turn_username.clone(),
new_config.turn_password.clone(),
)
.await;
}
tracing::info!(
"Stream config applied: encoder={:?}, bitrate={}",
new_config.encoder,
new_config.bitrate_preset
);
Ok(())
}
pub async fn apply_atx_config(
state: &Arc<AppState>,
_old_config: &AtxConfig,
new_config: &AtxConfig,
) -> Result<()> {
tracing::info!("Applying ATX config changes...");
let controller_config = new_config.to_controller_config();
let atx_guard = state.atx.read().await;
if let Some(atx) = atx_guard.as_ref() {
if let Err(e) = atx.reload(controller_config).await {
tracing::error!("ATX reload failed: {}", e);
return Err(AppError::Config(format!("ATX reload failed: {}", e)));
}
tracing::info!("ATX controller reloaded successfully");
} else {
drop(atx_guard);
if new_config.enabled {
tracing::info!("ATX enabled in config, initializing...");
let atx = crate::atx::AtxController::new(controller_config);
atx.init()
.await
.map_err(|e| AppError::Config(format!("ATX initialization failed: {}", e)))?;
*state.atx.write().await = Some(atx);
tracing::info!("ATX controller initialized successfully");
}
}
Ok(())
}
pub async fn apply_audio_config(
state: &Arc<AppState>,
_old_config: &AudioConfig,
new_config: &AudioConfig,
) -> Result<()> {
tracing::info!("Applying audio config changes...");
let audio_config = crate::audio::AudioControllerConfig {
enabled: new_config.enabled,
device: new_config.device.clone(),
quality: new_config.quality.parse::<crate::audio::AudioQuality>()?,
};
state.audio.update_config(audio_config).await?;
tracing::info!(
"Audio config applied: enabled={}, device={}",
new_config.enabled,
new_config.device
);
state
.stream_manager
.set_webrtc_audio_enabled(new_config.enabled)
.await?;
tracing::debug!("WebRTC audio enabled: {}", new_config.enabled);
if new_config.enabled {
state.stream_manager.reconnect_webrtc_audio_sources().await;
}
Ok(())
}

View File

@@ -2,11 +2,11 @@ use axum::{extract::State, Json};
use std::sync::Arc;
use crate::atx::AtxDriverType;
use crate::config::{AtxConfig, HidBackend, HidConfig};
use crate::config::{AtxConfig, HidConfig};
use crate::error::{AppError, Result};
use crate::runtime::{apply_atx_config, try_apply_lock};
use crate::state::AppState;
use super::apply::{apply_atx_config, try_apply_lock};
use super::types::AtxConfigUpdate;
pub async fn get_atx_config(State(state): State<Arc<AppState>>) -> Json<AtxConfig> {
@@ -17,12 +17,13 @@ pub async fn update_atx_config(
State(state): State<Arc<AppState>>,
Json(req): Json<AtxConfigUpdate>,
) -> Result<Json<AtxConfig>> {
let _usb_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
let current_config = state.config.get();
let old_atx_config = current_config.atx.clone();
req.validate_with_current(&old_atx_config)?;
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
let mut merged_atx_config = old_atx_config.clone();
req.apply_to(&mut merged_atx_config);
validate_windows_atx_backends(&merged_atx_config)?;
@@ -37,7 +38,7 @@ pub async fn update_atx_config(
let new_atx_config = state.config.get().atx.clone();
apply_atx_config(&state, &old_atx_config, &new_atx_config).await?;
apply_atx_config(&state, &new_atx_config).await?;
Ok(Json(new_atx_config))
}
@@ -56,8 +57,8 @@ fn validate_windows_atx_backends(atx: &AtxConfig) -> Result<()> {
Ok(())
}
fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
if hid.backend != HidBackend::Ch9329 {
pub(super) fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
if !hid.backend.uses_ch9329() {
return Ok(());
}
let reserved = hid.ch9329_port.trim();
@@ -78,6 +79,7 @@ fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::HidBackend;
#[test]
fn test_validate_serial_device_conflict_rejects_ch9329_overlap() {
@@ -90,6 +92,8 @@ mod tests {
hid.ch9329_port = "/dev/ttyUSB0".to_string();
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
hid.backend = HidBackend::Ch9329Otg;
assert!(validate_serial_device_conflict(&atx, &hid).is_err());
}
#[test]

View File

@@ -3,9 +3,9 @@ use std::sync::Arc;
use crate::config::AudioConfig;
use crate::error::Result;
use crate::runtime::{apply_audio_config, try_apply_lock};
use crate::state::AppState;
use super::apply::{apply_audio_config, try_apply_lock};
use super::types::AudioConfigUpdate;
pub async fn get_audio_config(State(state): State<Arc<AppState>>) -> Json<AudioConfig> {
@@ -19,7 +19,6 @@ pub async fn update_audio_config(
req.validate()?;
let _apply_guard = try_apply_lock(&state.config_apply_locks.audio, "audio")?;
let old_audio_config = state.config.get().audio.clone();
state
.config
@@ -30,7 +29,7 @@ pub async fn update_audio_config(
let new_audio_config = state.config.get().audio.clone();
apply_audio_config(&state, &old_audio_config, &new_audio_config).await?;
apply_audio_config(&state, &new_audio_config).await?;
Ok(Json(new_audio_config))
}

View File

@@ -15,7 +15,6 @@ pub async fn update_auth_config(
State(state): State<Arc<AppState>>,
Json(update): Json<AuthConfigUpdate>,
) -> Result<Json<AuthConfig>> {
update.validate()?;
state
.config
.update(|config| {

View File

@@ -1,4 +1,3 @@
pub(crate) mod apply;
mod types;
mod atx;
@@ -60,7 +59,8 @@ use crate::state::AppState;
fn sanitize_config_for_api(config: &mut AppConfig) {
config.stream.turn_password = None;
config.computer_use.api_key = None;
config.mcp.api_key_hash = None;
config.mcp.api_key = None;
config.rustdesk.device_password.clear();
config.rustdesk.relay_key = None;

View File

@@ -2,7 +2,7 @@ use axum::{extract::State, Json};
use serde::Serialize;
use typeshare::typeshare;
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig};
use crate::config::{HidConfig, MsdConfig, OtgNetworkConfig, UacConfig};
use crate::error::Result;
use crate::otg::OtgNetworkStatus;
use crate::web::state::UsbApiState;
@@ -16,6 +16,7 @@ pub struct OtgConfigResponse {
pub hid: HidConfig,
pub msd: MsdConfig,
pub network: OtgNetworkConfig,
pub uac: UacConfig,
pub status: OtgNetworkStatus,
}
@@ -48,6 +49,10 @@ pub(super) async fn update_otg_config_inner(
if let Some(ref update) = request.network {
update.apply_to(&mut staged.otg_network);
}
if let Some(ref uac) = request.uac {
uac.validate()?;
staged.uac = uac.clone();
}
Ok(requested_ch9329_descriptor)
})
@@ -57,6 +62,7 @@ pub(super) async fn update_otg_config_inner(
hid: staged_config.hid,
msd: staged_config.msd,
network: staged_config.otg_network,
uac: staged_config.uac,
status: state.otg.network_status().await,
})
}

View File

@@ -2,9 +2,9 @@ use axum::{extract::State, Json};
use std::sync::Arc;
use crate::error::Result;
use crate::runtime::{apply_stream_config, try_apply_lock, ConfigApplyOptions};
use crate::state::AppState;
use super::apply::{apply_stream_config, try_apply_lock, ConfigApplyOptions};
use super::types::{StreamConfigResponse, StreamConfigUpdate};
pub async fn get_stream_config(State(state): State<Arc<AppState>>) -> Json<StreamConfigResponse> {

View File

@@ -31,10 +31,6 @@ pub struct AuthConfigUpdate {
}
impl AuthConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> {
Ok(())
}
pub fn apply_to(&self, config: &mut AuthConfig) {
if let Some(allow_multiple) = self.single_user_allow_multiple_sessions {
config.single_user_allow_multiple_sessions = allow_multiple;
@@ -399,9 +395,28 @@ impl Ch9329DescriptorConfigUpdate {
}
}
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct MouseJigglerConfigUpdate {
pub enabled: Option<bool>,
pub interval_secs: Option<u32>,
}
impl MouseJigglerConfigUpdate {
fn apply_to(&self, config: &mut MouseJigglerConfig) {
if let Some(enabled) = self.enabled {
config.enabled = enabled;
}
if let Some(interval) = self.interval_secs {
config.interval_secs = interval;
}
}
}
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct HidConfigUpdate {
pub jiggler: Option<MouseJigglerConfigUpdate>,
/// Request-only; never persisted or replayed during startup.
pub bluetooth_reset_pairing: Option<bool>,
pub bluetooth: Option<crate::config::BluetoothHidConfig>,
@@ -427,10 +442,16 @@ pub struct OtgConfigUpdate {
pub hid: Option<HidConfigUpdate>,
pub msd: Option<MsdConfigUpdate>,
pub network: Option<OtgNetworkConfigUpdate>,
pub uac: Option<crate::config::UacConfig>,
}
impl HidConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> {
if let Some(update) = &self.jiggler {
let mut config = MouseJigglerConfig::default();
update.apply_to(&mut config);
config.validate()?;
}
if let Some(config) = &self.bluetooth {
config.validate()?;
}
@@ -452,6 +473,9 @@ impl HidConfigUpdate {
}
pub fn apply_to(&self, config: &mut HidConfig) {
if let Some(update) = &self.jiggler {
update.apply_to(&mut config.jiggler);
}
if let Some(bluetooth) = &self.bluetooth {
config.bluetooth = bluetooth.clone();
}

View File

@@ -12,15 +12,24 @@ pub(super) fn stage_hid_config_update(
update.validate()?;
let old_descriptor = staged_hid.ch9329_descriptor.clone();
update.apply_to(staged_hid);
let mut updated_hid = staged_hid.clone();
update.apply_to(&mut updated_hid);
if update.jiggler.as_ref().and_then(|update| update.enabled) == Some(true)
&& !updated_hid.has_mouse()
{
return Err(crate::error::AppError::BadRequest(
"Mouse jiggler requires a configured mouse interface".into(),
));
}
let requested_descriptor = update
.ch9329_descriptor
.as_ref()
.map(|_| staged_hid.ch9329_descriptor.clone());
.map(|_| updated_hid.ch9329_descriptor.clone());
if requested_descriptor.is_some() {
staged_hid.ch9329_descriptor = old_descriptor;
updated_hid.ch9329_descriptor = old_descriptor;
}
*staged_hid = updated_hid;
Ok(requested_descriptor)
}
@@ -47,6 +56,21 @@ where
let requested_ch9329_descriptor = stage_update(&mut staged_config)?;
staged_config.enforce_invariants();
staged_config.hid.validate_platform()?;
staged_config.hid.validate_composite_selection()?;
#[cfg(unix)]
if staged_config.hid.backend == HidBackend::Ch9329Otg
&& (old_config.hid.backend != staged_config.hid.backend
|| old_config.hid.otg_udc != staged_config.hid.otg_udc)
&& !crate::otg::list_udc_devices()
.iter()
.any(|udc| Some(udc) == staged_config.hid.otg_udc.as_ref())
{
return Err(AppError::BadRequest(
"Selected USB device controller is missing".into(),
));
}
super::atx::validate_serial_device_conflict(&staged_config.atx, &staged_config.hid)?;
staged_config.hid.validate_otg_functions()?;
#[cfg(unix)]
@@ -179,7 +203,7 @@ where
}
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
if staged_config.hid.backend == HidBackend::Ch9329 {
if staged_config.hid.backend.uses_ch9329() {
match state.hid.apply_ch9329_descriptor(descriptor).await {
Ok(actual) => {
staged_config.hid.ch9329_descriptor = actual.descriptor;
@@ -250,7 +274,7 @@ async fn rollback_after_failure(
{
rollback_errors.push(format!("runtime rollback failed: {error}"));
}
if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 {
if restore_descriptor && old_config.hid.backend.uses_ch9329() {
if let Err(error) = state
.hid
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
@@ -276,8 +300,34 @@ mod tests {
use crate::config::HidBackend;
use crate::web::handlers::config::types::Ch9329DescriptorConfigUpdate;
#[cfg(unix)]
#[test]
fn composite_advanced_features_can_be_submitted_in_one_usb_transaction() {
let request: super::super::types::OtgConfigUpdate = serde_json::from_value(serde_json::json!({
"hid": { "backend": "ch9329_otg", "ch9329_port": "/dev/ttyUSB1", "otg_udc": "test.udc" },
"msd": { "enabled": true },
"network": { "enabled": true },
"uac": { "enabled": true, "sample_rate": 48000, "channels": 2 }
})).unwrap();
let mut config = AppConfig::default();
stage_hid_config_update(&mut config.hid, request.hid.as_ref().unwrap()).unwrap();
request.msd.as_ref().unwrap().apply_to(&mut config.msd);
request
.network
.as_ref()
.unwrap()
.apply_to(&mut config.otg_network);
config.uac = request.uac.unwrap();
config.enforce_invariants();
assert!(config.msd.enabled && config.otg_network.enabled && config.uac.enabled);
assert!(config.hid.constrained_otg_functions().is_empty());
assert_eq!(config.hid.ch9329_port, "/dev/ttyUSB1");
assert_eq!(config.hid.otg_udc.as_deref(), Some("test.udc"));
}
fn hid_update() -> HidConfigUpdate {
HidConfigUpdate {
jiggler: None,
bluetooth_reset_pairing: None,
bluetooth: None,
backend: None,
@@ -295,6 +345,52 @@ mod tests {
}
}
#[test]
fn jiggler_partial_updates_validate_and_preserve_other_preferences() {
let mut hid = HidConfig::default();
hid.backend = HidBackend::Ch9329;
for (request, enabled, interval) in [
(serde_json::json!({"jiggler":{"enabled":true}}), true, 60),
(
serde_json::json!({"jiggler":{"interval_secs":120}}),
true,
120,
),
(serde_json::json!({"jiggler":{"enabled":false}}), false, 120),
(serde_json::json!({"mouse_absolute":false}), false, 120),
] {
let update: HidConfigUpdate = serde_json::from_value(request).unwrap();
stage_hid_config_update(&mut hid, &update).unwrap();
assert_eq!(hid.jiggler.enabled, enabled);
assert_eq!(hid.jiggler.interval_secs, interval);
assert_eq!(hid.backend, HidBackend::Ch9329);
}
for interval in [0, 9, 3601] {
let update: HidConfigUpdate =
serde_json::from_value(serde_json::json!({"jiggler":{"interval_secs":interval}}))
.unwrap();
assert!(stage_hid_config_update(&mut hid, &update).is_err());
assert_eq!(hid.jiggler.interval_secs, 120);
}
for interval in [
serde_json::json!(-1),
serde_json::json!(10.5),
serde_json::json!("60"),
] {
assert!(serde_json::from_value::<HidConfigUpdate>(
serde_json::json!({"jiggler":{"interval_secs":interval}})
)
.is_err());
}
hid.backend = HidBackend::None;
let enable: HidConfigUpdate =
serde_json::from_value(serde_json::json!({"jiggler":{"enabled":true}})).unwrap();
assert!(stage_hid_config_update(&mut hid, &enable).is_err());
let disable: HidConfigUpdate =
serde_json::from_value(serde_json::json!({"jiggler":{"enabled":false}})).unwrap();
assert!(stage_hid_config_update(&mut hid, &disable).is_ok());
}
#[test]
fn reset_is_request_only_and_feature_updates_preserve_device_selection() {
let mut hid = HidConfig::default();

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