Compare commits

...

20 Commits
v261001 ... dev

Author SHA1 Message Date
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
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
181 changed files with 15534 additions and 8247 deletions

1
.gitignore vendored
View File

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

174
Cargo.lock generated
View File

@@ -719,6 +719,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link",
] ]
@@ -1063,7 +1064,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11"
dependencies = [ dependencies = [
"darling", "darling 0.20.11",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -1076,8 +1077,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.20.11",
"darling_macro", "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]] [[package]]
@@ -1094,17 +1105,41 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.20.11" version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.20.11",
"quote", "quote",
"syn 2.0.119", "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]] [[package]]
name = "dary_heap" name = "dary_heap"
version = "0.3.9" version = "0.3.9"
@@ -1298,6 +1333,12 @@ dependencies = [
"x509-parser 0.16.0", "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]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.16.9" version = "0.16.9"
@@ -2070,6 +2111,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.1", "hashbrown 0.17.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -2879,6 +2922,7 @@ dependencies = [
"rand 0.10.3", "rand 0.10.3",
"rcgen 0.14.10", "rcgen 0.14.10",
"reqwest", "reqwest",
"rmcp",
"rtp", "rtp",
"rtsp-types", "rtsp-types",
"rust-embed", "rust-embed",
@@ -3049,6 +3093,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -3438,6 +3488,26 @@ dependencies = [
"bitflags 2.13.2", "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]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@@ -3574,6 +3644,51 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" 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]] [[package]]
name = "rtcp" name = "rtcp"
version = "0.17.2" version = "0.17.2"
@@ -3738,6 +3853,32 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -3839,6 +3980,17 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.151" version = "1.0.151"
@@ -4166,6 +4318,19 @@ dependencies = [
"url", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -4563,6 +4728,7 @@ dependencies = [
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"pin-project-lite", "pin-project-lite",
"tower", "tower",
"tower-layer", "tower-layer",

View File

@@ -11,10 +11,12 @@ categories = ["embedded", "network-programming"]
[features] [features]
default = ["desktop"] default = ["desktop"]
rkmpp = ["hwcodec/rkmpp"]
desktop = [ desktop = [
"dep:tokio", "dep:tokio",
"dep:tokio-util", "dep:tokio-util",
"dep:axum", "dep:axum",
"dep:rmcp",
"dep:axum-extra", "dep:axum-extra",
"dep:tower-http", "dep:tower-http",
"dep:sqlx", "dep:sqlx",
@@ -81,7 +83,9 @@ tokio-util = { version = "0.7", features = ["rt"], optional = true }
# Web framework # Web framework
axum = { version = "0.8", features = ["ws", "multipart", "tokio"], optional = true } axum = { version = "0.8", features = ["ws", "multipart", "tokio"], optional = true }
axum-extra = { version = "0.12", features = ["cookie"], 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 # Database - Use bundled SQLite for static linking
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled"], optional = true } 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 | | Area | Capabilities |
|------|----------------| |------|----------------|
| Video capture | HDMI USB / MIPI CSI / RK3588 HDMI IN; MJPEG and WebRTC (H.264 / H.265 / VP8 / VP9) | | 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 / RKMPP / V4L2 M2M hardware paths, with software fallback | | Video encoding | VAAPI / QSV / NVENC / AMF / RKMPP / V4L2 M2M hardware encoding, with software fallback |
| Keyboard & mouse | USB OTG HID or CH340 + CH9329 HID; absolute / relative mouse | | 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 | USB mass storage; ISO/IMG mount and Ventoy-style virtual USB | | Virtual media | Mount ISO / IMG images through USB mass storage; virtual CD-ROM, virtual USB drive, and Ventoy multi-image boot |
| ATX power | GPIO or USB relay; power control | | ATX power | GPIO, USB HID / serial relays for power on/off, forced shutdown, and reset; GPIO can sense power and disk activity LEDs |
| Audio | ALSA capture + Opus (HTTP / WebRTC) | | 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 ## 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) ### Debian / Ubuntu (.deb)
@@ -102,7 +109,7 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
### Thanks ### Thanks
<details> <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> </details>
### Sponsors ### Sponsors
This project is supported by the following sponsors:
**File hosting** **File hosting**
- **[Huang1111 public-interest program](https://pan.huang1111.cn/s/mxkx3T1)** — login-free downloads - **[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://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 - **[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) 视频流| | 视频采集与传输 | 支持 USB HDMI 采集卡、MIPI CSI 和 RK3588 HDMI IN,提供 MJPEG / WebRTC(H.264 / H.265 / VP8 / VP9)视频流 |
| 视频编码 | VAAPI/QSV/RKMPP/V4L2M2M 硬件编码支持,以及软件编码兜底 | | 视频编码 | 支持 VAAPI / QSV / NVENC / AMF / RKMPP / V4L2 M2M 硬件编码,以及软件编码兜底 |
| 键鼠控制 | USB OTG HID 或 CH340 + CH9329 HID,支持绝对/相对鼠标模式 | | 键鼠控制 | 支持 USB OTG HID、CH340 + CH9329 串口 HID 和蓝牙 HID;USB / 串口支持绝对与相对鼠标模式,蓝牙支持相对鼠标模式 |
| 虚拟媒体 | USB Mass Storage,支持 ISO/IMG 镜像挂载和 Ventoy 虚拟U盘模式 | | 虚拟媒体 | 通过 USB Mass Storage 挂载 ISO / IMG 镜像,支持虚拟光驱、虚拟 U 盘和 Ventoy 多镜像启动 |
| ATX 电源控制 | GPIO/USB 继电器,支持电源控制 | | ATX 电源控制 | 支持 GPIO、USB HID / 串口继电器,实现开关机、强制关机和重启;GPIO 可检测电源与硬盘活动指示灯 |
| 音频传输 | ALSA 采集 + Opus 编码(HTTP/WebRTC) | | 网络唤醒 | 支持 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> </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" /> <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 --chmod=755 init.sh /init.sh
# Copy binaries (these are placed by the build script) # 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 resources if they exist
COPY ventoy/ /etc/one-kvm/ventoy/ COPY ventoy/ /etc/one-kvm/ventoy/

View File

@@ -18,6 +18,10 @@ ARCH_MAP=(
# Build for specific architecture using cross # Build for specific architecture using cross
build_arch() { build_arch() {
local rust_target="$1" local rust_target="$1"
local feature_args=()
case "$rust_target" in
aarch64-*|armv7-*) feature_args=(--features rkmpp) ;;
esac
case "${CHINAMIRRO:-}" in case "${CHINAMIRRO:-}" in
1|true|TRUE|yes|YES|on|ON) 1|true|TRUE|yes|YES|on|ON)
@@ -40,13 +44,13 @@ build_arch() {
CARGO_NET_GIT_FETCH_WITH_CLI=true \ CARGO_NET_GIT_FETCH_WITH_CLI=true \
RUSTUP_DIST_SERVER="$rust_dist" \ RUSTUP_DIST_SERVER="$rust_dist" \
RUSTUP_UPDATE_ROOT="$rust_root" \ RUSTUP_UPDATE_ROOT="$rust_root" \
cross build --release --target "$rust_target" cross build --release --target "$rust_target" "${feature_args[@]}"
return return
;; ;;
esac esac
echo "=== Building: $rust_target (via cross with custom Dockerfile) ===" echo "=== Building: $rust_target (via cross with custom Dockerfile) ==="
cross build --release --target "$rust_target" cross build --release --target "$rust_target" "${feature_args[@]}"
} }
# Main # 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" chmod 755 "$PKG_DIR/DEBIAN/prerm"
# Create control file # Create control file
BASE_DEPS="libc6 (>= 2.36), libgcc-s1, libstdc++6, libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4)" BASE_DEPS="libc6 (>= 2.36), libgcc-s1, libstdc++6, libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4), libudev1"
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)" # 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" DEPS="$BASE_DEPS"
if [ "$DEB_ARCH" = "amd64" ]; then if [ "$DEB_ARCH" = "amd64" ]; then
DEPS="$DEPS, $AMD64_DEPS" DEPS="$DEPS, $AMD64_DEPS"

View File

@@ -118,7 +118,7 @@ while [[ $# -gt 0 ]]; do
echo " # Build for current platform and load locally" echo " # Build for current platform and load locally"
echo " $0 --platform linux/arm64 --load" echo " $0 --platform linux/arm64 --load"
echo "" 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 " $0 --variant full --platform linux/arm64 --load"
echo "" echo ""
echo " # Build and push single platform" echo " # Build and push single platform"
@@ -184,21 +184,12 @@ get_tool_urls() {
case "$platform" in case "$platform" in
"linux/amd64") "linux/amd64")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64" 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") "linux/arm64")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.aarch64" 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") "linux/arm/v7")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.armhf" 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 esac
} }
@@ -231,22 +222,9 @@ download_tools() {
fi fi
if [ "$INCLUDE_THIRD_PARTY" = true ]; then if [ "$INCLUDE_THIRD_PARTY" = true ]; then
# gostc python3 "$PROJECT_ROOT/build/download-remote-tools.py" \
if [ ! -f "$staging/gostc" ]; then --platform "$platform" --output "$staging"
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
# 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 fi
} }
@@ -268,14 +246,18 @@ build_for_platform() {
if [ "$BUILD_BINARY" = true ]; then if [ "$BUILD_BINARY" = true ]; then
echo_info "Building binary with cross..." echo_info "Building binary with cross..."
cd "$PROJECT_ROOT" 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 fi
# Check binary exists # Check binary exists
local binary="$PROJECT_ROOT/target/$target/release/one-kvm" local binary="$PROJECT_ROOT/target/$target/release/one-kvm"
if [ ! -f "$binary" ]; then if [ ! -f "$binary" ]; then
echo_error "Binary not found: $binary" 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 exit 1
fi 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 include_dir = Path::new(&custom_lib_path).join("include");
let avcodec_lib = lib_dir.join("libavcodec.a"); 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:info=Using custom FFmpeg from {}", custom_lib_path);
println!("cargo:rustc-link-search=native={}", lib_dir.display()); println!("cargo:rustc-link-search=native={}", lib_dir.display());
builder.include(&include_dir); builder.include(&include_dir);
@@ -171,7 +171,7 @@ mod ffmpeg {
println!("cargo:rustc-link-lib=va-drm"); println!("cargo:rustc-link-lib=va-drm");
println!("cargo:rustc-link-lib=va-x11"); // Required for vaGetDisplay println!("cargo:rustc-link-lib=va-x11"); // Required for vaGetDisplay
println!("cargo:rustc-link-lib=mfx"); println!("cargo:rustc-link-lib=mfx");
} else { } else if std::env::var_os("CARGO_FEATURE_RKMPP").is_some() {
for lib in ["rockchip_mpp", "rga"] { for lib in ["rockchip_mpp", "rga"] {
if !lib_dir.join(format!("lib{lib}.a")).exists() { if !lib_dir.join(format!("lib{lib}.a")).exists() {
panic!("missing static library: lib{lib}.a"); 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. // 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. // 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 target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm") let enable_rkmpp = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp { if enable_rkmpp {
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp")); builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
if enable_rkmpp { if enable_rkmpp {
@@ -459,8 +458,7 @@ mod ffmpeg {
.unwrap(); .unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm") let enable_rkmpp = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp { if enable_rkmpp {
// Include RGA headers for NV16->NV12 conversion (RGA im2d API) // Include RGA headers for NV16->NV12 conversion (RGA im2d API)
let rga_sys_dirs = [ let rga_sys_dirs = [

View File

@@ -9,12 +9,12 @@ use std::ffi::c_int;
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs")); 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; pub mod decode;
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder // 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). // 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 { pub mod decode {
use crate::ffmpeg::AVPixelFormat; use crate::ffmpeg::AVPixelFormat;

View File

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

View File

@@ -5,6 +5,7 @@
# <site_name>/v1/channels.json # <site_name>/v1/channels.json
# <site_name>/v1/releases.json # <site_name>/v1/releases.json
# <site_name>/v1/bin/<version>/one-kvm-<triple> # <site_name>/v1/bin/<version>/one-kvm-<triple>
# 默认从 GitHub Release 下载 deb,提取二进制;也支持本地二进制输入。
# #
set -euo pipefail set -euo pipefail
@@ -17,6 +18,11 @@ RELEASE_CHANNEL="stable"
STABLE_VERSION="" STABLE_VERSION=""
BETA_VERSION="" BETA_VERSION=""
PUBLISHED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" 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="" ARTIFACTS_DIR=""
X86_64_BIN="" X86_64_BIN=""
AARCH64_BIN="" AARCH64_BIN=""
@@ -32,10 +38,17 @@ declare -a NOTES=()
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: Usage:
./scripts/build-update-site.sh --version <x.x.x> [options] ./scripts/build-update-site.sh [options]
Required: GitHub input (无本地输入时默认使用 latest Release):
--version <x.x.x> Release 版本号(如 0.1.10) --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 (二选一,可混用): Artifact input (二选一,可混用):
--artifacts-dir <dir> 自动扫描目录中的标准文件名: --artifacts-dir <dir> 自动扫描目录中的标准文件名:
@@ -50,8 +63,8 @@ Manifest options:
--release-channel <stable|beta> releases.json 里该版本所属渠道,默认 stable --release-channel <stable|beta> releases.json 里该版本所属渠道,默认 stable
--stable <x.x.x> channels.json 的 stable 指针,默认等于 --version --stable <x.x.x> channels.json 的 stable 指针,默认等于 --version
--beta <x.x.x> channels.json 的 beta 指针,默认等于 --version --beta <x.x.x> channels.json 的 beta 指针,默认等于 --version
--published-at <RFC3339> 发布时间,默认当前 UTC 时间 --published-at <RFC3339> 发布时间,默认 GitHub 发布时间(本地输入为当前时间)
--note <text> 发布说明,可重复传入多次 --note <text> 发布说明,可重复;指定后覆盖 GitHub 说明
Output options: Output options:
--site-name <name> 打包根目录名,默认 one-kvm-update --site-name <name> 打包根目录名,默认 one-kvm-update
@@ -62,6 +75,12 @@ Other:
-h, --help 显示帮助 -h, --help 显示帮助
Example: 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 \ ./scripts/build-update-site.sh \
--version 0.1.10 \ --version 0.1.10 \
--artifacts-dir ./target/release \ --artifacts-dir ./target/release \
@@ -104,7 +123,26 @@ is_valid_channel() {
} }
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
if [[ "$1" != "-h" && "$1" != "--help" ]]; then
[[ $# -ge 2 && -n "$2" && "$2" != --* ]] || fail "Missing value for $1"
fi
case "$1" in 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)
VERSION="${2:-}" VERSION="${2:-}"
shift 2 shift 2
@@ -123,6 +161,7 @@ while [[ $# -gt 0 ]]; do
;; ;;
--published-at) --published-at)
PUBLISHED_AT="${2:-}" PUBLISHED_AT="${2:-}"
PUBLISHED_AT_SET=1
shift 2 shift 2
;; ;;
--note) --note)
@@ -175,6 +214,128 @@ require_cmd stat
require_cmd tar require_cmd tar
require_cmd mktemp 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" [[ -n "$VERSION" ]] || fail "--version is required"
is_valid_version "$VERSION" || fail "Invalid --version: ${VERSION} (expected x.x.x)" is_valid_version "$VERSION" || fail "Invalid --version: ${VERSION} (expected x.x.x)"
is_valid_channel "$RELEASE_CHANNEL" || fail "Invalid --release-channel: ${RELEASE_CHANNEL}" 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." fail "No artifact found. Provide --artifacts-dir or at least one of --x86_64/--aarch64/--armv7."
fi fi
BUILD_DIR="$(mktemp -d)"
trap 'rm -rf "$BUILD_DIR"' EXIT
SITE_DIR="${BUILD_DIR}/${SITE_NAME}" SITE_DIR="${BUILD_DIR}/${SITE_NAME}"
V1_DIR="${SITE_DIR}/v1" V1_DIR="${SITE_DIR}/v1"
BIN_DIR="${V1_DIR}/bin/${VERSION}" BIN_DIR="${V1_DIR}/bin/${VERSION}"
@@ -268,7 +426,7 @@ RELEASES_FILE="${V1_DIR}/releases.json"
echo ' {' echo ' {'
echo " \"version\": \"${VERSION}\"," echo " \"version\": \"${VERSION}\","
echo " \"channel\": \"${RELEASE_CHANNEL}\"," echo " \"channel\": \"${RELEASE_CHANNEL}\","
echo " \"published_at\": \"${PUBLISHED_AT}\"," echo " \"published_at\": \"$(json_escape "$PUBLISHED_AT")\","
if [[ ${#NOTES[@]} -eq 0 ]]; then if [[ ${#NOTES[@]} -eq 0 ]]; then
echo ' "notes": [],' echo ' "notes": [],'

View File

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

View File

@@ -9,6 +9,28 @@ use crate::error::{AppError, Result};
const MAGIC_PACKET_SIZE: usize = 102; 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]> { fn parse_mac_address(mac: &str) -> Result<[u8; 6]> {
let mac = mac.trim().to_uppercase(); let mac = mac.trim().to_uppercase();
let parts: Vec<&str> = if mac.contains(':') { 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()); 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] #[test]
fn test_build_magic_packet() { fn test_build_magic_packet() {
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];

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 { pub enum HidBackend {
Otg, Otg,
Ch9329, Ch9329,
#[serde(rename = "ch9329_otg")]
Ch9329Otg,
Bluetooth, Bluetooth,
#[default] #[default]
None, 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] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OtgDescriptorConfig { pub struct OtgDescriptorConfig {
@@ -141,6 +167,15 @@ pub struct OtgHidFunctions {
} }
impl OtgHidFunctions { impl OtgHidFunctions {
pub fn disabled() -> Self {
Self {
keyboard: false,
mouse_relative: false,
mouse_absolute: false,
consumer: false,
}
}
pub fn full() -> Self { pub fn full() -> Self {
Self { Self {
keyboard: true, 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] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct HidConfig { pub struct HidConfig {
pub jiggler: MouseJigglerConfig,
pub bluetooth: BluetoothHidConfig, pub bluetooth: BluetoothHidConfig,
pub backend: HidBackend, pub backend: HidBackend,
pub otg_udc: Option<String>, pub otg_udc: Option<String>,
@@ -241,6 +305,7 @@ pub struct HidConfig {
impl Default for HidConfig { impl Default for HidConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
jiggler: MouseJigglerConfig::default(),
backend: HidBackend::None, backend: HidBackend::None,
bluetooth: BluetoothHidConfig::default(), bluetooth: BluetoothHidConfig::default(),
otg_udc: None, otg_udc: None,
@@ -259,20 +324,66 @@ impl Default for HidConfig {
} }
impl 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 { pub fn effective_otg_functions(&self) -> OtgHidFunctions {
self.otg_profile.resolve_functions(&self.otg_functions) self.otg_profile.resolve_functions(&self.otg_functions)
} }
pub fn effective_otg_keyboard_leds(&self) -> bool { 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 { 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<()> { pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
if self.backend != HidBackend::Otg { if !self.backend.uses_otg_hid() {
return Ok(()); return Ok(());
} }
@@ -293,7 +404,7 @@ impl HidConfig {
#[inline] #[inline]
pub fn resolved_otg_udc(&self) -> Option<String> { pub fn resolved_otg_udc(&self) -> Option<String> {
if self.backend != HidBackend::Otg { if !self.backend.supports_otg_functions() {
return None; return None;
} }
self.otg_udc self.otg_udc
@@ -301,6 +412,9 @@ impl HidConfig {
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.or_else(|| { .or_else(|| {
if !self.backend.uses_otg_hid() {
return None;
}
#[cfg(unix)] #[cfg(unix)]
{ {
crate::otg::OtgGadgetManager::find_udc() crate::otg::OtgGadgetManager::find_udc()
@@ -316,6 +430,53 @@ impl HidConfig {
#[cfg(test)] #[cfg(test)]
mod bluetooth_tests { mod bluetooth_tests {
use super::*; 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] #[test]
fn mouse_compatibility_defaults_alias_and_otg_validation() { fn mouse_compatibility_defaults_alias_and_otg_validation() {
let defaults: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap(); 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 atx;
mod common; mod common;
mod computer_use;
mod hid; mod hid;
mod mcp;
mod otg_network; mod otg_network;
mod stream; mod stream;
mod uac; mod uac;
@@ -16,8 +16,8 @@ mod web;
pub use atx::*; pub use atx::*;
pub use common::*; pub use common::*;
pub use computer_use::*;
pub use hid::*; pub use hid::*;
pub use mcp::*;
pub use otg_network::*; pub use otg_network::*;
pub use stream::*; pub use stream::*;
pub use uac::*; pub use uac::*;
@@ -39,7 +39,7 @@ pub struct AppConfig {
pub audio: AudioConfig, pub audio: AudioConfig,
pub stream: StreamConfig, pub stream: StreamConfig,
pub web: WebConfig, pub web: WebConfig,
pub computer_use: ComputerUseConfig, pub mcp: McpConfig,
pub extensions: ExtensionsConfig, pub extensions: ExtensionsConfig,
pub rustdesk: RustDeskConfig, pub rustdesk: RustDeskConfig,
pub vnc: VncConfig, pub vnc: VncConfig,
@@ -51,7 +51,7 @@ pub struct AppConfig {
impl AppConfig { impl AppConfig {
pub fn enforce_invariants(&mut self) { pub fn enforce_invariants(&mut self) {
if self.hid.backend != HidBackend::Otg { if !self.hid.backend.supports_otg_functions() {
self.msd.enabled = false; self.msd.enabled = false;
self.otg_network.enabled = false; self.otg_network.enabled = false;
self.uac.enabled = false; self.uac.enabled = false;
@@ -60,6 +60,7 @@ impl AppConfig {
self.hid.mouse_absolute = false; self.hid.mouse_absolute = false;
} }
self.atx.normalize(); self.atx.normalize();
self.mcp.requests_per_second = self.mcp.requests_per_second.clamp(1, 100);
} }
pub fn apply_platform_defaults(&mut self) { pub fn apply_platform_defaults(&mut self) {

View File

@@ -27,9 +27,9 @@ impl ConfigStore {
} }
pub async fn load(&self) -> Result<()> { 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(); config.enforce_invariants();
if removed_legacy_totp { if removed_legacy_fields {
Self::save_config_to_db(&self.pool, &config).await?; Self::save_config_to_db(&self.pool, &config).await?;
} }
self.cache.store(Arc::new(config)); self.cache.store(Arc::new(config));
@@ -46,7 +46,9 @@ impl ConfigStore {
Some((json,)) => { Some((json,)) => {
let mut value: serde_json::Value = let mut value: serde_json::Value =
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))?; 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 if let Some(auth) = value
.get_mut("auth") .get_mut("auth")
.and_then(|value| value.as_object_mut()) .and_then(|value| value.as_object_mut())
@@ -142,6 +144,32 @@ mod tests {
use crate::db::DatabasePool; use crate::db::DatabasePool;
use tempfile::tempdir; 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] #[tokio::test]
async fn test_config_store() { async fn test_config_store() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@@ -175,6 +203,60 @@ mod tests {
assert_eq!(config.web.http_port, 9000); 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] #[tokio::test]
async fn failed_watchdog_persistence_does_not_update_cache() { async fn failed_watchdog_persistence_does_not_update_cache() {
let dir = tempdir().unwrap(); 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 block_size = stat.block_size() as u64;
let total = stat.blocks() as u64 * block_size; let total = stat.blocks() as u64 * block_size;
let available = stat.blocks_available() 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 { Ok(DiskSpaceInfo {
total, total,

View File

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

View File

@@ -9,7 +9,7 @@ use tokio::sync::RwLock;
use super::protected_config::ProtectedConfigFile; use super::protected_config::ProtectedConfigFile;
use super::types::*; 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; use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200; const LOG_BUFFER_SIZE: usize = 200;
@@ -82,8 +82,8 @@ impl ExtensionManager {
fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> bool { fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> bool {
match id { match id {
ExtensionId::Ttyd => config.ttyd.enabled, ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => { ExtensionId::Easyfrp => {
config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok() config.easyfrp.enabled && validate_easyfrp_config(&config.easyfrp).is_ok()
} }
ExtensionId::Easytier => { ExtensionId::Easytier => {
config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok() config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok()
@@ -144,12 +144,7 @@ impl ExtensionManager {
let launch = self.build_launch(id, config).await?; let launch = self.build_launch(id, config).await?;
tracing::info!( tracing::info!("Starting extension {}: {}", id, id.binary_path().display());
"Starting extension {}: {} {}",
id,
id.binary_path().display(),
launch.args.join(" ")
);
let mut child = Command::new(id.binary_path()) let mut child = Command::new(id.binary_path())
.args(&launch.args) .args(&launch.args)
@@ -272,17 +267,12 @@ impl ExtensionManager {
args args
} }
ExtensionId::Gostc => { ExtensionId::Easyfrp => {
let c = &config.gostc; let c = &config.easyfrp;
validate_gostc_config(c)?; validate_easyfrp_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()]);
let mut args = vec!["control".to_string()];
args.extend(c.control_urls.iter().map(|url| url.trim().to_string()));
args args
} }
@@ -580,6 +570,27 @@ impl ExtensionManager {
mod tests { mod tests {
use super::*; 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] #[tokio::test]
async fn easytier_launch_revalidates_full_configuration() { async fn easytier_launch_revalidates_full_configuration() {
let config = EasytierConfig { let config = EasytierConfig {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
use toml_edit::DocumentMut; use toml_edit::DocumentMut;
use super::types::{ use super::types::{
EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig, EasyfrpConfig, EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType,
FrpcConfigMode, GostcConfig, FrpcConfig, FrpcConfigMode,
}; };
pub(crate) fn validate_extension_config( pub(crate) fn validate_extension_config(
@@ -11,15 +11,36 @@ pub(crate) fn validate_extension_config(
) -> Result<(), String> { ) -> Result<(), String> {
match id { match id {
ExtensionId::Ttyd => Ok(()), 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::Easytier => validate_easytier_config(&config.easytier),
ExtensionId::Frpc => validate_frpc_config(&config.frpc), ExtensionId::Frpc => validate_frpc_config(&config.frpc),
} }
} }
pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> { pub(crate) fn validate_easyfrp_config(config: &EasyfrpConfig) -> Result<(), String> {
require_non_empty(config.addr.trim(), "GOSTC server address is required")?; if config.control_urls.is_empty() {
require_non_empty(config.key.as_str(), "GOSTC client key is required") 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> { 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 { mod tests {
use super::*; 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] #[test]
fn validates_easytier_full_configuration() { fn validates_easytier_full_configuration() {
let mut config = EasytierConfig { let mut config = EasytierConfig {

View File

@@ -37,6 +37,27 @@ pub enum HidBackendType {
None, 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 { impl HidBackendType {
pub fn name_str(&self) -> &str { pub fn name_str(&self) -> &str {
match self { 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 consumer;
pub mod datachannel; pub mod datachannel;
mod factory; mod factory;
mod jiggler;
pub mod keyboard; pub mod keyboard;
mod macos_drag; mod macos_drag;
#[cfg(unix)] #[cfg(unix)]
@@ -120,6 +121,10 @@ enum QueuedHidEvent {
Mouse(MouseEvent), Mouse(MouseEvent),
Consumer(ConsumerEvent), Consumer(ConsumerEvent),
Reset, Reset,
Confirmed(
Box<QueuedHidEvent>,
tokio::sync::oneshot::Sender<Result<()>>,
),
} }
pub struct HidController { pub struct HidController {
@@ -137,6 +142,8 @@ pub struct HidController {
backend_available: Arc<AtomicBool>, backend_available: Arc<AtomicBool>,
reset_requested: Arc<AtomicBool>, reset_requested: Arc<AtomicBool>,
screen_resolution: parking_lot::RwLock<(u32, u32)>, screen_resolution: parking_lot::RwLock<(u32, u32)>,
jiggler: Arc<parking_lot::Mutex<jiggler::JigglerState>>,
jiggler_changed: Arc<tokio::sync::Notify>,
} }
impl HidController { impl HidController {
@@ -160,6 +167,8 @@ impl HidController {
backend_available: Arc::new(AtomicBool::new(false)), backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)), 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)), backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)), 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<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller"); 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; self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -261,6 +275,7 @@ impl HidController {
info!("Preparing OTG HID backend for gadget rebuild"); info!("Preparing OTG HID backend for gadget rebuild");
self.backend_available.store(false, Ordering::Release); self.backend_available.store(false, Ordering::Release);
self.jiggler.lock().suspend();
self.stop_runtime_worker().await; self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -313,6 +328,36 @@ impl HidController {
self.enqueue_event(QueuedHidEvent::Consumer(event)).await 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> { pub async fn bluetooth_status(&self) -> Result<serde_json::Value> {
let backend = self let backend = self
.backend .backend
@@ -369,6 +414,14 @@ impl HidController {
self.runtime_state.read().await.clone() 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>> { async fn ch9329_backend(&self) -> Result<Arc<dyn HidBackend>> {
if !matches!( if !matches!(
*self.backend_type.read().await, *self.backend_type.read().await,
@@ -402,6 +455,7 @@ impl HidController {
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type); info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release); self.backend_available.store(false, Ordering::Release);
self.jiggler.lock().suspend();
self.stop_runtime_worker().await; self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() { 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 = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone(); let pending_move_flag = self.pending_move_flag.clone();
let reset_requested = self.reset_requested.clone(); let 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 handle = tokio::spawn(async move {
let mut rx = rx; let mut rx = rx;
let mut next_poll = tokio::time::Instant::now();
loop { loop {
let event = match rx.recv().await { let delay = jiggler.lock().delay();
Some(ev) => ev, let event = tokio::select! {
None => break, 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) { if reset_requested.swap(false, Ordering::AcqRel) {
@@ -525,15 +619,16 @@ impl HidController {
while rx.try_recv().is_ok() {} while rx.try_recv().is_ok() {}
*pending_move.lock() = None; *pending_move.lock() = None;
pending_move_flag.store(false, Ordering::Release); pending_move_flag.store(false, Ordering::Release);
process_hid_event(QueuedHidEvent::Reset, &backend).await; process_hid_event(QueuedHidEvent::Reset, &backend, &jiggler).await;
continue; continue;
} }
process_hid_event(event, &backend).await; process_hid_event(event, &backend, &jiggler).await;
if pending_move_flag.swap(false, Ordering::AcqRel) { if pending_move_flag.swap(false, Ordering::AcqRel) {
let move_event = { pending_move.lock().take() }; let move_event = { pending_move.lock().take() };
if let Some(move_event) = move_event { 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 events = self.events.clone();
let backend_available = self.backend_available.clone(); let backend_available = self.backend_available.clone();
let backend_type = self.backend_type.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 { let handle = tokio::spawn(async move {
loop { loop {
@@ -570,6 +667,11 @@ impl HidController {
Some(backend.as_ref()), Some(backend.as_ref()),
) )
.await; .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<()> { 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())) { match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => { Err(mpsc::error::TrySendError::Full(_)) => {
@@ -597,6 +700,8 @@ impl HidController {
} }
async fn enqueue_event(&self, event: QueuedHidEvent) -> Result<()> { 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) { match self.hid_tx.try_send(event) {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(ev)) => { Err(mpsc::error::TrySendError::Full(ev)) => {
@@ -625,6 +730,18 @@ 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
}
async fn apply_backend_runtime_state( async fn apply_backend_runtime_state(
backend_type: &Arc<RwLock<HidBackendType>>, backend_type: &Arc<RwLock<HidBackendType>>,
runtime_state: &Arc<RwLock<HidRuntimeState>>, runtime_state: &Arc<RwLock<HidRuntimeState>>,
@@ -644,37 +761,48 @@ async fn apply_backend_runtime_state(
async fn process_hid_event( async fn process_hid_event(
event: QueuedHidEvent, event: QueuedHidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>, backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
jiggler: &Arc<parking_lot::Mutex<jiggler::JigglerState>>,
) { ) {
let backend_opt = backend.read().await.clone(); let (event, reply) = match event {
let backend = match backend_opt { QueuedHidEvent::Confirmed(event, reply) => (*event, Some(reply)),
Some(b) => b, event => (event, None),
None => return,
}; };
{
let backend_for_send = backend.clone(); let mut state = jiggler.lock();
let result = tokio::task::spawn_blocking(move || { let now = std::time::Instant::now();
futures::executor::block_on(async move { match &event {
match event { QueuedHidEvent::Keyboard(event) => state.keyboard(event, now),
QueuedHidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await, QueuedHidEvent::Mouse(event) => state.mouse(event, now),
QueuedHidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await, QueuedHidEvent::Consumer(event) => state.consumer(event.usage, now),
QueuedHidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await, QueuedHidEvent::Reset => state.reset(now),
QueuedHidEvent::Reset => backend_for_send.reset().await, QueuedHidEvent::Confirmed(..) => unreachable!("nested confirmed HID event"),
}
})
})
.await;
let result = match result {
Ok(r) => r,
Err(_) => return,
};
match result {
Ok(_) => {}
Err(e) => {
warn!("HID event processing failed: {}", e);
} }
} }
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) => backend.send_mouse(ev).await,
QueuedHidEvent::Consumer(ev) => backend.send_consumer(ev).await,
QueuedHidEvent::Reset => backend.reset().await,
QueuedHidEvent::Confirmed(..) => unreachable!("nested confirmed HID event"),
}
})
})
.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);
}
} }
fn device_for_backend_type(backend_type: &HidBackendType) -> Option<String> { fn device_for_backend_type(backend_type: &HidBackendType) -> Option<String> {
@@ -722,6 +850,60 @@ fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
#[cfg(test)] #[cfg(test)]
mod queue_tests { mod queue_tests {
use super::*; use super::*;
#[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] #[tokio::test]
async fn screen_resolution_updates_backend_and_retains_valid_dimensions() { async fn screen_resolution_updates_backend_and_retains_valid_dimensions() {
#[cfg(unix)] #[cfg(unix)]

View File

@@ -10,8 +10,6 @@ pub mod audio;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub mod auth; pub mod auth;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub mod computer_use;
#[cfg(feature = "desktop")]
pub mod config; pub mod config;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub mod db; pub mod db;
@@ -25,6 +23,8 @@ pub mod events;
pub mod extensions; pub mod extensions;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub mod hid; pub mod hid;
#[cfg(feature = "desktop")]
pub mod mcp;
#[cfg(all(unix, feature = "desktop"))] #[cfg(all(unix, feature = "desktop"))]
pub mod msd; pub mod msd;
#[cfg(all(unix, feature = "desktop"))] #[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 typeshare::typeshare;
use uuid::Uuid; use uuid::Uuid;
use super::netifd_bridge;
use super::network_backend::{current_backend, NetworkBackend};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
const BRIDGE_IF: &str = "okvm-br0"; const BRIDGE_IF: &str = "okvm-br0";
@@ -104,11 +106,24 @@ impl TransactionProfiles {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NetworkBridgeRuntime { pub struct NetworkBridgeRuntime {
journal: BridgeJournal, journal: RuntimeJournal,
}
#[derive(Debug, Clone)]
enum RuntimeJournal {
NetworkManager(BridgeJournal),
Netifd(netifd_bridge::Journal),
} }
impl NetworkBridgeRuntime { impl NetworkBridgeRuntime {
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> { 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("nmcli")?;
ensure_command("ip")?; ensure_command("ip")?;
@@ -321,16 +336,23 @@ impl NetworkBridgeRuntime {
})(); })();
match result { match result {
Ok(_address) => Ok(Self { journal }), Ok(_address) => Ok(Self {
journal: RuntimeJournal::NetworkManager(journal),
}),
Err(error) => Err(restore_or_combine(&journal, error)), Err(error) => Err(restore_or_combine(&journal, error)),
} }
} }
pub fn deactivate(&self) -> Result<()> { 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<()> { pub fn recover_stale_transaction() -> Result<()> {
netifd_bridge::recover()?;
let value = match fs::read_to_string(JOURNAL_PATH) { let value = match fs::read_to_string(JOURNAL_PATH) {
Ok(value) => value, Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), 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) { match serde_json::from_str::<BridgeJournal>(&value) {
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal), Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
Ok(journal) => Err(AppError::Config(format!( Ok(journal) => Err(AppError::Config(format!(
@@ -354,10 +377,92 @@ impl NetworkBridgeRuntime {
} }
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> { 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()?; let devices = enumerate_network_manager_devices()?;
Ok(bridge_candidates(devices, is_physical_network_interface)) 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>> { fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
ensure_command("nmcli")?; ensure_command("nmcli")?;
let output = run_command( let output = run_command(
@@ -440,10 +545,11 @@ fn select_bridge_candidate<'a>(
if requested.trim().is_empty() { if requested.trim().is_empty() {
return interfaces return interfaces
.iter() .iter()
.filter(|item| item.bridge_supported)
.max_by_key(|item| item.has_default_route) .max_by_key(|item| item.has_default_route)
.ok_or_else(|| { .ok_or_else(|| {
AppError::Config( 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(), .to_string(),
) )
}); });
@@ -451,10 +557,15 @@ fn select_bridge_candidate<'a>(
interfaces interfaces
.iter() .iter()
.find(|item| item.name == requested) .find(|item| item.name == requested && item.bridge_supported)
.ok_or_else(|| { .ok_or_else(|| {
AppError::Config(format!( 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()) 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() ipv4_addresses(interface).into_iter().next()
} }
@@ -783,7 +894,7 @@ fn ipv4_addresses(interface: &str) -> Vec<String> {
.collect() .collect()
} }
fn default_route(interface: &str) -> Option<String> { pub(super) fn default_route(interface: &str) -> Option<String> {
let output = Command::new("ip") let output = Command::new("ip")
.args(["-4", "route", "show", "default", "dev", interface]) .args(["-4", "route", "show", "default", "dev", interface])
.output() .output()
@@ -801,7 +912,7 @@ fn gateway_from_route(route: &str) -> Option<&str> {
.find_map(|part| (part[0] == "via").then_some(part[1])) .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(); let status = Command::new(name).arg("--version").output();
if status.is_err() { if status.is_err() {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
@@ -815,7 +926,7 @@ fn run_nmcli(args: &[&str]) -> Result<Output> {
run_command("nmcli", args) 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) let output = Command::new(command)
.env("LC_ALL", "C") .env("LC_ALL", "C")
.args(args) .args(args)
@@ -842,6 +953,49 @@ fn run_command(command: &str, args: &[&str]) -> Result<Output> {
mod tests { mod tests {
use super::*; 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( fn device(
name: &str, name: &str,
interface_type: &str, interface_type: &str,
@@ -994,6 +1148,6 @@ mod tests {
let error = select_bridge_candidate(&[], "").unwrap_err(); let error = select_bridge_candidate(&[], "").unwrap_err();
assert!(matches!(error, AppError::Config(_))); 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; pub mod msd;
#[cfg(unix)] #[cfg(unix)]
pub mod network; pub mod network;
#[cfg(unix)]
mod network_backend;
#[cfg(unix)]
mod netifd_bridge;
pub mod report_desc; pub mod report_desc;
pub mod self_check; pub mod self_check;
#[cfg(unix)] #[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 /// OTG self-check status for troubleshooting USB gadget issues
pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse { pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg); 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; let gadget_expected = hid_backend_is_otg
|| config.msd.enabled
|| config.otg_network.enabled
|| config.uac.enabled;
let mut checks = Vec::new(); let mut checks = Vec::new();
let build_response = |checks: Vec<OtgSelfCheckItem>, let build_response = |checks: Vec<OtgSelfCheckItem>,
@@ -173,7 +176,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
overall_ok: error_count == 0, overall_ok: error_count == 0,
error_count, error_count,
warning_count, warning_count,
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), hid_backend: config.hid.backend.as_str().to_string(),
selected_udc, selected_udc,
bound_udc, bound_udc,
udc_state, udc_state,
@@ -415,7 +418,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
OtgSelfCheckLevel::Warn OtgSelfCheckLevel::Warn
}, },
"Check one-kvm gadget presence", "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()), Some(one_kvm_path.display().to_string()),
); );
} }
@@ -459,7 +462,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
false, false,
OtgSelfCheckLevel::Warn, OtgSelfCheckLevel::Warn,
"Check one-kvm UDC binding", "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()), Some(one_kvm_udc_path.display().to_string()),
); );
} else { } else {
@@ -494,18 +497,19 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
}) })
.cloned() .cloned()
.collect::<Vec<_>>(); .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( push_otg_check(
&mut checks, &mut checks,
"hid_functions_present", "hid_functions_present",
false, 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 { } else {
OtgSelfCheckLevel::Warn "OTG HID must be disabled when input is handled by another backend"
}, }),
"Check HID function creation",
Some("Check OTG HID config and enable at least one HID function"),
Some(functions_path.display().to_string()), Some(functions_path.display().to_string()),
); );
} else { } else {
@@ -514,7 +518,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
"hid_functions_present", "hid_functions_present",
true, true,
OtgSelfCheckLevel::Info, OtgSelfCheckLevel::Info,
"Check HID function creation", "Check OTG HID function selection",
None::<String>, None::<String>,
Some(functions_path.display().to_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()) .map(|name| name.to_string_lossy().to_string())
}) })
.is_some_and(|name| { .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( push_otg_check(
&mut checks, &mut checks,
@@ -841,7 +851,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
overall_ok: error_count == 0, overall_ok: error_count == 0,
error_count, error_count,
warning_count, warning_count,
hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), hid_backend: config.hid.backend.as_str().to_string(),
selected_udc, selected_udc,
bound_udc, bound_udc,
udc_state, udc_state,

View File

@@ -8,8 +8,7 @@ use super::bridge::NetworkBridgeRuntime;
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig}; use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
use crate::config::{ use crate::config::{
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, UacConfig,
UacConfig,
}; };
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
@@ -40,6 +39,17 @@ pub struct OtgNetworkStatus {
pub error: Option<String>, 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 { impl HidDevicePaths {
pub fn existing_paths(&self) -> Vec<PathBuf> { pub fn existing_paths(&self) -> Vec<PathBuf> {
[ [
@@ -93,7 +103,7 @@ impl OtgDesiredState {
network.validate()?; network.validate()?;
uac.validate()?; uac.validate()?;
msd.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(); let functions = hid.constrained_otg_functions();
Some(functions) Some(functions)
} else { } else {
@@ -107,7 +117,13 @@ impl OtgDesiredState {
.as_ref() .as_ref()
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.or_else(OtgGadgetManager::find_udc) .or_else(|| {
if hid.backend.uses_ch9329() {
None
} else {
OtgGadgetManager::find_udc()
}
})
} else { } else {
None 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( pub async fn apply_config(
&self, &self,
hid: &HidConfig, hid: &HidConfig,
@@ -687,6 +722,38 @@ impl From<&OtgDescriptorConfig> for GadgetDescriptor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn service_new_and_availability_probe() { fn service_new_and_availability_probe() {

View File

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

View File

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

View File

@@ -51,24 +51,33 @@ impl UsbCoordinator {
} }
pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> { 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)] #[cfg(unix)]
{ {
let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg
&& new_config.hid.backend != HidBackend::Otg; && new_config.hid.backend != HidBackend::Otg;
let hid_unchanged = old_config.hid == new_config.hid; let hid_unchanged = !hid_requires_reload(&old_config.hid, &new_config.hid);
let gadget_rebuilt = old_config.msd != new_config.msd let gadget_rebuilt = crate::otg::service::OtgDesiredState::from_config(
|| old_config.otg_network != new_config.otg_network &old_config.hid,
|| old_config.uac != new_config.uac &old_config.msd,
|| old_config.hid.otg_udc != new_config.hid.otg_udc &old_config.otg_network,
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor &old_config.uac,
|| old_config.hid.backend != new_config.hid.backend )? != crate::otg::service::OtgDesiredState::from_config(
|| old_config.hid.constrained_otg_functions() &new_config.hid,
!= new_config.hid.constrained_otg_functions() &new_config.msd,
|| old_config.hid.effective_otg_keyboard_leds() &new_config.otg_network,
!= new_config.hid.effective_otg_keyboard_leds(); &new_config.uac,
)?;
let restart_uac = let restart_uac =
old_config.uac != new_config.uac || (new_config.uac.enabled && gadget_rebuilt); 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 { if restart_uac {
let playback = self.uac_playback.write().await.take(); let playback = self.uac_playback.write().await.take();
if let Some(playback) = playback { if let Some(playback) = playback {
@@ -167,29 +176,11 @@ impl UsbCoordinator {
uac_config: &UacConfig, uac_config: &UacConfig,
options: ConfigApplyOptions, options: ConfigApplyOptions,
) -> Result<()> { ) -> Result<()> {
new_config.validate_platform()?;
new_config.validate_otg_functions()?; new_config.validate_otg_functions()?;
new_config.bluetooth.validate()?; new_config.bluetooth.validate()?;
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; if !hid_requires_reload(old_config, new_config) && !options.force {
let hid_functions_changed =
old_config.constrained_otg_functions() != new_config.constrained_otg_functions();
let keyboard_leds_changed =
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
let mouse_compatibility_changed = old_config.ch9329_hybrid_mouse
!= new_config.ch9329_hybrid_mouse
|| old_config.mouse_macos_drag != new_config.mouse_macos_drag;
if old_config.backend == new_config.backend
&& old_config.ch9329_port == new_config.ch9329_port
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
&& old_config.bluetooth == new_config.bluetooth
&& !mouse_compatibility_changed
&& old_config.otg_udc == new_config.otg_udc
&& !descriptor_changed
&& !hid_functions_changed
&& !keyboard_leds_changed
&& !options.force
{
tracing::info!("HID config unchanged, skipping reload"); tracing::info!("HID config unchanged, skipping reload");
return Ok(()); return Ok(());
} }
@@ -256,7 +247,7 @@ impl UsbCoordinator {
options: ConfigApplyOptions, options: ConfigApplyOptions,
) -> Result<()> { ) -> Result<()> {
let old_enabled = old_config.enabled; 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 directory_changed = old_config.msd_dir != new_config.msd_dir;
let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string let inquiry_changed = old_config.flash_inquiry_string != new_config.flash_inquiry_string
|| old_config.cdrom_inquiry_string != new_config.cdrom_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 { fn hid_backend_type(config: &HidConfig) -> HidBackendType {
match config.backend { HidBackendType::from(config)
HidBackend::Otg => HidBackendType::Otg {
macos_drag: config.mouse_macos_drag,
},
HidBackend::Ch9329 => HidBackendType::Ch9329 {
port: config.ch9329_port.clone(),
baud_rate: config.ch9329_baudrate,
hybrid_mouse: config.ch9329_hybrid_mouse,
macos_drag: config.mouse_macos_drag,
},
HidBackend::None => HidBackendType::None,
HidBackend::Bluetooth => HidBackendType::Bluetooth {
config: config.bluetooth.clone(),
},
}
} }
fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> bool { fn hid_requires_reload(old: &HidConfig, new: &HidConfig) -> bool {
old_config.backend == HidBackend::Otg HidBackendType::from(old) != HidBackendType::from(new)
|| new_config.backend == HidBackend::Otg || (new.backend.uses_otg_hid() && hid_otg_config_changed(old, new))
|| 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() fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool {
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds() 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

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

View File

@@ -24,6 +24,25 @@ use crate::video::VideoFrame;
pub type ClientId = String; 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)] #[derive(Debug, Clone)]
pub struct ClientSession { pub struct ClientSession {
pub id: ClientId, pub id: ClientId,
@@ -115,7 +134,7 @@ pub struct MjpegStreamHandler {
dropped_same_frames: AtomicU64, dropped_same_frames: AtomicU64,
max_drop_same_frames: AtomicU64, max_drop_same_frames: AtomicU64,
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
jpeg_encoder: ParkingMutex<Option<JpegEncoder>>, jpeg_encoder: ParkingMutex<Option<JpegEncoderState>>,
jpeg_quality: AtomicU64, jpeg_quality: AtomicU64,
} }
@@ -219,24 +238,43 @@ impl MjpegStreamHandler {
let resolution = frame.resolution; let resolution = frame.resolution;
let sequence = self.sequence.load(Ordering::Relaxed); let sequence = self.sequence.load(Ordering::Relaxed);
let desired_quality = self.jpeg_quality.load(Ordering::Relaxed) as u32; 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 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); let config = EncoderConfig::jpeg(resolution, desired_quality);
match JpegEncoder::new(config) { let encoder = JpegEncoder::new(config)
Ok(enc) => { .map_err(|e| format!("Failed to create JPEG encoder: {}", e))?;
debug!( debug!(
"Created JPEG encoder for MJPEG stream: {}x{} (q={})", "Created JPEG encoder for MJPEG stream: {}x{} (q={})",
resolution.width, resolution.height, desired_quality resolution.width, resolution.height, desired_quality
); );
enc *encoder_guard = Some(JpegEncoderState {
} encoder,
Err(e) => { last_input: None,
warn!("Failed to create JPEG encoder: {}", e); last_frame: None,
panic!("Failed to create JPEG encoder"); });
} }
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 { if encoder.config().resolution != resolution {
debug!( debug!(
@@ -285,13 +323,12 @@ impl MjpegStreamHandler {
} }
}; };
Ok(VideoFrame::new( let mut jpeg = VideoFrame::new(encoded.data, resolution, PixelFormat::Mjpeg, 0, sequence);
encoded.data, jpeg.capture_ts = frame.capture_ts;
resolution, jpeg.online = frame.online;
PixelFormat::Mjpeg, state.last_input = Some(fingerprint);
0, state.last_frame = Some(jpeg.clone());
sequence, Ok(jpeg)
))
} }
pub fn set_offline(&self) { pub fn set_offline(&self) {
@@ -550,6 +587,91 @@ mod tests {
use crate::video::{format::Resolution, PixelFormat}; use crate::video::{format::Resolution, PixelFormat};
use bytes::Bytes; 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] #[tokio::test]
async fn test_stream_handler() { async fn test_stream_handler() {
let handler = MjpegStreamHandler::new(); 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,6 +1,7 @@
//! MJPEG multipart streaming and WebSocket HID (for MJPEG mode). //! MJPEG multipart streaming and WebSocket HID (for MJPEG mode).
pub mod mjpeg; pub mod mjpeg;
pub(crate) mod mjpeg_http;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub mod ws_hid; pub mod ws_hid;

View File

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

View File

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

View File

@@ -33,14 +33,6 @@ pub struct JpegEncoder {
impl JpegEncoder { impl JpegEncoder {
/// Create a new JPEG encoder /// Create a new JPEG encoder
pub fn new(config: EncoderConfig) -> Result<Self> { 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| { let mut compressor = turbojpeg::Compressor::new().map_err(|e| {
AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e)) AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e))
})?; })?;
@@ -52,12 +44,18 @@ impl JpegEncoder {
Ok(Self { Ok(Self {
config, config,
compressor, compressor,
i420_buffer: vec![0u8; i420_size], i420_buffer: Vec::new(),
uv_split_buffer: vec![0u8; max_uv_plane_size * 2], uv_split_buffer: Vec::new(),
bgra_buffer: vec![0u8; bgra_size], 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 /// Create with specific quality
pub fn with_quality(resolution: Resolution, quality: u32) -> Result<Self> { pub fn with_quality(resolution: Resolution, quality: u32) -> Result<Self> {
let config = EncoderConfig::jpeg(resolution, quality); let config = EncoderConfig::jpeg(resolution, quality);
@@ -146,6 +144,7 @@ impl JpegEncoder {
} }
// Convert YUYV to I420 using libyuv (SIMD accelerated) // 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) 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)))?; .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. // Reuse bgra_buffer as scratch for the swapped YUYV data.
if self.bgra_buffer.len() < expected_size { if self.bgra_buffer.len() < expected_size {
self.bgra_buffer.resize(expected_size, 0); self.bgra_buffer.resize(expected_size, 0);
@@ -202,6 +202,7 @@ impl JpegEncoder {
} }
// Convert NV12 to I420 using libyuv (SIMD accelerated) // 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) 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)))?; .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 src_uv = &data[y_size..expected_size];
let chroma_plane_size = y_size / 2; let chroma_plane_size = y_size / 2;
let (u_plane_422, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size); 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 src_uv = &data[y_size..expected_size];
let chroma_plane_size = y_size; let chroma_plane_size = y_size;
let (u_plane_444, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_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) // 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) 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)))?; .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) // Convert BGR24 to I420 using libyuv (SIMD accelerated)
// Note: libyuv's RAWToI420 is BGR24 → I420 // 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) 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)))?; .map_err(|e| AppError::VideoError(format!("libyuv BGR24→I420 failed: {}", e)))?;
@@ -395,10 +402,31 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_i420_buffer_size() { fn scratch_buffers_are_allocated_only_for_the_formats_that_use_them() {
// 1920x1080 I420 = 1920*1080 + 960*540 + 960*540 = 3110400 bytes let resolution = Resolution::new(64, 32);
let config = EncoderConfig::jpeg(Resolution::HD1080, 80); let pixels = 64 * 32;
let encoder = JpegEncoder::new(config).unwrap(); let mut encoder = JpegEncoder::new(EncoderConfig::jpeg(resolution, 80)).unwrap();
assert_eq!(encoder.i420_buffer.len(), 1920 * 1080 * 3 / 2); 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 vp8;
pub mod vp9; 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 mod mjpeg_rkmpp;
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer}; pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};

View File

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

View File

@@ -256,6 +256,22 @@ fn run(
} }
let capture = active.as_mut().expect("opened capture"); 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 reservations = pipeline.reserve_outputs();
let result = if reservations.as_ref().is_none_or(Vec::is_empty) { let result = if reservations.as_ref().is_none_or(Vec::is_empty) {
match capture.encoder.as_mut().expect("active encoder") { 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::codec::vp9::{VP9Config, VP9Encoder};
use crate::video::format::{PixelFormat, Resolution}; use crate::video::format::{PixelFormat, Resolution};
use bytes::Bytes; use bytes::Bytes;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[cfg(feature = "rkmpp")]
use hwcodec::ffmpeg_hw::{ use hwcodec::ffmpeg_hw::{
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline, 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) nv12_converter: Option<Nv12Converter>,
pub(super) yuv420p_converter: Option<PixelConverter>, pub(super) yuv420p_converter: Option<PixelConverter>,
pub(super) encoder_needs_yuv420p: bool, pub(super) encoder_needs_yuv420p: bool,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[cfg(feature = "rkmpp")]
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>, 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) ffmpeg_hw_enabled: bool,
pub(super) fps: u32, pub(super) fps: u32,
pub(super) codec: VideoEncoderType, 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"); let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[cfg(feature = "rkmpp")]
if needs_mjpeg_decode if needs_mjpeg_decode
&& is_rkmpp_encoder && is_rkmpp_encoder
&& matches!( && matches!(
@@ -389,9 +389,9 @@ pub(super) fn build_encoder_state(
nv12_converter: None, nv12_converter: None,
yuv420p_converter: None, yuv420p_converter: None,
encoder_needs_yuv420p: false, encoder_needs_yuv420p: false,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: Some(pipeline), ffmpeg_hw_pipeline: Some(pipeline),
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: true, ffmpeg_hw_enabled: true,
fps: config.fps, fps: config.fps,
codec: config.output_codec, codec: config.output_codec,
@@ -573,9 +573,9 @@ pub(super) fn build_encoder_state(
nv12_converter, nv12_converter,
yuv420p_converter, yuv420p_converter,
encoder_needs_yuv420p: needs_yuv420p, encoder_needs_yuv420p: needs_yuv420p,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: None, ffmpeg_hw_pipeline: None,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: false, ffmpeg_hw_enabled: false,
fps: config.fps, fps: config.fps,
codec: config.output_codec, codec: config.output_codec,

View File

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

@@ -732,6 +732,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 /// Get the current video encoding configuration from the shared pipeline
pub async fn get_encoding_config( pub async fn get_encoding_config(
&self, &self,

View File

@@ -165,6 +165,7 @@ impl StreamerState {
pub struct Streamer { pub struct Streamer {
config: RwLock<StreamerConfig>, config: RwLock<StreamerConfig>,
mjpeg_handler: Arc<MjpegStreamHandler>, mjpeg_handler: Arc<MjpegStreamHandler>,
pub(crate) snapshot_slot: super::snapshot::SnapshotSlot,
current_device: RwLock<Option<VideoDeviceInfo>>, current_device: RwLock<Option<VideoDeviceInfo>>,
state: RwLock<StreamerState>, state: RwLock<StreamerState>,
start_lock: tokio::sync::Mutex<()>, start_lock: tokio::sync::Mutex<()>,
@@ -197,6 +198,7 @@ impl Streamer {
Arc::new(Self { Arc::new(Self {
config: RwLock::new(StreamerConfig::default()), config: RwLock::new(StreamerConfig::default()),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()), mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
snapshot_slot: Default::default(),
current_device: RwLock::new(None), current_device: RwLock::new(None),
state: RwLock::new(StreamerState::Uninitialized), state: RwLock::new(StreamerState::Uninitialized),
start_lock: tokio::sync::Mutex::new(()), start_lock: tokio::sync::Mutex::new(()),
@@ -221,6 +223,7 @@ impl Streamer {
Arc::new(Self { Arc::new(Self {
config: RwLock::new(config), config: RwLock::new(config),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()), mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
snapshot_slot: Default::default(),
current_device: RwLock::new(None), current_device: RwLock::new(None),
state: RwLock::new(StreamerState::Uninitialized), state: RwLock::new(StreamerState::Uninitialized),
start_lock: tokio::sync::Mutex::new(()), start_lock: tokio::sync::Mutex::new(()),
@@ -1236,6 +1239,7 @@ impl Streamer {
}); });
} }
self.snapshot_slot.publish(&frame);
self.mjpeg_handler.update_frame(frame); self.mjpeg_handler.update_frame(frame);
fps_frame_count += 1; fps_frame_count += 1;
@@ -1534,6 +1538,7 @@ impl Default for Streamer {
Self { Self {
config: RwLock::new(StreamerConfig::default()), config: RwLock::new(StreamerConfig::default()),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()), mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
snapshot_slot: Default::default(),
current_device: RwLock::new(None), current_device: RwLock::new(None),
state: RwLock::new(StreamerState::Uninitialized), state: RwLock::new(StreamerState::Uninitialized),
start_lock: tokio::sync::Mutex::new(()), start_lock: tokio::sync::Mutex::new(()),

View File

@@ -14,6 +14,8 @@ mod platform {
use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf}; 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_GETSUPPORT: libc::c_ulong = 0x8028_5700;
const WDIOC_SETOPTIONS: libc::c_ulong = 0x8004_5704; const WDIOC_SETOPTIONS: libc::c_ulong = 0x8004_5704;
const WDIOC_KEEPALIVE: libc::c_ulong = 0x8004_5705; const WDIOC_KEEPALIVE: libc::c_ulong = 0x8004_5705;
@@ -54,7 +56,7 @@ mod platform {
let supports_magic_close = unsafe { let supports_magic_close = unsafe {
libc::ioctl( libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&file), std::os::fd::AsRawFd::as_raw_fd(&file),
WDIOC_GETSUPPORT, WDIOC_GETSUPPORT as _,
&mut info, &mut info,
) == 0 ) == 0
&& info.options & WDIOF_MAGICCLOSE != 0 && info.options & WDIOF_MAGICCLOSE != 0
@@ -109,7 +111,7 @@ mod platform {
let result = unsafe { let result = unsafe {
libc::ioctl( libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file), std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_KEEPALIVE, WDIOC_KEEPALIVE as _,
0, 0,
) )
}; };
@@ -125,7 +127,7 @@ mod platform {
let result = unsafe { let result = unsafe {
libc::ioctl( libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file), std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_GETTIMEOUT, WDIOC_GETTIMEOUT as _,
&mut timeout, &mut timeout,
) )
}; };
@@ -152,7 +154,7 @@ mod platform {
let result = unsafe { let result = unsafe {
libc::ioctl( libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file), std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_SETOPTIONS, WDIOC_SETOPTIONS as _,
&mut option, &mut option,
) )
}; };

View File

@@ -135,30 +135,12 @@ pub struct WolHistoryResponse {
pub history: Vec<WolHistoryEntry>, 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 /// Send Wake-on-LAN magic packet
pub async fn atx_wol( pub async fn atx_wol(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<WolRequest>, Json(req): Json<WolRequest>,
) -> Result<Json<LoginResponse>> { ) -> 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 // Get WOL interface from config
let config = state.config.get(); 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

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

View File

@@ -60,7 +60,8 @@ use crate::state::AppState;
fn sanitize_config_for_api(config: &mut AppConfig) { fn sanitize_config_for_api(config: &mut AppConfig) {
config.stream.turn_password = None; 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.device_password.clear();
config.rustdesk.relay_key = None; config.rustdesk.relay_key = None;

View File

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

View File

@@ -399,9 +399,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] #[typeshare]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct HidConfigUpdate { pub struct HidConfigUpdate {
pub jiggler: Option<MouseJigglerConfigUpdate>,
/// Request-only; never persisted or replayed during startup. /// Request-only; never persisted or replayed during startup.
pub bluetooth_reset_pairing: Option<bool>, pub bluetooth_reset_pairing: Option<bool>,
pub bluetooth: Option<crate::config::BluetoothHidConfig>, pub bluetooth: Option<crate::config::BluetoothHidConfig>,
@@ -427,10 +446,16 @@ pub struct OtgConfigUpdate {
pub hid: Option<HidConfigUpdate>, pub hid: Option<HidConfigUpdate>,
pub msd: Option<MsdConfigUpdate>, pub msd: Option<MsdConfigUpdate>,
pub network: Option<OtgNetworkConfigUpdate>, pub network: Option<OtgNetworkConfigUpdate>,
pub uac: Option<crate::config::UacConfig>,
} }
impl HidConfigUpdate { impl HidConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> { 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 { if let Some(config) = &self.bluetooth {
config.validate()?; config.validate()?;
} }
@@ -452,6 +477,9 @@ impl HidConfigUpdate {
} }
pub fn apply_to(&self, config: &mut HidConfig) { 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 { if let Some(bluetooth) = &self.bluetooth {
config.bluetooth = bluetooth.clone(); config.bluetooth = bluetooth.clone();
} }

View File

@@ -12,15 +12,24 @@ pub(super) fn stage_hid_config_update(
update.validate()?; update.validate()?;
let old_descriptor = staged_hid.ch9329_descriptor.clone(); 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 let requested_descriptor = update
.ch9329_descriptor .ch9329_descriptor
.as_ref() .as_ref()
.map(|_| staged_hid.ch9329_descriptor.clone()); .map(|_| updated_hid.ch9329_descriptor.clone());
if requested_descriptor.is_some() { if requested_descriptor.is_some() {
staged_hid.ch9329_descriptor = old_descriptor; updated_hid.ch9329_descriptor = old_descriptor;
} }
*staged_hid = updated_hid;
Ok(requested_descriptor) Ok(requested_descriptor)
} }
@@ -47,6 +56,21 @@ where
let requested_ch9329_descriptor = stage_update(&mut staged_config)?; let requested_ch9329_descriptor = stage_update(&mut staged_config)?;
staged_config.enforce_invariants(); 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()?; staged_config.hid.validate_otg_functions()?;
#[cfg(unix)] #[cfg(unix)]
@@ -179,7 +203,7 @@ where
} }
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor { 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 { match state.hid.apply_ch9329_descriptor(descriptor).await {
Ok(actual) => { Ok(actual) => {
staged_config.hid.ch9329_descriptor = actual.descriptor; staged_config.hid.ch9329_descriptor = actual.descriptor;
@@ -250,7 +274,7 @@ async fn rollback_after_failure(
{ {
rollback_errors.push(format!("runtime rollback failed: {error}")); 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 if let Err(error) = state
.hid .hid
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor) .apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
@@ -276,8 +300,34 @@ mod tests {
use crate::config::HidBackend; use crate::config::HidBackend;
use crate::web::handlers::config::types::Ch9329DescriptorConfigUpdate; 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 { fn hid_update() -> HidConfigUpdate {
HidConfigUpdate { HidConfigUpdate {
jiggler: None,
bluetooth_reset_pairing: None, bluetooth_reset_pairing: None,
bluetooth: None, bluetooth: None,
backend: 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] #[test]
fn reset_is_request_only_and_feature_updates_preserve_device_selection() { fn reset_is_request_only_and_feature_updates_preserve_device_selection() {
let mut hid = HidConfig::default(); let mut hid = HidConfig::default();

View File

@@ -8,10 +8,10 @@ use typeshare::typeshare;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::extensions::{ use crate::extensions::{
validate_easytier_config, validate_extension_config, validate_frpc_config, validate_easyfrp_config, validate_easytier_config, validate_extension_config,
validate_gostc_config, EasytierConfig, EasytierConfigMode, EasytierInfo, ExtensionId, validate_frpc_config, EasyfrpConfig, EasyfrpInfo, EasytierConfig, EasytierConfigMode,
ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, FrpcConfig, FrpcConfigMode, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType,
FrpcInfo, GostcConfig, GostcInfo, TtydConfig, TtydInfo, FrpcConfig, FrpcConfigMode, FrpcInfo, TtydConfig, TtydInfo,
}; };
use crate::state::AppState; use crate::state::AppState;
@@ -29,10 +29,10 @@ pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<Extensi
status: mgr.status(ExtensionId::Ttyd).await, status: mgr.status(ExtensionId::Ttyd).await,
config: config.extensions.ttyd.clone(), config: config.extensions.ttyd.clone(),
}, },
gostc: GostcInfo { easyfrp: EasyfrpInfo {
available: mgr.check_available(ExtensionId::Gostc), available: mgr.check_available(ExtensionId::Easyfrp),
status: mgr.status(ExtensionId::Gostc).await, status: mgr.status(ExtensionId::Easyfrp).await,
config: config.extensions.gostc.clone(), config: config.extensions.easyfrp.clone(),
}, },
easytier: EasytierInfo { easytier: EasytierInfo {
available: mgr.check_available(ExtensionId::Easytier), available: mgr.check_available(ExtensionId::Easytier),
@@ -134,11 +134,9 @@ pub struct TtydConfigUpdate {
#[typeshare] #[typeshare]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct GostcConfigUpdate { pub struct EasyfrpConfigUpdate {
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub addr: Option<String>, pub control_urls: Option<Vec<String>>,
pub key: Option<String>,
pub tls: Option<bool>,
} }
#[typeshare] #[typeshare]
@@ -209,52 +207,46 @@ pub async fn update_ttyd_config(
Ok(Json(new_config.extensions.ttyd.clone())) Ok(Json(new_config.extensions.ttyd.clone()))
} }
pub async fn update_gostc_config( pub async fn update_easyfrp_config(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<GostcConfigUpdate>, Json(req): Json<EasyfrpConfigUpdate>,
) -> Result<Json<GostcConfig>> { ) -> Result<Json<EasyfrpConfig>> {
let current_config = state.config.get(); let current_config = state.config.get();
let was_enabled = current_config.extensions.gostc.enabled; let was_enabled = current_config.extensions.easyfrp.enabled;
let mut next_gostc = current_config.extensions.gostc.clone(); let mut next_easyfrp = current_config.extensions.easyfrp.clone();
if let Some(enabled) = req.enabled { if let Some(enabled) = req.enabled {
next_gostc.enabled = enabled; next_easyfrp.enabled = enabled;
} }
if let Some(ref addr) = req.addr { if let Some(ref urls) = req.control_urls {
next_gostc.addr = addr.clone(); next_easyfrp.control_urls = urls.iter().map(|url| url.trim().to_string()).collect();
}
if let Some(ref key) = req.key {
next_gostc.key = key.clone();
}
if let Some(tls) = req.tls {
next_gostc.tls = tls;
} }
if next_gostc.enabled { if next_easyfrp.enabled {
bad_request(validate_gostc_config(&next_gostc))?; bad_request(validate_easyfrp_config(&next_easyfrp))?;
} }
state state
.config .config
.update(|config| { .update(|config| {
config.extensions.gostc = next_gostc.clone(); config.extensions.easyfrp = next_easyfrp.clone();
}) })
.await?; .await?;
let new_config = state.config.get(); let new_config = state.config.get();
let is_enabled = new_config.extensions.gostc.enabled; let is_enabled = new_config.extensions.easyfrp.enabled;
if was_enabled && !is_enabled { if was_enabled && !is_enabled {
state.extensions.stop(ExtensionId::Gostc).await.ok(); state.extensions.stop(ExtensionId::Easyfrp).await.ok();
} else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Gostc) { } else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Easyfrp) {
state state
.extensions .extensions
.start(ExtensionId::Gostc, &new_config.extensions) .start(ExtensionId::Easyfrp, &new_config.extensions)
.await .await
.ok(); .ok();
} }
Ok(Json(new_config.extensions.gostc.clone())) Ok(Json(new_config.extensions.easyfrp.clone()))
} }
pub async fn update_easytier_config( pub async fn update_easytier_config(

View File

@@ -11,6 +11,9 @@ pub struct Ch9329DescriptorQuery {
pub struct HidStatus { pub struct HidStatus {
pub available: bool, pub available: bool,
pub backend: String, pub backend: String,
pub configured_backend: String,
#[cfg(unix)]
pub otg: crate::otg::service::OtgRuntimeStatus,
pub initialized: bool, pub initialized: bool,
pub online: bool, pub online: bool,
pub supports_absolute_mouse: bool, pub supports_absolute_mouse: bool,
@@ -34,9 +37,21 @@ pub async fn hid_otg_self_check(
/// Get HID status /// Get HID status
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> { pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
let hid = state.hid.snapshot().await; let hid = state.hid.snapshot().await;
let config = state.config.get();
Json(HidStatus { Json(HidStatus {
available: hid.available, available: hid.available,
backend: hid.backend, backend: hid.backend,
configured_backend: config.hid.backend.as_str().to_string(),
#[cfg(unix)]
otg: state
.otg_service
.runtime_status(
config.hid.backend.uses_otg_hid()
|| config.msd.enabled
|| config.otg_network.enabled
|| config.uac.enabled,
)
.await,
initialized: hid.initialized, initialized: hid.initialized,
online: hid.online, online: hid.online,
supports_absolute_mouse: hid.supports_absolute_mouse, supports_absolute_mouse: hid.supports_absolute_mouse,

View File

@@ -0,0 +1,151 @@
//! Host power controls are separate from restarting the One-KVM process.
use super::*;
#[derive(Deserialize)]
pub struct HostPowerRequest {
#[serde(default)]
pub confirmed: bool,
}
#[derive(Clone, Copy)]
enum HostPowerAction {
Reboot,
Poweroff,
}
impl HostPowerAction {
fn command(self) -> &'static str {
match self {
Self::Reboot => "reboot",
Self::Poweroff => "poweroff",
}
}
}
fn confirmed_request(
request: HostPowerRequest,
action: HostPowerAction,
execute: impl FnOnce(HostPowerAction) -> Result<()>,
) -> Result<Json<LoginResponse>> {
if !request.confirmed {
return Err(AppError::BadRequest(
"Explicit confirmation is required for host power control.".into(),
));
}
execute(action)?;
Ok(Json(LoginResponse {
success: true,
message: Some("Host power request accepted.".into()),
}))
}
#[cfg(target_os = "linux")]
fn schedule_action(action: HostPowerAction) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicBool, Ordering};
static PENDING: AtomicBool = AtomicBool::new(false);
// These are system commands; do not run them through a user-controlled shell.
if unsafe { libc::geteuid() } != 0 {
return Err(AppError::BadRequest(
"Host power control requires the One-KVM service to run as root.".into(),
));
}
let command = action.command();
let executable = ["/sbin", "/usr/sbin", "/bin", "/usr/bin"]
.into_iter()
.map(|directory| std::path::Path::new(directory).join(command))
.find(|path| {
std::fs::metadata(path)
.map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
})
.ok_or_else(|| AppError::NotFound(format!("Host command {command} is unavailable.")))?;
if PENDING.swap(true, Ordering::AcqRel) {
return Err(AppError::Conflict(
"A host power request is already pending.".into(),
));
}
tokio::spawn(async move {
// Let the accepted response reach the browser before the connection ends.
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
let result = tokio::process::Command::new(executable)
.stdin(std::process::Stdio::null())
.output()
.await;
match result {
Ok(output) if output.status.success() => info!("Host {command} command accepted"),
Ok(output) => {
PENDING.store(false, Ordering::Release);
warn!(
"Host {command} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
PENDING.store(false, Ordering::Release);
warn!("Could not execute host {command}: {error}");
}
}
});
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn schedule_action(_action: HostPowerAction) -> Result<()> {
Err(AppError::BadRequest(
"Host power control is only available on Linux.".into(),
))
}
pub async fn host_reboot(Json(request): Json<HostPowerRequest>) -> Result<Json<LoginResponse>> {
confirmed_request(request, HostPowerAction::Reboot, schedule_action)
}
pub async fn host_poweroff(Json(request): Json<HostPowerRequest>) -> Result<Json<LoginResponse>> {
confirmed_request(request, HostPowerAction::Poweroff, schedule_action)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_or_false_confirmation_cannot_reach_the_executor() {
for body in ["{}", r#"{"confirmed":false}"#] {
let request = serde_json::from_str(body).unwrap();
let result = confirmed_request(request, HostPowerAction::Poweroff, |_| {
panic!("Unconfirmed request must never execute a system command")
});
assert!(matches!(result, Err(AppError::BadRequest(_))));
}
}
#[test]
fn confirmation_dispatches_only_the_selected_host_command() {
for (action, expected) in [
(HostPowerAction::Reboot, "reboot"),
(HostPowerAction::Poweroff, "poweroff"),
] {
let mut dispatched = None;
let Json(response) =
confirmed_request(HostPowerRequest { confirmed: true }, action, |a| {
dispatched = Some(a.command());
Ok(())
})
.unwrap();
assert!(response.success);
assert_eq!(dispatched, Some(expected));
}
}
#[test]
fn executor_errors_are_not_reported_as_accepted() {
let result = confirmed_request(
HostPowerRequest { confirmed: true },
HostPowerAction::Reboot,
|_| Err(AppError::Conflict("pending".into())),
);
assert!(matches!(result, Err(AppError::Conflict(_))));
}
}

82
src/web/handlers/mcp.rs Normal file
View File

@@ -0,0 +1,82 @@
use crate::{
error::{AppError, Result},
state::AppState,
};
use axum::{extract::State, http::header, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Serialize)]
pub struct McpConfigResponse {
pub enabled: bool,
pub requests_per_second: u32,
pub api_key_configured: bool,
pub endpoint: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct McpConfigUpdate {
pub enabled: Option<bool>,
pub requests_per_second: Option<u32>,
#[serde(default)]
pub regenerate_api_key: bool,
}
fn response(state: &AppState) -> McpConfigResponse {
let config = state.config.get();
McpConfigResponse {
enabled: config.mcp.enabled,
requests_per_second: config.mcp.requests_per_second,
api_key_configured: config.mcp.api_key_hash.is_some(),
endpoint: "/mcp",
api_key: config.mcp.api_key.clone(),
}
}
pub async fn mcp_config(
State(state): State<Arc<AppState>>,
) -> (
[(header::HeaderName, &'static str); 1],
Json<McpConfigResponse>,
) {
(
[(header::CACHE_CONTROL, "no-store")],
Json(response(&state)),
)
}
pub async fn mcp_update_config(
State(state): State<Arc<AppState>>,
Json(req): Json<McpConfigUpdate>,
) -> Result<(
[(header::HeaderName, &'static str); 1],
Json<McpConfigResponse>,
)> {
if req
.requests_per_second
.is_some_and(|rate| !(1..=100).contains(&rate))
{
return Err(AppError::BadRequest(
"requests_per_second must be between 1 and 100".into(),
));
}
state
.config
.update(|config| {
if let Some(enabled) = req.enabled {
config.mcp.enabled = enabled;
}
if let Some(rate) = req.requests_per_second {
config.mcp.requests_per_second = rate;
}
if req.regenerate_api_key || (config.mcp.enabled && config.mcp.api_key_hash.is_none()) {
let key = crate::mcp::generate_key();
config.mcp.api_key_hash = Some(crate::mcp::hash_key(&key));
config.mcp.api_key = Some(key);
}
})
.await?;
Ok((
[(header::CACHE_CONTROL, "no-store")],
Json(response(&state)),
))
}

View File

@@ -7,9 +7,10 @@ mod account;
mod atx_api; mod atx_api;
mod audio_api; mod audio_api;
mod auth; mod auth;
mod computer_use;
mod hid_api; mod hid_api;
mod host_power;
mod inventory; mod inventory;
mod mcp;
#[cfg(unix)] #[cfg(unix)]
mod msd_api; mod msd_api;
mod setup; mod setup;
@@ -22,9 +23,10 @@ pub use account::*;
pub use atx_api::*; pub use atx_api::*;
pub use audio_api::*; pub use audio_api::*;
pub use auth::*; pub use auth::*;
pub use computer_use::*;
pub use hid_api::*; pub use hid_api::*;
pub use host_power::*;
pub use inventory::*; pub use inventory::*;
pub use mcp::*;
#[cfg(unix)] #[cfg(unix)]
pub use msd_api::*; pub use msd_api::*;
pub use setup::*; pub use setup::*;

View File

@@ -69,6 +69,8 @@ impl SetupRequest {
if let Some(enabled) = self.hid_otg_keyboard_leds { if let Some(enabled) = self.hid_otg_keyboard_leds {
hid.otg_keyboard_leds = enabled; hid.otg_keyboard_leds = enabled;
} }
hid.validate_platform()?;
hid.validate_composite_selection()?;
hid.bluetooth.validate()?; hid.bluetooth.validate()?;
hid.validate_otg_functions()?; hid.validate_otg_functions()?;
Ok(hid) Ok(hid)
@@ -305,6 +307,21 @@ mod tests {
assert!(hid.effective_otg_keyboard_leds()); assert!(hid.effective_otg_keyboard_leds());
} }
#[test]
#[cfg(target_os = "linux")]
fn setup_applies_composite_selection_to_both_usb_paths() {
let req = request(json!({
"hid_backend": "ch9329_otg", "hid_ch9329_port": "/dev/ttyUSB2",
"hid_ch9329_baudrate": 115200, "hid_otg_udc": "test.udc"
}));
let hid = req.hid_config(&HidConfig::default()).unwrap();
assert_eq!(hid.backend, HidBackend::Ch9329Otg);
assert_eq!(hid.ch9329_port, "/dev/ttyUSB2");
assert_eq!(hid.ch9329_baudrate, 115200);
assert_eq!(hid.otg_udc.as_deref(), Some("test.udc"));
assert!(hid.constrained_otg_functions().is_empty());
}
#[test] #[test]
fn setup_applies_selected_ch9329_port_and_baudrate() { fn setup_applies_selected_ch9329_port_and_baudrate() {
let req = request(json!({ let req = request(json!({

View File

@@ -470,122 +470,8 @@ pub async fn mjpeg_stream(
.filter(|id| !id.is_empty() && id.len() <= 64) // Validate: non-empty, max 64 chars .filter(|id| !id.is_empty() && id.len() <= 64) // Validate: non-empty, max 64 chars
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Create RAII guard - this will automatically register and unregister the client let guard = crate::stream::mjpeg::ClientGuard::new(client_id, handler.clone());
let guard = Arc::new(crate::stream::mjpeg::ClientGuard::new( let body_stream = crate::stream::mjpeg_http::body_stream(handler, guard);
client_id.clone(),
handler.clone(),
));
let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(1);
let guard_clone = guard.clone();
let handler_clone = handler.clone();
tokio::spawn(async move {
let _guard = guard_clone; // Keep guard alive
let mut notify_rx = handler_clone.subscribe();
let mut last_seq = 0u64;
let mut timeout_count = 0;
// Send initial frame if available
if let Some(frame) = handler_clone.current_frame() {
if frame.is_valid_jpeg() {
let data = create_mjpeg_part(frame.data());
// send() blocks until receiver is ready (backpressure)
if tx.send(data).await.is_ok() {
// FPS recording moved to async_stream after yield
last_seq = frame.sequence;
} else {
return; // Receiver closed
}
}
}
loop {
// Check if stream went offline (e.g., during config change)
if !handler_clone.is_online() {
break;
}
// Wait for new frame notification with timeout
let result =
tokio::time::timeout(std::time::Duration::from_secs(5), notify_rx.recv()).await;
match result {
Ok(Ok(())) => {
// Check online status after receiving notification
// set_offline() sends a notification, so we need to check here
if !handler_clone.is_online() {
break;
}
timeout_count = 0;
if let Some(frame) = handler_clone.current_frame() {
// Use != instead of > to handle sequence reset when capturer restarts
// (e.g., after video config change, new capturer starts from seq=0)
if frame.sequence != last_seq && frame.is_valid_jpeg() {
let data = create_mjpeg_part(frame.data());
if tx.send(data).await.is_ok() {
last_seq = frame.sequence;
} else {
break;
}
}
}
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
break;
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {
// Receiver was too slow - skip missed frames and jump to latest
if !handler_clone.is_online() {
break;
}
timeout_count = 0;
if let Some(frame) = handler_clone.current_frame() {
if frame.is_valid_jpeg() {
// Send current frame immediately and reset sequence tracking
if tx.send(create_mjpeg_part(frame.data())).await.is_ok() {
last_seq = frame.sequence;
} else {
break;
}
}
}
}
Err(_) => {
// Timeout - check if still online
timeout_count += 1;
if timeout_count > 6 || !handler_clone.is_online() {
break;
}
// Send last frame again to keep connection alive
let Some(frame) = handler_clone.current_frame() else {
continue;
};
if frame.is_valid_jpeg()
&& tx.send(create_mjpeg_part(frame.data())).await.is_err()
{
break;
}
}
}
}
});
// Create stream that receives from channel and forwards to the HTTP
// body. Record FPS *before* yield so the final frame of a session
// still gets counted (after-yield code in async_stream! only runs
// when the consumer polls again, which never happens for the last
// frame of a closing connection).
let handler_for_stream = handler.clone();
let guard_for_stream = guard.clone();
let body_stream = async_stream::stream! {
while let Some(data) = rx.recv().await {
handler_for_stream.record_frame_sent(guard_for_stream.id());
yield Ok::<bytes::Bytes, std::io::Error>(data);
}
};
Response::builder() Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
@@ -610,7 +496,7 @@ pub async fn snapshot(State(state): State<Arc<AppState>>) -> impl IntoResponse {
.status(StatusCode::OK) .status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg") .header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, "no-cache") .header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(frame.data_bytes())) .body(Body::from(frame.owned_data_bytes()))
.unwrap(), .unwrap(),
_ => Response::builder() _ => Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE) .status(StatusCode::SERVICE_UNAVAILABLE)
@@ -618,22 +504,3 @@ pub async fn snapshot(State(state): State<Arc<AppState>>) -> impl IntoResponse {
.unwrap(), .unwrap(),
} }
} }
/// Create MJPEG multipart frame bytes
fn create_mjpeg_part(jpeg_data: &[u8]) -> bytes::Bytes {
use bytes::{BufMut, BytesMut};
let mut buf = BytesMut::with_capacity(128 + jpeg_data.len());
// Write boundary and headers
buf.put_slice(b"--frame\r\n");
buf.put_slice(b"Content-Type: image/jpeg\r\n");
buf.put_slice(format!("Content-Length: {}\r\n", jpeg_data.len()).as_bytes());
buf.put_slice(b"\r\n");
// Write JPEG data
buf.put_slice(jpeg_data);
buf.put_slice(b"\r\n");
buf.freeze()
}

View File

@@ -19,12 +19,14 @@ pub async fn health_check() -> Json<HealthResponse> {
pub struct SystemInfo { pub struct SystemInfo {
pub version: &'static str, pub version: &'static str,
pub initialized: bool, pub initialized: bool,
pub openwrt_system: bool,
pub platform: PlatformCapabilities, pub platform: PlatformCapabilities,
pub capabilities: Capabilities, pub capabilities: Capabilities,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub disk_space: Option<DiskSpaceInfo>, pub disk_space: Option<DiskSpaceInfo>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub device_info: Option<DeviceInfo>, pub device_info: Option<DeviceInfo>,
pub host: crate::diagnostics::HostInfo,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -66,6 +68,7 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
Json(SystemInfo { Json(SystemInfo {
version: env!("CARGO_PKG_VERSION"), version: env!("CARGO_PKG_VERSION"),
initialized: config.initialized, initialized: config.initialized,
openwrt_system: crate::web::openwrt_system::enabled(),
platform: platform.clone(), platform: platform.clone(),
capabilities: Capabilities { capabilities: Capabilities {
video: CapabilityInfo { video: CapabilityInfo {
@@ -110,5 +113,6 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
}, },
disk_space, disk_space,
device_info, device_info,
host: crate::diagnostics::get_host_info(&config.msd.msd_dir_path()),
}) })
} }

View File

@@ -1,6 +1,7 @@
mod audio_ws; mod audio_ws;
mod error; mod error;
mod handlers; mod handlers;
pub mod openwrt_system;
mod routes; mod routes;
pub(crate) mod state; pub(crate) mod state;
mod static_files; mod static_files;

172
src/web/openwrt_system.rs Normal file
View File

@@ -0,0 +1,172 @@
//! Read-only shortcut to the independent OpenWrt web server. No proxy or shared auth.
use axum::{
http::{header, uri::Authority, HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use std::{net::IpAddr, time::Duration};
use tokio::process::Command;
/// Explicit opt-in supplied by the OpenWrt image's service launcher.
pub fn enabled() -> bool {
cfg!(target_os = "linux") && std::env::var("ONE_KVM_OPENWRT").as_deref() == Ok("1")
}
fn listener_port(list: &str) -> Option<u16> {
list.split_whitespace().find_map(|listener| {
let (address, port) = listener.rsplit_once(':').unwrap_or(("0.0.0.0", listener));
let ip: IpAddr = address
.trim_start_matches('[')
.trim_end_matches(']')
.parse()
.ok()?;
if ip.is_loopback() {
return None;
}
port.parse::<u16>().ok().filter(|port| *port != 0)
})
}
fn destination(host: &str, http: &str, https: &str, redirect_https: bool) -> Option<String> {
let authority: Authority = host.parse().ok()?;
if authority.as_str().contains('@') {
return None;
}
let http = listener_port(http);
let https = listener_port(https);
let (scheme, port) = match (http, https, redirect_https) {
(_, Some(port), true) | (None, Some(port), _) => ("https", port),
(Some(port), _, _) => ("http", port),
_ => return None,
};
Some(format!(
"{scheme}://{}:{port}/cgi-bin/luci/",
authority.host()
))
}
async fn setting(name: &str) -> Result<String, ()> {
let output = tokio::time::timeout(
Duration::from_secs(2),
Command::new("/sbin/uci")
.args(["-q", "get", name])
.kill_on_drop(true)
.output(),
)
.await
.map_err(|_| ())?
.map_err(|_| ())?;
// Missing listen_http/listen_https is valid when that protocol is disabled.
if !output.status.success() {
return Ok(String::new());
}
String::from_utf8(output.stdout).map_err(|_| ())
}
pub async fn redirect_handler(headers: HeaderMap) -> Response {
if !enabled() {
return StatusCode::NOT_FOUND.into_response();
}
let Some(host) = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
else {
return (StatusCode::BAD_REQUEST, "Missing or invalid host").into_response();
};
let Ok((http, https, redirect)) = tokio::try_join!(
setting("uhttpd.main.listen_http"),
setting("uhttpd.main.listen_https"),
setting("uhttpd.main.redirect_https")
) else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"OpenWrt web configuration is unavailable",
)
.into_response();
};
match destination(
host,
&http,
&https,
matches!(redirect.trim(), "1" | "on" | "true" | "yes"),
) {
Some(url) => (
[(header::CACHE_CONTROL, "no-store")],
Redirect::temporary(&url),
)
.into_response(),
None => (
StatusCode::SERVICE_UNAVAILABLE,
"No public OpenWrt web listener is configured",
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn independent_http_port_uses_current_hostname() {
assert_eq!(
destination(
"device.test:8080",
"0.0.0.0:8081 [::]:8081",
"0.0.0.0:8444",
false
)
.as_deref(),
Some("http://device.test:8081/cgi-bin/luci/")
);
assert_eq!(
destination("192.168.1.11:9080", "0.0.0.0:9091", "", false).as_deref(),
Some("http://192.168.1.11:9091/cgi-bin/luci/")
);
}
#[test]
fn https_redirect_and_https_only_follow_openwrt() {
for http in ["0.0.0.0:8081", ""] {
assert_eq!(
destination("n1.test:8443", http, "0.0.0.0:9444", true).as_deref(),
Some("https://n1.test:9444/cgi-bin/luci/")
);
}
assert!(destination("n1.test", "", "[::]:8444", false)
.unwrap()
.starts_with("https://"));
assert!(destination("n1.test", "0.0.0.0:8081", "", true)
.unwrap()
.starts_with("http://"));
}
#[test]
fn ipv6_and_default_browser_ports() {
assert_eq!(
destination("[2001:db8::1]:8080", "[::]:8081", "", false).as_deref(),
Some("http://[2001:db8::1]:8081/cgi-bin/luci/")
);
assert!(destination("n1.test", "0.0.0.0:8081", "", false)
.unwrap()
.contains("n1.test:8081"));
}
#[test]
fn invalid_or_loopback_only_listeners_do_not_invent_a_port() {
for list in [
"",
"127.0.0.1:8081",
"[::1]:8081",
"0.0.0.0:0",
"0.0.0.0:99999",
"bad",
] {
assert_eq!(destination("n1.test:8080", list, "", false), None);
}
assert_eq!(
destination("user@n1.test:8080", "0.0.0.0:8081", "", false),
None
);
assert_eq!(
destination("https://n1.test/path", "0.0.0.0:8081", "", false),
None
);
assert_eq!(listener_port("127.0.0.1:8081 0.0.0.0:9091"), Some(9091));
}
}

View File

@@ -206,18 +206,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/config/watchdog", "/config/watchdog",
patch(handlers::config::update_watchdog_config), patch(handlers::config::update_watchdog_config),
) )
.route("/config/computer-use", get(handlers::computer_use_config))
.route( .route(
"/config/computer-use", "/config/mcp",
patch(handlers::computer_use_update_config), get(handlers::mcp_config).patch(handlers::mcp_update_config),
) )
.route("/computer-use/session", get(handlers::computer_use_session))
.route("/computer-use/session", post(handlers::computer_use_start))
.route(
"/computer-use/session/stop",
post(handlers::computer_use_stop),
)
.route("/ws/computer-use", any(handlers::computer_use_ws))
// Auth configuration // Auth configuration
.route("/config/auth", get(handlers::config::get_auth_config)) .route("/config/auth", get(handlers::config::get_auth_config))
.route("/config/auth", patch(handlers::config::update_auth_config)) .route("/config/auth", patch(handlers::config::update_auth_config))
@@ -229,6 +221,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
) )
// System control // System control
.route("/system/restart", post(handlers::system_restart)) .route("/system/restart", post(handlers::system_restart))
.route("/system/reboot", post(handlers::host_reboot))
.route("/system/poweroff", post(handlers::host_poweroff))
.route("/update/overview", get(handlers::update_overview)) .route("/update/overview", get(handlers::update_overview))
.route("/update/upgrade", post(handlers::update_upgrade)) .route("/update/upgrade", post(handlers::update_upgrade))
.route("/update/status", get(handlers::update_status)) .route("/update/status", get(handlers::update_status))
@@ -259,8 +253,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
patch(handlers::extensions::update_ttyd_config), patch(handlers::extensions::update_ttyd_config),
) )
.route( .route(
"/extensions/gostc/config", "/extensions/easyfrp/config",
patch(handlers::extensions::update_gostc_config), patch(handlers::extensions::update_easyfrp_config),
) )
.route( .route(
"/extensions/easytier/config", "/extensions/easytier/config",
@@ -276,6 +270,11 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/terminal/ws", get(handlers::terminal::terminal_ws)) .route("/terminal/ws", get(handlers::terminal::terminal_ws))
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy)); .route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
let user_routes = user_routes.route(
"/system/openwrt",
get(super::openwrt_system::redirect_handler),
);
#[cfg(unix)] #[cfg(unix)]
let user_routes = { let user_routes = {
user_routes user_routes
@@ -377,6 +376,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
// Main router // Main router
let main_router = Router::new() let main_router = Router::new()
.nest("/api", api_routes) .nest("/api", api_routes)
.merge(crate::mcp::router(state.mcp.clone(), state.config.clone()))
.merge(static_routes) .merge(static_routes)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(cors) .layer(cors)

2747
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.6", "vite": "^7.3.6",
"vue-tsc": "^3.3.7" "vue-tsc": "3.3.6"
}, },
"overrides": { "overrides": {
"defu": "^6.1.5", "defu": "^6.1.5",

View File

@@ -26,8 +26,8 @@ import type {
ExtensionLogs, ExtensionLogs,
TtydConfig, TtydConfig,
TtydConfigUpdate, TtydConfigUpdate,
GostcConfig, EasyfrpConfig,
GostcConfigUpdate, EasyfrpConfigUpdate,
EasytierConfig, EasytierConfig,
EasytierConfigUpdate, EasytierConfigUpdate,
FrpcConfig, FrpcConfig,
@@ -201,8 +201,8 @@ export const extensionsApi = {
body: JSON.stringify(config), body: JSON.stringify(config),
}), }),
updateGostc: (config: GostcConfigUpdate) => updateEasyfrp: (config: EasyfrpConfigUpdate) =>
request<GostcConfig>('/extensions/gostc/config', { request<EasyfrpConfig>('/extensions/easyfrp/config', {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(config), body: JSON.stringify(config),
}), }),

View File

@@ -2,12 +2,6 @@ import { request, uploadRequest, ApiError } from './request'
import type { import type {
CanonicalKey, CanonicalKey,
Ch9329DescriptorState, Ch9329DescriptorState,
ComputerUseButton,
ComputerUseConfigResponse,
ComputerUseConfigUpdate,
ComputerUseSessionStatus,
ComputerUseSessionSummary,
ComputerUseStartRequest,
} from '@/types/generated' } from '@/types/generated'
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket' import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
import type { StreamSignalReason, StreamState } from '@/types/websocket' import type { StreamSignalReason, StreamState } from '@/types/websocket'
@@ -99,6 +93,18 @@ export interface NetworkAddress {
ip: string ip: string
} }
export interface HostInfo {
operating_system: string
architecture: string
kernel: string | null
cpu_count: number
storage: {
system: { total: number; used: number; available: number } | null
msd: { total: number; used: number; available: number } | null
shared_partition: boolean | null
}
}
export interface DeviceInfo { export interface DeviceInfo {
hostname: string hostname: string
cpu_model: string cpu_model: string
@@ -138,6 +144,7 @@ export const systemApi = {
request<{ request<{
version: string version: string
initialized: boolean initialized: boolean
openwrt_system: boolean
platform: PlatformCapabilities platform: PlatformCapabilities
capabilities: { capabilities: {
video: { available: boolean; backend?: string; reason?: string } video: { available: boolean; backend?: string; reason?: string }
@@ -153,6 +160,7 @@ export const systemApi = {
available: number available: number
used: number used: number
} }
host?: HostInfo
device_info?: DeviceInfo device_info?: DeviceInfo
}>('/info'), }>('/info'),
@@ -169,7 +177,7 @@ export const systemApi = {
video_width?: number video_width?: number
video_height?: number video_height?: number
video_fps?: number video_fps?: number
hid_backend?: 'otg' | 'ch9329' | 'bluetooth' | 'none' hid_backend?: 'otg' | 'ch9329' | 'ch9329_otg' | 'bluetooth' | 'none'
hid_bluetooth?: { adapter: string; name: string } hid_bluetooth?: { adapter: string; name: string }
hid_ch9329_port?: string hid_ch9329_port?: string
hid_ch9329_baudrate?: number hid_ch9329_baudrate?: number
@@ -191,6 +199,12 @@ export const systemApi = {
request<{ success: boolean; message?: string }>('/system/restart', { request<{ success: boolean; message?: string }>('/system/restart', {
method: 'POST', method: 'POST',
}), }),
hostPower: (action: 'reboot' | 'poweroff') =>
request<{ success: boolean; message?: string }>(`/system/${action}`, {
method: 'POST',
body: JSON.stringify({ confirmed: true }),
}, { toastOnError: false }),
} }
export type UpdateChannel = 'stable' | 'beta' export type UpdateChannel = 'stable' | 'beta'
@@ -416,6 +430,8 @@ export const hidApi = {
request<{ request<{
available: boolean available: boolean
backend: string backend: string
configured_backend: string
otg?: import('@/lib/hidGuide').OtgDeviceStatus
initialized: boolean initialized: boolean
online: boolean online: boolean
supports_absolute_mouse: boolean supports_absolute_mouse: boolean
@@ -507,48 +523,17 @@ export const hidApi = {
isWebSocketConnected: () => hidWs.connected.value, isWebSocketConnected: () => hidWs.connected.value,
} }
export type ComputerUseStatus = ComputerUseSessionStatus export interface McpConfig {
enabled: boolean
export type ComputerUseAction = requests_per_second: number
| { type: 'click'; x: number; y: number; button?: ComputerUseButton } api_key_configured: boolean
| { type: 'double_click'; x: number; y: number; button?: ComputerUseButton } endpoint: string
| { type: 'move'; x: number; y: number } api_key?: string
| { type: 'drag'; path: Array<{ x: number; y: number }>; button?: ComputerUseButton } }
| { type: 'scroll'; x: number; y: number; dx?: number; dy?: number } export const mcpApi = {
| { type: 'type'; text: string } config: () => request<McpConfig>('/config/mcp'),
| { type: 'keypress'; keys: string[] } updateConfig: (data: { enabled?: boolean; requests_per_second?: number; regenerate_api_key?: boolean }) =>
| { type: 'wait'; ms: number } request<McpConfig>('/config/mcp', { method: 'PATCH', body: JSON.stringify(data) }),
| { type: 'screenshot' }
export type ComputerUseConversationMessage =
| { role: 'user'; text: string }
| { role: 'assistant'; text: string }
export type ComputerUseConfig = ComputerUseConfigResponse
export type ComputerUseSession = ComputerUseSessionSummary
export const computerUseApi = {
config: () => request<ComputerUseConfig>('/config/computer-use'),
updateConfig: (data: ComputerUseConfigUpdate) =>
request<ComputerUseConfig>('/config/computer-use', {
method: 'PATCH',
body: JSON.stringify(data),
}),
session: () => request<ComputerUseSession>('/computer-use/session'),
start: (data: ComputerUseStartRequest) =>
request<ComputerUseSession>('/computer-use/session', {
method: 'POST',
body: JSON.stringify(data),
}),
stop: () =>
request<ComputerUseSession>('/computer-use/session/stop', {
method: 'POST',
}),
} }
export const atxApi = { export const atxApi = {
@@ -883,13 +868,6 @@ export type {
StreamMode, StreamMode,
EncoderType, EncoderType,
BitratePreset, BitratePreset,
ComputerUseButton,
ComputerUseConfigResponse,
ComputerUseConfigUpdate,
ComputerUseScreenshot,
ComputerUseSessionStatus,
ComputerUseSessionSummary,
ComputerUseStartRequest,
} from '@/types/generated' } from '@/types/generated'
export const audioApi = { export const audioApi = {

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Copy, ExternalLink } from 'lucide-vue-next'
import { useSystemStore } from '@/stores/system'
import { useClipboard } from '@/composables/useClipboard'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import CardTitle from './SettingsCardTitle.vue'
import { Button } from '@/components/ui/button'
const props = defineProps<{ active: boolean }>()
const { t } = useI18n()
const system = useSystemStore()
const clipboard = useClipboard()
const failed = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let generation = 0
async function refresh(own = generation) {
try {
await system.fetchSystemInfo()
if (own === generation) failed.value = false
} catch {
if (own === generation) failed.value = true
} finally {
if (own === generation && props.active) timer = setTimeout(() => void refresh(own), 5000)
}
}
watch(
() => props.active,
(active) => {
clearTimeout(timer)
const own = ++generation
if (active) void refresh(own)
},
{ immediate: true },
)
onUnmounted(() => {
++generation
clearTimeout(timer)
})
const host = computed(() => system.hostInfo)
function bytes(value?: number) {
if (value === undefined || !Number.isFinite(value)) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index++
}
return `${value.toLocaleString(undefined, { maximumFractionDigits: index > 2 ? 1 : 0 })} ${units[index]}`
}
function percent(used?: number, total?: number) {
return used !== undefined && total && Number.isFinite(used)
? Math.min(100, Math.max(0, (used / total) * 100))
: null
}
const metrics = computed(() => {
const device = system.deviceInfo
const shared = host.value?.storage.shared_partition === true
const result = [
{
name: t('settingsUi.cpu'),
value: device ? Math.min(100, Math.max(0, device.cpu_usage)) : null,
detail: host.value ? t('settingsUi.cores', { count: host.value.cpu_count }) : '—',
},
{
name: t('settingsUi.memory'),
value: percent(device?.memory_used, device?.memory_total),
detail: device ? `${bytes(device.memory_used)} / ${bytes(device.memory_total)}` : '—',
},
{
name: t(shared ? 'settingsUi.sharedDisk' : 'settingsUi.systemDisk'),
value: percent(host.value?.storage.system?.used, host.value?.storage.system?.total),
detail: host.value?.storage.system
? `${bytes(host.value.storage.system.used)} / ${bytes(host.value.storage.system.total)}`
: '—',
},
]
if (!shared && system.platform?.msd.available !== false) {
const msd = host.value?.storage.msd
result.push({
name: t('settingsUi.msdDisk'),
value: percent(msd?.used, msd?.total),
detail: msd ? `${bytes(msd.used)} / ${bytes(msd.total)}` : '—',
})
}
return result
})
const fields = computed(() => [
[t('settingsUi.softwareVersion'), system.version ? `One-KVM v${system.version}` : '—'],
[t('settingsUi.operatingSystem'), host.value?.operating_system || '—'],
[t('settingsUi.processor'), system.deviceInfo?.cpu_model || '—'],
[t('settingsUi.architecture'), host.value?.architecture || '—'],
[t('settingsUi.kernel'), host.value?.kernel || '—'],
[t('settingsUi.hostname'), system.deviceInfo?.hostname || '—'],
])
async function copyInfo() {
const copied = await clipboard.copy(
fields.value.map(([key, value]) => `${key}: ${value}`).join('\n'),
)
copied ? toast.success(t('settingsUi.copied')) : toast.error(t('settingsUi.copyFailed'))
}
</script>
<template>
<Card class="system-load-card">
<CardHeader
><CardTitle :help="t('settingsUi.loadHelp')">{{
t('settingsUi.load')
}}</CardTitle></CardHeader
>
<CardContent>
<div class="load-metrics" :style="{ '--metric-count': metrics.length }">
<div v-for="metric in metrics" :key="metric.name" class="load-metric">
<div
class="usage-ring"
role="img"
:aria-label="`${metric.name}: ${metric.value === null ? t('settingsUi.unavailable') : Math.round(metric.value) + '%'}`"
>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-track" cx="60" cy="60" r="49" />
<circle
v-if="metric.value !== null"
class="ring-value"
cx="60"
cy="60"
r="49"
pathLength="100"
:stroke-dasharray="`${metric.value} 100`"
/>
</svg>
<span class="usage-value"
>{{ metric.value === null ? '—' : Math.round(metric.value)
}}<small v-if="metric.value !== null">%</small></span
>
</div>
<div class="metric-label">{{ metric.name }}</div>
<div class="metric-detail">{{ metric.detail }}</div>
</div>
</div>
<p
v-if="failed"
role="status"
class="mt-4 flex items-center gap-2 text-sm text-muted-foreground"
>
{{ t('settingsUi.loadFailed') }}
</p>
</CardContent>
</Card>
<Card class="system-info-card">
<CardHeader
><CardTitle :help="t('settingsUi.systemInfoHelp')">{{ t('settingsUi.systemInfo') }}</CardTitle
><Button variant="outline" @click="copyInfo"
><Copy class="size-4" />{{ t('settingsUi.copyInfo') }}</Button
></CardHeader
>
<CardContent>
<dl class="system-info-grid">
<div v-for="[key, value] in fields" :key="key">
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
</div>
</dl>
<div class="project-links">
<a href="https://github.com/mofeng-git/One-KVM" target="_blank" rel="noopener noreferrer"
>{{ t('settingsUi.project') }}<ExternalLink /></a
><a href="https://docs.one-kvm.cn/" target="_blank" rel="noopener noreferrer"
>{{ t('settingsUi.docs') }}<ExternalLink /></a
><a
href="https://github.com/mofeng-git/One-KVM/issues"
target="_blank"
rel="noopener noreferrer"
>{{ t('settingsUi.feedback') }}<ExternalLink
/></a>
</div>
</CardContent>
</Card>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling' import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -33,12 +32,10 @@ import {
import { import {
ClipboardPaste, ClipboardPaste,
HardDrive, HardDrive,
Settings,
Power, Power,
BarChart3, BarChart3,
Terminal, Terminal,
MoreHorizontal, MoreHorizontal,
Bot,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
Keyboard, Keyboard,
@@ -51,19 +48,16 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
import AudioConfigPopover from '@/components/AudioConfigPopover.vue' import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
import MsdDialog from '@/components/MsdDialog.vue' import MsdDialog from '@/components/MsdDialog.vue'
import VideoDisplayControls from '@/components/VideoDisplayControls.vue' import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
import KeyboardShortcutsPopover from '@/components/KeyboardShortcutsPopover.vue'
import type { KeyboardShortcut } from '@/lib/keyboardShortcuts'
import type { ConsoleLayout } from '@/composables/useConsoleLayout' import type { ConsoleLayout } from '@/composables/useConsoleLayout'
const { t, locale } = useI18n() const { t, locale } = useI18n()
const router = useRouter()
const systemStore = useSystemStore() const systemStore = useSystemStore()
const overflowMenuOpen = ref(false) const overflowMenuOpen = ref(false)
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase()) const showMsd = computed(() => !!systemStore.msd?.available)
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
const showMsd = computed(() => {
return !!systemStore.msd?.available && !isCh9329Backend.value
})
const props = defineProps<{ const props = defineProps<{
layout?: ConsoleLayout layout?: ConsoleLayout
mouseMode?: 'absolute' | 'relative' mouseMode?: 'absolute' | 'relative'
@@ -73,11 +67,17 @@ const props = defineProps<{
showPower?: boolean showPower?: boolean
atxEnabled?: boolean atxEnabled?: boolean
showTerminal?: boolean showTerminal?: boolean
showComputerUse?: boolean
showPasteText?: boolean showPasteText?: boolean
showMic?: boolean showMic?: boolean
scaleMode?: VideoScaleMode scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean sourceSizeAvailable?: boolean
keyboardVisible?: boolean
fullscreen?: boolean
statsOpen?: boolean
terminalOpen?: boolean
shortcutsReady?: boolean
shortcutInputBusy?: boolean
shortcutBusy?: boolean
}>() }>()
const isSidebarLayout = computed(() => props.layout === 'sidebar') const isSidebarLayout = computed(() => props.layout === 'sidebar')
const isFloatingLayout = computed(() => props.layout === 'floating') const isFloatingLayout = computed(() => props.layout === 'floating')
@@ -111,7 +111,8 @@ const emit = defineEmits<{
(e: 'reset'): void (e: 'reset'): void
(e: 'wol', macAddress: string): void (e: 'wol', macAddress: string): void
(e: 'openTerminal'): void (e: 'openTerminal'): void
(e: 'openComputerUse'): void (e: 'executeShortcut', shortcut: KeyboardShortcut): void
(e: 'cancelShortcut'): void
}>() }>()
const pasteOpen = ref(false) const pasteOpen = ref(false)
@@ -120,6 +121,7 @@ const videoPopoverOpen = ref(false)
const hidPopoverOpen = ref(false) const hidPopoverOpen = ref(false)
const audioPopoverOpen = ref(false) const audioPopoverOpen = ref(false)
const msdDialogOpen = ref(false) const msdDialogOpen = ref(false)
const shortcutsOpen = ref(false)
const mobileAtxOpen = ref(false) const mobileAtxOpen = ref(false)
const mobilePasteOpen = ref(false) const mobilePasteOpen = ref(false)
@@ -166,7 +168,7 @@ let layoutResizeObserver: ResizeObserver | null = null
type CollapsibleItem = type CollapsibleItem =
| 'msd' | 'atx' | 'paste' | 'msd' | 'atx' | 'paste'
| 'stats' | 'terminal' | 'settings' | 'ai' | 'stats' | 'terminal'
interface ItemSpec { interface ItemSpec {
id: CollapsibleItem id: CollapsibleItem
@@ -179,8 +181,6 @@ const ITEM_SPECS: ItemSpec[] = [
{ id: 'paste', side: 'left' }, { id: 'paste', side: 'left' },
{ id: 'stats', side: 'right' }, { id: 'stats', side: 'right' },
{ id: 'terminal', side: 'right' }, { id: 'terminal', side: 'right' },
{ id: 'settings', side: 'right' },
{ id: 'ai', side: 'right' },
] ]
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map()) const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
@@ -256,7 +256,7 @@ watch(locale, () => {
void measureLayout() void measureLayout()
}) })
watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minimalDisplayControls], () => { watch([() => props.layout, floatingCollapsed, minimalDisplayControls], () => {
// Layout changes can move this bar while its portal based controls are open. // Layout changes can move this bar while its portal based controls are open.
// Close every transient surface first so no old portal remains attached to // Close every transient surface first so no old portal remains attached to
// the document after the new layout is painted. // the document after the new layout is painted.
@@ -267,6 +267,7 @@ watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minim
hidPopoverOpen.value = false hidPopoverOpen.value = false
audioPopoverOpen.value = false audioPopoverOpen.value = false
msdDialogOpen.value = false msdDialogOpen.value = false
shortcutsOpen.value = false
mobileAtxOpen.value = false mobileAtxOpen.value = false
mobilePasteOpen.value = false mobilePasteOpen.value = false
void observeLayout() void observeLayout()
@@ -296,7 +297,6 @@ const collapsibleItems = computed(() => {
if (item.id === 'paste' && !showPasteText.value) return false if (item.id === 'paste' && !showPasteText.value) return false
if (item.id === 'stats' && !showStats.value) return false if (item.id === 'stats' && !showStats.value) return false
if (item.id === 'terminal' && props.showTerminal === false) return false if (item.id === 'terminal' && props.showTerminal === false) return false
if (item.id === 'ai' && props.showComputerUse === false) return false
return true return true
}) })
return items return items
@@ -309,7 +309,7 @@ const visibleSet = computed(() => {
if (isSidebarLayout.value) { if (isSidebarLayout.value) {
// Reserve More and a gap between the two groups before assigning vertical slots. // Reserve More and a gap between the two groups before assigning vertical slots.
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16 let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal', 'ai'] const priority: CollapsibleItem[] = ['paste', 'msd', 'atx', 'stats', 'terminal']
for (const id of priority) { for (const id of priority) {
if (!collapsibleItems.value.some(item => item.id === id)) continue if (!collapsibleItems.value.some(item => item.id === id)) continue
if (available < actionHeight.value) break if (available < actionHeight.value) break
@@ -374,7 +374,7 @@ const hasRightOverflow = computed(() => {
:aria-expanded="false" :aria-expanded="false"
@click="setFloatingCollapsed(false)" @click="setFloatingCollapsed(false)"
> >
<ChevronDown class="size-4" />{{ t('actionbar.expandToolbar') }} <ChevronDown class="size-4" /><span class="hidden sm:inline">{{ t('actionbar.expandToolbar') }}</span>
</Button> </Button>
<div <div
v-show="!isFloatingLayout || !floatingCollapsed" v-show="!isFloatingLayout || !floatingCollapsed"
@@ -433,6 +433,8 @@ const hasRightOverflow = computed(() => {
size="sm" size="sm"
class="h-8 gap-1.5 text-xs" class="h-8 gap-1.5 text-xs"
:aria-label="t('actionbar.virtualMedia')" :aria-label="t('actionbar.virtualMedia')"
aria-haspopup="dialog"
:aria-expanded="msdDialogOpen"
:title="t('actionbar.virtualMedia')" :title="t('actionbar.virtualMedia')"
@click="msdDialogOpen = true" @click="msdDialogOpen = true"
> >
@@ -521,17 +523,29 @@ const hasRightOverflow = computed(() => {
:text-only-scale="isSidebarLayout" :text-only-scale="isSidebarLayout"
:scale-mode="props.scaleMode" :scale-mode="props.scaleMode"
:source-size-available="props.sourceSizeAvailable" :source-size-available="props.sourceSizeAvailable"
:keyboard-visible="props.keyboardVisible"
:fullscreen="props.fullscreen"
@toggle-fullscreen="emit('toggleFullscreen')" @toggle-fullscreen="emit('toggleFullscreen')"
@update:scale-mode="emit('update:scaleMode', $event)" @update:scale-mode="emit('update:scaleMode', $event)"
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')" @toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
/> />
<KeyboardShortcutsPopover
v-model:open="shortcutsOpen"
:ready="props.shortcutsReady === true"
:input-busy="props.shortcutInputBusy"
:busy="props.shortcutBusy"
:side="isSidebarLayout ? 'right' : 'bottom'"
@execute="emit('executeShortcut', $event)"
@cancel="emit('cancelShortcut')"
/>
<!-- Connection Stats - Adaptive --> <!-- Connection Stats - Adaptive -->
<div v-if="isVisible('stats')"> <div v-if="isVisible('stats')">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" @click="emit('toggleStats')"> <Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" :aria-pressed="!!props.statsOpen" @click="emit('toggleStats')">
<BarChart3 class="size-4" /> <BarChart3 class="size-4" />
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span> <span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
</Button> </Button>
@@ -554,6 +568,7 @@ const hasRightOverflow = computed(() => {
class="h-8 gap-1.5 text-xs" class="h-8 gap-1.5 text-xs"
:disabled="!props.ttydRunning" :disabled="!props.ttydRunning"
:aria-label="t('actionbar.webTerminal')" :aria-label="t('actionbar.webTerminal')"
:aria-pressed="!!props.terminalOpen"
@click="emit('openTerminal')" @click="emit('openTerminal')"
> >
<Terminal class="size-4" /> <Terminal class="size-4" />
@@ -567,44 +582,6 @@ const hasRightOverflow = computed(() => {
</TooltipProvider> </TooltipProvider>
</div> </div>
<!-- Computer Use - Optional -->
<TooltipProvider v-if="isVisible('ai')">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
:aria-label="t('computerUse.title')"
@click="emit('openComputerUse')"
>
<Bot class="size-3.5 sm:size-4" />
<span v-if="visibleSet.get('ai') === 'label'">AI</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('computerUse.title') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Settings - Adaptive -->
<div v-if="isVisible('settings')">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.settings')" @click="router.push('/settings')">
<Settings class="size-4" />
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.settingsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Overflow Menu - Only show if there are overflowed items --> <!-- Overflow Menu - Only show if there are overflowed items -->
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen"> <DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
@@ -660,16 +637,6 @@ const hasRightOverflow = computed(() => {
<Terminal class="size-4 mr-2" /> <Terminal class="size-4 mr-2" />
{{ t('actionbar.webTerminal') }} {{ t('actionbar.webTerminal') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-if="!isFloatingLayout && props.showComputerUse !== false && !isVisible('ai')" @click="openFromOverflow(() => emit('openComputerUse'))">
<Bot class="size-4 mr-2" />{{ t('computerUse.title') }}
</DropdownMenuItem>
<!-- Settings -->
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
<Settings class="size-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button <Button
@@ -732,7 +699,7 @@ const hasRightOverflow = computed(() => {
<!-- Hidden measurement container: renders each collapsible button in both <!-- Hidden measurement container: renders each collapsible button in both
icon-only and with-label forms so we can read their real offsetWidth. --> icon-only and with-label forms so we can read their real offsetWidth. -->
<div ref="measureRef" aria-hidden="true" class="fixed pointer-events-none" style="visibility: hidden; top: -9999px; left: -9999px; white-space: nowrap;"> <div ref="measureRef" aria-hidden="true" class="console-action-measure fixed pointer-events-none" style="visibility: hidden; top: -9999px; left: -9999px; white-space: nowrap;">
<div class="flex items-center gap-0.5 sm:gap-1.5 px-2 sm:px-4 py-1 sm:py-1.5"> <div class="flex items-center gap-0.5 sm:gap-1.5 px-2 sm:px-4 py-1 sm:py-1.5">
<!-- MSD --> <!-- MSD -->
<Button data-measure="msd-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button> <Button data-measure="msd-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
@@ -749,11 +716,6 @@ const hasRightOverflow = computed(() => {
<!-- Web Terminal --> <!-- Web Terminal -->
<Button data-measure="terminal-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" /></Button> <Button data-measure="terminal-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" /></Button>
<Button data-measure="terminal-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</Button> <Button data-measure="terminal-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</Button>
<!-- Settings -->
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" /></Button>
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" />{{ t('actionbar.settings') }}</Button>
<Button data-measure="ai-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" /></Button>
<Button data-measure="ai-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" />AI</Button>
</div> </div>
</div> </div>
</template> </template>
@@ -799,19 +761,13 @@ const hasRightOverflow = computed(() => {
gap: 0; gap: 0;
overflow: hidden; overflow: hidden;
padding-inline: 0.5rem; padding-inline: 0.5rem;
border-radius: 0.5rem; border-radius: 10px;
} }
.console-action-bar--sidebar :deep(button > span) { .console-action-bar--sidebar :deep(button > span) {
display: none; display: none;
} }
.console-action-bar--sidebar :deep([data-fixed-action][aria-hidden='true']) {
width: 1.5rem;
height: 1px;
margin: 0.35rem auto;
}
.console-action-bar--sidebar .console-action-bar__inner { .console-action-bar--sidebar .console-action-bar__inner {
overflow-y: auto; overflow-y: auto;
gap: 1rem; gap: 1rem;
@@ -835,10 +791,6 @@ const hasRightOverflow = computed(() => {
border-radius: 10px; border-radius: 10px;
} }
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
background: var(--accent);
}
@media (max-width: 639px) { @media (max-width: 639px) {
.console-action-bar--floating .console-action-bar__inner { .console-action-bar--floating .console-action-bar__inner {
padding-inline: 4px; padding-inline: 4px;

View File

@@ -1,460 +0,0 @@
<script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Bot,
Brain,
ChevronDown,
ChevronRight,
Image,
KeyRound,
SendHorizontal,
Settings,
Square,
Trash2,
Wifi,
WifiOff,
X,
} from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { computerUseApi, type ComputerUseAction, type ComputerUseConfig, type ComputerUseSession } from '@/api'
import { ApiError } from '@/api/request'
import type { ComputerUseTimelineItem } from '@/types/computerUseTimeline'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const props = defineProps<{
open: boolean
connected: boolean
wsError: string | null
session: ComputerUseSession | null
timeline: ComputerUseTimelineItem[]
}>()
const { t } = useI18n()
const consoleAppearance = useConsoleAppearance()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'start', prompt: string): void
(e: 'stop'): void
(e: 'clear'): void
}>()
const config = ref<ComputerUseConfig | null>(null)
const prompt = ref('')
const apiKey = ref('')
const savingConfig = ref(false)
const starting = ref(false)
const settingsOpen = ref(false)
const messagesRef = ref<HTMLDivElement | null>(null)
const reasoningOverrides = ref<Record<string, boolean>>({})
const defaultModel = computed({
get: () => config.value?.model ?? 'gpt-5.5',
set: (value: string) => {
if (config.value) config.value.model = value
},
})
const defaultBaseUrl = computed({
get: () => config.value?.base_url ?? 'https://api.openai.com/v1/responses',
set: (value: string) => {
if (config.value) config.value.base_url = value
},
})
const status = computed(() => props.session?.status ?? 'idle')
const isRunning = computed(() => ['waiting_screenshot', 'thinking', 'executing'].includes(status.value))
const canStart = computed(() => (
!!config.value?.enabled
&& !!config.value?.api_key_configured
&& prompt.value.trim().length > 0
&& !isRunning.value
))
const statusLabel = computed(() => {
switch (status.value) {
case 'waiting_screenshot': return t('computerUse.status.waitingScreenshot')
case 'thinking': return t('computerUse.status.thinking')
case 'executing': return t('computerUse.status.executing')
case 'completed': return t('computerUse.status.completed')
case 'failed': return t('computerUse.status.failed')
case 'stopped': return t('computerUse.status.stopped')
default: return t('computerUse.status.idle')
}
})
const apiKeyPlaceholder = computed(() => {
if (!config.value?.api_key_configured) return t('computerUse.settings.apiKey')
const source = config.value.api_key_source
const sourceLabel = source === 'env'
? t('computerUse.settings.sourceEnv')
: source === 'config'
? t('computerUse.settings.sourceConfig')
: t('computerUse.settings.sourceNone')
return t('computerUse.settings.configured', { source: sourceLabel })
})
async function loadConfig() {
try {
config.value = await computerUseApi.config()
} catch (err) {
// Auth failures are handled globally (redirect to login); don't toast over it.
if (err instanceof ApiError && err.status === 401) return
toast.error(t('computerUse.errors.configLoadFailed'), {
description: err instanceof Error ? err.message : undefined,
})
}
}
async function saveConfig() {
if (!config.value) return
savingConfig.value = true
try {
config.value = await computerUseApi.updateConfig({
enabled: config.value.enabled,
base_url: config.value.base_url,
model: config.value.model,
api_key: apiKey.value.trim() || undefined,
})
apiKey.value = ''
settingsOpen.value = false
toast.success(t('computerUse.success.configSaved'))
} finally {
savingConfig.value = false
}
}
async function clearApiKey() {
savingConfig.value = true
try {
config.value = await computerUseApi.updateConfig({ clear_api_key: true })
apiKey.value = ''
toast.success(t('computerUse.success.apiKeyCleared'))
} finally {
savingConfig.value = false
}
}
function start() {
if (!canStart.value) return
const text = prompt.value.trim()
starting.value = true
emit('start', text)
prompt.value = ''
starting.value = false
}
function formatAction(action: ComputerUseAction): string {
switch (action.type) {
case 'click':
return t('computerUse.actions.click', {
x: action.x,
y: action.y,
button: t(`computerUse.mouseButtons.${action.button ?? 'left'}`),
})
case 'double_click':
return t('computerUse.actions.doubleClick', {
x: action.x,
y: action.y,
button: t(`computerUse.mouseButtons.${action.button ?? 'left'}`),
})
case 'move':
return t('computerUse.actions.move', { x: action.x, y: action.y })
case 'drag':
return t('computerUse.actions.drag', { count: action.path.length })
case 'scroll':
return t('computerUse.actions.scroll', {
x: action.x,
y: action.y,
dx: action.dx ?? 0,
dy: action.dy ?? 0,
})
case 'type':
return t('computerUse.actions.typeAscii', { count: action.text.length })
case 'keypress':
return t('computerUse.actions.keypress', { keys: action.keys.join('+') })
case 'wait':
return t('computerUse.actions.wait', { ms: action.ms })
case 'screenshot':
return t('computerUse.actions.screenshot')
}
}
function reasoningOpen(item: Extract<ComputerUseTimelineItem, { type: 'reasoning' }>) {
return reasoningOverrides.value[item.id] ?? !item.completed
}
function toggleReasoning(item: Extract<ComputerUseTimelineItem, { type: 'reasoning' }>) {
reasoningOverrides.value[item.id] = !reasoningOpen(item)
}
function scrollToBottom() {
nextTick(() => {
const el = messagesRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
watch(() => props.timeline, scrollToBottom, { deep: true })
watch(() => props.open, (open) => {
if (open) scrollToBottom()
})
onMounted(loadConfig)
</script>
<template>
<aside
data-slot="computer-use-panel"
:data-console-layout="consoleAppearance"
v-show="open"
class="absolute inset-y-0 right-0 z-30 h-full min-h-0 w-full border-l bg-background shadow-xl sm:w-[420px] md:relative md:z-auto xl:w-[460px]"
>
<div class="flex h-full min-h-0 flex-col">
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Bot class="size-5 shrink-0" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">{{ t('computerUse.title') }}</div>
<div
class="flex min-w-0 items-center gap-1 text-[11px]"
:class="connected ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'"
>
<Wifi v-if="connected" class="size-3 shrink-0" />
<WifiOff v-else class="size-3 shrink-0" />
<span class="truncate">
{{ connected ? t('computerUse.connection.connected') : (wsError || t('computerUse.connection.disconnected')) }}
</span>
<span class="text-muted-foreground">· {{ statusLabel }}</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="size-8"
:title="t('computerUse.buttons.settings')"
:aria-label="t('computerUse.buttons.settings')"
@click="settingsOpen = true"
>
<Settings class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
:title="t('computerUse.buttons.clear')"
:aria-label="t('computerUse.buttons.clear')"
:disabled="isRunning || timeline.length === 0"
@click="emit('clear')"
>
<Trash2 class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
:title="t('computerUse.buttons.close')"
:aria-label="t('computerUse.buttons.close')"
@click="emit('update:open', false)"
>
<X class="size-4" />
</Button>
</header>
<div ref="messagesRef" class="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div v-if="timeline.length === 0" class="flex h-full items-center justify-center text-muted-foreground/45">
<Bot class="size-10" />
</div>
<div v-else class="space-y-5">
<template v-for="item in timeline" :key="item.id">
<div v-if="item.type === 'user'" class="flex justify-end">
<div class="max-w-[86%] whitespace-pre-wrap break-words rounded-md bg-muted px-3 py-2 text-sm">
{{ item.text }}
</div>
</div>
<div v-else-if="item.type === 'assistant'" class="whitespace-pre-wrap break-words text-sm leading-6">
{{ item.text }}
</div>
<div v-else-if="item.type === 'reasoning'" class="border-l-2 border-muted pl-3 text-xs text-muted-foreground">
<button
type="button"
class="flex w-full items-center gap-1.5 py-1 text-left font-medium hover:text-foreground"
@click="toggleReasoning(item)"
>
<ChevronDown v-if="reasoningOpen(item)" class="size-3.5 shrink-0" />
<ChevronRight v-else class="size-3.5 shrink-0" />
<Brain class="size-3.5 shrink-0" />
<span>
{{ item.failed
? t('computerUse.reasoning.failed')
: (item.completed ? t('computerUse.reasoning.process') : t('computerUse.reasoning.thinking')) }}
</span>
</button>
<div
v-if="reasoningOpen(item)"
class="max-h-72 overflow-y-auto whitespace-pre-wrap break-words pb-2 pt-1 leading-5"
>
{{ item.text || t('computerUse.reasoning.thinking') }}
</div>
</div>
<div v-else-if="item.type === 'screenshot'" class="overflow-hidden rounded-md border bg-card">
<div class="flex items-center justify-between border-b px-2.5 py-1.5 text-[11px] text-muted-foreground">
<span class="inline-flex items-center gap-1.5">
<Image class="size-3.5" />{{ t('computerUse.trace.screenshot') }}
</span>
<span>{{ item.screenshot.width }}x{{ item.screenshot.height }}</span>
</div>
<div
class="w-full bg-black"
:style="{ aspectRatio: `${item.screenshot.width} / ${item.screenshot.height}` }"
>
<img
:src="item.screenshot.data_url"
class="h-full w-full object-contain"
:alt="t('computerUse.trace.screenshotAlt')"
/>
</div>
</div>
<div v-else-if="item.type === 'actions_executed'" class="border-l-2 border-emerald-500/50 pl-3 text-xs">
<div class="mb-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
{{ t('computerUse.trace.executed') }}
</div>
<div class="space-y-1 text-muted-foreground">
<div v-for="(action, index) in item.actions" :key="index" class="break-words">
{{ formatAction(action) }}
</div>
</div>
</div>
<div
v-else-if="item.type === 'error'"
class="whitespace-pre-wrap break-words rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive"
>
{{ item.text }}
</div>
<div v-else class="text-center text-xs text-muted-foreground">
{{ item.text }}
</div>
</template>
</div>
</div>
<footer class="shrink-0 border-t bg-background p-3">
<div class="relative">
<Textarea
v-model="prompt"
rows="3"
class="max-h-40 min-h-20 resize-none pr-12"
:placeholder="t('computerUse.input.placeholder')"
:disabled="isRunning"
@keydown.meta.enter.prevent="start"
@keydown.ctrl.enter.prevent="start"
/>
<Button
v-if="!isRunning"
size="icon"
class="absolute bottom-2 right-2 size-8"
:title="t('computerUse.buttons.send')"
:aria-label="t('computerUse.buttons.send')"
:disabled="!canStart || starting"
@click="start"
>
<SendHorizontal class="size-4" />
</Button>
<Button
v-else
size="icon"
variant="destructive"
class="absolute bottom-2 right-2 size-8"
:title="t('computerUse.buttons.stop')"
:aria-label="t('computerUse.buttons.stop')"
@click="emit('stop')"
>
<Square class="size-3.5 fill-current" />
</Button>
</div>
<p v-if="config && !config.api_key_configured" class="mt-2 text-xs text-muted-foreground">
{{ t('computerUse.input.apiKeyRequired') }}
</p>
</footer>
</div>
<Dialog v-model:open="settingsOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>{{ t('computerUse.settings.title') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="flex items-center justify-between gap-4">
<Label for="cua-enabled">{{ t('computerUse.settings.enableAi') }}</Label>
<Switch
id="cua-enabled"
:model-value="config?.enabled ?? false"
@update:model-value="(value) => { if (config) config.enabled = value }"
/>
</div>
<div class="space-y-1.5">
<Label for="cua-model">{{ t('computerUse.settings.model') }}</Label>
<Input id="cua-model" v-model="defaultModel" :disabled="!config" placeholder="gpt-5.5" />
</div>
<div class="space-y-1.5">
<Label for="cua-url">{{ t('computerUse.settings.apiUrl') }}</Label>
<Input
id="cua-url"
v-model="defaultBaseUrl"
:disabled="!config"
placeholder="https://api.example.com/v1/responses"
/>
</div>
<div class="space-y-1.5">
<Label for="cua-key" class="flex items-center gap-1.5">
<KeyRound class="size-3.5" />
{{ t('computerUse.settings.apiKey') }}
</Label>
<Input
id="cua-key"
v-model="apiKey"
type="password"
autocomplete="off"
:placeholder="apiKeyPlaceholder"
/>
</div>
</div>
<DialogFooter class="gap-2 sm:justify-between">
<Button
variant="outline"
:disabled="savingConfig || !config?.api_key_configured"
@click="clearApiKey"
>
{{ t('computerUse.buttons.clearKey') }}
</Button>
<Button :disabled="savingConfig || !config" @click="saveConfig">
{{ t('computerUse.buttons.save') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</aside>
</template>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { BarChart3, Bot, Settings, Terminal, MoreHorizontal } from 'lucide-vue-next' import { BarChart3, Terminal, MoreHorizontal } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -10,14 +9,13 @@ defineProps<{
showStats: boolean showStats: boolean
showTerminal: boolean showTerminal: boolean
terminalRunning: boolean terminalRunning: boolean
showComputerUse: boolean statsOpen?: boolean
terminalOpen?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'openStats'): void (e: 'openStats'): void
(e: 'openTerminal'): void (e: 'openTerminal'): void
(e: 'openComputerUse'): void
}>() }>()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const menuOpen = ref(false) const menuOpen = ref(false)
function openFromMenu(action: () => void) { function openFromMenu(action: () => void) {
@@ -29,28 +27,21 @@ function openFromMenu(action: () => void) {
<template> <template>
<div class="console-header-actions flex shrink-0 items-center gap-1"> <div class="console-header-actions flex shrink-0 items-center gap-1">
<div class="hidden items-center gap-1 md:flex"> <div class="hidden items-center gap-1 md:flex">
<Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" @click="emit('openStats')"> <Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" :aria-pressed="!!statsOpen" @click="emit('openStats')">
<BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span> <BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span>
</Button> </Button>
<Button v-if="showTerminal" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.webTerminal')" :title="t('actionbar.webTerminal')" :disabled="!terminalRunning" @click="emit('openTerminal')"> <Button v-if="showTerminal" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.webTerminal')" :title="t('actionbar.webTerminal')" :disabled="!terminalRunning" :aria-pressed="!!terminalOpen" @click="emit('openTerminal')">
<Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span> <Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span>
</Button> </Button>
<Button v-if="showComputerUse" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('computerUse.title')" :title="t('computerUse.title')" @click="emit('openComputerUse')">
<Bot class="size-4" /><span class="hidden lg:inline">AI</span>
</Button>
</div> </div>
<DropdownMenu v-if="showStats || showTerminal || showComputerUse" v-model:open="menuOpen"> <DropdownMenu v-if="showStats || showTerminal" v-model:open="menuOpen">
<DropdownMenuTrigger as-child class="md:hidden"> <DropdownMenuTrigger as-child class="md:hidden">
<Button variant="ghost" size="icon-sm" :aria-label="t('actionbar.more')"><MoreHorizontal class="size-4" /></Button> <Button variant="ghost" size="icon-sm" :aria-label="t('actionbar.more')"><MoreHorizontal class="size-4" /></Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48"> <DropdownMenuContent align="end" class="w-48">
<DropdownMenuItem v-if="showStats" @select="openFromMenu(() => emit('openStats'))"><BarChart3 class="size-4" />{{ t('actionbar.stats') }}</DropdownMenuItem> <DropdownMenuItem v-if="showStats" @select="openFromMenu(() => emit('openStats'))"><BarChart3 class="size-4" />{{ t('actionbar.stats') }}</DropdownMenuItem>
<DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem> <DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem>
<DropdownMenuItem v-if="showComputerUse" @select="openFromMenu(() => emit('openComputerUse'))"><Bot class="size-4" />{{ t('computerUse.title') }}</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button variant="ghost" size="sm" class="gap-1.5 rounded-lg px-2 text-xs" :aria-label="t('actionbar.settings')" :title="t('actionbar.settings')" @click="router.push('/settings')">
<Settings class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.settings') }}</span>
</Button>
</div> </div>
</template> </template>

View File

@@ -48,7 +48,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class="min-w-0 shrink gap-1.5 px-2 text-xs" class="console-status-summary min-w-0 shrink gap-1.5 px-2 text-xs"
:class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''" :class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''"
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`" :aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
:title="summary" :title="summary"
@@ -56,7 +56,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" /> <AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
<Info v-else-if="issues.length" class="size-3.5 shrink-0" /> <Info v-else-if="issues.length" class="size-3.5 shrink-0" />
<span v-else class="size-2 shrink-0 rounded-full bg-status-active" /> <span v-else class="size-2 shrink-0 rounded-full bg-status-active" />
<span class="truncate" role="status">{{ summary }}</span> <span class="console-status-summary__label truncate" role="status">{{ summary }}</span>
<span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span> <span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span>
<ChevronDown class="size-3 shrink-0" /> <ChevronDown class="size-3 shrink-0" />
</Button> </Button>

View File

@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Input } from '@/components/ui/input'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -17,6 +18,7 @@ import { MousePointer, Move, Loader2 } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue' import HelpTooltip from '@/components/HelpTooltip.vue'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { HidBackend, OtgHidProfile } from '@/types/generated' import { HidBackend, OtgHidProfile } from '@/types/generated'
import { usesCh9329 } from '@/lib/hidGuide'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -66,7 +68,54 @@ watch(showCursor, (newValue, oldValue) => {
const buttonText = computed(() => t('actionbar.hidConfig')) const buttonText = computed(() => t('actionbar.hidConfig'))
const hidSaving = ref(false) const hidSaving = ref(false)
const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid) const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid)
const supportsLinuxCompatibility = computed(() => configStore.hid?.backend === HidBackend.Ch9329) const jigglerEnabled = computed(() => configStore.hid?.jiggler?.enabled ?? false)
const jigglerInterval = ref<string | number>(60)
const jigglerIntervalInvalid = ref(false)
const hasMouseInterface = computed(() => {
const hid = configStore.hid
if (!hid || hid.backend === HidBackend.None) return false
if (hid.backend !== HidBackend.Otg) return true
if (hid.otg_profile === OtgHidProfile.LegacyKeyboard) return false
if (hid.otg_profile === OtgHidProfile.Custom) {
return !!(hid.otg_functions?.mouse_relative || hid.otg_functions?.mouse_absolute)
}
return true
})
watch(() => [configStore.hid?.jiggler?.interval_secs, configStore.hid?.jiggler?.enabled] as const, ([interval]) => {
jigglerInterval.value = interval ?? 60
jigglerIntervalInvalid.value = false
}, { immediate: true })
async function saveJiggler(update: { enabled?: boolean; interval_secs?: number }) {
if (hidControlsDisabled.value) return
hidSaving.value = true
try {
await configStore.updateHid({ jiggler: update })
} catch (error) {
toast.error(t('config.updateFailed'), {
description: error instanceof Error ? error.message : undefined,
})
await configStore.refreshHid().catch(() => undefined)
jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60
} finally {
hidSaving.value = false
}
}
function toggleJiggler(enabled: boolean) {
if (enabled && !hasMouseInterface.value) return
void saveJiggler({ enabled })
}
function saveJigglerInterval() {
if (hidControlsDisabled.value || !jigglerEnabled.value) return
const interval = Number(jigglerInterval.value)
jigglerIntervalInvalid.value = !Number.isInteger(interval) || interval < 10 || interval > 3600
if (jigglerIntervalInvalid.value || interval === configStore.hid?.jiggler?.interval_secs) return
void saveJiggler({ interval_secs: interval })
}
const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend))
const supportsMacosCompatibility = computed(() => const supportsMacosCompatibility = computed(() =>
supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg
) )
@@ -139,6 +188,8 @@ watch(() => props.open, (open) => {
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage() mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
showCursor.value = localStorage.getItem('hidShowCursor') !== 'false' showCursor.value = localStorage.getItem('hidShowCursor') !== 'false'
if (!hidSaving.value) void configStore.refreshHid().catch(() => undefined) if (!hidSaving.value) void configStore.refreshHid().catch(() => undefined)
jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60
jigglerIntervalInvalid.value = false
}) })
</script> </script>
@@ -159,7 +210,7 @@ watch(() => props.open, (open) => {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@open-auto-focus="focusConsolePanel" @open-auto-focus="focusConsolePanel"
class="console-config-panel w-[min(320px,92vw)] p-3" class="console-config-panel w-[min(320px,92vw)] max-h-(--reka-popover-content-available-height) overflow-y-auto p-3"
align="start" align="start"
:side="props.side ?? 'bottom'" :side="props.side ?? 'bottom'"
> >
@@ -230,6 +281,45 @@ watch(() => props.open, (open) => {
<Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label> <Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label>
<Switch v-model="showCursor" /> <Switch v-model="showCursor" />
</div> </div>
<div class="space-y-2" :aria-busy="hidSaving">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1">
<Label for="hid-mouse-jiggler" class="text-xs text-muted-foreground">{{ t('actionbar.mouseJiggler') }}</Label>
<HelpTooltip :content="t('actionbar.mouseJigglerHelp')" icon-size="sm" />
</div>
<Switch
id="hid-mouse-jiggler"
:model-value="jigglerEnabled"
:disabled="hidControlsDisabled || (!hasMouseInterface && !jigglerEnabled)"
@update:model-value="toggleJiggler"
/>
</div>
<div v-if="jigglerEnabled" class="flex items-center justify-between gap-3">
<Label for="hid-jiggler-interval" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerInterval') }}</Label>
<div class="flex items-center gap-2">
<Input
id="hid-jiggler-interval"
v-model="jigglerInterval"
type="number" min="10" max="3600" step="1" size="sm"
class="w-20 text-xs"
:disabled="hidControlsDisabled"
:aria-invalid="jigglerIntervalInvalid"
:aria-describedby="jigglerIntervalInvalid ? 'hid-jiggler-interval-error' : undefined"
@input="jigglerIntervalInvalid = false"
@blur="saveJigglerInterval"
@keydown.enter.prevent="saveJigglerInterval"
/>
<span class="text-xs text-muted-foreground">{{ t('actionbar.jigglerSeconds') }}</span>
</div>
</div>
<p v-if="jigglerEnabled && jigglerIntervalInvalid" id="hid-jiggler-interval-error" role="alert" class="text-xs text-destructive">{{ t('actionbar.jigglerIntervalInvalid') }}</p>
<p v-if="!hasMouseInterface && configStore.hid" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerNoMouse') }}</p>
</div>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div> </div>
<template v-if="supportsMacosCompatibility"> <template v-if="supportsMacosCompatibility">
@@ -264,10 +354,6 @@ watch(() => props.open, (open) => {
</div> </div>
<p v-if="macosDragRequiresInterfaces" class="text-xs text-warning">{{ t('actionbar.macosDragRequiresBoth') }}</p> <p v-if="macosDragRequiresInterfaces" class="text-xs text-warning">{{ t('actionbar.macosDragRequiresBoth') }}</p>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div> </div>
</template> </template>
</div> </div>

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