Compare commits

...

67 Commits
v260720 ... dev

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Signed-off-by: BigfootACA <bigfoot@radxa.com>
2026-08-26 15:51:55 +08:00
a15355447898
1d48d45592 feat: 支持高通 iris v4l2m2m 硬件编码器
- 新增高通平台检测,避免 Amlogic v4l2m2m 检测误判
- 对高通 Iris 编码器调整 GOP 和 capture buffers
- 强制关键帧仅在高通 Iris 之外启用
2026-08-26 15:51:55 +08:00
TXIuTnVsbA
d613739b06 fix(hid): 兼容 CH9329/CH9329F 串口响应 (#300)
支持 CH9329/CH9329F 扩展参数响应、粘包与乱序处理,保留严格校验和及有界接收缓冲。
2026-08-26 15:19:41 +08:00
mofeng-git
6e1194fe4c refactor: 移除 AMLENC 私有编码后端 2026-08-26 12:06:10 +08:00
mofeng-git
f101ddb918 refactor: 降低 hwcodec 探测日志噪音 2026-08-26 12:04:52 +08:00
mofeng-git
0054100414 refactor: 精简运行时重复初始化
统一服务与 CLI 的数据库初始化入口,由视频流管理器负责 WebRTC 采集源同步,并将 MSD 目录创建收敛到控制器内部。
2026-08-26 11:37:39 +08:00
mofeng-git
486f3887c2 refactor: 简化日志级别配置并移除 verbose 参数 2026-08-26 11:04:40 +08:00
mofeng-git
37af369b43 refactor: 简化运行时与服务协调
提取运行时生命周期管理,集中远程访问与 USB 协调,并缩小 Web 路由状态依赖。
2026-08-26 10:53:08 +08:00
mofeng-git
827d24fde4 feat: 增加 Amlogic 硬件编码的并行解码支持 2026-08-25 14:58:09 +08:00
mofeng-git
b8ea38b5f3 feat: 增加 s905 晶晨 H.264 硬件编码支持 2026-08-15 21:26:34 +08:00
mofeng-git
4530d8663d fix: 将电源管理选项自动隐藏修改为手动控制,避免 WOL 功能无法显示 #289 2026-08-07 15:49:58 +08:00
SilentWind
a4073d64cb Merge pull request #287 from mofeng-git/dev
Dev
2026-08-02 16:38:35 +08:00
mofeng-git
16400df182 chore: bump version to v0.2.6 2026-08-02 16:37:47 +08:00
mofeng-git
47af17bebc feat: 新增 Amlogic AMLENC 硬件编码支持
- 新增动态加载及 ABI 校验的 AMLENC H.264/H.265 编码器
- 注册 Amlogic 后端并扩展编码器自检
- 并行执行 MJPEG 解码与 AMLENC 编码
- 复用 NV12 缓冲区并移除冗余帧初始化
- 优化低延迟帧分发及 RTCP 关键帧恢复
- 动态调整 AMLENC 码率尚未完成,效果不佳
2026-08-02 16:24:10 +08:00
mofeng-git
814f23a27c ci: 优化 Cargo 构建缓存
- 共享 deb 与 Docker 的 Linux cross 编译缓存
- 为 Windows MSVC 构建添加独立缓存
- 固定 cross 版本以保证缓存一致性
2026-07-31 09:55:14 +08:00
mofeng-git
6bbe137f09 fix: 修复 Windows 音频设备函数可见性 2026-07-31 09:46:32 +08:00
mofeng-git
6fdcf5c7c9 fix: 修复无信号状态同步与采集管线阻塞
- 使用非阻塞 V4L2 句柄并类型化采集错误
- 完善视频管线生命周期,避免复用停止中的管线
- 区分音视频事件并支持 REST/WebSocket 状态快照
- 修复信号丢失后保留最后一帧及刷新后黑屏问题
- 优化无信号提示、状态展示优先级及相关界面细节
- 降低重复 OTG 错误日志级别
2026-07-30 22:43:59 +08:00
mofeng-git
ce1712ff2e feat: 为 EasyTier 添加完整 TOML 配置支持
- 新增快速配置与完整配置模式,兼容旧版配置
 - 统一扩展配置校验及受保护临时文件生命周期
- 支持通过 easytier-core -c 加载 TOML 配置
- 复用前端配置模式编辑组件并更新中英文文案与类型
- 补充配置校验、启动参数、文件权限及清理测试
2026-07-30 21:26:00 +08:00
mofeng-git
8b09824e04 fix: 修正 RustDesk UDP 注册状态判定
- 移除无法证明服务器可达的 connected 状态
- 引入注册跟踪器统一管理响应、超时与退避
- 连续注册超时后正确降级为未连接
 - 分离本地服务状态与 HBBS 注册状态
- 补充注册状态机单元测试
2026-07-30 19:43:28 +08:00
mofeng-git
9fb23476ac fix: 完善 RK3588 HDMI RX 信号检测与自动恢复
- 统一使用 QUERY_DV_TIMINGS 判断 HDMI RX 输入状态
  - 增加 source-following 设备状态 API 与前端只读展示
  - 支持长时间无信号后的 MJPEG/WebRTC 自动恢复
  - 修复模式切换时采集设备尚未释放导致的 EBUSY
  - 取消陈旧 WebRTC 重连并统一采集恢复策略
  - 移除视频输入状态区域的冗余标题
2026-07-30 19:41:44 +08:00
mofeng-git
971c263bf8 feat: 虚拟媒体显示镜像上传进度百分比 2026-07-29 23:25:12 +08:00
mofeng-git
cb3fd3e646 feat: 添加视频窗口等比例缩放按钮 2026-07-29 19:32:46 +08:00
mofeng-git
d414557d74 fix: 修复设置页页眉回弹和状态色辨识度 #281
- 在侧栏布局滚动容器统一阻止纵向 overscroll
  - 拆分状态指示色与成功文本色,提升在线状态辨识度
  - 统一连接、运行及键盘 LED 等状态点样式
2026-07-29 18:50:20 +08:00
mofeng-git
fbcd95b617 fix: 升级目录改为使用 /tmp 目录,避免遗留升级文件 #280 2026-07-29 17:59:28 +08:00
mofeng-git
887f29096f feat: 增加 OTG HID 远程唤醒和 OTG MSD 设备名称配置
- 在补丁内核上启用 HID 写入唤醒及 USB 远程唤醒描述符
  - 支持全局配置 Flash 和 CD-ROM INQUIRY 字符串
  - 兼容普通内核的通用 INQUIRY 属性
  - 调整 OTG 功能布局并更新大容量 DVD 镜像提示
2026-07-29 13:56:52 +08:00
mofeng-git
e0bddc2faa fix: 修复设备识别问题 #286 2026-07-27 15:12:54 +08:00
mofeng-git
4f770a1eea fix: 完善 MSD 错误提示;解决 MSD 文件名称过长问题;优化粘贴文本样式;优化所有下拉框样式;删除“画面静止时会自动降帧”提示 2026-07-26 23:13:47 +08:00
mofeng-git
376dc97134 fix: 修复 MSD ISO/FLASH 挂载识别错误;完善错误提示 2026-07-26 22:59:27 +08:00
mofeng-git
27c8da9a75 fix: 完善 RK3588 HDMI RX 兼容性 2026-07-26 22:27:47 +08:00
mofeng-git
5963dfa01a feat: 完善 OTG UAC 音频支持 2026-07-26 11:59:58 +08:00
mofeng-git
f86cba6ce5 Merge branch 'main' into dev
# Conflicts:
#	src/web/handlers/setup.rs
#	web/src/i18n/en-US.ts
#	web/src/i18n/zh-CN.ts
2026-07-24 11:09:36 +08:00
mofeng-git
e1b82519b9 test: 更新报告格式 2026-07-24 11:03:22 +08:00
mofeng-git
32d8a62fec fix: 初始化时完整应用 USB 运行时配置,避免遗漏 MSD 功能 2026-07-24 11:02:14 +08:00
mofeng-git
4cd622ff07 fix: OTG 网卡桥接改由 NetworkManager 生成桥接配置 UUID;docker 补全 NetworkManager 依赖 2026-07-24 11:01:54 +08:00
SilentWind
6ec4361f43 Merge pull request #282 from arounyf/fix/hid-reload-on-gadget-rebuild
fix: 修复OTG gadget重建后HID后端未重载导致键鼠失效
2026-07-23 15:25:37 +08:00
arounyf
e9bed3688f feat: UAC USB microphone passthrough
- Add UAC1 gadget function (ConfigFS) with optimized endpoint config
- Browser mic capture with Opus encoding via WebCodecs AudioEncoder
- WebSocket audio transport (Opus 64kbps, 100x bandwidth reduction vs raw PCM)
- aplay subprocess for reliable PCM playback to USB gadget
- Settings toggle + ActionBar mic button (hidden when UAC disabled)
- Dynamic PCM device resolution (/proc/asound)
- c_chmask=0 + req_number=4 fixes DWC3 composite isochronous endpoint issue
2026-07-23 06:56:53 +00:00
mofeng-git
8d5366444b fix: 完善测试套件 2026-07-22 22:53:21 +08:00
arounyf
4e32b05124 fix: 修复OTG gadget重建后HID后端未重载导致键鼠失效
当MSD或OTG网络配置变更导致gadget被重建时,新的/dev/hidg*设备
被创建但HID后端因'配置未变'跳过了reload,导致仍持有已删除设备
的旧文件描述符,键鼠操作无效。

修复: 在apply_usb_config中检测gadget是否因MSD/网络变更被重建,
若是且HID后端为OTG,强制reload HID以打开新的/dev/hidg*设备。

同时为MsdConfig添加PartialEq derive以支持变更检测。
2026-07-22 04:54:18 +00:00
mofeng-git
e0055bf491 fix: 删除 nmcli 命令缺失弹窗提示 2026-07-20 21:24:27 +08:00
241 changed files with 23099 additions and 11056 deletions

View File

@@ -34,13 +34,11 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build frontend
working-directory: web
run: |
npm ci
npm install
npm run build
- name: Upload frontend dist
@@ -66,8 +64,16 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
# The Docker workflow builds the same targets with the same Cross images.
shared-key: linux-cross-release
key: ${{ hashFiles('Cross.toml', 'build/cross/Dockerfile.*') }}
cache-all-crates: true
- name: Install cross
run: cargo install cross --locked
run: cargo install cross --version 0.2.5 --locked
- name: Build linux binary
run: bash build/build-images.sh
@@ -127,6 +133,12 @@ jobs:
"TURBOJPEG_LIB_DIR=$env:TURBOJPEG_LIB_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
"TURBOJPEG_INCLUDE_DIR=$env:TURBOJPEG_INCLUDE_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
shared-key: windows-msvc-release
key: ${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}
- name: Build Windows exe
shell: pwsh
run: .\build\windows\build.ps1 -Configuration release -Package

View File

@@ -37,21 +37,27 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
# Reuse dependency artifacts produced by the deb job and vice versa.
shared-key: linux-cross-release
key: ${{ hashFiles('Cross.toml', 'build/cross/Dockerfile.*') }}
cache-all-crates: true
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y unzip xz-utils
cargo install cross --locked
cargo install cross --version 0.2.5 --locked
- name: Build frontend
working-directory: web
run: |
npm ci
npm install
npm run build
- name: Set up QEMU

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.2.5"
version = "0.2.6"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"
@@ -56,6 +56,7 @@ desktop = [
"dep:serialport",
"dep:async-trait",
"dep:libc",
"dep:libloading",
"dep:ventoy-img",
"dep:protobuf",
"dep:sodiumoxide",
@@ -69,6 +70,7 @@ desktop = [
"dep:v4l2r",
"dep:alsa",
"dep:gpio-cdev",
"dep:one-kvm-bluetooth-hid",
"dep:cpal",
"dep:windows-sys",
]
@@ -156,6 +158,7 @@ sdp-types = { version = "0.1", optional = true }
serialport = { version = "4", optional = true }
async-trait = { version = "0.1", optional = true }
libc = { version = "0.2", optional = true }
libloading = { version = "0.8", optional = true }
# Ventoy bootable image support
ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
@@ -177,6 +180,8 @@ turbojpeg = { version = "1.3", optional = true }
audiopus = { version = "0.2", optional = true }
[target.'cfg(target_os = "linux")'.dependencies]
one-kvm-bluetooth-hid = { path = "libs/bluetooth-hid", optional = true }
# Utilities
nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
[package]
name = "one-kvm-bluetooth-hid"
version = "0.1.0"
edition = "2021"
license = "GPL-2.0"
[dependencies]
bluer = { version = "0.17", features = ["bluetoothd", "l2cap"] }
dbus = { version = "0.9", features = ["vendored"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dbus-crossroads = "0.5"
dbus-tokio = "0.7"
libc = "0.2"
tracing = "0.1"

View File

@@ -0,0 +1,49 @@
//! Explicit hardware regression: opens a 10-second pairing window without
//! removing existing bonds or sending input. Stop the production HID first.
use one_kvm_bluetooth_hid::{Action, Config, Peripheral};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), String> {
let peripheral = Peripheral::start(Config {
adapter: "hci0".into(),
name: "One-KVM HID".into(),
peer: None,
})?;
let result = async {
for _ in 0..50 {
if peripheral.status().initialized {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let before = peripheral.status();
if !before.initialized {
return Err(format!("Initialization failed: {:?}", before.error));
}
if !before
.devices
.iter()
.any(|d| Some(&d.address) == before.peer.as_ref() && d.paired)
{
return Err("This regression needs an existing bonded computer".into());
}
peripheral.action(Action::Pair(10)).await?;
tokio::time::sleep(Duration::from_secs(1)).await;
let open = peripheral.status();
println!("open: {}", serde_json::to_string(&open).unwrap());
if open.pairing_seconds == 0 {
return Err("Existing bond prematurely closed pairing".into());
}
tokio::time::sleep(Duration::from_secs(10)).await;
let expired = peripheral.status();
println!("expired: {}", serde_json::to_string(&expired).unwrap());
if expired.pairing_seconds != 0 || expired.peer != before.peer {
return Err("Window did not expire while preserving the bonded peer".into());
}
Ok(())
}
.await;
let cleanup = peripheral.shutdown().await;
result.and(cleanup)
}

View File

@@ -0,0 +1,24 @@
//! Native hardware smoke check. Registers for 12 seconds, sends no input,
//! opens no pairing window, then restores the adapter configuration.
use one_kvm_bluetooth_hid::{Config, Peripheral};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), String> {
let peripheral = Peripheral::start(Config {
adapter: "hci0".into(),
name: "One-KVM HID".into(),
peer: None,
})?;
let mut initialized = false;
for _ in 0..12 {
tokio::time::sleep(Duration::from_secs(1)).await;
let status = peripheral.status();
initialized |= status.initialized;
println!("{}", serde_json::to_string(&status).unwrap());
}
peripheral.shutdown().await?;
if !initialized {
return Err("Native BlueZ peripheral never initialized".into());
}
Ok(())
}

View File

@@ -0,0 +1,236 @@
use crate::Shared;
use dbus::{channel::MatchingReceiver, message::MatchRule, nonblock::Proxy, Path};
use dbus_crossroads::Crossroads;
use std::{
sync::Arc,
time::{Duration, Instant},
};
const PATH: &str = "/org/onekvm/bluetooth/agent";
pub struct Agent {
connection: Arc<dbus::nonblock::SyncConnection>,
task: tokio::task::JoinHandle<()>,
}
impl Agent {
pub async fn register(shared: Arc<Shared>, adapter: String) -> Result<Self, String> {
let (resource, connection) =
dbus_tokio::connection::new_system_sync().map_err(|e| e.to_string())?;
let task = tokio::spawn(async move {
let _ = resource.await;
});
let mut cr = Crossroads::new();
let iface = cr.register("org.bluez.Agent1", |b| {
b.method("Release", (), (), |_, _: &mut (), ()| Ok(()));
b.method("Cancel", (), (), |_, _: &mut (), ()| Ok(()));
let state = shared.clone();
let name = adapter.clone();
b.method(
"RequestAuthorization",
("device",),
(),
move |_, _: &mut (), (path,): (Path<'static>,)| {
authorize(&state, &name, &path, true)
},
);
let state = shared.clone();
let name = adapter.clone();
b.method(
"RequestConfirmation",
("device", "passkey"),
(),
move |_, _: &mut (), (path, _): (Path<'static>, u32)| {
authorize(&state, &name, &path, true)
},
);
b.method(
"AuthorizeService",
("device", "uuid"),
(),
move |_, _: &mut (), (path, uuid): (Path<'static>, String)| {
if !matches!(uuid.as_str(), "00001124-0000-1000-8000-00805f9b34fb") {
return Err(dbus::MethodErr::from((
"org.bluez.Error.Rejected",
"Not an HID service",
)));
}
authorize(&shared, &adapter, &path, false)
},
);
});
cr.insert(PATH, &[iface], ());
let profile = cr.register("org.bluez.Profile1", |b| {
b.method("Release", (), (), |_, _: &mut (), ()| Ok(()));
b.method(
"RequestDisconnection",
("device",),
(),
|_, _: &mut (), (_device,): (Path<'static>,)| Ok(()),
);
b.method(
"NewConnection",
("device", "fd", "properties"),
(),
|_,
_: &mut (),
(_device, _fd, _props): (
Path<'static>,
dbus::arg::OwnedFd,
dbus::arg::PropMap,
)| {
// This profile only publishes SDP. Our L2CAP sockets own both HID channels.
Err::<(), _>(dbus::MethodErr::from((
"org.bluez.Error.Rejected",
"Unexpected profile connection",
)))
},
);
});
cr.insert("/org/onekvm/bluetooth/profile", &[profile], ());
connection.start_receive(
MatchRule::new_method_call(),
Box::new(move |msg, conn| {
let _ = cr.handle_message(msg, conn);
true
}),
);
let agent = Self { connection, task };
let proxy = Proxy::new(
"org.bluez",
"/org/bluez",
Duration::from_secs(5),
agent.connection.clone(),
);
let result: Result<(), dbus::Error> = proxy
.method_call(
"org.bluez.AgentManager1",
"RegisterAgent",
(Path::from(PATH), "NoInputNoOutput"),
)
.await;
if let Err(error) = result {
return Err(error.to_string());
}
let result: Result<(), dbus::Error> = proxy
.method_call(
"org.bluez.AgentManager1",
"RequestDefaultAgent",
(Path::from(PATH),),
)
.await;
result.map_err(|e| e.to_string())?;
let mut options: dbus::arg::PropMap = std::collections::HashMap::new();
options.insert(
"ServiceRecord".into(),
dbus::arg::Variant(Box::new(crate::protocol::sdp())),
);
options.insert(
"Role".into(),
dbus::arg::Variant(Box::new("server".to_string())),
);
options.insert(
"RequireAuthentication".into(),
dbus::arg::Variant(Box::new(true)),
);
options.insert(
"RequireAuthorization".into(),
dbus::arg::Variant(Box::new(false)),
);
let result: Result<(), dbus::Error> = proxy
.method_call(
"org.bluez.ProfileManager1",
"RegisterProfile",
(
Path::from("/org/onekvm/bluetooth/profile"),
crate::protocol::HID_UUID,
options,
),
)
.await;
result.map_err(|e| format!("Register HID SDP: {e}"))?;
Ok(agent)
}
}
impl Drop for Agent {
fn drop(&mut self) {
self.task.abort();
}
}
fn authorize(
shared: &Shared,
adapter: &str,
path: &str,
pairing: bool,
) -> Result<(), dbus::MethodErr> {
let rejected = || {
dbus::MethodErr::from((
"org.bluez.Error.Rejected",
"Open pairing in One-KVM or select the paired computer",
))
};
let prefix = format!("/org/bluez/{adapter}/dev_");
let address = path
.strip_prefix(&prefix)
.ok_or_else(rejected)?
.replace('_', ":");
let mut state = shared.state.lock().unwrap();
if pairing
&& !state
.pairing_until
.is_some_and(|until| until > Instant::now())
{
return Err(rejected());
}
if state.peer.as_ref().is_some_and(|peer| peer != &address) {
return Err(rejected());
}
if state.peer.is_none() {
if !pairing || state.bonded_before_pairing.contains(&address) {
return Err(rejected());
}
state.peer = Some(address);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn existing_unrelated_bond_cannot_be_adopted_by_agent_callback() {
let shared = Shared::new(None);
{
let mut state = shared.state.lock().unwrap();
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
state
.bonded_before_pairing
.insert("10:6F:D9:66:97:88".into());
}
assert!(authorize(
&shared,
"hci0",
"/org/bluez/hci0/dev_10_6F_D9_66_97_88",
true
)
.is_err());
assert!(shared.state.lock().unwrap().peer.is_none());
}
#[test]
fn only_pair_during_window_and_pin_first_peer() {
let state = Shared::new(None);
let first = "/org/bluez/hci0/dev_10_6F_D9_66_97_88";
assert!(authorize(&state, "hci0", first, true).is_err());
state.state.lock().unwrap().pairing_until = Some(Instant::now() + Duration::from_secs(60));
assert!(authorize(&state, "hci0", first, true).is_ok());
assert!(authorize(
&state,
"hci0",
"/org/bluez/hci0/dev_10_6F_D9_66_97_89",
true
)
.is_err());
assert!(authorize(&state, "hci1", first, true).is_err());
state.state.lock().unwrap().pairing_until = None;
assert!(authorize(&state, "hci0", first, false).is_ok());
assert!(authorize(&state, "hci0", first, true).is_err());
}
}

View File

@@ -0,0 +1,145 @@
//! Only bonds explicitly owned by this HID peripheral may be removed.
use bluer::{Adapter, Session};
use std::{future::Future, pin::Pin};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bond {
pub adapter: String,
pub peer: String,
pub pending: bool,
}
pub type Operation<'a, T> = Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'a>>;
pub trait BondStore: Send + Sync {
fn list(&self) -> Operation<'_, Vec<Bond>>;
fn save(&self, bond: Bond) -> Operation<'_, ()>;
fn remove(&self, bond: Bond) -> Operation<'_, ()>;
}
pub async fn clean_adapter(store: &dyn BondStore, adapter: &Adapter) -> Result<(), String> {
let address = adapter
.address()
.await
.map_err(|e| e.to_string())?
.to_string();
clean_owned(store, &address, |peer| async move {
let peer = peer.parse().map_err(|_| "Invalid stored HID peer")?;
if adapter
.device_addresses()
.await
.map_err(|e| e.to_string())?
.contains(&peer)
{
adapter
.remove_device(peer)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
})
.await
}
async fn clean_owned<F, Fut>(
store: &dyn BondStore,
address: &str,
mut remove: F,
) -> Result<(), String>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = Result<(), String>>,
{
for bond in store
.list()
.await?
.into_iter()
.filter(|b| b.pending && b.adapter == address)
{
remove(bond.peer.clone()).await?;
// Keep the tombstone until BlueZ confirms removal; retry safely after interruption.
store.remove(bond).await?;
}
Ok(())
}
pub async fn reset(store: &dyn BondStore, explicit: Vec<Bond>) -> Result<(), String> {
let session = Session::new().await.map_err(|e| e.to_string())?;
let mut owned = store.list().await?;
owned.extend(explicit);
for mut bond in owned {
bond.pending = true;
store.save(bond).await?;
}
for name in session.adapter_names().await.map_err(|e| e.to_string())? {
clean_adapter(store, &session.adapter(&name).map_err(|e| e.to_string())?).await?;
}
// Records for absent adapters remain pending and are cleaned before their next use.
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct Memory(Mutex<Vec<Bond>>);
impl BondStore for Memory {
fn list(&self) -> Operation<'_, Vec<Bond>> {
Box::pin(async { Ok(self.0.lock().unwrap().clone()) })
}
fn save(&self, bond: Bond) -> Operation<'_, ()> {
Box::pin(async move {
let mut rows = self.0.lock().unwrap();
rows.retain(|b| b.adapter != bond.adapter || b.peer != bond.peer);
rows.push(bond);
Ok(())
})
}
fn remove(&self, bond: Bond) -> Operation<'_, ()> {
Box::pin(async move {
self.0.lock().unwrap().retain(|b| b != &bond);
Ok(())
})
}
}
fn bond(adapter: &str, peer: &str, pending: bool) -> Bond {
Bond {
adapter: adapter.into(),
peer: peer.into(),
pending,
}
}
#[tokio::test]
async fn cleanup_only_removes_owned_pending_bonds_on_the_selected_hardware() {
let current = bond("adapter-A", "host-1", true);
let absent = bond("adapter-B", "host-2", true);
let retained = bond("adapter-A", "host-3", false);
let store = Memory(Mutex::new(vec![current, absent.clone(), retained.clone()]));
let removed = Mutex::new(Vec::new());
clean_owned(&store, "adapter-A", |peer| {
removed.lock().unwrap().push(peer);
async { Ok(()) }
})
.await
.unwrap();
assert_eq!(*removed.lock().unwrap(), vec!["host-1"]);
assert_eq!(store.list().await.unwrap(), vec![absent, retained]);
}
#[tokio::test]
async fn cleanup_failure_keeps_tombstone_and_is_reported() {
let record = bond("adapter-A", "host-1", true);
let store = Memory(Mutex::new(vec![record.clone()]));
assert_eq!(
clean_owned(&store, "adapter-A", |_| async {
Err("BlueZ denied removal".into())
})
.await
.unwrap_err(),
"BlueZ denied removal"
);
assert_eq!(store.list().await.unwrap(), vec![record]);
clean_owned(&store, "adapter-A", |_| async { Ok(()) })
.await
.unwrap();
assert!(store.list().await.unwrap().is_empty());
}
}

View File

@@ -0,0 +1,135 @@
//! Exclusive controller ownership. No BLE switching or power cycling.
use std::{
fs::{File, OpenOptions},
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
process::Stdio,
time::Duration,
};
use tokio::process::Command;
pub struct Controller {
_lock: File,
adapter: String,
old_class: (u8, u8),
old_connectable: bool,
}
async fn run_once(adapter: &str, args: &[&str]) -> Result<String, String> {
let mut cmd = Command::new("btmgmt");
cmd.arg("--index")
.arg(adapter.trim_start_matches("hci"))
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| format!("Install bluez/btmgmt: {e}"))?;
// BlueZ 5.55's btmgmt exits on stdin EOF, even with a command argument.
// Keep a pipe open until it finishes; systemd normally supplies /dev/null.
let stdin = child.stdin.take();
let result = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output())
.await
.map_err(|_| "btmgmt timeout")?
.map_err(|e| format!("Install bluez/btmgmt: {e}"))?;
drop(stdin);
let text = String::from_utf8_lossy(&result.stdout).into_owned();
if !result.status.success()
|| !result.stderr.is_empty()
|| text.to_lowercase().contains("failed")
{
return Err(format!(
"btmgmt {}: {text} {}",
args.join(" "),
String::from_utf8_lossy(&result.stderr)
));
}
Ok(text)
}
async fn run(adapter: &str, args: &[&str]) -> Result<String, String> {
// BlueZ updates the EIR/class asynchronously after registering SDP.
// Kernel management rejects a simultaneous class change with Busy.
for attempt in 0..5 {
match run_once(adapter, args).await {
Err(error) if error.contains("(Busy)") && attempt < 4 => {
tokio::time::sleep(Duration::from_millis(200)).await;
}
result => return result,
}
}
unreachable!()
}
impl Controller {
pub async fn acquire(adapter: &str) -> Result<Self, String> {
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(format!("/run/one-kvm-bluetooth-{adapter}.lock"))
.map_err(|e| e.to_string())?;
if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
return Err("Bluetooth adapter already owned by One-KVM".into());
}
let info = run(adapter, &["info"]).await?;
let settings = info
.lines()
.find_map(|l| l.trim().strip_prefix("current settings:"))
.ok_or("Cannot read Bluetooth settings")?;
if !settings.split_whitespace().any(|s| s == "br/edr") {
return Err(
"Classic Bluetooth disabled; enable BR/EDR with btmgmt before using HID".into(),
);
}
let class = info
.split_whitespace()
.skip_while(|s| *s != "class")
.nth(1)
.ok_or("Missing Bluetooth class")?;
let class =
u32::from_str_radix(class.trim_start_matches("0x"), 16).map_err(|e| e.to_string())?;
Ok(Self {
_lock: lock,
adapter: adapter.into(),
old_class: (((class >> 8) & 0x1f) as u8, (class & 0xfc) as u8),
old_connectable: settings.split_whitespace().any(|s| s == "connectable"),
})
}
pub async fn configure(&self) -> Result<(), String> {
run(&self.adapter, &["class", "5", "192"]).await?;
run(&self.adapter, &["connectable", "on"]).await?;
Ok(())
}
pub async fn restore(&self) -> Result<(), String> {
let mut errors = vec![];
if let Err(e) = run(
&self.adapter,
&[
"class",
&self.old_class.0.to_string(),
&self.old_class.1.to_string(),
],
)
.await
{
errors.push(e);
}
if let Err(e) = run(
&self.adapter,
&[
"connectable",
if self.old_connectable { "on" } else { "off" },
],
)
.await
{
errors.push(e);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
}

View File

@@ -0,0 +1,964 @@
//! Classic Bluetooth HID peripheral: SDP + L2CAP. Linux/BlueZ only.
mod agent;
pub mod bonds;
mod controller;
mod protocol;
use bluer::{
l2cap::{Security, SecurityLevel, SeqPacket, SeqPacketListener, Socket, SocketAddr},
Adapter, Address, AddressType, Session,
};
use serde::{Deserialize, Serialize};
use std::{
collections::HashSet,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tokio::sync::{mpsc, oneshot, watch, Mutex as AsyncMutex};
#[derive(Debug, Clone)]
pub struct Config {
pub adapter: String,
pub name: String,
pub peer: Option<String>,
}
impl Config {
pub fn validate(&self) -> Result<(), String> {
if !self
.adapter
.strip_prefix("hci")
.is_some_and(|n| !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()))
{
return Err("Bluetooth adapter must be hci followed by an index".into());
}
if self.name.is_empty() || self.name.len() > 64 || self.name.chars().any(char::is_control) {
return Err(
"Bluetooth name must contain 164 UTF-8 bytes without control characters".into(),
);
}
if let Some(peer) = &self.peer {
peer.parse::<Address>()
.map_err(|_| "Invalid Bluetooth peer address")?;
}
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Status {
pub initialized: bool,
pub connected: bool,
pub ready: bool,
pub adapter: String,
pub adapter_address: String,
pub peer: Option<String>,
pub pairing_seconds: u32,
pub control_connected: bool,
pub interrupt_connected: bool,
pub leds: u8,
pub generation: u64,
pub error: Option<String>,
pub devices: Vec<Device>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Device {
pub address: String,
pub name: String,
pub paired: bool,
pub connected: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum Report {
Keyboard,
Mouse,
Consumer,
}
impl Report {
fn len(self) -> usize {
match self {
Self::Keyboard => 8,
Self::Mouse => 4,
Self::Consumer => 2,
}
}
fn id(self) -> u8 {
match self {
Self::Keyboard => 1,
Self::Mouse => 2,
Self::Consumer => 3,
}
}
}
struct State {
peer: Option<String>,
pairing_until: Option<Instant>,
bonded_before_pairing: HashSet<String>,
}
impl State {
fn observe_bond(&mut self, address: &str, paired: bool) {
// Just Works may complete without an Agent1 authorization callback.
// Adopt only a new bond from our explicit window, respecting a pinned peer.
if paired
&& self.peer.is_none()
&& self
.pairing_until
.is_some_and(|until| until > Instant::now())
&& !self.bonded_before_pairing.contains(address)
{
self.peer = Some(address.to_owned());
tracing::info!(
peer = address,
"Bluetooth HID selected newly bonded computer"
);
}
}
fn close_pairing(&mut self, paired: bool, fallback: Option<String>) {
self.pairing_until = None;
if !paired {
self.peer = fallback;
}
}
fn pairing_completed(&self, selected_paired: bool) -> bool {
selected_paired
&& self
.peer
.as_ref()
.is_some_and(|peer| !self.bonded_before_pairing.contains(peer))
}
}
struct Shared {
state: Mutex<State>,
}
impl Shared {
fn new(peer: Option<String>) -> Self {
Self {
state: Mutex::new(State {
peer,
pairing_until: None,
bonded_before_pairing: HashSet::new(),
}),
}
}
fn allowed(&self, address: Address) -> bool {
self.state.lock().unwrap().peer.as_deref() == Some(address.to_string().as_str())
}
}
pub enum Action {
Pair(u32),
ClosePairing,
Forget,
Disconnect,
Reset,
}
enum Command {
Report(Report, Vec<u8>),
Action(Action),
Stop,
}
struct Request {
command: Command,
result: oneshot::Sender<Result<(), String>>,
expires: Instant,
}
pub struct Peripheral {
tx: mpsc::Sender<Request>,
status: watch::Receiver<Status>,
task: AsyncMutex<Option<tokio::task::JoinHandle<()>>>,
}
impl Peripheral {
pub fn start(config: Config) -> Result<Self, String> {
Self::start_with_store(config, None)
}
pub fn start_with_store(
config: Config,
bonds: Option<Arc<dyn bonds::BondStore>>,
) -> Result<Self, String> {
config.validate()?;
let (tx, rx) = mpsc::channel(64);
let (state_tx, status) = watch::channel(Status {
adapter: config.adapter.clone(),
..Default::default()
});
let task = tokio::spawn(supervise(config, bonds, rx, state_tx));
Ok(Self {
tx,
status,
task: AsyncMutex::new(Some(task)),
})
}
pub fn status(&self) -> Status {
self.status.borrow().clone()
}
pub fn subscribe(&self) -> watch::Receiver<Status> {
self.status.clone()
}
async fn request(&self, command: Command, duration: Duration) -> Result<(), String> {
let (result, rx) = oneshot::channel();
let request = Request {
command,
result,
expires: Instant::now() + duration,
};
self.tx
.try_send(request)
.map_err(|_| "Bluetooth input queue unavailable or full".to_string())?;
tokio::time::timeout(duration, rx)
.await
.map_err(|_| "Bluetooth operation timed out".to_string())?
.map_err(|_| "Bluetooth worker stopped".to_string())?
}
pub async fn send(&self, report: Report, value: Vec<u8>) -> Result<(), String> {
if value.len() != report.len() {
return Err("Invalid HID report size".into());
}
self.request(Command::Report(report, value), Duration::from_millis(500))
.await
}
pub async fn action(&self, action: Action) -> Result<(), String> {
self.request(Command::Action(action), Duration::from_secs(15))
.await
}
pub async fn shutdown(&self) -> Result<(), String> {
if let Some(task) = self.task.lock().await.take() {
let result = self.request(Command::Stop, Duration::from_secs(45)).await;
// Do not abort: the worker must restore the radio before a backend switch.
task.await.map_err(|e| e.to_string())?;
result
} else {
Ok(())
}
}
}
fn listen(address: Address, psm: u16) -> Result<SeqPacketListener, String> {
let socket = Socket::<SeqPacket>::new_seq_packet().map_err(|e| e.to_string())?;
socket
.set_security(Security {
level: SecurityLevel::Medium,
key_size: 0,
})
.map_err(|e| e.to_string())?;
socket.bind(SocketAddr::new(address,AddressType::BrEdr,psm))
.map_err(|e|format!("Cannot bind classic HID PSM 0x{psm:02x}: {e}. Disable BlueZ input plugin; see Bluetooth HID setup."))?;
socket.listen(2).map_err(|e| e.to_string())
}
struct Runtime {
adapter: Adapter,
_session: Session,
shared: Arc<Shared>,
agent: Option<agent::Agent>,
controller: controller::Controller,
listeners: [SeqPacketListener; 2],
channels: [Option<Arc<SeqPacket>>; 2],
channel_peer: Option<Address>,
partial_since: Option<Instant>,
hid: protocol::HidProtocol,
generation: u64,
old_pairable: bool,
old_powered: bool,
old_alias: String,
old_discoverable: bool,
old_discoverable_timeout: u32,
old_pairable_timeout: u32,
config: Config,
bonds: Option<Arc<dyn bonds::BondStore>>,
recorded_peer: Option<String>,
}
impl Runtime {
async fn open(
config: &Config,
bonds: Option<Arc<dyn bonds::BondStore>>,
) -> Result<Self, String> {
let controller = controller::Controller::acquire(&config.adapter).await?;
let session = Session::new().await.map_err(|e| e.to_string())?;
let adapter = session
.adapter(&config.adapter)
.map_err(|e| e.to_string())?;
let address = adapter.address().await.map_err(|e| e.to_string())?;
let mut config = config.clone();
if let Some(store) = &bonds {
bonds::clean_adapter(store.as_ref(), &adapter).await?;
if config.peer.is_none() {
config.peer = store
.list()
.await?
.into_iter()
.find(|bond| bond.adapter == address.to_string() && !bond.pending)
.map(|bond| bond.peer);
}
}
// Acquire BOTH channels before changing adapter state. Conflict is a startup error.
let listeners = [listen(address, 0x11)?, listen(address, 0x13)?];
let old_powered = adapter.is_powered().await.map_err(|e| e.to_string())?;
let old_pairable = adapter.is_pairable().await.map_err(|e| e.to_string())?;
let old_alias = adapter.alias().await.map_err(|e| e.to_string())?;
let old_discoverable = adapter.is_discoverable().await.map_err(|e| e.to_string())?;
let old_discoverable_timeout = adapter
.discoverable_timeout()
.await
.map_err(|e| e.to_string())?;
let old_pairable_timeout = adapter
.pairable_timeout()
.await
.map_err(|e| e.to_string())?;
let shared = Arc::new(Shared::new(
config.peer.as_ref().map(|p| p.to_ascii_uppercase()),
));
let mut runtime = Self {
adapter,
_session: session,
shared,
agent: None,
controller,
listeners,
channels: [None, None],
channel_peer: None,
partial_since: None,
hid: Default::default(),
generation: 0,
old_pairable,
old_powered,
old_alias,
old_discoverable,
old_discoverable_timeout,
old_pairable_timeout,
config: config.clone(),
bonds,
recorded_peer: None,
};
if let Err(e) = runtime.setup().await {
let restore = runtime.close().await;
return Err(format!("{e}; cleanup: {restore:?}"));
}
Ok(runtime)
}
async fn setup(&mut self) -> Result<(), String> {
self.adapter
.set_powered(true)
.await
.map_err(|e| e.to_string())?;
self.adapter
.set_discoverable(false)
.await
.map_err(|e| e.to_string())?;
self.adapter
.set_pairable(false)
.await
.map_err(|e| e.to_string())?;
self.adapter
.set_alias(self.config.name.clone())
.await
.map_err(|e| e.to_string())?;
self.agent =
Some(agent::Agent::register(self.shared.clone(), self.config.adapter.clone()).await?);
self.controller.configure().await?;
self.public(None).await?;
Ok(())
}
async fn public(&self, seconds: Option<u32>) -> Result<(), String> {
if let Some(seconds) = seconds {
self.adapter
.set_pairable_timeout(seconds)
.await
.map_err(|e| e.to_string())?;
self.adapter
.set_discoverable_timeout(seconds)
.await
.map_err(|e| e.to_string())?;
self.adapter
.set_pairable(true)
.await
.map_err(|e| e.to_string())?;
if let Err(e) = self.adapter.set_discoverable(true).await {
let _ = self.adapter.set_pairable(false).await;
return Err(e.to_string());
}
} else {
self.adapter
.set_discoverable(false)
.await
.map_err(|e| e.to_string())?;
self.adapter
.set_pairable(false)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
fn drop_channels(&mut self) {
for socket in self.channels.iter_mut().filter_map(Option::take) {
let _ = socket.shutdown(std::net::Shutdown::Both);
}
self.channel_peer = None;
self.partial_since = None;
self.hid = Default::default();
self.generation = self.generation.wrapping_add(1);
}
async fn packet(&self, index: usize, data: &[u8]) -> Result<(), String> {
let socket = self.channels[index]
.as_ref()
.ok_or("Classic HID channel not connected")?;
let sent = tokio::time::timeout(Duration::from_millis(250), socket.send(data))
.await
.map_err(|_| "HID send timeout")?
.map_err(|e| e.to_string())?;
if sent != data.len() {
return Err("Short HID packet write".into());
}
Ok(())
}
async fn release(&mut self) {
for packet in self.hid.release() {
if self.channels[1].is_some() && self.packet(1, &packet).await.is_err() {
self.drop_channels();
break;
}
}
self.generation = self.generation.wrapping_add(1);
}
async fn accept(
&mut self,
index: usize,
socket: SeqPacket,
peer: SocketAddr,
) -> Result<(), String> {
if peer.addr_type != AddressType::BrEdr {
return Ok(());
}
// A host can open HID channels before the next status poll.
let paired = self
.adapter
.device(peer.addr)
.map_err(|e| e.to_string())?
.is_paired()
.await
.map_err(|e| e.to_string())?;
self.shared
.state
.lock()
.unwrap()
.observe_bond(&peer.addr.to_string(), paired);
if !self.shared.allowed(peer.addr) {
return Ok(());
}
if self.channel_peer.is_some_and(|p| p != peer.addr) || self.channels[index].is_some() {
return Ok(());
}
// Kernel BT_SECURITY_MEDIUM negotiates encryption before accept completes.
if socket
.as_ref()
.security()
.map_err(|e| e.to_string())?
.key_size
< 7
{
return Ok(());
}
self.channel_peer = Some(peer.addr);
self.channels[index] = Some(Arc::new(socket));
if self.channels.iter().all(Option::is_some) {
self.partial_since = None;
self.release().await;
} else {
self.partial_since = Some(Instant::now());
}
Ok(())
}
async fn status(&mut self) -> Result<Status, String> {
if !self.adapter.is_powered().await.map_err(|e| e.to_string())? {
return Err("Bluetooth adapter powered off".into());
}
// A daemon restart loses the SDP registration even if the adapter comes back powered.
if !self
.adapter
.uuids()
.await
.map_err(|e| e.to_string())?
.unwrap_or_default()
.iter()
.any(|id| id.to_string() == protocol::HID_UUID)
{
return Err("Classic HID SDP registration lost; restarting Bluetooth backend".into());
}
let mut status = Status {
initialized: true,
adapter: self.config.adapter.clone(),
adapter_address: self
.adapter
.address()
.await
.map_err(|e| e.to_string())?
.to_string(),
..Default::default()
};
for address in self
.adapter
.device_addresses()
.await
.map_err(|e| e.to_string())?
{
let device = self.adapter.device(address).map_err(|e| e.to_string())?;
let connected = device.is_connected().await.unwrap_or(false);
let paired = device.is_paired().await.unwrap_or(false);
self.shared
.state
.lock()
.unwrap()
.observe_bond(&address.to_string(), paired);
if paired || connected {
status.devices.push(Device {
address: address.to_string(),
name: device.alias().await.unwrap_or_default(),
paired,
connected,
});
}
}
status.devices.sort_by(|a, b| a.address.cmp(&b.address));
let (peer, until) = {
let state = self.shared.state.lock().unwrap();
(state.peer.clone(), state.pairing_until)
};
status.peer = peer.clone();
let selected = status
.devices
.iter()
.find(|device| Some(&device.address) == peer.as_ref());
let selected_paired = selected.is_some_and(|device| device.paired);
if selected_paired && self.recorded_peer != peer {
if let (Some(store), Some(peer)) = (&self.bonds, &peer) {
store
.save(bonds::Bond {
adapter: status.adapter_address.clone(),
peer: peer.clone(),
pending: false,
})
.await?;
self.recorded_peer = Some(peer.clone());
}
}
status.connected = selected.is_some_and(|device| device.paired && device.connected);
if self.channels.iter().any(Option::is_some)
&& (!status.connected
|| self
.partial_since
.is_some_and(|time| time.elapsed() > Duration::from_secs(10)))
{
self.drop_channels();
}
if let Some(until) = until {
let completed = self
.shared
.state
.lock()
.unwrap()
.pairing_completed(selected_paired);
if until <= Instant::now() || completed {
{
let mut state = self.shared.state.lock().unwrap();
state.close_pairing(
selected_paired,
self.config.peer.as_ref().map(|p| p.to_ascii_uppercase()),
);
status.peer = state.peer.clone();
}
self.public(None).await?;
tracing::info!(reason = if completed { "bonded" } else { "expired" }, peer = ?status.peer, "Bluetooth HID pairing window closed");
} else {
status.pairing_seconds =
until.saturating_duration_since(Instant::now()).as_secs() as u32 + 1;
}
}
status.control_connected = self.channels[0].is_some();
status.interrupt_connected = self.channels[1].is_some();
status.ready = status.connected
&& status.control_connected
&& status.interrupt_connected
&& !self.hid.suspended;
status.leds = self.hid.leds;
status.generation = self.generation;
Ok(status)
}
async fn forget(&mut self) -> Result<(), String> {
self.release().await;
self.drop_channels();
let peer = self.shared.state.lock().unwrap().peer.clone();
if let Some(peer) = &peer {
let address = peer.parse().map_err(|_| "Invalid peer")?;
if self
.adapter
.device_addresses()
.await
.map_err(|e| e.to_string())?
.contains(&address)
{
self.adapter
.remove_device(address)
.await
.map_err(|e| e.to_string())?;
}
self.shared
.state
.lock()
.unwrap()
.bonded_before_pairing
.remove(peer);
tracing::info!(%peer, "Bluetooth HID forgot computer");
}
if let Some(store) = &self.bonds {
let address = self
.adapter
.address()
.await
.map_err(|e| e.to_string())?
.to_string();
for bond in store
.list()
.await?
.into_iter()
.filter(|b| b.adapter == address && Some(&b.peer) == peer.as_ref())
{
store.remove(bond).await?;
}
}
self.recorded_peer = None;
self.config.peer = None;
self.shared.state.lock().unwrap().peer = None;
Ok(())
}
async fn incoming(&mut self, index: usize, data: &[u8]) -> Result<(), String> {
if index == 1 {
self.hid.output(data);
return Ok(());
}
let result = self.hid.control(data);
if let Some(reply) = result.reply {
self.packet(0, &reply).await?;
}
if result.reset {
self.release().await;
}
if result.unplug {
self.forget().await?;
}
Ok(())
}
async fn execute(&mut self, command: Command) -> Result<(), String> {
match command {
Command::Report(kind, value) => {
let peer = self.channel_peer.ok_or("Classic HID is not connected")?;
if !self.shared.allowed(peer) || self.channels.iter().any(Option::is_none) {
return Err("Classic HID channels are not ready".into());
}
let device = self.adapter.device(peer).map_err(|e| e.to_string())?;
if !device.is_paired().await.unwrap_or(false)
|| !device.is_connected().await.unwrap_or(false)
{
self.drop_channels();
return Err("Selected Bluetooth computer disconnected".into());
}
let packet = self.hid.input(kind, &value)?;
if let Err(e) = self.packet(1, &packet).await {
self.drop_channels();
return Err(e);
}
}
Command::Action(Action::Pair(seconds)) => {
if !(10..=300).contains(&seconds) {
return Err("Pairing window must be 10300 seconds".into());
}
let mut bonded = HashSet::new();
for address in self
.adapter
.device_addresses()
.await
.map_err(|e| e.to_string())?
{
if self
.adapter
.device(address)
.map_err(|e| e.to_string())?
.is_paired()
.await
.map_err(|e| e.to_string())?
{
bonded.insert(address.to_string());
}
}
{
let mut state = self.shared.state.lock().unwrap();
// Preserve the original baseline when extending an open window.
if !state
.pairing_until
.is_some_and(|until| until > Instant::now())
{
state.bonded_before_pairing = bonded;
}
state.pairing_until =
Some(Instant::now() + Duration::from_secs(seconds.into()));
}
if let Err(e) = self.public(Some(seconds)).await {
self.shared.state.lock().unwrap().pairing_until = None;
return Err(e);
}
tracing::info!(seconds, "Bluetooth HID pairing window opened");
}
Command::Action(Action::ClosePairing) => {
self.public(None).await?;
let peer = self.shared.state.lock().unwrap().peer.clone();
let paired = if let Some(peer) = peer {
self.adapter
.device(peer.parse().map_err(|_| "Invalid HID peer")?)
.map_err(|e| e.to_string())?
.is_paired()
.await
.map_err(|e| e.to_string())?
} else {
false
};
self.shared
.state
.lock()
.unwrap()
.close_pairing(paired, self.config.peer.clone());
tracing::info!(reason = "manual", "Bluetooth HID pairing window closed");
}
Command::Action(Action::Forget) => self.forget().await?,
Command::Action(Action::Disconnect) => {
self.release().await;
self.drop_channels();
let peer = self.shared.state.lock().unwrap().peer.clone();
if let Some(peer) = peer {
self.adapter
.device(peer.parse().map_err(|_| "Invalid peer")?)
.map_err(|e| e.to_string())?
.disconnect()
.await
.map_err(|e| e.to_string())?;
}
}
Command::Action(Action::Reset) => self.release().await,
Command::Stop => {}
}
Ok(())
}
async fn close(&mut self) -> Result<(), String> {
self.release().await;
self.drop_channels();
self.agent.take();
tokio::time::sleep(Duration::from_millis(100)).await;
let mut errors = vec![];
if let Err(e) = self.adapter.set_discoverable(false).await {
errors.push(e.to_string());
}
if let Err(e) = self.controller.restore().await {
errors.push(e);
}
if let Err(e) = self.adapter.set_alias(self.old_alias.clone()).await {
errors.push(e.to_string());
}
if let Err(e) = self
.adapter
.set_discoverable_timeout(self.old_discoverable_timeout)
.await
{
errors.push(e.to_string());
}
if let Err(e) = self
.adapter
.set_pairable_timeout(self.old_pairable_timeout)
.await
{
errors.push(e.to_string());
}
if let Err(e) = self.adapter.set_pairable(self.old_pairable).await {
errors.push(e.to_string());
}
if let Err(e) = self.adapter.set_discoverable(self.old_discoverable).await {
errors.push(e.to_string());
}
if let Err(e) = self.adapter.set_powered(self.old_powered).await {
errors.push(e.to_string());
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
}
async fn receive(socket: Option<Arc<SeqPacket>>, buffer: &mut [u8]) -> std::io::Result<usize> {
match socket {
Some(socket) => socket.recv(buffer).await,
None => std::future::pending().await,
}
}
async fn supervise(
config: Config,
bonds: Option<Arc<dyn bonds::BondStore>>,
mut rx: mpsc::Receiver<Request>,
tx: watch::Sender<Status>,
) {
let mut generation = 0;
loop {
let mut runtime = match Runtime::open(&config, bonds.clone()).await {
Ok(runtime) => runtime,
Err(error) => {
tx.send_replace(Status {
adapter: config.adapter.clone(),
error: Some(error.clone()),
generation,
..Default::default()
});
let delay = tokio::time::sleep(Duration::from_secs(5));
tokio::pin!(delay);
loop {
tokio::select! {_=&mut delay=>break,request=rx.recv()=>{
let Some(request)=request else{return;};
if matches!(request.command,Command::Stop){let _=request.result.send(Ok(()));return;}
let _=request.result.send(Err(error.clone()));
}}
}
continue;
}
};
runtime.generation = generation;
let mut timer = tokio::time::interval(Duration::from_millis(500));
let failure = loop {
let ctl = runtime.channels[0].clone();
let intr = runtime.channels[1].clone();
let mut ctl_buffer = [0; 1024];
let mut intr_buffer = [0; 1024];
tokio::select! {
accepted=runtime.listeners[0].accept()=>{match accepted{Ok((socket,peer))=>{if let Err(e)=runtime.accept(0,socket,peer).await{break e;}},Err(e)=>break e.to_string()}}
accepted=runtime.listeners[1].accept()=>{match accepted{Ok((socket,peer))=>{if let Err(e)=runtime.accept(1,socket,peer).await{break e;}},Err(e)=>break e.to_string()}}
read=receive(ctl,&mut ctl_buffer)=>{match read{Ok(n)if n>0=>{if runtime.incoming(0,&ctl_buffer[..n]).await.is_err(){runtime.drop_channels();}},_=>runtime.drop_channels()}}
read=receive(intr,&mut intr_buffer)=>{match read{Ok(n)if n>0=>{if runtime.incoming(1,&intr_buffer[..n]).await.is_err(){runtime.drop_channels();}},_=>runtime.drop_channels()}}
_=timer.tick()=>{match runtime.status().await{Ok(status)=>{tx.send_if_modified(|old|{if *old!=status{*old=status;true}else{false}});},Err(e)=>break e}}
request=rx.recv()=>{
let Some(request)=request else{let _=runtime.close().await;return;};
if matches!(request.command,Command::Stop){let result=runtime.close().await;tx.send_replace(Status{adapter:config.adapter.clone(),..Default::default()});let _=request.result.send(result);return;}
if request.expires<=Instant::now()||request.result.is_closed(){runtime.release().await;continue;}
let action = matches!(&request.command, Command::Action(_));
let result=runtime.execute(request.command).await;
if result.is_err(){runtime.release().await;}
if action {
if let Ok(status) = runtime.status().await { tx.send_replace(status); }
}
let _=request.result.send(result);
}
}
};
let restore = runtime.close().await;
generation = runtime.generation.wrapping_add(1);
tx.send_replace(Status {
adapter: config.adapter.clone(),
error: Some(format!("{failure}; cleanup: {restore:?}")),
generation,
..Default::default()
});
drop(runtime);
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn closing_pairing_preserves_completed_bonds_but_releases_unpaired_targets() {
let shared = Shared::new(Some("10:6F:D9:66:97:88".into()));
let mut state = shared.state.lock().unwrap();
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
state.close_pairing(true, None);
assert_eq!(state.peer.as_deref(), Some("10:6F:D9:66:97:88"));
assert!(state.pairing_until.is_none());
state.close_pairing(false, None);
assert!(state.peer.is_none());
}
#[test]
fn configuration_validation() {
let mut c = Config {
adapter: "hci0".into(),
name: "One-KVM".into(),
peer: None,
};
assert!(c.validate().is_ok());
c.adapter = "hci0;reboot".into();
assert!(c.validate().is_err());
c.adapter = "hci1".into();
c.peer = Some("bogus".into());
assert!(c.validate().is_err());
}
#[test]
fn peer_isolation() {
let state = Shared::new(Some("10:6F:D9:66:97:88".into()));
assert!(state.allowed("10:6F:D9:66:97:88".parse().unwrap()));
assert!(!state.allowed("10:6F:D9:66:97:89".parse().unwrap()));
}
#[test]
fn new_bond_without_agent_callback_completes_pairing() {
let shared = Shared::new(None);
let mut state = shared.state.lock().unwrap();
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
let peer = "10:6F:D9:66:97:88";
state.observe_bond(peer, false);
assert_eq!(state.peer, None);
state.observe_bond(peer, true);
assert_eq!(state.peer.as_deref(), Some(peer));
assert!(state.pairing_completed(true));
// A second computer cannot replace the one selected in this window.
state.observe_bond("10:6F:D9:66:97:89", true);
assert_eq!(state.peer.as_deref(), Some(peer));
}
#[test]
fn old_bond_does_not_finish_a_new_window() {
let peer = "10:6F:D9:66:97:88";
let shared = Shared::new(Some(peer.into()));
let mut state = shared.state.lock().unwrap();
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
state.bonded_before_pairing.insert(peer.into());
state.observe_bond(peer, true);
assert!(!state.pairing_completed(true));
state.peer = None;
state.observe_bond(peer, true);
assert_eq!(state.peer, None);
}
#[test]
fn bond_observation_respects_closed_window_and_pinned_peer() {
let peer = "10:6F:D9:66:97:88";
let shared = Shared::new(None);
let mut state = shared.state.lock().unwrap();
state.observe_bond(peer, true);
assert_eq!(state.peer, None);
state.pairing_until = Some(Instant::now() - Duration::from_secs(1));
state.observe_bond(peer, true);
assert_eq!(state.peer, None);
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
state.peer = Some("10:6F:D9:66:97:89".into());
state.observe_bond(peer, true);
assert_eq!(state.peer.as_deref(), Some("10:6F:D9:66:97:89"));
}
}
#[derive(Serialize)]
pub struct AdapterInfo {
pub name: String,
pub address: String,
pub powered: bool,
}
pub async fn adapters() -> Result<Vec<AdapterInfo>, String> {
let session = Session::new().await.map_err(|e| e.to_string())?;
let mut output = vec![];
for name in session.adapter_names().await.map_err(|e| e.to_string())? {
let adapter = session.adapter(&name).map_err(|e| e.to_string())?;
output.push(AdapterInfo {
name,
address: adapter
.address()
.await
.map_err(|e| e.to_string())?
.to_string(),
powered: adapter.is_powered().await.map_err(|e| e.to_string())?,
});
}
Ok(output)
}

View File

@@ -0,0 +1,267 @@
//! Classic HIDP framing and report descriptor.
use crate::Report;
pub const MAP: &[u8] = &[
0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x01, 0x05, 0x07, 0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00,
0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x95, 0x01, 0x75, 0x08, 0x81, 0x01, 0x95, 0x05,
0x75, 0x01, 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x91, 0x02, 0x95, 0x01, 0x75, 0x03, 0x91, 0x01,
0x95, 0x06, 0x75, 0x08, 0x15, 0x00, 0x25, 0x65, 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x81, 0x00,
0xc0, 0x05, 0x01, 0x09, 0x02, 0xa1, 0x01, 0x85, 0x02, 0x09, 0x01, 0xa1, 0x00, 0x05, 0x09, 0x19,
0x01, 0x29, 0x05, 0x15, 0x00, 0x25, 0x01, 0x95, 0x05, 0x75, 0x01, 0x81, 0x02, 0x95, 0x01, 0x75,
0x03, 0x81, 0x01, 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38, 0x15, 0x81, 0x25, 0x7f, 0x75,
0x08, 0x95, 0x03, 0x81, 0x06, 0xc0, 0xc0, 0x05, 0x0c, 0x09, 0x01, 0xa1, 0x01, 0x85, 0x03, 0x15,
0x00, 0x26, 0xff, 0x03, 0x19, 0x00, 0x2a, 0xff, 0x03, 0x75, 0x10, 0x95, 0x01, 0x81, 0x00, 0xc0,
];
pub const HID_UUID: &str = "00001124-0000-1000-8000-00805f9b34fb";
pub fn sdp() -> String {
let hex: String = MAP.iter().map(|byte| format!("{byte:02x}")).collect();
format!(
r#"<?xml version="1.0"?>
<record>
<attribute id="0x0001"><sequence><uuid value="0x1124"/></sequence></attribute>
<attribute id="0x0004"><sequence><sequence><uuid value="0x0100"/><uint16 value="0x0011"/></sequence><sequence><uuid value="0x0011"/></sequence></sequence></attribute>
<attribute id="0x0005"><sequence><uuid value="0x1002"/></sequence></attribute>
<attribute id="0x0006"><sequence><uint16 value="0x656e"/><uint16 value="0x006a"/><uint16 value="0x0100"/></sequence></attribute>
<attribute id="0x0009"><sequence><sequence><uuid value="0x1124"/><uint16 value="0x0100"/></sequence></sequence></attribute>
<attribute id="0x000d"><sequence><sequence><sequence><uuid value="0x0100"/><uint16 value="0x0013"/></sequence><sequence><uuid value="0x0011"/></sequence></sequence></sequence></attribute>
<attribute id="0x0100"><text value="One-KVM Keyboard and Mouse"/></attribute>
<attribute id="0x0101"><text value="Classic Bluetooth HID"/></attribute>
<attribute id="0x0102"><text value="One-KVM"/></attribute>
<attribute id="0x0200"><uint16 value="0x0100"/></attribute>
<attribute id="0x0201"><uint16 value="0x0111"/></attribute>
<attribute id="0x0202"><uint8 value="0xc0"/></attribute>
<attribute id="0x0203"><uint8 value="0x00"/></attribute>
<attribute id="0x0204"><boolean value="false"/></attribute>
<attribute id="0x0205"><boolean value="false"/></attribute>
<attribute id="0x0206"><sequence><sequence><uint8 value="0x22"/><text encoding="hex" value="{hex}"/></sequence></sequence></attribute>
<attribute id="0x0207"><sequence><sequence><uint16 value="0x0409"/><uint16 value="0x0100"/></sequence></sequence></attribute>
<attribute id="0x0209"><boolean value="false"/></attribute>
<attribute id="0x020a"><boolean value="false"/></attribute>
<attribute id="0x020b"><uint16 value="0x0100"/></attribute>
<attribute id="0x020c"><uint16 value="0x0c80"/></attribute>
<attribute id="0x020d"><boolean value="true"/></attribute>
<attribute id="0x020e"><boolean value="true"/></attribute>
</record>"#
)
}
#[derive(Debug, Default)]
pub struct HidProtocol {
pub boot: bool,
pub suspended: bool,
pub leds: u8,
pub keyboard: [u8; 8],
pub buttons: u8,
pub consumer: [u8; 2],
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ControlResult {
pub reply: Option<Vec<u8>>,
pub unplug: bool,
pub reset: bool,
}
impl HidProtocol {
pub fn input(&mut self, kind: Report, value: &[u8]) -> Result<Vec<u8>, String> {
if value.len() != kind.len() {
return Err("Invalid HID report length".into());
}
if self.suspended {
return Err("Bluetooth HID is suspended".into());
}
let mut packet = vec![0xa1, kind.id()];
match kind {
Report::Keyboard => {
self.keyboard.copy_from_slice(value);
packet.extend_from_slice(value);
}
Report::Mouse => {
self.buttons = value[0];
packet.extend_from_slice(&value[..if self.boot { 3 } else { 4 }]);
if self.boot {
packet[2] &= 7;
}
}
Report::Consumer if !self.boot => {
self.consumer.copy_from_slice(value);
packet.extend_from_slice(value);
}
Report::Consumer => return Err("Consumer keys unavailable in boot protocol".into()),
}
Ok(packet)
}
pub fn release(&mut self) -> Vec<Vec<u8>> {
self.keyboard = [0; 8];
self.buttons = 0;
self.consumer = [0; 2];
let mut packets = vec![
vec![0xa1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
if self.boot {
vec![0xa1, 2, 0, 0, 0]
} else {
vec![0xa1, 2, 0, 0, 0, 0]
},
];
if !self.boot {
packets.push(vec![0xa1, 3, 0, 0]);
}
packets
}
pub fn output(&mut self, data: &[u8]) -> bool {
if data.len() == 3 && data[0] == 0xa2 && data[1] == 1 {
self.leds = data[2] & 0x1f;
true
} else {
false
}
}
pub fn control(&mut self, data: &[u8]) -> ControlResult {
let mut result = ControlResult::default();
let handshake = |code| ControlResult {
reply: Some(vec![code]),
..Default::default()
};
let Some(&header) = data.first() else {
return handshake(4);
};
match header {
0x13 if data.len() == 1 => {
self.suspended = true;
result.reset = true;
}
0x14 if data.len() == 1 => {
self.suspended = false;
result.reset = true;
}
0x15 if data.len() == 1 => {
result.unplug = true;
}
0x11 | 0x12 if data.len() == 1 => {
self.suspended = false;
result.reset = true;
}
0x60 if data.len() == 1 => {
result.reply = Some(vec![0xa0, if self.boot { 0 } else { 1 }]);
}
0x70 | 0x71 if data.len() == 1 => {
self.boot = header == 0x70;
result.reply = Some(vec![0]);
result.reset = true;
}
0x52 if data.len() == 3 && data[1] == 1 => {
self.leds = data[2] & 0x1f;
return handshake(0);
}
0x41 | 0x49 | 0x42 | 0x4a => {
let sized = header & 8 != 0;
if data.len() != if sized { 4 } else { 2 } {
return handshake(4);
}
let mut report = vec![0xa0 | (header & 3), data[1]];
match (header & 3, data[1]) {
(1, 1) => report.extend(self.keyboard),
(1, 2) => report.extend(if self.boot {
vec![self.buttons & 7, 0, 0]
} else {
vec![self.buttons, 0, 0, 0]
}),
(1, 3) if !self.boot => report.extend(self.consumer),
(2, 1) => report.push(self.leds),
_ => return handshake(2),
}
if sized {
report.truncate(1 + u16::from_le_bytes([data[2], data[3]]) as usize);
}
result.reply = Some(report);
}
_ => return handshake(3), // ERR_UNSUPPORTED_REQUEST
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classic_frames_and_boot_protocol() {
let mut hid = HidProtocol::default();
assert_eq!(
hid.input(Report::Keyboard, &[2, 0, 4, 0, 0, 0, 0, 0])
.unwrap(),
[0xa1, 1, 2, 0, 4, 0, 0, 0, 0, 0]
);
assert_eq!(hid.control(&[0x60]).reply.unwrap(), [0xa0, 1]);
assert_eq!(hid.control(&[0x70]).reply.unwrap(), [0]);
assert_eq!(
hid.input(Report::Mouse, &[0x1f, 127, 128, 1]).unwrap(),
[0xa1, 2, 7, 127, 128]
);
assert!(hid.input(Report::Consumer, &[0, 0]).is_err());
assert!(hid.input(Report::Keyboard, &[0]).is_err());
}
#[test]
fn led_read_write_and_errors() {
let mut hid = HidProtocol::default();
assert!(hid.output(&[0xa2, 1, 3]));
assert_eq!(hid.leds, 3);
assert_eq!(hid.control(&[0x42, 1]).reply.unwrap(), [0xa2, 1, 3]);
assert_eq!(hid.control(&[0x52, 1, 2]).reply.unwrap(), [0]);
assert_eq!(hid.leds, 2);
assert_eq!(hid.control(&[0x41, 99]).reply.unwrap(), [2]);
assert_eq!(hid.control(&[0x41]).reply.unwrap(), [4]);
assert_eq!(hid.control(&[0x90, 0]).reply.unwrap(), [3]);
assert_eq!(
hid.control(&[0x49, 1, 3, 0]).reply.unwrap(),
[0xa1, 1, 0, 0]
);
}
#[test]
fn suspend_reset_and_virtual_unplug() {
let mut hid = HidProtocol::default();
assert!(hid.control(&[0x13]).reset);
assert!(hid.input(Report::Mouse, &[0; 4]).is_err());
assert!(hid.control(&[0x14]).reset);
assert!(hid.input(Report::Mouse, &[0; 4]).is_ok());
assert!(hid.control(&[0x15]).unplug);
hid.keyboard[2] = 4;
hid.buttons = 1;
assert_eq!(hid.release().len(), 3);
assert_eq!(hid.keyboard, [0; 8]);
assert_eq!(hid.buttons, 0);
}
#[test]
fn sdp_describes_both_classic_channels() {
let record = sdp();
assert!(record.contains("uuid value=\"0x1124\""));
assert!(record.contains("uint16 value=\"0x0011\""));
assert!(record.contains("uint16 value=\"0x0013\""));
assert!(!record.contains("0x1812"));
}
#[test]
fn descriptor_report_sizes() {
let (mut pos, mut size, mut count, mut id, mut depth) = (0, 0, 0, 0, 0);
let mut input = [0; 4];
let mut output = [0; 4];
while pos < MAP.len() {
let prefix = MAP[pos];
pos += 1;
let length = [0, 1, 2, 4][(prefix & 3) as usize];
let mut value = 0;
for i in 0..length {
value |= (MAP[pos + i] as usize) << (8 * i);
}
pos += length;
match ((prefix >> 2) & 3, prefix >> 4) {
(1, 7) => size = value,
(1, 8) => id = value,
(1, 9) => count = value,
(0, 8) => input[id] += size * count,
(0, 9) => output[id] += size * count,
(0, 10) => depth += 1,
(0, 12) => depth -= 1,
_ => {}
}
}
assert_eq!(depth, 0);
assert_eq!(input, [0, 64, 32, 16]);
assert_eq!(output, [0, 8, 0, 0]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// Validate bounded JPEG headers before giving hardware a fixed-size output
// buffer. Only baseline 8-bit JPEG is accepted; other streams use copy fallback.
// No scan-data traversal or full-packet copy is needed.
inline bool rkmpp_dma_jpeg_header(const uint8_t *data, size_t size, int width, int height) {
if (!data || size < 4 || data[0] != 0xff || data[1] != 0xd8) return false;
size_t pos = 2;
bool sof = false;
while (pos < size) {
if (data[pos++] != 0xff) return false;
while (pos < size && data[pos] == 0xff) ++pos;
if (pos == size) return false;
const unsigned marker = data[pos++];
if (!marker || marker == 0xd8 || marker == 0xd9 || marker == 1 ||
(marker >= 0xd0 && marker <= 0xd7)) return false;
if (size - pos < 2) return false;
const size_t length = (size_t(data[pos]) << 8) | data[pos + 1];
if (length < 2 || length > size - pos) return false;
if (marker == 0xc0) {
if (sof || length < 8 || data[pos + 2] != 8) return false;
const unsigned h = (unsigned(data[pos + 3]) << 8) | data[pos + 4];
const unsigned w = (unsigned(data[pos + 5]) << 8) | data[pos + 6];
const unsigned components = data[pos + 7];
if (w != unsigned(width) || h != unsigned(height) ||
(components != 1 && components != 3) || length != 8 + 3 * components) return false;
sof = true;
} else if (marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 && marker != 0xcc) {
return false; // Progressive, lossless, extended or differential SOF.
} else if (marker == 0xda) {
return sof && length >= 6 && size - pos > length;
}
pos += length;
}
return false;
}

View File

@@ -0,0 +1,329 @@
#include "rkmpp_dmabuf_ffi.h"
#include <array>
#include <cstdio>
#include <limits>
#include <new>
#include "rkmpp_dma_jpeg.h"
// Native MPP is already linked by the ARM FFmpeg/RKMPP build. Keep this
// optional for toolchains which only supply the FFmpeg headers.
#if defined(__linux__) && __has_include(<rockchip/rk_mpi.h>)
#define HAVE_MPP_DMA 1
#include <cerrno>
#include <linux/dma-buf.h>
#include <sys/ioctl.h>
extern "C" {
#include <rockchip/rk_mpi.h>
#include <rockchip/mpp_buffer.h>
#include <rockchip/mpp_frame.h>
#include <rockchip/mpp_packet.h>
#include <rockchip/mpp_task.h>
#include <rockchip/rk_venc_cfg.h>
#include <rockchip/rk_venc_rc.h>
}
#endif
static thread_local char dma_error[192] = {};
static int fail(const char *operation, int code) {
std::snprintf(dma_error, sizeof(dma_error), "%s (ret=%d)", operation, code);
return -1;
}
#ifdef HAVE_MPP_DMA
struct RkmppDmaEncoder {
MppCtx ctx = nullptr;
MppApi *api = nullptr;
MppEncCfg cfg = nullptr;
MppPacket packet = nullptr;
std::array<MppBuffer, 16> buffers{};
std::array<size_t, 16> capacities{};
size_t count = 0;
size_t minimum = 0;
int width = 0, height = 0, stride = 0;
MppFrameFormat format = MPP_FMT_YUV420SP;
bool jpeg = false;
MppCtx decoder = nullptr;
MppApi *dec_api = nullptr;
MppBufferGroup decoded_group = nullptr;
MppBuffer decoded_buffer = nullptr;
MppFrame decoded_frame = nullptr;
MppPacket input_packet = nullptr;
bool decoded_layout_set = false;
int decoded_stride = 0, decoded_vstride = 0;
void close() {
if (packet) mpp_packet_deinit(&packet);
if (ctx) {
// A timeout must not expose still-in-use input to V4L2 QBUF.
api->reset(ctx);
mpp_destroy(ctx);
ctx = nullptr;
}
// On any decoder failure, end hardware access before releasing the
// input packet, exported buffers, or allowing the caller's QBUF.
if (decoder) {
dec_api->reset(decoder);
mpp_destroy(decoder);
decoder = nullptr;
}
if (input_packet) mpp_packet_deinit(&input_packet);
if (decoded_frame) mpp_frame_deinit(&decoded_frame);
if (decoded_buffer) { mpp_buffer_put(decoded_buffer); decoded_buffer = nullptr; }
if (decoded_group) { mpp_buffer_group_put(decoded_group); decoded_group = nullptr; }
for (auto &buffer : buffers) {
if (buffer) { mpp_buffer_put(buffer); buffer = nullptr; }
}
if (cfg) { mpp_enc_cfg_deinit(cfg); cfg = nullptr; }
}
~RkmppDmaEncoder() { close(); }
};
static bool set_cfg(RkmppDmaEncoder *e, const char *key, int value) {
int ret = mpp_enc_cfg_set_s32(e->cfg, key, value);
if (ret) fail(key, ret);
return ret == 0;
}
extern "C" int rkmpp_dma_reconfigure(RkmppDmaEncoder *e, int kbps, int gop) {
if (!e || !e->ctx || kbps <= 0 || kbps > 1000000 || gop <= 0)
return fail("invalid DMA encoder configuration", -1);
const int bps = kbps * 1000;
if (!set_cfg(e, "rc:bps_target", bps) ||
!set_cfg(e, "rc:bps_max", bps + bps / 16) ||
!set_cfg(e, "rc:bps_min", bps - bps / 16) ||
!set_cfg(e, "rc:gop", gop)) return -1;
int ret = e->api->control(e->ctx, MPP_ENC_SET_CFG, e->cfg);
return ret ? fail("MPP_ENC_SET_CFG", ret) : 0;
}
extern "C" RkmppDmaEncoder *rkmpp_dma_new(
int width, int height, int stride, int format, int codec, int fps,
int kbps, int gop, const int *fds, const size_t *sizes, size_t count) {
if (width <= 0 || height <= 0 || width > 8192 || height > 8192 ||
(width & 1) || (height & 1) || format < 0 || format > 4 ||
codec < 0 || codec > 1 || fps <= 0 || fps > 240 ||
(format != 4 && stride < width * (format == 1 || format == 3 ? 3 : format == 2 ? 2 : 1)) ||
(format == 2 && stride % 16 != 0) || !fds || !sizes || !count || count > 16) {
fail("invalid DMA frame layout", -1); return nullptr;
}
auto *e = new (std::nothrow) RkmppDmaEncoder;
if (!e) { fail("allocate DMA encoder", -1); return nullptr; }
e->width = width; e->height = height; e->stride = stride; e->count = count;
e->jpeg = format == 4;
if (e->jpeg) e->stride = stride = (width + 15) & ~15;
const int vstride = e->jpeg ? (height + 15) & ~15 : height;
e->format = format == 3 ? MPP_FMT_RGB888 : format == 2 ? MPP_FMT_YUV422_YUYV : format == 1 ? MPP_FMT_BGR888 : MPP_FMT_YUV420SP;
auto abort_init = [e](const char *op, int ret) -> RkmppDmaEncoder * {
fail(op, ret); delete e; return nullptr;
};
int ret = mpp_create(&e->ctx, &e->api);
if (ret) return abort_init("mpp_create", ret);
RK_S64 timeout = 2000;
ret = e->api->control(e->ctx, MPP_SET_OUTPUT_TIMEOUT, &timeout);
if (ret) return abort_init("MPP_SET_OUTPUT_TIMEOUT", ret);
ret = e->api->control(e->ctx, MPP_SET_INPUT_TIMEOUT, &timeout);
if (ret) return abort_init("MPP_SET_INPUT_TIMEOUT", ret);
ret = mpp_init(e->ctx, MPP_CTX_ENC, codec ? MPP_VIDEO_CodingHEVC : MPP_VIDEO_CodingAVC);
if (ret) return abort_init("mpp_init", ret);
ret = mpp_enc_cfg_init(&e->cfg);
if (ret) return abort_init("mpp_enc_cfg_init", ret);
ret = e->api->control(e->ctx, MPP_ENC_GET_CFG, e->cfg);
if (ret) return abort_init("MPP_ENC_GET_CFG", ret);
if (!set_cfg(e, "prep:width", width) || !set_cfg(e, "prep:height", height) ||
!set_cfg(e, "prep:hor_stride", stride) || !set_cfg(e, "prep:ver_stride", vstride) ||
!set_cfg(e, "prep:format", e->format) || !set_cfg(e, "rc:mode", MPP_ENC_RC_MODE_CBR) ||
!set_cfg(e, "rc:fps_in_flex", 0) || !set_cfg(e, "rc:fps_in_num", fps) ||
!set_cfg(e, "rc:fps_in_denorm", 1) || !set_cfg(e, "rc:fps_out_flex", 0) ||
!set_cfg(e, "rc:fps_out_num", fps) || !set_cfg(e, "rc:fps_out_denorm", 1) ||
!set_cfg(e, "codec:type", codec ? MPP_VIDEO_CodingHEVC : MPP_VIDEO_CodingAVC)) {
delete e; return nullptr;
}
// Match the browser-friendly baseline profile used by the existing RKMPP
// byte encoder, rather than inheriting MPP's High-profile default.
const int level = int64_t(width) * height * fps <= int64_t(1920) * 1080 * 60 ? 42 : 52;
if (!codec && (!set_cfg(e, "h264:profile", 66) || !set_cfg(e, "h264:level", level) ||
!set_cfg(e, "h264:cabac_en", 0) || !set_cfg(e, "h264:trans8x8", 0))) {
delete e; return nullptr;
}
if (rkmpp_dma_reconfigure(e, kbps, gop)) { delete e; return nullptr; }
MppEncHeaderMode mode = MPP_ENC_HEADER_MODE_EACH_IDR;
ret = e->api->control(e->ctx, MPP_ENC_SET_HEADER_MODE, &mode);
if (ret) return abort_init("MPP_ENC_SET_HEADER_MODE", ret);
// Reject arithmetic overflow even on 32-bit ARM; stride is supplied by a driver.
if (size_t(stride) > std::numeric_limits<size_t>::max() / size_t(height))
return abort_init("DMA buffer size overflow", -1);
size_t minimum = size_t(stride) * height;
if (format == 0) {
if (minimum > std::numeric_limits<size_t>::max() / 3)
return abort_init("DMA buffer size overflow", -1);
minimum = minimum * 3 / 2;
}
if (e->jpeg) minimum = 68; // SOI/payload plus bounded hardware read headroom.
e->minimum = minimum;
for (size_t i = 0; i < count; ++i) {
if (fds[i] < 0 || sizes[i] < minimum) return abort_init("short DMA buffer", -1);
MppBufferInfo info{};
info.type = MPP_BUFFER_TYPE_EXT_DMA; info.fd = fds[i];
info.size = sizes[i]; info.index = static_cast<int>(i);
ret = mpp_buffer_import(&e->buffers[i], &info);
if (ret) return abort_init("mpp_buffer_import", ret);
e->capacities[i] = sizes[i];
}
if (e->jpeg) {
ret = mpp_create(&e->decoder, &e->dec_api);
if (ret) return abort_init("mpp_create JPEG decoder", ret);
ret = mpp_init(e->decoder, MPP_CTX_DEC, MPP_VIDEO_CodingMJPEG);
if (ret) return abort_init("mpp_init JPEG decoder", ret);
MppFrameFormat output = MPP_FMT_YUV420SP;
ret = e->dec_api->control(e->decoder, MPP_DEC_SET_OUTPUT_FORMAT, &output);
if (ret) return abort_init("JPEG NV12 output", ret);
ret = mpp_buffer_group_get_internal(&e->decoded_group, MPP_BUFFER_TYPE_DRM);
if (ret) return abort_init("JPEG output buffer group", ret);
// MPP JPEG requires aligned storage; reserve the conservative size used
// by its advanced-task decoder demo. One output reused after encode.
ret = mpp_buffer_get(e->decoded_group, &e->decoded_buffer, size_t(stride) * vstride * 4);
if (ret) return abort_init("JPEG output buffer", ret);
ret = mpp_frame_init(&e->decoded_frame);
if (ret) return abort_init("JPEG output frame", ret);
mpp_frame_set_buffer(e->decoded_frame, e->decoded_buffer);
}
return e;
}
static int dma_read_sync(MppBuffer buffer, bool start) {
dma_buf_sync sync{};
sync.flags = DMA_BUF_SYNC_READ | (start ? DMA_BUF_SYNC_START : DMA_BUF_SYNC_END);
int ret;
do { ret = ioctl(mpp_buffer_get_fd(buffer), DMA_BUF_IOCTL_SYNC, &sync); }
while (ret < 0 && errno == EINTR);
return ret;
}
static int decode_jpeg(RkmppDmaEncoder *e, size_t index, size_t bytes_used) {
MppBuffer input = e->buffers[index];
// The parser reads only header bytes with explicit DMA CPU-read ownership.
if (dma_read_sync(input, true)) return fail("JPEG DMA read sync start", errno);
const auto *data = static_cast<const uint8_t *>(mpp_buffer_get_ptr(input));
const bool valid = rkmpp_dma_jpeg_header(data, bytes_used, e->width, e->height);
if (dma_read_sync(input, false)) return fail("JPEG DMA read sync end", errno);
if (!valid) return fail("unsupported/mismatched JPEG header", -1);
int ret = mpp_packet_init_with_buffer(&e->input_packet, input);
if (ret) return fail("JPEG input packet", ret);
mpp_packet_set_length(e->input_packet, bytes_used);
MppTask task = nullptr;
ret = e->dec_api->poll(e->decoder, MPP_PORT_INPUT, static_cast<MppPollType>(2000));
if (ret) return fail("JPEG input poll", ret);
ret = e->dec_api->dequeue(e->decoder, MPP_PORT_INPUT, &task);
if (ret || !task) return fail("JPEG input task", ret);
ret = mpp_task_meta_set_packet(task, KEY_INPUT_PACKET, e->input_packet);
if (ret) return fail("JPEG input metadata", ret);
ret = mpp_task_meta_set_frame(task, KEY_OUTPUT_FRAME, e->decoded_frame);
if (ret) return fail("JPEG output metadata", ret);
ret = e->dec_api->enqueue(e->decoder, MPP_PORT_INPUT, task);
if (ret) return fail("JPEG submit", ret);
task = nullptr;
ret = e->dec_api->poll(e->decoder, MPP_PORT_OUTPUT, static_cast<MppPollType>(2000));
if (ret) return fail("JPEG output poll", ret);
ret = e->dec_api->dequeue(e->decoder, MPP_PORT_OUTPUT, &task);
if (ret || !task) return fail("JPEG output task", ret);
MppFrame result = nullptr;
ret = mpp_task_meta_get_frame(task, KEY_OUTPUT_FRAME, &result);
if (ret || result != e->decoded_frame) return fail("JPEG output frame mismatch", ret);
ret = e->dec_api->enqueue(e->decoder, MPP_PORT_OUTPUT, task);
if (ret) return fail("JPEG return output task", ret);
ret = e->dec_api->poll(e->decoder, MPP_PORT_INPUT, static_cast<MppPollType>(2000));
if (ret) return fail("JPEG input completion", ret);
mpp_packet_deinit(&e->input_packet);
if (mpp_frame_get_errinfo(result) || mpp_frame_get_discard(result) ||
mpp_frame_get_info_change(result) ||
mpp_frame_get_width(result) != unsigned(e->width) ||
mpp_frame_get_height(result) != unsigned(e->height) ||
mpp_frame_get_fmt(result) != MPP_FMT_YUV420SP ||
mpp_frame_get_buffer(result) != e->decoded_buffer) {
std::snprintf(dma_error, sizeof(dma_error),
"invalid JPEG decoded frame: err=%u discard=%u info_change=%u size=%ux%u fmt=%x buffer_match=%d",
mpp_frame_get_errinfo(result), mpp_frame_get_discard(result), mpp_frame_get_info_change(result),
mpp_frame_get_width(result), mpp_frame_get_height(result), unsigned(mpp_frame_get_fmt(result)),
int(mpp_frame_get_buffer(result) == e->decoded_buffer));
return -1;
}
const int hs = mpp_frame_get_hor_stride(result), vs = mpp_frame_get_ver_stride(result);
if (hs < e->width || vs < e->height || hs > 8192 || vs > 8192 || (hs & 15) || (vs & 15) ||
size_t(hs) * vs * 3 / 2 > mpp_buffer_get_size(e->decoded_buffer))
return fail("invalid JPEG decoded stride", -1);
if (!e->decoded_layout_set) {
if (!set_cfg(e, "prep:hor_stride", hs) || !set_cfg(e, "prep:ver_stride", vs)) return -1;
ret = e->api->control(e->ctx, MPP_ENC_SET_CFG, e->cfg);
if (ret) return fail("JPEG encoder layout", ret);
e->decoded_stride = hs; e->decoded_vstride = vs; e->decoded_layout_set = true;
} else if (hs != e->decoded_stride || vs != e->decoded_vstride) {
return fail("JPEG decoded layout changed", -1);
}
return 0;
}
extern "C" int rkmpp_dma_encode(RkmppDmaEncoder *e, size_t index, size_t bytes_used, int fresh_fd, int64_t pts_us,
int force_idr, const uint8_t **data, size_t *size) {
if (!e || !e->ctx || index >= e->count || !data || !size)
return fail("invalid DMA encode call", -1);
*data = nullptr; *size = 0;
if (e->packet) mpp_packet_deinit(&e->packet);
auto abort_encode = [e](const char *op, int ret) {
fail(op, ret); e->close(); return -1;
};
if (bytes_used > e->capacities[index] ||
(e->jpeg ? (bytes_used < 4 || e->capacities[index] - bytes_used < 64) : bytes_used != e->minimum))
return abort_encode("invalid DMA payload length", -1);
if (fresh_fd >= 0) {
MppBuffer replacement = nullptr;
MppBufferInfo info{};
info.type = MPP_BUFFER_TYPE_EXT_DMA; info.fd = fresh_fd;
info.size = e->capacities[index]; info.index = static_cast<int>(index);
const int ret = mpp_buffer_import(&replacement, &info);
if (ret) return abort_encode("refresh USB DMA import", ret);
if (e->buffers[index]) mpp_buffer_put(e->buffers[index]);
e->buffers[index] = replacement;
}
if (e->jpeg && decode_jpeg(e, index, bytes_used)) {
// Preserve the detailed decoder failure while ending BOTH engines.
e->close(); return -1;
}
if (force_idr) {
int ret = e->api->control(e->ctx, MPP_ENC_SET_IDR_FRAME, nullptr);
if (ret) return abort_encode("MPP_ENC_SET_IDR_FRAME", ret);
}
MppFrame frame = e->jpeg ? e->decoded_frame : nullptr;
int ret = 0;
if (!e->jpeg) {
ret = mpp_frame_init(&frame);
if (ret) return abort_encode("mpp_frame_init", ret);
mpp_frame_set_width(frame, e->width); mpp_frame_set_height(frame, e->height);
mpp_frame_set_hor_stride(frame, e->stride); mpp_frame_set_ver_stride(frame, e->height);
mpp_frame_set_fmt(frame, e->format);
mpp_frame_set_buffer(frame, e->buffers[index]);
}
mpp_frame_set_pts(frame, pts_us);
ret = e->api->encode_put_frame(e->ctx, frame);
if (!e->jpeg) mpp_frame_deinit(&frame);
if (ret) return abort_encode("encode_put_frame", ret);
ret = e->api->encode_get_packet(e->ctx, &e->packet);
if (ret || !e->packet) return abort_encode("encode_get_packet", ret);
if (mpp_packet_is_partition(e->packet) || !mpp_packet_get_length(e->packet))
return abort_encode("incomplete DMA encoder output", -1);
// One synchronous input, no temporal scalability/reordering/split output.
// A completed packet is the input-consumption barrier for this mode.
*data = static_cast<const uint8_t *>(mpp_packet_get_pos(e->packet));
*size = mpp_packet_get_length(e->packet);
return 0;
}
extern "C" void rkmpp_dma_free(RkmppDmaEncoder *e) { delete e; }
#else
extern "C" RkmppDmaEncoder *rkmpp_dma_new(int,int,int,int,int,int,int,int,const int*,const size_t*,size_t) {
fail("RKMPP DMA support not built", -1); return nullptr;
}
extern "C" int rkmpp_dma_encode(RkmppDmaEncoder*,size_t,size_t,int,int64_t,int,const uint8_t**,size_t*) { return -1; }
extern "C" int rkmpp_dma_reconfigure(RkmppDmaEncoder*,int,int) { return -1; }
extern "C" void rkmpp_dma_free(RkmppDmaEncoder*) {}
#endif
extern "C" const char *rkmpp_dma_error(void) { return dma_error; }

View File

@@ -0,0 +1,31 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct RkmppDmaEncoder RkmppDmaEncoder;
// format: 0=NV12, 1=BGR24, 2=YUYV, 3=RGB24 (byte stride), 4=MJPEG (stride ignored).
// codec: 0=H264, 1=HEVC. MJPEG is decoded to hardware NV12, then encoded.
RkmppDmaEncoder *rkmpp_dma_new(int width, int height, int stride, int format,
int codec, int fps, int kbps, int gop,
const int *fds, const size_t *sizes, size_t count);
// Synchronous input-completion boundary. On failure the encoder is destroyed
// internally BEFORE returning, so it cannot keep reading the capture buffer.
// Output is borrowed until the next call or destruction; copy before reusing it.
// bytes_used must be the actual captured payload. MJPEG needs 64 bytes of
// readable allocation headroom; never pass sizeimage as the compressed length.
// fresh_fd=-1 reuses the original import (native HDMI). UVC supplies a new
// export of this dequeued slot. Keep it open until replacement/free; the old
// export can be closed AFTER this call, including on failure.
int rkmpp_dma_encode(RkmppDmaEncoder *encoder, size_t index, size_t bytes_used, int fresh_fd, int64_t pts_us,
int force_idr, const uint8_t **data, size_t *size);
int rkmpp_dma_reconfigure(RkmppDmaEncoder *encoder, int kbps, int gop);
void rkmpp_dma_free(RkmppDmaEncoder *encoder);
const char *rkmpp_dma_error(void);
#ifdef __cplusplus
}
#endif

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,193 @@
//! Synchronous RKMPP encoder for pre-exported V4L2 DMA buffers.
//! Unlike the byte-slice encoder this never reads raw pixels on the CPU.
use std::ffi::{c_char, c_int, c_void, CStr};
use std::os::fd::{AsRawFd, OwnedFd};
use std::ptr::NonNull;
unsafe extern "C" {
fn rkmpp_dma_new(
width: c_int,
height: c_int,
stride: c_int,
format: c_int,
codec: c_int,
fps: c_int,
kbps: c_int,
gop: c_int,
fds: *const c_int,
sizes: *const usize,
count: usize,
) -> *mut c_void;
fn rkmpp_dma_encode(
encoder: *mut c_void,
index: usize,
bytes_used: usize,
fresh_fd: c_int,
pts_us: i64,
force_idr: c_int,
data: *mut *const u8,
size: *mut usize,
) -> c_int;
fn rkmpp_dma_reconfigure(encoder: *mut c_void, kbps: c_int, gop: c_int) -> c_int;
fn rkmpp_dma_free(encoder: *mut c_void);
fn rkmpp_dma_error() -> *const c_char;
}
#[derive(Debug, Clone, Copy)]
#[repr(i32)]
pub enum DmaFormat {
Nv12 = 0,
Bgr24 = 1,
Yuyv = 2,
Rgb24 = 3,
Mjpeg = 4,
}
pub struct DmaEncoderConfig {
pub width: u32,
pub height: u32,
pub stride: u32,
pub format: DmaFormat,
pub hevc: bool,
pub fps: u32,
pub bitrate_kbps: u32,
pub gop: u32,
}
pub struct DmaEncoder {
ctx: NonNull<c_void>,
// Export FDs remain open until AFTER mpp_destroy and imported buffer release.
_buffers: Vec<(OwnedFd, usize)>,
// Keep refreshed exports alive until native replacement/release has ended
// all references to the previous import. At most one FD per capture slot.
fresh_buffers: Vec<Option<OwnedFd>>,
}
// Exclusive ownership: the context can move between threads but all calls are sequential.
unsafe impl Send for DmaEncoder {}
fn last_error() -> String {
unsafe {
CStr::from_ptr(rkmpp_dma_error())
.to_string_lossy()
.into_owned()
}
}
impl DmaEncoder {
pub fn new(config: DmaEncoderConfig, buffers: Vec<(OwnedFd, usize)>) -> Result<Self, String> {
let fds: Vec<_> = buffers.iter().map(|(fd, _)| fd.as_raw_fd()).collect();
let sizes: Vec<_> = buffers.iter().map(|(_, size)| *size).collect();
for value in [
config.width,
config.height,
config.stride,
config.fps,
config.bitrate_kbps,
config.gop,
] {
if value > c_int::MAX as u32 {
return Err("DMA encoder parameter overflow".into());
}
}
let ptr = unsafe {
rkmpp_dma_new(
config.width as _,
config.height as _,
config.stride as _,
config.format as c_int,
config.hevc as _,
config.fps as _,
config.bitrate_kbps as _,
config.gop as _,
fds.as_ptr(),
sizes.as_ptr(),
buffers.len(),
)
};
Ok(Self {
ctx: NonNull::new(ptr).ok_or_else(last_error)?,
fresh_buffers: (0..buffers.len()).map(|_| None).collect(),
_buffers: buffers,
})
}
/// # Safety
/// The indexed buffer must be dequeued and exclusively leased to this call.
/// Do not requeue/write it until this function returns. On failure native MPP
/// is synchronously destroyed before returning, ending all input access.
/// `bytes_used` is the actual DQBUF payload length, not the buffer capacity.
/// A refreshed FD, if supplied, must refer to the same leased capture slot
/// with the capacity registered at construction. Ownership is retained here.
pub unsafe fn encode(
&mut self,
index: usize,
bytes_used: usize,
fresh_fd: Option<OwnedFd>,
pts_ms: i64,
force_idr: bool,
) -> Result<Vec<u8>, String> {
let mut data = std::ptr::null();
let mut size = 0;
if index >= self.fresh_buffers.len() {
return Err("Invalid DMA capture index".into());
}
let ret = unsafe {
rkmpp_dma_encode(
self.ctx.as_ptr(),
index,
bytes_used,
fresh_fd.as_ref().map_or(-1, AsRawFd::as_raw_fd),
pts_ms.saturating_mul(1000),
force_idr as _,
&mut data,
&mut size,
)
};
if fresh_fd.is_some() {
// Native has now released the previous import, or destroyed both
// hardware contexts on error. Only now may its old FD be closed.
self.fresh_buffers[index] = fresh_fd;
}
if ret != 0 {
return Err(last_error());
}
if data.is_null() || size == 0 {
return Err("Empty RKMPP DMA packet".into());
}
// Copy only the compressed output, releasing the driver's packet promptly
// regardless of how long a network subscriber retains its Bytes.
Ok(unsafe { std::slice::from_raw_parts(data, size) }.to_vec())
}
pub fn reconfigure(&mut self, kbps: u32, gop: u32) -> Result<(), String> {
if kbps > c_int::MAX as u32 || gop > c_int::MAX as u32 {
return Err("DMA encoder parameter overflow".into());
}
if unsafe { rkmpp_dma_reconfigure(self.ctx.as_ptr(), kbps as _, gop as _) } != 0 {
return Err(last_error());
}
Ok(())
}
}
impl Drop for DmaEncoder {
fn drop(&mut self) {
unsafe { rkmpp_dma_free(self.ctx.as_ptr()) };
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_discriminants_match_native_abi() {
assert_eq!(DmaFormat::Nv12 as c_int, 0);
assert_eq!(DmaFormat::Bgr24 as c_int, 1);
assert_eq!(DmaFormat::Yuyv as c_int, 2);
assert_eq!(DmaFormat::Rgb24 as c_int, 3);
assert_eq!(DmaFormat::Mjpeg as c_int, 4);
}
}

View File

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

View File

@@ -1119,25 +1119,73 @@ pub fn mjpg_size(src: &[u8]) -> Result<(i32, i32)> {
/// Decode MJPEG directly to NV12.
pub fn mjpg_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
if width % 2 != 0 || height % 2 != 0 {
return Err(YuvError::InvalidDimensions);
}
let w = width as usize;
let h = height as usize;
if dst.len() < nv12_size(w, h) {
let (y_size, output_size) = mjpg_nv12_plane_sizes(width, height)?;
if dst.len() < output_size {
return Err(YuvError::BufferTooSmall);
}
let y_size = w * h;
let (dst_y, dst_uv) = dst.split_at_mut(y_size);
// SAFETY: the length check above guarantees writable storage for both planes.
unsafe { mjpg_to_nv12_raw(src, dst_y.as_mut_ptr(), dst_uv.as_mut_ptr(), width, height) }
}
/// Decode MJPEG directly into a reusable `Vec` without zero-filling the output first.
///
/// `Vec::resize` must initialize every byte before libyuv immediately overwrites the
/// complete NV12 frame. This variant lets libyuv initialize spare capacity directly
/// and publishes the new length only after a successful conversion.
pub fn mjpg_to_nv12_vec(src: &[u8], dst: &mut Vec<u8>, width: i32, height: i32) -> Result<()> {
let (y_size, output_size) = mjpg_nv12_plane_sizes(width, height)?;
dst.clear();
dst.reserve(output_size);
// SAFETY: reserve above guarantees writable capacity for the Y and UV planes.
// MJPGToNV12 writes the complete output on success; set_len is deliberately
// delayed until then so callers can never observe partially initialized bytes.
let result = unsafe {
let dst_y = dst.as_mut_ptr();
mjpg_to_nv12_raw(src, dst_y, dst_y.add(y_size), width, height)
};
result?;
// SAFETY: a successful MJPGToNV12 call initialized exactly output_size bytes.
unsafe { dst.set_len(output_size) };
Ok(())
}
#[inline]
fn mjpg_nv12_plane_sizes(width: i32, height: i32) -> Result<(usize, usize)> {
if width % 2 != 0 || height % 2 != 0 || width <= 0 || height <= 0 {
return Err(YuvError::InvalidDimensions);
}
let y_size = (width as usize)
.checked_mul(height as usize)
.ok_or(YuvError::InvalidDimensions)?;
let output_size = y_size
.checked_mul(3)
.map(|size| size / 2)
.ok_or(YuvError::InvalidDimensions)?;
Ok((y_size, output_size))
}
/// # Safety
///
/// `dst_y` and `dst_uv` must point to writable planes sized for `width` x `height` NV12.
#[inline]
unsafe fn mjpg_to_nv12_raw(
src: &[u8],
dst_y: *mut u8,
dst_uv: *mut u8,
width: i32,
height: i32,
) -> Result<()> {
call_yuv!(MJPGToNV12(
src.as_ptr(),
usize_to_size_t(src.len()),
dst_y.as_mut_ptr(),
dst_y,
width,
dst_uv.as_mut_ptr(),
dst_uv,
width,
width,
height,

View File

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

View File

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

View File

@@ -1,3 +1,15 @@
//! Platform-neutral capture lifecycle and PCM frame types.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use bytes::Bytes;
use tokio::sync::{broadcast, watch, Mutex};
use tracing::{debug, info};
use crate::error::Result;
use crate::utils::LogThrottler;
#[cfg(unix)]
#[path = "capture_linux.rs"]
mod imp;
@@ -6,4 +18,146 @@ mod imp;
#[path = "capture_windows.rs"]
mod imp;
pub use imp::*;
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48_000,
channels: 2,
buffer_frames: 4096,
period_frames: 960,
}
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32) -> Self {
Self {
data,
sample_rate,
channels,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Starting,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: watch::Sender<CaptureState>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
task: Mutex<Option<tokio::task::JoinHandle<()>>>,
lifecycle: Mutex<()>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state,
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
task: Mutex::new(None),
lifecycle: Mutex::new(()),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
if matches!(self.state(), CaptureState::Starting | CaptureState::Running) {
return Ok(());
}
if let Some(previous) = self.task.lock().await.take() {
let _ = previous.await;
}
debug!(
"Starting audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::Release);
let _ = self.state.send(CaptureState::Starting);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = Arc::clone(&self.stop_flag);
let log_throttler = self.log_throttler.clone();
let task = tokio::task::spawn_blocking(move || {
match imp::run_capture(&config, &state, &frame_tx, &stop_flag, &log_throttler) {
Ok(()) => {
let _ = state.send(CaptureState::Stopped);
}
Err(error) => {
crate::error_throttled!(
log_throttler,
"capture_error",
"Audio capture error: {}",
error
);
let _ = state.send(CaptureState::Error);
}
}
});
*self.task.lock().await = Some(task);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
self.stop_flag.store(true, Ordering::Release);
if let Some(task) = self.task.lock().await.take() {
let _ = task.await;
}
let _ = self.state.send(CaptureState::Stopped);
info!("Audio capture stopped");
Ok(())
}
}

View File

@@ -1,271 +1,60 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use alsa::pcm::{Access, Format, Frames, HwParams, State, IO};
use alsa::{Direction, ValueOr, PCM};
use bytes::Bytes;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, watch, Mutex};
use tracing::{debug, info};
use tokio::sync::{broadcast, watch};
use tracing::debug;
use crate::audio::device::AudioDeviceInfo;
use super::{AudioConfig, AudioFrame, CaptureState};
use crate::error::{AppError, Result};
use crate::utils::LogThrottler;
use crate::{error_throttled, warn_throttled};
use crate::warn_throttled;
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub frame_size: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
const RETRY_DELAY: Duration = Duration::from_millis(5);
const MAX_CONSECUTIVE_READ_ERRORS: u32 = 10;
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48000,
channels: 2,
frame_size: 960,
buffer_frames: 4096,
period_frames: 960,
}
}
}
impl AudioConfig {
pub fn for_device(device: &AudioDeviceInfo) -> Self {
Self {
device_name: device.name.clone(),
..Default::default()
}
}
pub fn bytes_per_sample(&self) -> u32 {
2 * self.channels
}
pub fn bytes_per_frame(&self) -> usize {
(self.frame_size * self.bytes_per_sample()) as usize
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
pub samples: u32,
pub sequence: u64,
pub timestamp: Instant,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
let bps = 2 * channels;
Self {
samples: data.len() as u32 / bps,
data,
sample_rate,
channels,
sequence,
timestamp: Instant::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state: Arc::new(state_tx),
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
sequence: Arc::new(AtomicU64::new(0)),
capture_handle: Mutex::new(None),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
if self.state() == CaptureState::Running {
return Ok(());
}
debug!(
"Starting audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::SeqCst);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = self.stop_flag.clone();
let sequence = self.sequence.clone();
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
info!("Stopping audio capture");
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.capture_handle.lock().await.take() {
let _ = handle.await;
}
let _ = self.state.send(CaptureState::Stopped);
Ok(())
}
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
}
fn run_capture(
pub(super) fn run_capture(
config: &AudioConfig,
state: &watch::Sender<CaptureState>,
frame_tx: &broadcast::Sender<AudioFrame>,
stop_flag: &AtomicBool,
sequence: &AtomicU64,
log_throttler: &LogThrottler,
) -> Result<()> {
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
// Non-blocking mode guarantees that stop() can always join the worker.
let pcm = PCM::new(&config.device_name, Direction::Capture, true).map_err(|error| {
AppError::AudioError(format!(
"Failed to open audio device {}: {}",
config.device_name, e
config.device_name, error
))
})?;
{
let hwp = HwParams::any(&pcm)
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
hwp.set_channels(config.channels)
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
hwp.set_format(Format::s16())
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
hwp.set_access(Access::RWInterleaved)
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
hwp.set_buffer_size_near(config.buffer_frames as Frames)
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
pcm.hw_params(&hwp)
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
}
let hw_now = pcm.hw_params_current().map_err(|e| {
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
})?;
let actual_rate = hw_now
.get_rate()
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
let actual_ch = hw_now
.get_channels()
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
if actual_rate != 48_000 {
return Err(AppError::AudioError(format!(
"Audio capture requires 48000 Hz; device is {} Hz",
actual_rate
)));
}
if actual_ch != 2 {
return Err(AppError::AudioError(format!(
"Audio capture requires 2 channels (stereo); device has {}",
actual_ch
)));
}
debug!("Audio capture: 48000 Hz, 2 ch");
configure_pcm(&pcm, config)?;
pcm.prepare()
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
.map_err(|error| AppError::AudioError(format!("Failed to prepare PCM: {error}")))?;
let _ = state.send(CaptureState::Running);
let period_frames = pcm
.hw_params_current()
.ok()
.and_then(|h| h.get_period_size().ok())
.map(|f| f as usize)
.unwrap_or(1024)
.and_then(|params| params.get_period_size().ok())
.map(|frames| frames as usize)
.unwrap_or(config.period_frames as usize)
.max(256);
let buf_frames = period_frames.saturating_mul(4).max(2048);
let bytes_per_frame = (config.channels as usize) * 2;
let mut buffer = vec![0u8; buf_frames * bytes_per_frame];
let mut buffer = vec![0u8; period_frames * config.channels as usize * 2];
let io: IO<u8> = pcm.io_bytes();
let mut consecutive_errors = 0;
while !stop_flag.load(Ordering::Relaxed) {
while !stop_flag.load(Ordering::Acquire) {
match pcm.state() {
State::XRun => {
warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering");
let _ = pcm.prepare();
pcm.prepare().map_err(|error| {
AppError::AudioError(format!("Failed to recover audio xrun: {error}"))
})?;
consecutive_errors = 0;
continue;
}
State::Suspended => {
@@ -274,61 +63,95 @@ fn run_capture(
"suspended",
"Audio device suspended, recovering"
);
let _ = pcm.resume();
if pcm.resume().is_err() {
pcm.prepare().map_err(|error| {
AppError::AudioError(format!("Failed to resume audio capture: {error}"))
})?;
}
consecutive_errors = 0;
continue;
}
_ => {}
}
// io_bytes: USB capture often lacks mmap (io_checked requires it).
let io: IO<u8> = pcm.io_bytes();
match io.readi(&mut buffer) {
Ok(0) => thread::sleep(RETRY_DELAY),
Ok(frames_read) => {
if frames_read == 0 {
continue;
}
consecutive_errors = 0;
let byte_count = frames_read * config.channels as usize * 2;
let seq = sequence.fetch_add(1, Ordering::Relaxed);
let frame = AudioFrame::new_interleaved(
Bytes::copy_from_slice(&buffer[..byte_count]),
config.channels,
48_000,
seq,
config.sample_rate,
);
if frame_tx.receiver_count() > 0 {
if let Err(e) = frame_tx.send(frame) {
debug!("No audio receivers: {}", e);
}
let _ = frame_tx.send(frame);
}
}
Err(e) => {
let desc = e.to_string();
if is_device_lost_error(&desc) {
Err(error) if error.errno() == libc::EAGAIN => thread::sleep(RETRY_DELAY),
Err(error) if is_device_lost_errno(error.errno()) => {
return Err(AppError::AudioError(format!(
"Audio device lost while reading {}: {}",
config.device_name, error
)));
}
Err(error) if error.errno() == libc::EPIPE => {
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
pcm.prepare().map_err(|prepare_error| {
AppError::AudioError(format!(
"Failed to recover after audio overrun ({error}): {prepare_error}"
))
})?;
consecutive_errors = 0;
}
Err(error) => {
consecutive_errors += 1;
warn_throttled!(log_throttler, "read_error", "Audio read error: {}", error);
if consecutive_errors >= MAX_CONSECUTIVE_READ_ERRORS {
return Err(AppError::AudioError(format!(
"Audio device lost while reading {}: {}",
config.device_name, e
"Audio capture failed {consecutive_errors} times consecutively: {error}"
)));
} else if desc.contains("EPIPE") || desc.contains("Broken pipe") {
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
let _ = pcm.prepare();
} else {
error_throttled!(log_throttler, "read_error", "Audio read error: {}", e);
}
thread::sleep(RETRY_DELAY);
}
}
}
info!("Audio capture stopped");
debug!("ALSA capture worker stopped");
Ok(())
}
fn is_device_lost_error(desc: &str) -> bool {
desc.contains("No such device")
|| desc.contains("ENODEV")
|| desc.contains("ENXIO")
|| desc.contains("ESHUTDOWN")
fn configure_pcm(pcm: &PCM, config: &AudioConfig) -> Result<()> {
let params = HwParams::any(pcm)
.map_err(|error| AppError::AudioError(format!("Failed to get HwParams: {error}")))?;
params
.set_channels(config.channels)
.and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest))
.and_then(|_| params.set_format(Format::s16()))
.and_then(|_| params.set_access(Access::RWInterleaved))
.and_then(|_| params.set_buffer_size_near(config.buffer_frames as Frames))
.and_then(|_| params.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest))
.and_then(|_| pcm.hw_params(&params))
.map_err(|error| AppError::AudioError(format!("Failed to configure audio PCM: {error}")))?;
let actual = pcm
.hw_params_current()
.map_err(|error| AppError::AudioError(format!("Failed to read PCM parameters: {error}")))?;
let actual_rate = actual
.get_rate()
.map_err(|error| AppError::AudioError(format!("Failed to read sample rate: {error}")))?;
let actual_channels = actual
.get_channels()
.map_err(|error| AppError::AudioError(format!("Failed to read channels: {error}")))?;
if actual_rate != config.sample_rate || actual_channels != config.channels {
return Err(AppError::AudioError(format!(
"Audio device negotiated {actual_rate} Hz/{actual_channels} ch; expected {} Hz/{} ch",
config.sample_rate, config.channels
)));
}
Ok(())
}
fn is_device_lost_errno(errno: i32) -> bool {
matches!(errno, libc::ENODEV | libc::ENXIO | libc::ESHUTDOWN)
}

View File

@@ -1,198 +1,23 @@
use bytes::Bytes;
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::{BufferSize, SampleFormat, StreamConfig};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, watch, Mutex};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use tracing::{debug, info};
use crate::audio::device::{find_wasapi_device, AudioDeviceInfo};
use super::{AudioConfig, AudioFrame, CaptureState};
use crate::audio::device::find_wasapi_device;
use crate::error::{AppError, Result};
use crate::error_throttled;
use crate::utils::LogThrottler;
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub frame_size: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48000,
channels: 2,
frame_size: 960,
buffer_frames: 4096,
period_frames: 960,
}
}
}
impl AudioConfig {
pub fn for_device(device: &AudioDeviceInfo) -> Self {
Self {
device_name: device.name.clone(),
..Default::default()
}
}
pub fn bytes_per_sample(&self) -> u32 {
2 * self.channels
}
pub fn bytes_per_frame(&self) -> usize {
(self.frame_size * self.bytes_per_sample()) as usize
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
pub samples: u32,
pub sequence: u64,
pub timestamp: Instant,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
let bps = 2 * channels;
Self {
samples: data.len() as u32 / bps,
data,
sample_rate,
channels,
sequence,
timestamp: Instant::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state: Arc::new(state_tx),
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
sequence: Arc::new(AtomicU64::new(0)),
capture_handle: Mutex::new(None),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
if self.state() == CaptureState::Running {
return Ok(());
}
debug!(
"Starting WASAPI audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::SeqCst);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = self.stop_flag.clone();
let sequence = self.sequence.clone();
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(
log_throttler,
"capture_error",
"WASAPI audio capture error: {}",
e
);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
info!("Stopping WASAPI audio capture");
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.capture_handle.lock().await.take() {
let _ = handle.await;
}
let _ = self.state.send(CaptureState::Stopped);
Ok(())
}
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
}
fn run_capture(
pub(super) fn run_capture(
config: &AudioConfig,
state: &watch::Sender<CaptureState>,
frame_tx: &broadcast::Sender<AudioFrame>,
stop_flag: &AtomicBool,
sequence: &AtomicU64,
log_throttler: &LogThrottler,
) -> Result<()> {
let device = find_wasapi_device(&config.device_name)?;
@@ -272,12 +97,10 @@ fn run_capture(
if samples.is_empty() {
continue;
}
let seq = sequence.fetch_add(1, Ordering::Relaxed);
let frame = AudioFrame::new_interleaved(
Bytes::copy_from_slice(bytemuck::cast_slice(&samples)),
2,
48_000,
seq,
);
if frame_tx.receiver_count() > 0 {
if let Err(e) = frame_tx.send(frame) {

View File

@@ -1,8 +1,7 @@
//! Device selection, quality presets, streaming.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info};
use super::capture::AudioConfig;
@@ -22,23 +21,37 @@ pub(super) type AudioRecoveredCallback = Arc<dyn Fn() + Send + Sync>;
pub struct AudioController {
config: Arc<RwLock<AudioControllerConfig>>,
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
devices: Arc<RwLock<Vec<AudioDeviceInfo>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovery: recovery::AudioRecovery,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
operation: Arc<Mutex<()>>,
}
impl AudioController {
pub fn new(config: AudioControllerConfig) -> Self {
let config = Arc::new(RwLock::new(config));
let streamer = Arc::new(RwLock::new(None));
let event_bus = Arc::new(RwLock::new(None));
let monitor = Arc::new(AudioHealthMonitor::new());
let recovered_callback = Arc::new(RwLock::new(None));
let operation = Arc::new(Mutex::new(()));
let recovery = recovery::AudioRecovery::new(
config.clone(),
streamer.clone(),
event_bus.clone(),
monitor.clone(),
recovered_callback.clone(),
operation.clone(),
);
Self {
config: Arc::new(RwLock::new(config)),
streamer: Arc::new(RwLock::new(None)),
devices: Arc::new(RwLock::new(Vec::new())),
event_bus: Arc::new(RwLock::new(None)),
monitor: Arc::new(AudioHealthMonitor::new()),
recovery_in_progress: Arc::new(AtomicBool::new(false)),
recovered_callback: Arc::new(RwLock::new(None)),
config,
streamer,
event_bus,
monitor,
recovery,
recovered_callback,
operation,
}
}
@@ -55,31 +68,6 @@ impl AudioController {
bus.mark_device_info_dirty();
}
}
fn spawn_recovery_task(&self, lost_device: String, reason: String) {
recovery::spawn_recovery_task(
self.config.clone(),
self.streamer.clone(),
self.event_bus.clone(),
self.monitor.clone(),
self.recovery_in_progress.clone(),
self.recovered_callback.clone(),
lost_device,
reason,
);
}
fn spawn_stream_monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
recovery::spawn_stream_monitor(
self.config.clone(),
self.streamer.clone(),
self.event_bus.clone(),
self.monitor.clone(),
self.recovery_in_progress.clone(),
self.recovered_callback.clone(),
streamer,
device,
);
}
pub async fn list_devices(&self) -> Result<Vec<AudioDeviceInfo>> {
let current_device = if self.is_streaming().await {
@@ -88,26 +76,19 @@ impl AudioController {
None
};
let devices = enumerate_audio_devices_with_current(current_device.as_deref())?;
*self.devices.write().await = devices.clone();
Ok(devices)
}
pub async fn get_cached_devices(&self) -> Vec<AudioDeviceInfo> {
self.devices.read().await.clone()
enumerate_audio_devices_with_current(current_device.as_deref())
}
pub async fn select_device(&self, device: &str) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
let devices = self.list_devices().await?;
let found = devices
.iter()
.any(|d| d.name == device || d.description.contains(device));
if !found {
return Err(AppError::AudioError(format!(
"Audio device not found: {}",
device
)));
return Err(AppError::NotFound(format!("audio device {device}")));
}
{
@@ -118,14 +99,15 @@ impl AudioController {
info!("Audio device selected: {}", device);
if self.is_streaming().await {
self.stop_streaming().await?;
self.start_streaming().await?;
self.stop_streaming_inner().await?;
self.start_streaming_inner().await?;
}
Ok(())
}
pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> {
let _operation = self.operation.lock().await;
{
let mut config = self.config.write().await;
config.quality = quality;
@@ -144,6 +126,12 @@ impl AudioController {
}
pub async fn start_streaming(&self) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
self.start_streaming_inner().await
}
async fn start_streaming_inner(&self) -> Result<()> {
{
let config = self.config.read().await;
if !config.enabled {
@@ -171,7 +159,7 @@ impl AudioController {
if let Some(error_msg) = select_error {
self.monitor.report_error(&error_msg, "start_failed").await;
self.spawn_recovery_task("auto".to_string(), error_msg.clone());
self.recovery.start("auto".to_string(), error_msg.clone());
self.mark_device_info_dirty().await;
return Err(AppError::AudioError(error_msg));
}
@@ -194,7 +182,7 @@ impl AudioController {
let error_msg = format!("Failed to start audio: {}", e);
self.monitor.report_error(&error_msg, "start_failed").await;
self.spawn_recovery_task(device_name.clone(), error_msg.clone());
self.recovery.start(device_name.clone(), error_msg.clone());
self.mark_device_info_dirty().await;
@@ -203,14 +191,13 @@ impl AudioController {
let streamer_for_monitor = streamer.clone();
*self.streamer.write().await = Some(streamer);
self.spawn_stream_monitor(streamer_for_monitor, device_name.clone());
self.recovery
.monitor(streamer_for_monitor, device_name.clone());
if self.monitor.is_error().await {
self.monitor.report_recovered().await;
}
self.recovery_in_progress.store(false, Ordering::SeqCst);
self.mark_device_info_dirty().await;
info!("Audio streaming started");
@@ -218,7 +205,12 @@ impl AudioController {
}
pub async fn stop_streaming(&self) -> Result<()> {
self.recovery_in_progress.store(false, Ordering::SeqCst);
let _operation = self.operation.lock().await;
self.stop_streaming_inner().await
}
async fn stop_streaming_inner(&self) -> Result<()> {
self.recovery.cancel();
if let Some(streamer) = self.streamer.write().await.take() {
streamer.stop().await?;
@@ -249,7 +241,7 @@ impl AudioController {
let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await
{
let streaming = streamer.is_running();
let subscriber_count = streamer.stats().subscriber_count;
let subscriber_count = streamer.subscriber_count();
(streaming, subscriber_count)
} else {
(false, 0)
@@ -278,13 +270,15 @@ impl AudioController {
}
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
{
let mut config = self.config.write().await;
config.enabled = enabled;
}
if !enabled && self.is_streaming().await {
self.stop_streaming().await?;
self.stop_streaming_inner().await?;
}
info!("Audio enabled: {}", enabled);
@@ -292,16 +286,18 @@ impl AudioController {
}
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
let was_streaming = self.is_streaming().await;
if was_streaming {
self.stop_streaming().await?;
self.stop_streaming_inner().await?;
}
*self.config.write().await = new_config.clone();
if new_config.enabled {
self.start_streaming().await?;
self.start_streaming_inner().await?;
}
Ok(())

View File

@@ -1,3 +1,9 @@
//! Shared device description with platform-specific enumeration backends.
use serde::Serialize;
use crate::error::Result;
#[cfg(unix)]
#[path = "device_linux.rs"]
mod imp;
@@ -6,4 +12,32 @@ mod imp;
#[path = "device_windows.rs"]
mod imp;
pub use imp::*;
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
imp::enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
imp::enumerate_audio_devices_with_current(current_device)
}
pub(crate) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
imp::find_best_audio_device()
}
#[cfg(windows)]
pub(crate) use imp::find_wasapi_device;

View File

@@ -1,23 +1,10 @@
use alsa::pcm::HwParams;
use alsa::{Direction, PCM};
use serde::Serialize;
use tracing::{debug, info, warn};
use super::AudioDeviceInfo;
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
fn get_usb_bus_info(card_index: i32) -> Option<String> {
if card_index < 0 {
return None;
@@ -28,26 +15,18 @@ fn get_usb_bus_info(card_index: i32) -> Option<String> {
let link_str = link_target.to_string_lossy();
for component in link_str.split('/') {
if component.contains('-') && !component.contains(':') {
if component
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
return Some(component.to_string());
}
if component.contains('-')
&& !component.contains(':')
&& component.chars().next().is_some_and(|c| c.is_ascii_digit())
{
return Some(component.to_string());
}
}
None
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
pub(super) fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
let mut devices = Vec::new();
@@ -153,8 +132,8 @@ fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
(supported_rates, supported_channels)
}
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices()?;
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices_with_current(None)?;
if devices.is_empty() {
return Err(AppError::AudioError(
@@ -194,7 +173,7 @@ mod tests {
#[test]
fn test_enumerate_devices() {
let result = enumerate_audio_devices();
let result = enumerate_audio_devices_with_current(None);
println!("Audio devices: {:?}", result);
assert!(result.is_ok());
}

View File

@@ -1,29 +1,12 @@
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::DeviceId;
use serde::Serialize;
use std::str::FromStr;
use tracing::{debug, info, warn};
use super::AudioDeviceInfo;
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
pub(super) fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
let host = cpal::default_host();
@@ -192,8 +175,8 @@ pub(crate) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device>
)))
}
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices()?;
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices_with_current(None)?;
if devices.is_empty() {
return Err(AppError::AudioError(

View File

@@ -5,7 +5,6 @@ use audiopus::{coder::Encoder, Application, Bitrate, Channels, SampleRate};
use bytes::Bytes;
use tracing::debug;
use super::capture::AudioFrame;
use crate::error::{AppError, Result};
#[derive(Debug, Clone)]
@@ -154,11 +153,6 @@ impl OpusEncoder {
})
}
pub fn encode_frame(&mut self, frame: &AudioFrame) -> Result<OpusFrame> {
let samples: &[i16] = bytemuck::cast_slice(&frame.data);
self.encode(samples)
}
pub fn config(&self) -> &OpusConfig {
&self.config
}

View File

@@ -1,16 +1,18 @@
//! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor.
#[cfg(any(unix, windows))]
pub mod capture;
pub mod controller;
mod capture;
mod controller;
#[cfg(any(unix, windows))]
pub mod device;
mod device;
#[cfg(any(unix, windows))]
pub mod encoder;
pub mod monitor;
pub mod recovery;
pub mod streamer;
pub mod types;
mod encoder;
mod monitor;
mod recovery;
mod streamer;
mod types;
#[cfg(unix)]
pub mod uac;
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
pub use controller::AudioController;

View File

@@ -71,14 +71,14 @@ impl AudioHealthMonitor {
pub async fn report_recovered(&self) {
let prev_status = self.status.read().await.clone();
self.suppress_display.store(false, Ordering::Relaxed);
if prev_status != AudioHealthStatus::Healthy {
let retry_count = self.retry_count.load(Ordering::Relaxed);
info!("Audio recovered after {} retries", retry_count);
self.suppress_display.store(false, Ordering::Relaxed);
self.retry_count.store(0, Ordering::Relaxed);
self.throttler.clear("audio_");
self.throttler.clear_all();
*self.last_error_code.write().await = None;
*self.status.write().await = AudioHealthStatus::Healthy;
}

View File

@@ -1,6 +1,9 @@
use std::sync::atomic::{AtomicBool, Ordering};
//! Audio device-loss monitoring and serialized recovery.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
use super::capture::AudioConfig;
@@ -9,312 +12,395 @@ use super::device::{enumerate_audio_devices, AudioDeviceInfo};
use super::monitor::AudioHealthMonitor;
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
use super::types::AudioControllerConfig;
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
use crate::events::{EventBus, StreamKind, SystemEvent};
const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
struct RecoveryControl {
/// Even values are idle; the following odd value is that recovery's token.
/// A single compare-exchange therefore owns both activity and generation.
state: AtomicU64,
}
impl RecoveryControl {
fn new() -> Self {
Self {
state: AtomicU64::new(0),
}
}
fn begin(&self) -> Option<u64> {
let idle = self.state.load(Ordering::Acquire);
if !idle.is_multiple_of(2) {
return None;
}
let token = idle.wrapping_add(1);
self.state
.compare_exchange(idle, token, Ordering::AcqRel, Ordering::Acquire)
.ok()
.map(|_| token)
}
fn is_current(&self, token: u64) -> bool {
self.state.load(Ordering::Acquire) == token
}
fn finish(&self, token: u64) {
let _ = self.state.compare_exchange(
token,
token.wrapping_add(1),
Ordering::AcqRel,
Ordering::Acquire,
);
}
fn cancel(&self) {
let token = self.state.load(Ordering::Acquire);
if !token.is_multiple_of(2) {
let _ = self.state.compare_exchange(
token,
token.wrapping_add(1),
Ordering::AcqRel,
Ordering::Acquire,
);
}
}
}
struct RecoveryLease {
control: Arc<RecoveryControl>,
generation: u64,
}
impl Drop for RecoveryLease {
fn drop(&mut self) {
self.control.finish(self.generation);
}
}
struct RecoveryInner {
config: Arc<RwLock<AudioControllerConfig>>,
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
operation: Arc<Mutex<()>>,
control: Arc<RecoveryControl>,
}
#[derive(Clone)]
pub(super) struct AudioRecovery {
inner: Arc<RecoveryInner>,
}
impl AudioRecovery {
pub(super) fn new(
config: Arc<RwLock<AudioControllerConfig>>,
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
operation: Arc<Mutex<()>>,
) -> Self {
Self {
inner: Arc::new(RecoveryInner {
config,
streamer,
event_bus,
monitor,
recovered_callback,
operation,
control: Arc::new(RecoveryControl::new()),
}),
}
}
pub(super) fn cancel(&self) {
self.inner.control.cancel();
}
pub(super) fn monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
let recovery = self.clone();
let mut state = streamer.state_watch();
tokio::spawn(async move {
loop {
let current_state = *state.borrow();
match current_state {
AudioStreamState::Error => {}
AudioStreamState::Stopped => return,
AudioStreamState::Starting | AudioStreamState::Running => {
if state.changed().await.is_err() {
return;
}
continue;
}
}
// Serialize the ownership check with user-driven start/stop
// operations. If a stop already owns the operation lock, it
// removes the streamer before this monitor may start recovery.
let _operation = recovery.inner.operation.lock().await;
let is_current = recovery
.inner
.streamer
.read()
.await
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, &streamer));
if !is_current {
return;
}
let reason = format!("Audio device lost: {device}");
recovery
.inner
.monitor
.report_error(&reason, "device_lost")
.await;
recovery.start(device, reason);
return;
}
});
}
pub(super) fn start(&self, lost_device: String, reason: String) {
let Some(generation) = self.inner.control.begin() else {
debug!("Audio recovery already in progress");
return;
};
let recovery = self.clone();
tokio::spawn(async move {
let _lease = RecoveryLease {
control: recovery.inner.control.clone(),
generation,
};
recovery.run(generation, lost_device, reason).await;
});
}
async fn run(&self, generation: u64, lost_device: String, reason: String) {
warn!("Audio recovery started for {lost_device}: {reason}");
self.publish_device_lost(&lost_device, &reason).await;
self.publish_state(
"device_lost",
Some(lost_device.clone()),
Some("audio_device_lost"),
Some(RETRY_DELAY.as_millis() as u64),
)
.await;
let mut attempt = 0u32;
while self.inner.control.is_current(generation) {
let config = self.inner.config.read().await.clone();
if !config.enabled {
return;
}
if self
.inner
.streamer
.read()
.await
.as_ref()
.is_some_and(|streamer| streamer.is_running())
{
return;
}
attempt = attempt.saturating_add(1);
self.publish_reconnecting(&lost_device, attempt).await;
self.publish_state(
"device_lost",
Some(lost_device.clone()),
Some("audio_reconnecting"),
Some(RETRY_DELAY.as_millis() as u64),
)
.await;
tokio::time::sleep(RETRY_DELAY).await;
if !self.inner.control.is_current(generation) {
return;
}
let devices = match enumerate_audio_devices() {
Ok(devices) => devices,
Err(error) => {
debug!("Audio recovery enumeration attempt {attempt} failed: {error}");
continue;
}
};
let Some(device) = select_recovery_device(&devices, &config.device) else {
debug!("No audio device found on recovery attempt {attempt}");
continue;
};
let streamer = Arc::new(AudioStreamer::with_config(AudioStreamerConfig {
capture: AudioConfig {
device_name: device.name.clone(),
..Default::default()
},
opus: config.quality.to_opus_config(),
}));
if let Err(error) = streamer.start().await {
debug!(
"Audio recovery attempt {attempt} failed with {}: {error}",
device.name
);
continue;
}
// Commit a recovered streamer under the same operation lock used by
// user-driven start/stop/config updates. Cancellation is rechecked
// after acquiring the lock so an old task cannot resurrect itself.
let _operation = self.inner.operation.lock().await;
if !self.inner.control.is_current(generation) || !self.inner.config.read().await.enabled
{
let _ = streamer.stop().await;
return;
}
self.inner.config.write().await.device = device.name.clone();
*self.inner.streamer.write().await = Some(streamer.clone());
self.inner.monitor.report_recovered().await;
self.publish_recovered(&device.name).await;
if let Some(callback) = self.inner.recovered_callback.read().await.clone() {
callback();
}
self.publish_state("streaming", Some(device.name.clone()), None, None)
.await;
info!(
"Audio recovered with {} after {} attempts",
device.name, attempt
);
self.inner.control.finish(generation);
self.monitor(streamer, device.name);
drop(_operation);
return;
}
}
async fn publish_state(
&self,
state: &str,
device: Option<String>,
reason: Option<&str>,
next_retry_ms: Option<u64>,
) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Audio,
state: state.to_string(),
device,
reason: reason.map(str::to_string),
next_retry_ms,
});
bus.mark_device_info_dirty();
}
}
async fn publish_device_lost(&self, device: &str, reason: &str) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamDeviceLost {
kind: StreamKind::Audio,
device: device.to_string(),
reason: reason.to_string(),
});
}
}
async fn publish_reconnecting(&self, device: &str, attempt: u32) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamReconnecting {
device: device.to_string(),
attempt,
});
}
}
async fn publish_recovered(&self, device: &str) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamRecovered {
device: device.to_string(),
});
}
}
}
pub(super) fn select_recovery_device(
devices: &[AudioDeviceInfo],
preferred: &str,
) -> Option<AudioDeviceInfo> {
if let Some(device) = devices
.iter()
.find(|d| !preferred.trim().is_empty() && d.name == preferred)
{
return Some(device.clone());
}
devices
.iter()
.find(|d| d.is_hdmi && d.sample_rates.contains(&48_000) && d.channels.contains(&2))
.find(|device| !preferred.trim().is_empty() && device.name == preferred)
.or_else(|| {
devices
.iter()
.find(|d| d.sample_rates.contains(&48_000) && d.channels.contains(&2))
devices.iter().find(|device| {
device.is_hdmi
&& device.sample_rates.contains(&48_000)
&& device.channels.contains(&2)
})
})
.or_else(|| {
devices.iter().find(|device| {
device.sample_rates.contains(&48_000) && device.channels.contains(&2)
})
})
.or_else(|| devices.first())
.cloned()
}
async fn publish_state(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
state: &str,
device: Option<String>,
reason: Option<&str>,
next_retry_ms: Option<u64>,
) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamStateChanged {
state: state.to_string(),
device,
reason: reason.map(str::to_string),
next_retry_ms,
});
bus.mark_device_info_dirty();
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn publish_device_lost(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
device: &str,
reason: &str,
) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: device.to_string(),
reason: reason.to_string(),
});
}
}
async fn publish_reconnecting(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
device: &str,
attempt: u32,
) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamReconnecting {
device: device.to_string(),
attempt,
});
}
}
async fn publish_recovered(event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>, device: &str) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamRecovered {
device: device.to_string(),
});
}
}
fn spawn_stream_monitor_from_parts(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
streamer: Arc<AudioStreamer>,
device: String,
) {
let mut state_rx = streamer.state_watch();
tokio::spawn(async move {
loop {
if state_rx.changed().await.is_err() {
return;
}
if *state_rx.borrow() != AudioStreamState::Error {
continue;
}
{
let current = streamer_slot.read().await;
if !current
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, &streamer))
{
return;
}
}
let reason = format!("Audio device lost: {}", device);
monitor.report_error(&reason, "device_lost").await;
spawn_recovery_task_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
device,
reason,
);
return;
fn device(name: &str, compatible: bool, hdmi: bool) -> AudioDeviceInfo {
AudioDeviceInfo {
name: name.to_string(),
description: name.to_string(),
card_index: 0,
device_index: 0,
sample_rates: if compatible {
vec![48_000]
} else {
vec![44_100]
},
channels: vec![2],
is_capture: true,
is_hdmi: hdmi,
usb_bus: None,
}
});
}
fn spawn_recovery_task_from_parts(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
lost_device: String,
reason: String,
) {
if recovery_in_progress.swap(true, Ordering::SeqCst) {
debug!("Audio recovery already in progress");
return;
}
tokio::spawn(async move {
warn!("Audio recovery started for {}: {}", lost_device, reason);
publish_device_lost(&event_bus, &lost_device, &reason).await;
publish_state(
&event_bus,
"device_lost",
Some(lost_device.clone()),
Some("audio_device_lost"),
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
)
.await;
#[test]
fn stale_recovery_cannot_finish_a_new_generation() {
let control = RecoveryControl::new();
let stale = control.begin().unwrap();
control.cancel();
let current = control.begin().unwrap();
let mut attempt = 0u32;
control.finish(stale);
assert!(control.is_current(current));
}
loop {
if !recovery_in_progress.load(Ordering::SeqCst) {
debug!("Audio recovery canceled");
return;
}
#[test]
fn completed_recovery_cannot_finish_the_next_recovery() {
let control = RecoveryControl::new();
let completed = control.begin().unwrap();
control.finish(completed);
let current = control.begin().unwrap();
if streamer_slot
.read()
.await
.as_ref()
.is_some_and(|s| s.is_running())
{
recovery_in_progress.store(false, Ordering::SeqCst);
return;
}
control.finish(completed);
assert!(control.is_current(current));
}
let cfg: AudioControllerConfig = config.read().await.clone();
if !cfg.enabled {
recovery_in_progress.store(false, Ordering::SeqCst);
return;
}
attempt = attempt.saturating_add(1);
publish_reconnecting(&event_bus, &lost_device, attempt).await;
publish_state(
&event_bus,
"device_lost",
Some(lost_device.clone()),
Some("audio_reconnecting"),
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
)
.await;
tokio::time::sleep(AUDIO_RECOVERY_RETRY_DELAY).await;
let devices = match enumerate_audio_devices() {
Ok(devices) => devices,
Err(e) => {
debug!(
"Audio recovery enumerate failed (attempt {}): {}",
attempt, e
);
continue;
}
};
let Some(device) = select_recovery_device(&devices, &cfg.device) else {
debug!("No audio devices found during recovery attempt {}", attempt);
continue;
};
let streamer_config = AudioStreamerConfig {
capture: AudioConfig {
device_name: device.name.clone(),
..Default::default()
},
opus: cfg.quality.to_opus_config(),
};
let new_streamer = Arc::new(AudioStreamer::with_config(streamer_config));
match new_streamer.start().await {
Ok(()) => {
{
let mut cfg = config.write().await;
cfg.device = device.name.clone();
}
*streamer_slot.write().await = Some(new_streamer.clone());
monitor.report_recovered().await;
publish_recovered(&event_bus, &device.name).await;
if let Some(callback) = recovered_callback.read().await.clone() {
callback();
}
publish_state(
&event_bus,
"streaming",
Some(device.name.clone()),
None,
None,
)
.await;
recovery_in_progress.store(false, Ordering::SeqCst);
info!(
"Audio device recovered with {} after {} attempts",
device.name, attempt
);
spawn_stream_monitor_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
new_streamer,
device.name,
);
return;
}
Err(e) => {
debug!(
"Audio recovery start failed with {} (attempt {}): {}",
device.name, attempt, e
);
}
}
}
});
}
pub(super) fn spawn_stream_monitor(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
streamer: Arc<AudioStreamer>,
device: String,
) {
spawn_stream_monitor_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
streamer,
device,
);
}
pub(super) fn spawn_recovery_task(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
lost_device: String,
reason: String,
) {
spawn_recovery_task_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
lost_device,
reason,
);
#[test]
fn recovery_prefers_requested_then_compatible_hdmi() {
let devices = vec![device("fallback", true, false), device("hdmi", true, true)];
assert_eq!(
select_recovery_device(&devices, "fallback").unwrap().name,
"fallback"
);
assert_eq!(
select_recovery_device(&devices, "missing").unwrap().name,
"hdmi"
);
}
}

View File

@@ -2,15 +2,15 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
use crate::error::{AppError, Result};
use bytemuck;
use bytes::Bytes;
use std::time::Duration;
/// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE).
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
@@ -40,16 +40,6 @@ impl AudioStreamerConfig {
opus: OpusConfig::default(),
}
}
pub fn with_bitrate(mut self, bitrate: u32) -> Self {
self.opus.bitrate = bitrate;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct AudioStreamStats {
pub subscriber_count: usize,
}
pub struct AudioStreamer {
@@ -60,6 +50,9 @@ pub struct AudioStreamer {
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
stop_flag: Arc<AtomicBool>,
shutdown_generation: watch::Sender<u64>,
lifecycle: AsyncMutex<()>,
stream_task: AsyncMutex<Option<JoinHandle<()>>>,
}
impl AudioStreamer {
@@ -69,6 +62,7 @@ impl AudioStreamer {
pub fn with_config(config: AudioStreamerConfig) -> Self {
let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped);
let (shutdown_generation, _) = watch::channel(0);
Self {
config: RwLock::new(config),
@@ -78,6 +72,9 @@ impl AudioStreamer {
encoder: Arc::new(AsyncMutex::new(None)),
opus_subscribers: Arc::new(Mutex::new(Vec::new())),
stop_flag: Arc::new(AtomicBool::new(false)),
shutdown_generation,
lifecycle: AsyncMutex::new(()),
stream_task: AsyncMutex::new(None),
}
}
@@ -90,7 +87,9 @@ impl AudioStreamer {
}
pub fn subscribe_opus(&self) -> mpsc::Receiver<Arc<OpusFrame>> {
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(128);
// Keep latency bounded for real-time consumers. Slow receivers lose
// new frames instead of accumulating seconds of stale audio.
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(4);
self.opus_subscribers.lock().unwrap().push(tx);
rx
}
@@ -104,22 +103,6 @@ impl AudioStreamer {
.count()
}
pub fn stats(&self) -> AudioStreamStats {
AudioStreamStats {
subscriber_count: self.subscriber_count(),
}
}
pub async fn set_config(&self, config: AudioStreamerConfig) -> Result<()> {
if self.state() != AudioStreamState::Stopped {
return Err(AppError::AudioError(
"Cannot change config while streaming".to_string(),
));
}
*self.config.write().await = config;
Ok(())
}
pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> {
self.config.write().await.opus.bitrate = bitrate;
@@ -132,10 +115,25 @@ impl AudioStreamer {
}
pub async fn start(&self) -> Result<()> {
if self.state() == AudioStreamState::Running {
let _lifecycle = self.lifecycle.lock().await;
if matches!(
self.state(),
AudioStreamState::Starting | AudioStreamState::Running
) {
return Ok(());
}
// Error and stopped states may still own completed task handles. Reap
// them before installing a new capture pipeline so restart is a clean
// lifecycle transition rather than an overwrite of old resources.
if let Some(capturer) = self.capturer.write().await.take() {
let _ = capturer.stop().await;
}
if let Some(task) = self.stream_task.lock().await.take() {
let _ = task.await;
}
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Starting);
self.stop_flag.store(false, Ordering::SeqCst);
@@ -149,13 +147,21 @@ impl AudioStreamer {
config.opus.bitrate
);
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
*self.capturer.write().await = Some(capturer.clone());
let encoder = OpusEncoder::new(config.opus.clone())?;
let encoder = match OpusEncoder::new(config.opus.clone()) {
Ok(encoder) => encoder,
Err(error) => {
let _ = self.state.send(AudioStreamState::Error);
return Err(error);
}
};
*self.encoder.lock().await = Some(encoder);
capturer.start().await?;
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
*self.capturer.write().await = Some(capturer.clone());
if let Err(error) = capturer.start().await {
self.cleanup_failed_start(&capturer).await;
return Err(error);
}
let mut capture_state = capturer.state_watch();
let startup_result = tokio::time::timeout(Duration::from_secs(2), async {
@@ -168,7 +174,7 @@ impl AudioStreamer {
"Audio capture failed to start".to_string(),
))
}
CaptureState::Stopped => {
CaptureState::Stopped | CaptureState::Starting => {
if capture_state.changed().await.is_err() {
return Err(AppError::AudioError(
"Audio capture stopped during startup".to_string(),
@@ -183,17 +189,11 @@ impl AudioStreamer {
match startup_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
self.cleanup_failed_start(&capturer).await;
return Err(e);
}
Err(_) => {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
self.cleanup_failed_start(&capturer).await;
return Err(AppError::AudioError(
"Timed out waiting for audio capture to start".to_string(),
));
@@ -205,22 +205,27 @@ impl AudioStreamer {
let opus_subscribers = self.opus_subscribers.clone();
let state = self.state.clone();
let stop_flag = self.stop_flag.clone();
let shutdown_rx = self.shutdown_generation.subscribe();
let _ = self.state.send(AudioStreamState::Running);
tokio::spawn(async move {
let task = tokio::spawn(async move {
Self::stream_task(
capturer_for_task,
encoder,
opus_subscribers,
state,
stop_flag,
shutdown_rx,
)
.await;
});
*self.stream_task.lock().await = Some(task);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
if self.state() == AudioStreamState::Stopped {
return Ok(());
}
@@ -228,10 +233,16 @@ impl AudioStreamer {
info!("Stopping audio stream");
self.stop_flag.store(true, Ordering::SeqCst);
self.shutdown_generation.send_modify(|generation| {
*generation = generation.wrapping_add(1);
});
if let Some(ref capturer) = *self.capturer.read().await {
capturer.stop().await?;
}
if let Some(task) = self.stream_task.lock().await.take() {
let _ = task.await;
}
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
@@ -242,28 +253,26 @@ impl AudioStreamer {
Ok(())
}
async fn cleanup_failed_start(&self, capturer: &AudioCapturer) {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
}
pub fn is_running(&self) -> bool {
self.state() == AudioStreamState::Running
}
async fn fanout_opus(
fn fanout_opus(
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
frame: Arc<OpusFrame>,
) {
let txs: Vec<_> = {
let g = subscribers.lock().unwrap();
if g.is_empty() {
return;
}
g.clone()
};
for tx in &txs {
let _ = tx.send(frame.clone()).await;
}
if txs.iter().any(|tx| tx.is_closed()) {
let mut g = subscribers.lock().unwrap();
g.retain(|tx| !tx.is_closed());
}
let mut subscribers = subscribers.lock().unwrap();
subscribers.retain(|subscriber| match subscriber.try_send(frame.clone()) {
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true,
Err(mpsc::error::TrySendError::Closed(_)) => false,
});
}
async fn stream_task(
@@ -272,9 +281,9 @@ impl AudioStreamer {
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
state: watch::Sender<AudioStreamState>,
stop_flag: Arc<AtomicBool>,
mut shutdown_rx: watch::Receiver<u64>,
) {
let mut pcm_rx = capturer.subscribe();
let _ = state.send(AudioStreamState::Running);
debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)");
@@ -291,8 +300,19 @@ impl AudioStreamer {
break;
}
let recv_result =
tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await;
let recv_result = tokio::select! {
biased;
changed = shutdown_rx.changed() => {
if changed.is_ok() || stop_flag.load(Ordering::Relaxed) {
break;
}
continue;
}
result = tokio::time::timeout(
std::time::Duration::from_secs(2),
pcm_rx.recv(),
) => result,
};
match recv_result {
Ok(Ok(audio_frame)) => {
@@ -316,23 +336,17 @@ impl AudioStreamer {
}
while pending.len() >= OPUS_STEREO_SAMPLES {
let pcm_20ms = Bytes::copy_from_slice(bytemuck::cast_slice(
&pending[..OPUS_STEREO_SAMPLES],
));
pending.drain(..OPUS_STEREO_SAMPLES);
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48_000, 0);
let opus_result = {
let mut enc_guard = encoder.lock().await;
(*enc_guard)
.as_mut()
.map(|enc| enc.encode_frame(&frame_48k))
.map(|enc| enc.encode(&pending[..OPUS_STEREO_SAMPLES]))
};
pending.drain(..OPUS_STEREO_SAMPLES);
match opus_result {
Some(Ok(opus_frame)) => {
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)).await;
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame));
}
Some(Err(e)) => {
error!("Opus encode error: {}", e);
@@ -365,6 +379,7 @@ impl AudioStreamer {
let _ = state.send(AudioStreamState::Stopped);
} else {
opus_subscribers.lock().unwrap().clear();
let _ = capturer.stop().await;
}
info!("Audio stream task ended");
}
@@ -379,6 +394,7 @@ impl Default for AudioStreamer {
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
#[test]
fn test_streamer_config_default() {
@@ -398,4 +414,42 @@ mod tests {
let streamer = AudioStreamer::new();
assert_eq!(streamer.state(), AudioStreamState::Stopped);
}
#[test]
fn slow_subscriber_does_not_block_or_grow_unbounded() {
let streamer = AudioStreamer::new();
let mut receiver = streamer.subscribe_opus();
for sequence in 0..20 {
AudioStreamer::fanout_opus(
&streamer.opus_subscribers,
Arc::new(OpusFrame {
data: Bytes::from_static(&[1]),
duration_ms: 20,
sequence,
}),
);
}
let mut received = 0;
while receiver.try_recv().is_ok() {
received += 1;
}
assert_eq!(received, 4);
}
#[test]
fn closed_subscriber_is_pruned() {
let streamer = AudioStreamer::new();
let receiver = streamer.subscribe_opus();
drop(receiver);
AudioStreamer::fanout_opus(
&streamer.opus_subscribers,
Arc::new(OpusFrame {
data: Bytes::from_static(&[1]),
duration_ms: 20,
sequence: 0,
}),
);
assert_eq!(streamer.subscriber_count(), 0);
}
}

59
src/audio/uac/decoder.rs Normal file
View File

@@ -0,0 +1,59 @@
use audiopus::coder::Decoder;
use audiopus::{Channels, SampleRate};
use crate::error::{AppError, Result};
const CHANNELS: usize = 2;
const MAX_PACKET_BYTES: usize = 1275;
const MAX_SAMPLES_PER_CHANNEL: usize = 5760;
pub struct UacOpusDecoder {
decoder: Decoder,
buffer: Vec<i16>,
}
impl UacOpusDecoder {
pub fn new() -> Result<Self> {
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Stereo)
.map_err(|error| AppError::AudioError(format!("Opus decoder init failed: {error}")))?;
Ok(Self {
decoder,
buffer: vec![0; MAX_SAMPLES_PER_CHANNEL * CHANNELS],
})
}
pub fn decode(&mut self, packet: &[u8]) -> Result<&[i16]> {
if packet.is_empty() || packet.len() > MAX_PACKET_BYTES {
return Err(AppError::BadRequest(format!(
"invalid Opus packet length {}",
packet.len()
)));
}
let frames = self
.decoder
.decode(Some(packet), &mut self.buffer, false)
.map_err(|error| AppError::AudioError(format!("Opus decode failed: {error}")))?;
Ok(&self.buffer[..frames * CHANNELS])
}
}
#[cfg(test)]
mod tests {
use super::*;
use audiopus::coder::Encoder;
use audiopus::Application;
#[test]
fn decode_preserves_all_stereo_samples() {
let encoder =
Encoder::new(SampleRate::Hz48000, Channels::Stereo, Application::Audio).unwrap();
let pcm = vec![0i16; 960 * CHANNELS];
let mut packet = vec![0u8; MAX_PACKET_BYTES];
let packet_len = encoder.encode(&pcm, &mut packet).unwrap();
let mut decoder = UacOpusDecoder::new().unwrap();
let decoded = decoder.decode(&packet[..packet_len]).unwrap();
assert_eq!(decoded.len(), pcm.len());
}
}

9
src/audio/uac/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! Browser-to-USB microphone audio pipeline.
mod decoder;
mod playback;
mod protocol;
pub use decoder::UacOpusDecoder;
pub use playback::{UacPlayback, UacPlaybackConfig, UacPlaybackState, UacSession};
pub use protocol::{parse_audio_packet, UacAudioPacket};

509
src/audio/uac/playback.rs Normal file
View File

@@ -0,0 +1,509 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use alsa::pcm::{Access, Format, Frames, HwParams, State};
use alsa::{Direction, ValueOr, PCM};
use tracing::{info, warn};
use crate::error::{AppError, Result};
const RETRY_BACKOFF: Duration = Duration::from_secs(1);
const PERIOD_FRAMES: Frames = 960;
const BUFFER_FRAMES: Frames = 4_800;
const START_THRESHOLD_PERIODS: Frames = 4;
const SINK_STALL_TIMEOUT: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UacPlaybackState {
Idle,
Waiting,
Active,
Stalled,
}
impl UacPlaybackState {
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Waiting => "waiting",
Self::Active => "active",
Self::Stalled => "stalled",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UacPlaybackConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u16,
}
impl Default for UacPlaybackConfig {
fn default() -> Self {
Self {
device_name: crate::otg::uac::find_uac_pcm_device()
.unwrap_or_else(crate::otg::uac::uac_pcm_device),
sample_rate: 48_000,
channels: 2,
}
}
}
struct PlaybackInner {
config: UacPlaybackConfig,
stopped: AtomicBool,
active_session: Mutex<Option<Arc<Mutex<SessionRuntime>>>>,
}
enum SessionSink {
Closed { retry_at: Option<Instant> },
Probing { pcm: PCM, stalled: bool },
Active { pcm: PCM, last_progress: Instant },
}
impl SessionSink {
fn state(&self) -> UacPlaybackState {
match self {
Self::Closed { retry_at: None } => UacPlaybackState::Waiting,
Self::Closed { retry_at: Some(_) } => UacPlaybackState::Stalled,
Self::Probing { stalled: false, .. } => UacPlaybackState::Waiting,
Self::Probing { stalled: true, .. } => UacPlaybackState::Stalled,
Self::Active { .. } => UacPlaybackState::Active,
}
}
}
struct SessionRuntime {
sink: SessionSink,
}
impl SessionRuntime {
fn new() -> Self {
Self {
sink: SessionSink::Closed { retry_at: None },
}
}
fn state(&self) -> UacPlaybackState {
self.sink.state()
}
fn close(&mut self) {
self.sink = SessionSink::Closed { retry_at: None };
}
/// Advance playback only when a WebSocket frame arrives. All ALSA handles
/// are non-blocking, so a slow or absent USB host drops the current frame
/// instead of occupying a worker thread or accumulating stale speech.
fn write(&mut self, config: &UacPlaybackConfig, samples: &[i16]) -> bool {
let sink = std::mem::replace(&mut self.sink, SessionSink::Closed { retry_at: None });
let (next_sink, accepted) = drive_sink(sink, config, samples);
self.sink = next_sink;
accepted
}
}
#[derive(Clone)]
pub struct UacPlayback {
inner: Arc<PlaybackInner>,
}
pub struct UacSession {
playback: UacPlayback,
runtime: Arc<Mutex<SessionRuntime>>,
}
impl UacPlayback {
pub fn start(config: UacPlaybackConfig) -> Result<Self> {
if config.sample_rate != 48_000 || config.channels != 2 {
return Err(AppError::BadRequest(
"UAC playback supports only 48000 Hz stereo".to_string(),
));
}
Ok(Self {
inner: Arc::new(PlaybackInner {
config,
stopped: AtomicBool::new(false),
active_session: Mutex::new(None),
}),
})
}
pub fn acquire_session(&self) -> Result<UacSession> {
let mut active = self.inner.active_session.lock().unwrap();
if self.inner.stopped.load(Ordering::Acquire) {
return Err(AppError::ServiceUnavailable(
"UAC playback is stopping".to_string(),
));
}
if active.is_some() {
return Err(AppError::ServiceUnavailable(
"another UAC microphone session is already active".to_string(),
));
}
let runtime = Arc::new(Mutex::new(SessionRuntime::new()));
*active = Some(Arc::clone(&runtime));
Ok(UacSession {
playback: self.clone(),
runtime,
})
}
/// Stop accepting frames and synchronously close an active ALSA handle.
/// This guarantees configfs may rebuild the UAC function after this call.
pub fn stop(&self) {
if self.inner.stopped.swap(true, Ordering::AcqRel) {
return;
}
let runtime = self.inner.active_session.lock().unwrap().take();
if let Some(runtime) = runtime {
runtime.lock().unwrap().close();
}
}
}
impl UacSession {
pub fn state(&self) -> UacPlaybackState {
self.runtime.lock().unwrap().state()
}
/// Returns whether the frame was accepted and the resulting target state.
pub fn try_write(&self, pcm: &[i16]) -> Result<(bool, UacPlaybackState)> {
let channels = self.playback.inner.config.channels as usize;
if pcm.is_empty() || !pcm.len().is_multiple_of(channels) {
return Err(AppError::BadRequest(
"UAC PCM must contain complete stereo frames".to_string(),
));
}
if self.playback.inner.stopped.load(Ordering::Acquire) {
return Err(AppError::ServiceUnavailable(
"UAC playback has stopped".to_string(),
));
}
let mut runtime = self.runtime.lock().unwrap();
if self.playback.inner.stopped.load(Ordering::Acquire) {
runtime.close();
return Err(AppError::ServiceUnavailable(
"UAC playback has stopped".to_string(),
));
}
let accepted = runtime.write(&self.playback.inner.config, pcm);
Ok((accepted, runtime.state()))
}
}
impl Drop for UacSession {
fn drop(&mut self) {
let mut active = self.playback.inner.active_session.lock().unwrap();
if active
.as_ref()
.is_some_and(|session| Arc::ptr_eq(session, &self.runtime))
{
*active = None;
}
drop(active);
self.runtime.lock().unwrap().close();
}
}
fn drive_sink(
sink: SessionSink,
config: &UacPlaybackConfig,
samples: &[i16],
) -> (SessionSink, bool) {
match sink {
SessionSink::Closed { retry_at } => {
if retry_at.is_some_and(|deadline| Instant::now() < deadline) {
return (SessionSink::Closed { retry_at }, false);
}
match open_pcm(config).and_then(|pcm| {
prime_pcm_with_silence(&pcm, config.channels as usize)?;
Ok(pcm)
}) {
Ok(pcm) => drive_probe(pcm, false, config, samples),
Err(error) => {
warn!("Failed to open UAC playback device; retrying later: {error}");
(
SessionSink::Closed {
retry_at: Some(Instant::now() + RETRY_BACKOFF),
},
false,
)
}
}
}
SessionSink::Probing { pcm, stalled } => drive_probe(pcm, stalled, config, samples),
SessionSink::Active { pcm, last_progress } => {
drive_active(pcm, last_progress, config, samples)
}
}
}
fn drive_probe(
pcm: PCM,
stalled: bool,
config: &UacPlaybackConfig,
samples: &[i16],
) -> (SessionSink, bool) {
match sink_is_consuming(&pcm) {
Ok(false) => (SessionSink::Probing { pcm, stalled }, false),
Ok(true) => {
if let Err(error) = reset_pcm_buffer(&pcm) {
warn!("Failed to activate UAC playback; retrying later: {error}");
return retry_later();
}
info!("UAC target started consuming microphone audio");
drive_active(pcm, Instant::now(), config, samples)
}
Err(error) => {
warn!("Failed to probe UAC playback; retrying later: {error}");
retry_later()
}
}
}
fn drive_active(
pcm: PCM,
last_progress: Instant,
config: &UacPlaybackConfig,
samples: &[i16],
) -> (SessionSink, bool) {
match write_pcm_nonblocking(&pcm, samples, config.channels as usize) {
Ok(WriteOutcome::Progress) => (
SessionSink::Active {
pcm,
last_progress: Instant::now(),
},
true,
),
Ok(WriteOutcome::Recovered) => (
SessionSink::Active {
pcm,
last_progress: Instant::now(),
},
false,
),
Ok(WriteOutcome::Blocked) if last_progress.elapsed() < SINK_STALL_TIMEOUT => {
(SessionSink::Active { pcm, last_progress }, false)
}
Ok(WriteOutcome::Blocked) => {
if let Err(error) = reset_pcm_buffer(&pcm)
.and_then(|_| prime_pcm_with_silence(&pcm, config.channels as usize))
{
warn!("Failed to reset stalled UAC playback: {error}");
return retry_later();
}
info!("UAC target stopped consuming audio; waiting for playback activity");
(SessionSink::Probing { pcm, stalled: true }, false)
}
Err(error) => {
warn!("UAC playback write failed; retrying later: {error}");
retry_later()
}
}
}
fn retry_later() -> (SessionSink, bool) {
(
SessionSink::Closed {
retry_at: Some(Instant::now() + RETRY_BACKOFF),
},
false,
)
}
fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
let pcm = PCM::new(&config.device_name, Direction::Playback, true).map_err(|error| {
AppError::AudioError(format!(
"Failed to open UAC device {}: {error}",
config.device_name
))
})?;
{
let params = HwParams::any(&pcm)
.map_err(|error| AppError::AudioError(format!("UAC HwParams failed: {error}")))?;
params
.set_channels(config.channels as u32)
.and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest))
.and_then(|_| params.set_format(Format::s16()))
.and_then(|_| params.set_access(Access::RWInterleaved))
.and_then(|_| params.set_period_size_near(PERIOD_FRAMES, ValueOr::Nearest))
.and_then(|_| params.set_buffer_size_near(BUFFER_FRAMES))
.and_then(|_| pcm.hw_params(&params))
.map_err(|error| {
AppError::AudioError(format!("Failed to configure UAC playback: {error}"))
})?;
}
let (buffer_frames, period_frames) = pcm.get_params().map_err(|error| {
AppError::AudioError(format!("Failed to read UAC PCM parameters: {error}"))
})?;
{
let params = pcm.sw_params_current().map_err(|error| {
AppError::AudioError(format!("Failed to read UAC SwParams: {error}"))
})?;
let start_threshold =
(period_frames as Frames * START_THRESHOLD_PERIODS).min(buffer_frames as Frames);
params
.set_start_threshold(start_threshold)
.and_then(|_| params.set_avail_min(period_frames as Frames))
.and_then(|_| pcm.sw_params(&params))
.map_err(|error| {
AppError::AudioError(format!("Failed to configure UAC SwParams: {error}"))
})?;
}
pcm.prepare().map_err(|error| {
AppError::AudioError(format!("Failed to prepare UAC playback: {error}"))
})?;
info!(
"UAC playback opened on {} (buffer={} frames, period={} frames)",
config.device_name, buffer_frames, period_frames
);
Ok(pcm)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteOutcome {
Progress,
Blocked,
Recovered,
}
fn write_pcm_nonblocking(pcm: &PCM, samples: &[i16], channels: usize) -> Result<WriteOutcome> {
let total_frames = samples.len() / channels;
match pcm.avail() {
Ok(available) if available < total_frames as Frames => return Ok(WriteOutcome::Blocked),
Ok(_) => {}
Err(error) => {
recover_pcm(pcm, error)?;
return Ok(WriteOutcome::Recovered);
}
}
let io = pcm
.io_i16()
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
match io.writei(samples) {
Ok(0) => Ok(WriteOutcome::Blocked),
Ok(_) => Ok(WriteOutcome::Progress),
Err(error) if error.errno() == libc::EAGAIN => Ok(WriteOutcome::Blocked),
Err(error) => {
recover_pcm(pcm, error)?;
Ok(WriteOutcome::Recovered)
}
}
}
/// Once a full playback buffer gains at least one period of free space, the
/// USB host has enabled the UAC streaming interface and is consuming samples.
fn sink_is_consuming(pcm: &PCM) -> Result<bool> {
if pcm.state() == State::XRun {
return Ok(true);
}
match pcm.avail() {
Ok(available) => Ok(available >= PERIOD_FRAMES),
Err(error) if error.errno() == libc::EPIPE => Ok(true),
Err(error) => Err(AppError::AudioError(format!(
"Failed to query UAC playback availability: {error}"
))),
}
}
fn recover_pcm(pcm: &PCM, error: alsa::Error) -> Result<()> {
let errno = error.errno();
pcm.try_recover(error, true).map_err(|recover_error| {
AppError::AudioError(format!("Failed to recover UAC playback: {recover_error}"))
})?;
if matches!(errno, libc::EPIPE | libc::ESTRPIPE) {
warn!("Recovered UAC playback after ALSA error {errno}");
}
Ok(())
}
fn reset_pcm_buffer(pcm: &PCM) -> Result<()> {
pcm.drop()
.and_then(|_| pcm.prepare())
.map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))
}
/// Prime the non-blocking ALSA buffer with silence. Subsequent WebSocket
/// frames inspect buffer progress to detect when the USB host starts reading.
fn prime_pcm_with_silence(pcm: &PCM, channels: usize) -> Result<()> {
let silence = vec![0i16; BUFFER_FRAMES as usize * channels];
let io = pcm
.io_i16()
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
let mut frame_offset = 0usize;
while frame_offset < BUFFER_FRAMES as usize {
match io.writei(&silence[frame_offset * channels..]) {
Ok(0) => break,
Ok(written) => frame_offset += written,
Err(error) if error.errno() == libc::EAGAIN => break,
Err(error) => {
return Err(AppError::AudioError(format!(
"Failed to prime UAC PCM with silence: {error}"
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permits_only_one_microphone_session() {
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
let first = playback.acquire_session().unwrap();
assert_eq!(first.state(), UacPlaybackState::Waiting);
assert!(playback.acquire_session().is_err());
drop(first);
assert!(playback.acquire_session().is_ok());
playback.stop();
}
#[test]
fn stop_rejects_new_and_existing_session_writes() {
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
let session = playback.acquire_session().unwrap();
playback.stop();
assert!(session.try_write(&[0, 0]).is_err());
assert!(playback.acquire_session().is_err());
}
#[test]
fn rejects_incomplete_stereo_frames_before_opening_alsa() {
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
let session = playback.acquire_session().unwrap();
assert!(session.try_write(&[0]).is_err());
assert_eq!(session.state(), UacPlaybackState::Waiting);
}
#[test]
fn closed_sink_state_reflects_retry_backoff() {
assert_eq!(
SessionSink::Closed { retry_at: None }.state(),
UacPlaybackState::Waiting
);
assert_eq!(
SessionSink::Closed {
retry_at: Some(Instant::now())
}
.state(),
UacPlaybackState::Stalled
);
}
}

98
src/audio/uac/protocol.rs Normal file
View File

@@ -0,0 +1,98 @@
use crate::error::{AppError, Result};
const HEADER_SIZE: usize = 15;
const OPUS_MESSAGE: u8 = 0x03;
const PCM_MESSAGE: u8 = 0x04;
const CHANNELS: usize = 2;
const MAX_PCM_SAMPLES: usize = 5760 * CHANNELS;
#[derive(Debug, PartialEq, Eq)]
pub enum UacAudioPacket<'a> {
Opus(&'a [u8]),
Pcm(&'a [u8]),
}
impl UacAudioPacket<'_> {
pub fn pcm_samples(&self) -> Result<Vec<i16>> {
let Self::Pcm(bytes) = self else {
return Err(AppError::BadRequest("packet is not raw PCM".to_string()));
};
if bytes.is_empty() || bytes.len() % (CHANNELS * 2) != 0 {
return Err(AppError::BadRequest(format!(
"invalid stereo PCM byte length {}",
bytes.len()
)));
}
if bytes.len() / 2 > MAX_PCM_SAMPLES {
return Err(AppError::BadRequest("PCM frame exceeds 120 ms".to_string()));
}
Ok(bytes
.chunks_exact(2)
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
.collect())
}
}
pub fn parse_audio_packet(data: &[u8]) -> Result<UacAudioPacket<'_>> {
if data.len() < HEADER_SIZE {
return Err(AppError::BadRequest(
"UAC frame is shorter than its header".to_string(),
));
}
let payload_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
let expected_len = HEADER_SIZE
.checked_add(payload_len)
.ok_or_else(|| AppError::BadRequest("UAC payload length overflow".to_string()))?;
if data.len() != expected_len {
return Err(AppError::BadRequest(
"UAC payload length does not match its header".to_string(),
));
}
let payload = &data[HEADER_SIZE..];
match data[0] {
OPUS_MESSAGE => Ok(UacAudioPacket::Opus(payload)),
PCM_MESSAGE => Ok(UacAudioPacket::Pcm(payload)),
message_type => Err(AppError::BadRequest(format!(
"unsupported UAC message type 0x{message_type:02x}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn message(message_type: u8, payload: &[u8]) -> Vec<u8> {
let mut data = vec![0; HEADER_SIZE + payload.len()];
data[0] = message_type;
data[11..15].copy_from_slice(&(payload.len() as u32).to_le_bytes());
data[HEADER_SIZE..].copy_from_slice(payload);
data
}
#[test]
fn requires_exact_payload_length() {
let valid = message(OPUS_MESSAGE, &[1, 2, 3]);
assert_eq!(
parse_audio_packet(&valid).unwrap(),
UacAudioPacket::Opus(&[1, 2, 3])
);
let mut trailing = valid.clone();
trailing.push(4);
assert!(parse_audio_packet(&trailing).is_err());
assert!(parse_audio_packet(&valid[..valid.len() - 1]).is_err());
}
#[test]
fn converts_little_endian_stereo_pcm() {
let data = message(PCM_MESSAGE, &[1, 0, 255, 255]);
assert_eq!(
parse_audio_packet(&data).unwrap().pcm_samples().unwrap(),
vec![1, -1]
);
}
}

View File

@@ -71,6 +71,7 @@ pub async fn auth_middleware(
fn unauthorized_response(message: &str) -> Response {
let body = ErrorResponse {
success: false,
code: None,
message: message.to_string(),
};
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
@@ -92,6 +93,12 @@ fn is_public_endpoint(path: &str) -> bool {
fn is_setup_public_endpoint(path: &str) -> bool {
matches!(
path,
"/setup" | "/setup/init" | "/devices" | "/stream/codecs"
"/setup"
| "/setup/init"
| "/devices"
| "/hid/bluetooth/adapters"
| "/video/input-status"
| "/stream/codecs"
| "/video/codecs"
)
}

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ mod computer_use;
mod hid;
mod otg_network;
mod stream;
mod uac;
mod watchdog;
mod web;
@@ -19,6 +20,7 @@ pub use computer_use::*;
pub use hid::*;
pub use otg_network::*;
pub use stream::*;
pub use uac::*;
pub use watchdog::*;
pub use web::*;
@@ -44,6 +46,7 @@ pub struct AppConfig {
pub rtsp: RtspConfig,
pub redfish: RedfishConfig,
pub watchdog: WatchdogConfig,
pub uac: UacConfig,
}
impl AppConfig {
@@ -51,6 +54,10 @@ impl AppConfig {
if self.hid.backend != HidBackend::Otg {
self.msd.enabled = false;
self.otg_network.enabled = false;
self.uac.enabled = false;
}
if self.hid.backend == HidBackend::Bluetooth {
self.hid.mouse_absolute = false;
}
self.atx.normalize();
}

View File

@@ -102,6 +102,7 @@ pub enum EncoderType {
Qsv,
Amf,
Rkmpp,
#[serde(alias = "amlogic")]
V4l2m2m,
}

78
src/config/schema/uac.rs Normal file
View File

@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::error::{AppError, Result};
/// Configuration for the USB Audio Class microphone gadget.
#[typeshare]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct UacConfig {
pub enabled: bool,
pub sample_rate: u32,
pub channels: u8,
}
impl Default for UacConfig {
fn default() -> Self {
Self {
enabled: false,
sample_rate: 48_000,
channels: 2,
}
}
}
impl UacConfig {
pub fn validate(&self) -> Result<()> {
// Older configurations stored zero-valued placeholders while UAC was
// disabled. Accept them until the feature is enabled and normalized.
if !self.enabled {
return Ok(());
}
if self.sample_rate != 48_000 {
return Err(AppError::BadRequest(format!(
"unsupported UAC sample rate {} (expected 48000)",
self.sample_rate
)));
}
if self.channels != 2 {
return Err(AppError::BadRequest(format!(
"unsupported UAC channel count {} (expected 2)",
self.channels
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_audio_transport() {
let config = UacConfig::default();
assert_eq!(config.sample_rate, 48_000);
assert_eq!(config.channels, 2);
assert!(config.validate().is_ok());
}
#[test]
fn rejects_formats_the_transport_cannot_convert() {
assert!(UacConfig {
enabled: true,
sample_rate: 44_100,
..Default::default()
}
.validate()
.is_err());
assert!(UacConfig {
enabled: true,
channels: 1,
..Default::default()
}
.validate()
.is_err());
}
}

View File

@@ -44,23 +44,56 @@ impl Default for VideoConfig {
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct MsdConfig {
pub enabled: bool,
pub msd_dir: String,
pub flash_inquiry_string: String,
pub cdrom_inquiry_string: String,
}
pub const DEFAULT_FLASH_INQUIRY_STRING: &str = "One-KVM Virtual Flash";
pub const DEFAULT_CDROM_INQUIRY_STRING: &str = "One-KVM Virtual CD-ROM";
pub const MAX_INQUIRY_STRING_BYTES: usize = 28;
impl Default for MsdConfig {
fn default() -> Self {
Self {
enabled: true,
msd_dir: String::new(),
flash_inquiry_string: DEFAULT_FLASH_INQUIRY_STRING.to_string(),
cdrom_inquiry_string: DEFAULT_CDROM_INQUIRY_STRING.to_string(),
}
}
}
impl MsdConfig {
pub fn validate(&self) -> crate::error::Result<()> {
Self::validate_inquiry_string("Flash", &self.flash_inquiry_string)?;
Self::validate_inquiry_string("CD-ROM", &self.cdrom_inquiry_string)
}
pub fn validate_inquiry_string(kind: &str, value: &str) -> crate::error::Result<()> {
let value = value.trim();
if value.is_empty() {
return Err(crate::error::AppError::BadRequest(format!(
"MSD {kind} inquiry string cannot be empty"
)));
}
if value.len() > MAX_INQUIRY_STRING_BYTES {
return Err(crate::error::AppError::BadRequest(format!(
"MSD {kind} inquiry string must be at most {MAX_INQUIRY_STRING_BYTES} bytes"
)));
}
if !value.bytes().all(|byte| (0x20..=0x7e).contains(&byte)) {
return Err(crate::error::AppError::BadRequest(format!(
"MSD {kind} inquiry string must contain printable ASCII characters only"
)));
}
Ok(())
}
pub fn msd_dir_path(&self) -> std::path::PathBuf {
std::path::PathBuf::from(&self.msd_dir)
}
@@ -123,3 +156,18 @@ impl Default for WebConfig {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn msd_inquiry_strings_default_and_validate() {
assert!(MsdConfig::default().validate().is_ok());
assert!(MsdConfig::validate_inquiry_string("Flash", " Custom Drive ").is_ok());
assert!(MsdConfig::validate_inquiry_string("Flash", "").is_err());
assert!(MsdConfig::validate_inquiry_string("Flash", &"x".repeat(29)).is_err());
assert!(MsdConfig::validate_inquiry_string("CD-ROM", "虚拟光驱").is_err());
assert!(MsdConfig::validate_inquiry_string("CD-ROM", "bad\tname").is_err());
}
}

View File

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

80
src/db/hid_bonds.rs Normal file
View File

@@ -0,0 +1,80 @@
use one_kvm_bluetooth_hid::bonds::{Bond, BondStore, Operation};
use sqlx::{Pool, Sqlite};
#[derive(Clone)]
pub struct HidBondStore(pub Pool<Sqlite>);
impl BondStore for HidBondStore {
fn list(&self) -> Operation<'_, Vec<Bond>> {
Box::pin(async move {
let rows: Vec<(String, String, bool)> = sqlx::query_as(
"SELECT adapter, peer, pending FROM hid_bonds ORDER BY adapter, peer",
)
.fetch_all(&self.0)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|(adapter, peer, pending)| Bond {
adapter,
peer,
pending,
})
.collect())
})
}
fn save(&self, bond: Bond) -> Operation<'_, ()> {
Box::pin(async move {
sqlx::query("INSERT INTO hid_bonds(adapter, peer, pending) VALUES (?, ?, ?) ON CONFLICT(adapter, peer) DO UPDATE SET pending = MAX(pending, excluded.pending)")
.bind(bond.adapter.to_ascii_uppercase()).bind(bond.peer.to_ascii_uppercase()).bind(bond.pending)
.execute(&self.0).await.map_err(|e| e.to_string())?;
Ok(())
})
}
fn remove(&self, bond: Bond) -> Operation<'_, ()> {
Box::pin(async move {
sqlx::query("DELETE FROM hid_bonds WHERE adapter = ? AND peer = ?")
.bind(bond.adapter)
.bind(bond.peer)
.execute(&self.0)
.await
.map_err(|e| e.to_string())?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ownership_and_pending_cleanup_survive_reopen() {
let dir = tempfile::tempdir().unwrap();
let db = super::super::open_database_pool(dir.path()).await.unwrap();
let store = HidBondStore(db.clone_pool());
let bond = Bond {
adapter: "AA:BB:CC:DD:EE:FF".into(),
peer: "11:22:33:44:55:66".into(),
pending: false,
};
store.save(bond.clone()).await.unwrap();
store
.save(Bond {
pending: true,
..bond.clone()
})
.await
.unwrap();
store.save(bond.clone()).await.unwrap(); // Late status cannot undo a pending reset.
let reopened = super::super::open_database_pool(dir.path()).await.unwrap();
let records = HidBondStore(reopened.clone_pool()).list().await.unwrap();
assert_eq!(
records,
vec![Bond {
pending: true,
..bond.clone()
}]
);
store.remove(bond).await.unwrap();
assert!(store.list().await.unwrap().is_empty());
}
}

View File

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

View File

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

39
src/db/wol_history.rs Normal file
View File

@@ -0,0 +1,39 @@
use sqlx::{Pool, Sqlite};
use crate::error::Result;
const MAX_ENTRIES: i64 = 50;
#[derive(Clone)]
pub struct WolHistoryStore {
pool: Pool<Sqlite>,
}
impl WolHistoryStore {
pub(crate) fn new(pool: Pool<Sqlite>) -> Self {
Self { pool }
}
pub async fn record(&self, mac_address: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("INSERT INTO wol_history (mac_address, updated_at) VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER)) ON CONFLICT(mac_address) DO UPDATE SET updated_at = excluded.updated_at")
.bind(mac_address)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM wol_history WHERE mac_address NOT IN (SELECT mac_address FROM wol_history ORDER BY updated_at DESC LIMIT ?1)")
.bind(MAX_ENTRIES)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn list(&self, limit: usize) -> Result<Vec<(String, i64)>> {
Ok(sqlx::query_as(
"SELECT mac_address, updated_at FROM wol_history ORDER BY updated_at DESC LIMIT ?1",
)
.bind(limit as i64)
.fetch_all(&self.pool)
.await?)
}
}

View File

@@ -1,5 +1,238 @@
use serde::Serialize;
use std::fmt;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MsdErrorCode {
MsdUnavailable,
MsdOperationInProgress,
MsdOperationFailed,
MsdInvalidRequest,
MsdResourceNotFound,
MsdResourceAlreadyExists,
MsdMediaSlotsFull,
MsdMediaAlreadyMounted,
MsdMediaInUse,
MsdImageTooLarge,
MsdInvalidUrl,
MsdRemoteDownloadFailed,
MsdDownloadIncomplete,
MsdDriveNotInitialized,
MsdDriveConnected,
MsdDriveFilesystemUnsupported,
MsdDriveSizeInvalid,
MsdStorageSpaceUnavailable,
MsdStorageFull,
MsdStorageReadOnly,
MsdStoragePermissionDenied,
MsdMediumRemovalPrevented,
MsdDisconnectFailed,
}
impl MsdErrorCode {
pub const ALL: [Self; 23] = [
Self::MsdUnavailable,
Self::MsdOperationInProgress,
Self::MsdOperationFailed,
Self::MsdInvalidRequest,
Self::MsdResourceNotFound,
Self::MsdResourceAlreadyExists,
Self::MsdMediaSlotsFull,
Self::MsdMediaAlreadyMounted,
Self::MsdMediaInUse,
Self::MsdImageTooLarge,
Self::MsdInvalidUrl,
Self::MsdRemoteDownloadFailed,
Self::MsdDownloadIncomplete,
Self::MsdDriveNotInitialized,
Self::MsdDriveConnected,
Self::MsdDriveFilesystemUnsupported,
Self::MsdDriveSizeInvalid,
Self::MsdStorageSpaceUnavailable,
Self::MsdStorageFull,
Self::MsdStorageReadOnly,
Self::MsdStoragePermissionDenied,
Self::MsdMediumRemovalPrevented,
Self::MsdDisconnectFailed,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::MsdUnavailable => "MSD_UNAVAILABLE",
Self::MsdOperationInProgress => "MSD_OPERATION_IN_PROGRESS",
Self::MsdOperationFailed => "MSD_OPERATION_FAILED",
Self::MsdInvalidRequest => "MSD_INVALID_REQUEST",
Self::MsdResourceNotFound => "MSD_RESOURCE_NOT_FOUND",
Self::MsdResourceAlreadyExists => "MSD_RESOURCE_ALREADY_EXISTS",
Self::MsdMediaSlotsFull => "MSD_MEDIA_SLOTS_FULL",
Self::MsdMediaAlreadyMounted => "MSD_MEDIA_ALREADY_MOUNTED",
Self::MsdMediaInUse => "MSD_MEDIA_IN_USE",
Self::MsdImageTooLarge => "MSD_IMAGE_TOO_LARGE",
Self::MsdInvalidUrl => "MSD_INVALID_URL",
Self::MsdRemoteDownloadFailed => "MSD_REMOTE_DOWNLOAD_FAILED",
Self::MsdDownloadIncomplete => "MSD_DOWNLOAD_INCOMPLETE",
Self::MsdDriveNotInitialized => "MSD_DRIVE_NOT_INITIALIZED",
Self::MsdDriveConnected => "MSD_DRIVE_CONNECTED",
Self::MsdDriveFilesystemUnsupported => "MSD_DRIVE_FILESYSTEM_UNSUPPORTED",
Self::MsdDriveSizeInvalid => "MSD_DRIVE_SIZE_INVALID",
Self::MsdStorageSpaceUnavailable => "MSD_STORAGE_SPACE_UNAVAILABLE",
Self::MsdStorageFull => "MSD_STORAGE_FULL",
Self::MsdStorageReadOnly => "MSD_STORAGE_READ_ONLY",
Self::MsdStoragePermissionDenied => "MSD_STORAGE_PERMISSION_DENIED",
Self::MsdMediumRemovalPrevented => "MSD_MEDIUM_REMOVAL_PREVENTED",
Self::MsdDisconnectFailed => "MSD_DISCONNECT_FAILED",
}
}
pub const fn message(self) -> &'static str {
match self {
Self::MsdUnavailable => "Virtual media service is unavailable.",
Self::MsdOperationInProgress => "Another virtual media operation is in progress.",
Self::MsdOperationFailed => "The virtual media operation failed.",
Self::MsdInvalidRequest => "The virtual media request is invalid.",
Self::MsdResourceNotFound => "The requested virtual media resource was not found.",
Self::MsdResourceAlreadyExists => "The virtual media resource already exists.",
Self::MsdMediaSlotsFull => "All virtual media slots are in use.",
Self::MsdMediaAlreadyMounted => "The virtual medium is already mounted.",
Self::MsdMediaInUse => "The virtual medium is currently in use.",
Self::MsdImageTooLarge => "The virtual media image is too large.",
Self::MsdInvalidUrl => "The download URL is invalid.",
Self::MsdRemoteDownloadFailed => "The remote image download failed.",
Self::MsdDownloadIncomplete => "The remote image download was incomplete.",
Self::MsdDriveNotInitialized => "The virtual drive is not initialized.",
Self::MsdDriveConnected => "The virtual drive is connected to the controlled computer.",
Self::MsdDriveFilesystemUnsupported => {
"Web file management does not support this virtual drive format."
}
Self::MsdDriveSizeInvalid => "The virtual drive size is invalid.",
Self::MsdStorageSpaceUnavailable => {
"Available virtual media storage space could not be determined."
}
Self::MsdStorageFull => "Virtual media storage does not have enough free space.",
Self::MsdStorageReadOnly => "Virtual media storage is read-only.",
Self::MsdStoragePermissionDenied => {
"Permission to access virtual media storage was denied."
}
Self::MsdMediumRemovalPrevented => {
"The controlled computer prevented removal of the virtual medium."
}
Self::MsdDisconnectFailed => "The virtual medium could not be disconnected.",
}
}
pub const fn redfish_key(self) -> &'static str {
match self {
Self::MsdUnavailable => "MsdUnavailable",
Self::MsdOperationInProgress => "MsdOperationInProgress",
Self::MsdOperationFailed => "MsdOperationFailed",
Self::MsdInvalidRequest => "MsdInvalidRequest",
Self::MsdResourceNotFound => "MsdResourceNotFound",
Self::MsdResourceAlreadyExists => "MsdResourceAlreadyExists",
Self::MsdMediaSlotsFull => "MsdMediaSlotsFull",
Self::MsdMediaAlreadyMounted => "MsdMediaAlreadyMounted",
Self::MsdMediaInUse => "MsdMediaInUse",
Self::MsdImageTooLarge => "MsdImageTooLarge",
Self::MsdInvalidUrl => "MsdInvalidUrl",
Self::MsdRemoteDownloadFailed => "MsdRemoteDownloadFailed",
Self::MsdDownloadIncomplete => "MsdDownloadIncomplete",
Self::MsdDriveNotInitialized => "MsdDriveNotInitialized",
Self::MsdDriveConnected => "MsdDriveConnected",
Self::MsdDriveFilesystemUnsupported => "MsdDriveFilesystemUnsupported",
Self::MsdDriveSizeInvalid => "MsdDriveSizeInvalid",
Self::MsdStorageSpaceUnavailable => "MsdStorageSpaceUnavailable",
Self::MsdStorageFull => "MsdStorageFull",
Self::MsdStorageReadOnly => "MsdStorageReadOnly",
Self::MsdStoragePermissionDenied => "MsdStoragePermissionDenied",
Self::MsdMediumRemovalPrevented => "MsdMediumRemovalPrevented",
Self::MsdDisconnectFailed => "MsdDisconnectFailed",
}
}
pub const fn severity(self) -> &'static str {
match self {
Self::MsdUnavailable | Self::MsdOperationFailed | Self::MsdDisconnectFailed => {
"Critical"
}
_ => "Warning",
}
}
pub const fn resolution(self) -> &'static str {
match self {
Self::MsdUnavailable => "Enable or restore the virtual media service, then retry.",
Self::MsdOperationInProgress => {
"Wait for the current virtual media operation to finish, then retry."
}
Self::MsdResourceNotFound | Self::MsdDriveNotInitialized => {
"Verify that the requested virtual media resource exists, then retry."
}
Self::MsdResourceAlreadyExists => {
"Use a different resource name or remove the existing resource, then retry."
}
Self::MsdMediaSlotsFull => "Eject an inserted virtual medium, then retry.",
Self::MsdMediaAlreadyMounted => {
"Eject the existing virtual medium before mounting it again."
}
Self::MsdMediaInUse | Self::MsdDriveConnected | Self::MsdMediumRemovalPrevented => {
"Eject or unmount the virtual medium on the controlled computer, then retry."
}
Self::MsdImageTooLarge | Self::MsdDriveSizeInvalid => {
"Use a supported image or virtual drive size, then retry."
}
Self::MsdInvalidUrl | Self::MsdInvalidRequest => "Correct the request and retry.",
Self::MsdRemoteDownloadFailed | Self::MsdDownloadIncomplete => {
"Verify the remote server and network connection, then retry."
}
Self::MsdDriveFilesystemUnsupported => {
"Mount the drive on the controlled computer, or use a supported format for web file management."
}
Self::MsdStorageSpaceUnavailable => {
"Verify that virtual media storage is available, then retry."
}
Self::MsdStorageFull => {
"Free space in virtual media storage or select a smaller image, then retry."
}
Self::MsdStorageReadOnly => "Make virtual media storage writable, then retry.",
Self::MsdStoragePermissionDenied => {
"Correct virtual media storage permissions, then retry."
}
Self::MsdOperationFailed | Self::MsdDisconnectFailed => {
"Retry the operation. If the problem persists, check the One-KVM system logs."
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MsdError {
code: MsdErrorCode,
}
impl MsdError {
pub const fn new(code: MsdErrorCode) -> Self {
Self { code }
}
pub const fn code(self) -> MsdErrorCode {
self.code
}
}
impl fmt::Display for MsdError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.code.message())
}
}
impl std::error::Error for MsdError {}
impl From<MsdErrorCode> for AppError {
fn from(code: MsdErrorCode) -> Self {
Self::Msd(MsdError::new(code))
}
}
#[derive(Error, Debug)]
pub enum AppError {
#[error("Authentication failed: {0}")]
@@ -26,6 +259,9 @@ pub enum AppError {
#[error("Internal error: {0}")]
Internal(String),
#[error(transparent)]
Msd(#[from] MsdError),
#[error("Configuration error: {0}")]
Config(String),
@@ -66,3 +302,135 @@ impl From<sqlx::Error> for AppError {
AppError::Persistence(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::MsdErrorCode::*;
#[test]
fn msd_codes_and_messages_are_stable() {
let cases = [
(
MsdUnavailable,
"MSD_UNAVAILABLE",
"Virtual media service is unavailable.",
),
(
MsdOperationInProgress,
"MSD_OPERATION_IN_PROGRESS",
"Another virtual media operation is in progress.",
),
(
MsdOperationFailed,
"MSD_OPERATION_FAILED",
"The virtual media operation failed.",
),
(
MsdInvalidRequest,
"MSD_INVALID_REQUEST",
"The virtual media request is invalid.",
),
(
MsdResourceNotFound,
"MSD_RESOURCE_NOT_FOUND",
"The requested virtual media resource was not found.",
),
(
MsdResourceAlreadyExists,
"MSD_RESOURCE_ALREADY_EXISTS",
"The virtual media resource already exists.",
),
(
MsdMediaSlotsFull,
"MSD_MEDIA_SLOTS_FULL",
"All virtual media slots are in use.",
),
(
MsdMediaAlreadyMounted,
"MSD_MEDIA_ALREADY_MOUNTED",
"The virtual medium is already mounted.",
),
(
MsdMediaInUse,
"MSD_MEDIA_IN_USE",
"The virtual medium is currently in use.",
),
(
MsdImageTooLarge,
"MSD_IMAGE_TOO_LARGE",
"The virtual media image is too large.",
),
(
MsdInvalidUrl,
"MSD_INVALID_URL",
"The download URL is invalid.",
),
(
MsdRemoteDownloadFailed,
"MSD_REMOTE_DOWNLOAD_FAILED",
"The remote image download failed.",
),
(
MsdDownloadIncomplete,
"MSD_DOWNLOAD_INCOMPLETE",
"The remote image download was incomplete.",
),
(
MsdDriveNotInitialized,
"MSD_DRIVE_NOT_INITIALIZED",
"The virtual drive is not initialized.",
),
(
MsdDriveConnected,
"MSD_DRIVE_CONNECTED",
"The virtual drive is connected to the controlled computer.",
),
(
MsdDriveFilesystemUnsupported,
"MSD_DRIVE_FILESYSTEM_UNSUPPORTED",
"Web file management does not support this virtual drive format.",
),
(
MsdDriveSizeInvalid,
"MSD_DRIVE_SIZE_INVALID",
"The virtual drive size is invalid.",
),
(
MsdStorageSpaceUnavailable,
"MSD_STORAGE_SPACE_UNAVAILABLE",
"Available virtual media storage space could not be determined.",
),
(
MsdStorageFull,
"MSD_STORAGE_FULL",
"Virtual media storage does not have enough free space.",
),
(
MsdStorageReadOnly,
"MSD_STORAGE_READ_ONLY",
"Virtual media storage is read-only.",
),
(
MsdStoragePermissionDenied,
"MSD_STORAGE_PERMISSION_DENIED",
"Permission to access virtual media storage was denied.",
),
(
MsdMediumRemovalPrevented,
"MSD_MEDIUM_REMOVAL_PREVENTED",
"The controlled computer prevented removal of the virtual medium.",
),
(
MsdDisconnectFailed,
"MSD_DISCONNECT_FAILED",
"The virtual medium could not be disconnected.",
),
];
assert_eq!(cases.len(), super::MsdErrorCode::ALL.len());
for (code, expected_code, expected_message) in cases {
assert_eq!(code.as_str(), expected_code);
assert_eq!(code.message(), expected_message);
}
}
}

View File

@@ -6,9 +6,10 @@ use self::types::EXACT_EVENT_TOPICS;
pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
MsdDeviceMediaInfo, StreamKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
};
use std::sync::RwLock;
use tokio::sync::broadcast;
const EVENT_CHANNEL_CAPACITY: usize = 256;
@@ -40,6 +41,7 @@ pub struct EventBus {
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
prefix_topics: std::collections::HashMap<String, broadcast::Sender<SystemEvent>>,
device_info_dirty_tx: broadcast::Sender<()>,
latest_video_stream_state: RwLock<Option<SystemEvent>>,
}
impl EventBus {
@@ -60,12 +62,26 @@ impl EventBus {
exact_topics,
prefix_topics,
device_info_dirty_tx,
latest_video_stream_state: RwLock::new(None),
}
}
pub fn publish(&self, event: SystemEvent) {
let event_name = event.event_name();
if matches!(
event,
SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
..
}
) {
*self
.latest_video_stream_state
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(event.clone());
}
if let Some(tx) = self.exact_topics.get(event_name) {
let _ = tx.send(event.clone());
}
@@ -103,6 +119,15 @@ impl EventBus {
self.device_info_dirty_tx.subscribe()
}
/// Stateful video status topics replay this value to new WebSocket
/// subscribers so a page refresh cannot miss an earlier signal-loss edge.
pub fn latest_video_stream_state(&self) -> Option<SystemEvent> {
self.latest_video_stream_state
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub fn subscriber_count(&self) -> usize {
self.tx.receiver_count()
}
@@ -124,6 +149,7 @@ mod tests {
let mut rx = bus.subscribe();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
@@ -132,6 +158,10 @@ mod tests {
let event = rx.recv().await.unwrap();
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
assert!(matches!(
bus.latest_video_stream_state(),
Some(SystemEvent::StreamStateChanged { state, .. }) if state == "streaming"
));
}
#[tokio::test]
@@ -143,6 +173,7 @@ mod tests {
assert_eq!(bus.subscriber_count(), 2);
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
@@ -162,6 +193,7 @@ mod tests {
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: None,
reason: None,
@@ -178,6 +210,7 @@ mod tests {
let mut rx = bus.subscribe_topic("stream.*").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: None,
reason: None,
@@ -200,10 +233,36 @@ mod tests {
assert_eq!(bus.subscriber_count(), 0);
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: None,
reason: None,
next_retry_ms: None,
});
}
#[test]
fn audio_state_does_not_replace_latest_video_state() {
let bus = EventBus::new();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "no_signal".to_string(),
device: Some("/dev/video0".to_string()),
reason: Some("no_sync".to_string()),
next_retry_ms: Some(500),
});
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Audio,
state: "streaming".to_string(),
device: Some("hw:0,0".to_string()),
reason: None,
next_retry_ms: None,
});
assert!(matches!(
bus.latest_video_stream_state(),
Some(SystemEvent::StreamStateChanged { state, reason, .. })
if state == "no_signal" && reason.as_deref() == Some("no_sync")
));
}
}

View File

@@ -92,10 +92,10 @@ pub struct ClientStats {
pub connected_secs: u64,
}
/// Video vs audio source for [`SystemEvent::StreamDeviceLost`] (WebSocket `stream.device_lost`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
/// Media subsystem that owns a stream state or device event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamDeviceLostKind {
pub enum StreamKind {
Video,
Audio,
}
@@ -114,6 +114,7 @@ pub enum SystemEvent {
#[serde(rename = "stream.state_changed")]
StreamStateChanged {
kind: StreamKind,
state: String,
device: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -141,7 +142,7 @@ pub enum SystemEvent {
#[serde(rename = "stream.device_lost")]
StreamDeviceLost {
kind: StreamDeviceLostKind,
kind: StreamKind,
device: String,
reason: String,
},
@@ -204,6 +205,8 @@ pub enum SystemEvent {
total_bytes: Option<u64>,
progress_pct: Option<f32>,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
error_code: Option<String>,
},
#[serde(rename = "system.device_info")]
@@ -272,6 +275,7 @@ mod tests {
#[test]
fn test_event_name() {
let event = SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
@@ -283,7 +287,7 @@ mod tests {
#[test]
fn stream_device_lost_json_snake_case_kind() {
let event = SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
kind: StreamKind::Audio,
device: "hw:0,0".to_string(),
reason: "test".to_string(),
};
@@ -304,6 +308,7 @@ mod tests {
from_mode: String::new(),
},
SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: String::new(),
device: None,
reason: None,
@@ -321,7 +326,7 @@ mod tests {
fps: 0,
},
SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
kind: StreamKind::Video,
device: String::new(),
reason: String::new(),
},
@@ -372,6 +377,7 @@ mod tests {
total_bytes: None,
progress_pct: None,
status: String::new(),
error_code: None,
},
SystemEvent::DeviceInfo {
video: VideoDeviceInfo {

View File

@@ -1,15 +1,15 @@
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::RwLock;
use toml_edit::DocumentMut;
use super::protected_config::ProtectedConfigFile;
use super::types::*;
use super::validation::{validate_easytier_config, validate_frpc_config, validate_gostc_config};
use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200;
@@ -27,12 +27,12 @@ const TTYD_TCP_PORT: &str = "7681";
struct ExtensionProcess {
child: Child,
logs: Arc<RwLock<VecDeque<String>>>,
_temp_dir: Option<TempDir>,
_config_file: Option<ProtectedConfigFile>,
}
struct ExtensionLaunch {
args: Vec<String>,
temp_dir: Option<TempDir>,
config_file: Option<ProtectedConfigFile>,
}
pub struct ExtensionManager {
@@ -83,24 +83,12 @@ impl ExtensionManager {
match id {
ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => {
config.gostc.enabled
&& !config.gostc.key.is_empty()
&& !config.gostc.addr.trim().is_empty()
config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok()
}
ExtensionId::Easytier => {
config.easytier.enabled && !config.easytier.network_name.is_empty()
}
ExtensionId::Frpc => {
config.frpc.enabled
&& match config.frpc.config_mode {
FrpcConfigMode::Quick => {
!config.frpc.proxy_name.trim().is_empty()
&& !config.frpc.server_addr.trim().is_empty()
&& !config.frpc.token.is_empty()
}
FrpcConfigMode::Full => !config.frpc.custom_toml.trim().is_empty(),
}
config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok()
}
ExtensionId::Frpc => config.frpc.enabled && validate_frpc_config(&config.frpc).is_ok(),
}
}
@@ -203,7 +191,7 @@ impl ExtensionManager {
ExtensionProcess {
child,
logs,
_temp_dir: launch.temp_dir,
_config_file: launch.config_file,
},
);
drop(processes);
@@ -286,12 +274,7 @@ impl ExtensionManager {
ExtensionId::Gostc => {
let c = &config.gostc;
if c.addr.trim().is_empty() {
return Err("GOSTC server address is required".into());
}
if c.key.is_empty() {
return Err("GOSTC client key is required".into());
}
validate_gostc_config(c)?;
let mut args = Vec::new();
@@ -307,35 +290,7 @@ impl ExtensionManager {
}
ExtensionId::Easytier => {
let c = &config.easytier;
if c.network_name.is_empty() {
return Err("EasyTier network name is required".into());
}
let mut args = vec![
"--network-name".to_string(),
c.network_name.clone(),
"--network-secret".to_string(),
c.network_secret.clone(),
];
for peer in &c.peer_urls {
if !peer.is_empty() {
args.extend(["--peers".to_string(), peer.clone()]);
}
}
if let Some(ref ip) = c.virtual_ip {
if !ip.is_empty() {
args.extend(["-i".to_string(), ip.clone()]);
} else {
args.push("-d".to_string());
}
} else {
args.push("-d".to_string());
}
args
return Self::build_easytier_launch(&config.easytier).await;
}
ExtensionId::Frpc => {
@@ -345,58 +300,78 @@ impl ExtensionManager {
Ok(ExtensionLaunch {
args,
temp_dir: None,
config_file: None,
})
}
async fn build_easytier_launch(config: &EasytierConfig) -> Result<ExtensionLaunch, String> {
validate_easytier_config(config)?;
match config.config_mode {
EasytierConfigMode::Quick => Ok(ExtensionLaunch {
args: Self::build_easytier_quick_args(config),
config_file: None,
}),
EasytierConfigMode::Full => {
let config_file = ProtectedConfigFile::create(
"EasyTier",
"easytier.toml",
config.custom_toml.as_str(),
)
.await?;
Ok(ExtensionLaunch {
args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())],
config_file: Some(config_file),
})
}
}
}
fn build_easytier_quick_args(config: &EasytierConfig) -> Vec<String> {
let mut args = vec![
"--network-name".to_string(),
config.network_name.clone(),
"--network-secret".to_string(),
config.network_secret.clone(),
];
for peer in &config.peer_urls {
if !peer.is_empty() {
args.extend(["--peers".to_string(), peer.clone()]);
}
}
if let Some(ref ip) = config.virtual_ip {
if !ip.is_empty() {
args.extend(["-i".to_string(), ip.clone()]);
} else {
args.push("-d".to_string());
}
} else {
args.push("-d".to_string());
}
args
}
async fn build_frpc_launch(config: &FrpcConfig) -> Result<ExtensionLaunch, String> {
validate_frpc_config(config)?;
let config_text = match config.config_mode {
FrpcConfigMode::Quick => Self::build_frpc_quick_toml(config)?,
FrpcConfigMode::Full => Self::validate_frpc_full_toml(config)?.to_string(),
FrpcConfigMode::Full => config.custom_toml.clone(),
};
let temp_dir =
tempfile::tempdir().map_err(|e| format!("Failed to create FRPC config dir: {}", e))?;
let config_path = temp_dir.path().join("frpc.toml");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Failed to protect FRPC config dir: {}", e))?;
}
tokio::fs::write(&config_path, config_text)
.await
.map_err(|e| format!("Failed to write FRPC config: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|e| format!("Failed to protect FRPC config: {}", e))?;
}
let config_file =
ProtectedConfigFile::create("FRPC", "frpc.toml", config_text.as_str()).await?;
Ok(ExtensionLaunch {
args: vec!["-c".to_string(), Self::path_to_arg(&config_path)],
temp_dir: Some(temp_dir),
args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())],
config_file: Some(config_file),
})
}
fn validate_frpc_full_toml(config: &FrpcConfig) -> Result<&str, String> {
let trimmed = config.custom_toml.trim();
if trimmed.is_empty() {
return Err("FRPC full configuration is required".into());
}
trimmed
.parse::<DocumentMut>()
.map_err(|e| format!("FRPC full configuration is not valid TOML: {}", e))?;
Ok(config.custom_toml.as_str())
}
fn build_frpc_quick_toml(config: &FrpcConfig) -> Result<String, String> {
if config.proxy_name.trim().is_empty() {
return Err("FRPC proxy name is required".into());
@@ -480,7 +455,7 @@ impl ExtensionManager {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
fn path_to_arg(path: &PathBuf) -> String {
fn path_to_arg(path: &Path) -> String {
path.to_string_lossy().to_string()
}
@@ -603,3 +578,107 @@ impl ExtensionManager {
futures::future::join_all(stop_futures).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn easytier_launch_revalidates_full_configuration() {
let config = EasytierConfig {
config_mode: EasytierConfigMode::Full,
custom_toml: "instance_name = [".to_string(),
..Default::default()
};
let error = ExtensionManager::build_easytier_launch(&config)
.await
.err()
.expect("invalid full configuration should fail launch validation");
assert!(error.starts_with("EasyTier full configuration is not valid TOML:"));
}
#[test]
fn easytier_quick_mode_keeps_command_line_arguments() {
let config = EasytierConfig {
network_name: "one-kvm".to_string(),
network_secret: "secret".to_string(),
peer_urls: vec![
"tcp://peer-one:11010".to_string(),
String::new(),
"udp://peer-two:11010".to_string(),
],
virtual_ip: Some("10.20.30.40/24".to_string()),
..Default::default()
};
assert_eq!(
ExtensionManager::build_easytier_quick_args(&config),
vec![
"--network-name",
"one-kvm",
"--network-secret",
"secret",
"--peers",
"tcp://peer-one:11010",
"--peers",
"udp://peer-two:11010",
"-i",
"10.20.30.40/24",
]
);
}
#[tokio::test]
async fn easytier_full_mode_uses_protected_temporary_config() {
let config_text = "instance_name = \"one-kvm\"\n";
let config = EasytierConfig {
config_mode: EasytierConfigMode::Full,
network_name: "ignored-quick-network".to_string(),
custom_toml: config_text.to_string(),
..Default::default()
};
let launch = ExtensionManager::build_easytier_launch(&config)
.await
.expect("full EasyTier launch should build");
assert_eq!(launch.args[0], "-c");
let config_path = std::path::PathBuf::from(&launch.args[1]);
assert_eq!(
config_path.file_name().and_then(|name| name.to_str()),
Some("easytier.toml")
);
assert_eq!(
tokio::fs::read_to_string(&config_path).await.unwrap(),
config_text
);
drop(launch);
assert!(!config_path.exists());
}
#[test]
fn easytier_auto_start_uses_fields_for_selected_mode() {
let mut config = ExtensionsConfig::default();
config.easytier.enabled = true;
config.easytier.network_name = "quick-network".to_string();
assert!(ExtensionManager::is_enabled_for_config(
ExtensionId::Easytier,
&config
));
config.easytier.config_mode = EasytierConfigMode::Full;
assert!(!ExtensionManager::is_enabled_for_config(
ExtensionId::Easytier,
&config
));
config.easytier.network_name.clear();
config.easytier.custom_toml = "instance_name = \"one-kvm\"".to_string();
assert!(ExtensionManager::is_enabled_for_config(
ExtensionId::Easytier,
&config
));
}
}

View File

@@ -1,6 +1,8 @@
mod manager;
mod protected_config;
mod software;
mod types;
mod validation;
pub use manager::ExtensionManager;
#[cfg(unix)]
@@ -8,3 +10,7 @@ pub use manager::TTYD_SOCKET_PATH;
#[cfg(windows)]
pub use manager::TTYD_TCP_ADDR;
pub use types::*;
pub(crate) use validation::{
validate_easytier_config, validate_extension_config, validate_frpc_config,
validate_gostc_config,
};

View File

@@ -0,0 +1,95 @@
use std::path::{Path, PathBuf};
use tempfile::TempDir;
pub(crate) struct ProtectedConfigFile {
_temp_dir: TempDir,
path: PathBuf,
}
impl ProtectedConfigFile {
pub(crate) async fn create(
extension_name: &str,
file_name: &str,
contents: &str,
) -> Result<Self, String> {
let temp_dir = tempfile::tempdir().map_err(|error| {
format!("Failed to create {} config dir: {}", extension_name, error)
})?;
let path = temp_dir.path().join(file_name);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700))
.map_err(|error| {
format!("Failed to protect {} config dir: {}", extension_name, error)
})?;
}
tokio::fs::write(&path, contents)
.await
.map_err(|error| format!("Failed to write {} config: {}", extension_name, error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|error| {
format!("Failed to protect {} config: {}", extension_name, error)
})?;
}
Ok(Self {
_temp_dir: temp_dir,
path,
})
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn protects_and_cleans_up_config_file() {
let config =
ProtectedConfigFile::create("Test extension", "extension.toml", "enabled = true\n")
.await
.unwrap();
let path = config.path().to_path_buf();
assert_eq!(
tokio::fs::read_to_string(&path).await.unwrap(),
"enabled = true\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
drop(config);
assert!(!path.exists());
}
}

View File

@@ -103,11 +103,25 @@ impl Default for GostcConfig {
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EasytierConfigMode {
Quick,
Full,
}
impl Default for EasytierConfigMode {
fn default() -> Self {
Self::Quick
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
#[derive(Default)]
pub struct EasytierConfig {
pub enabled: bool,
pub config_mode: EasytierConfigMode,
pub network_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub network_secret: String,
@@ -115,6 +129,8 @@ pub struct EasytierConfig {
pub peer_urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub virtual_ip: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub custom_toml: String,
}
#[typeshare]
@@ -260,3 +276,26 @@ pub struct ExtensionLogs {
pub id: ExtensionId,
pub logs: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::{EasytierConfig, EasytierConfigMode};
#[test]
fn legacy_easytier_config_defaults_to_quick_mode() {
let config: EasytierConfig = serde_json::from_str(
r#"{
"enabled": true,
"network_name": "legacy-network",
"network_secret": "secret",
"peer_urls": ["tcp://example.com:11010"],
"virtual_ip": "10.10.10.2/24"
}"#,
)
.expect("legacy EasyTier config should deserialize");
assert_eq!(config.config_mode, EasytierConfigMode::Quick);
assert!(config.custom_toml.is_empty());
assert_eq!(config.network_name, "legacy-network");
}
}

View File

@@ -0,0 +1,119 @@
use toml_edit::DocumentMut;
use super::types::{
EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig,
FrpcConfigMode, GostcConfig,
};
pub(crate) fn validate_extension_config(
id: ExtensionId,
config: &ExtensionsConfig,
) -> Result<(), String> {
match id {
ExtensionId::Ttyd => Ok(()),
ExtensionId::Gostc => validate_gostc_config(&config.gostc),
ExtensionId::Easytier => validate_easytier_config(&config.easytier),
ExtensionId::Frpc => validate_frpc_config(&config.frpc),
}
}
pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> {
require_non_empty(config.addr.trim(), "GOSTC server address is required")?;
require_non_empty(config.key.as_str(), "GOSTC client key is required")
}
pub(crate) fn validate_easytier_config(config: &EasytierConfig) -> Result<(), String> {
match config.config_mode {
EasytierConfigMode::Quick => require_non_empty(
config.network_name.trim(),
"EasyTier network name is required",
),
EasytierConfigMode::Full => validate_full_toml("EasyTier", config.custom_toml.as_str()),
}
}
pub(crate) fn validate_frpc_config(config: &FrpcConfig) -> Result<(), String> {
match config.config_mode {
FrpcConfigMode::Quick => {
require_non_empty(config.proxy_name.trim(), "FRPC proxy name is required")?;
require_non_empty(config.server_addr.trim(), "FRPC server address is required")?;
require_non_empty(config.token.as_str(), "FRPC token is required")?;
require_non_empty(config.local_ip.trim(), "FRPC local IP is required")?;
if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp)
&& config.remote_port.is_none()
{
return Err("FRPC remote port is required for TCP/UDP proxies".to_string());
}
Ok(())
}
FrpcConfigMode::Full => validate_full_toml("FRPC", config.custom_toml.as_str()),
}
}
fn require_non_empty(value: &str, message: &str) -> Result<(), String> {
if value.is_empty() {
Err(message.to_string())
} else {
Ok(())
}
}
fn validate_full_toml(extension_name: &str, config: &str) -> Result<(), String> {
let trimmed = config.trim();
if trimmed.is_empty() {
return Err(format!("{} full configuration is required", extension_name));
}
trimmed.parse::<DocumentMut>().map_err(|error| {
format!(
"{} full configuration is not valid TOML: {}",
extension_name, error
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_easytier_full_configuration() {
let mut config = EasytierConfig {
config_mode: EasytierConfigMode::Full,
..Default::default()
};
assert_eq!(
validate_easytier_config(&config).unwrap_err(),
"EasyTier full configuration is required"
);
config.custom_toml = "instance_name = [".to_string();
assert!(validate_easytier_config(&config)
.unwrap_err()
.starts_with("EasyTier full configuration is not valid TOML:"));
config.custom_toml = "instance_name = \"one-kvm\"".to_string();
assert!(validate_easytier_config(&config).is_ok());
}
#[test]
fn validates_frpc_through_the_same_entry_point() {
let mut config = FrpcConfig {
config_mode: FrpcConfigMode::Full,
..Default::default()
};
assert_eq!(
validate_frpc_config(&config).unwrap_err(),
"FRPC full configuration is required"
);
config.custom_toml = "serverAddr = \"frps.example.com\"".to_string();
assert!(validate_frpc_config(&config).is_ok());
}
}

View File

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

283
src/hid/bluetooth.rs Normal file
View File

@@ -0,0 +1,283 @@
//! Translate canonical One-KVM input into the native BlueZ peripheral.
use super::{
backend::{HidBackend, HidBackendRuntimeSnapshot},
types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
},
};
use crate::{
config::BluetoothHidConfig,
error::{AppError, Result},
events::LedState,
};
use async_trait::async_trait;
use one_kvm_bluetooth_hid::{Action, Config, Peripheral, Report};
use tokio::sync::{watch, Mutex};
fn error(message: String) -> AppError {
AppError::HidError {
backend: "bluetooth".into(),
error_code: "bluetooth_error".into(),
reason: message,
}
}
#[derive(Default)]
struct InputState {
keyboard: KeyboardReport,
buttons: u8,
generation: u64,
}
pub struct BluetoothBackend {
peripheral: Peripheral,
input: Mutex<InputState>,
runtime: watch::Sender<()>,
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl BluetoothBackend {
pub fn new(
config: BluetoothHidConfig,
bonds: Option<std::sync::Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
) -> Result<Self> {
let peripheral = Peripheral::start_with_store(
Config {
adapter: config.adapter,
name: config.name,
peer: config.peer,
},
bonds,
)
.map_err(error)?;
let (runtime, _) = watch::channel(());
Ok(Self {
peripheral,
input: Mutex::new(InputState::default()),
runtime,
worker: Mutex::new(None),
})
}
fn check(&self, input: &mut InputState) -> Result<()> {
let status = self.peripheral.status();
if input.generation != status.generation || !status.ready {
*input = InputState {
generation: status.generation,
..Default::default()
};
}
if !status.ready {
return Err(error(status.error.unwrap_or_else(|| {
"Pair and connect a computer; HID reports are not ready".into()
})));
}
Ok(())
}
}
#[async_trait]
impl HidBackend for BluetoothBackend {
async fn init(&self) -> Result<()> {
let mut status = self.peripheral.subscribe();
let runtime = self.runtime.clone();
*self.worker.lock().await = Some(tokio::spawn(async move {
let mut last_error = None;
while status.changed().await.is_ok() {
let error = status.borrow_and_update().error.clone();
if error != last_error {
if let Some(reason) = &error {
tracing::warn!(%reason, "Bluetooth HID unavailable");
}
last_error = error;
}
runtime.send_replace(());
}
}));
Ok(())
}
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
let mut input = self.input.lock().await;
self.check(&mut input)?;
apply_key(&mut input.keyboard, &event);
let result = self
.peripheral
.send(Report::Keyboard, input.keyboard.to_bytes().to_vec())
.await
.map_err(error);
if result.is_err() {
*input = InputState::default();
}
result
}
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
let mut input = self.input.lock().await;
self.check(&mut input)?;
let (mut x, mut y, wheel) = match event.event_type {
MouseEventType::Move => (event.x, event.y, 0),
MouseEventType::MoveAbs => {
return Err(AppError::BadRequest(
"Bluetooth HID supports relative mouse only".into(),
))
}
MouseEventType::Down | MouseEventType::Up => {
if let Some(button) = event.button {
let bit = button.to_hid_bit();
if event.event_type == MouseEventType::Down {
input.buttons |= bit;
} else {
input.buttons &= !bit;
}
}
(0, 0, 0)
}
MouseEventType::Scroll => (0, 0, event.scroll),
};
// Bound malformed remote input without silently clipping normal relative movements.
if x.unsigned_abs() > 32767 || y.unsigned_abs() > 32767 {
return Err(AppError::BadRequest(
"Relative mouse displacement too large".into(),
));
}
loop {
let dx = x.clamp(-127, 127);
let dy = y.clamp(-127, 127);
self.peripheral
.send(
Report::Mouse,
vec![input.buttons, dx as i8 as u8, dy as i8 as u8, wheel as u8],
)
.await
.map_err(error)?;
x -= dx;
y -= dy;
if x == 0 && y == 0 {
break;
}
}
Ok(())
}
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
let mut input = self.input.lock().await;
self.check(&mut input)?;
if event.usage > 0x3ff {
return Err(AppError::BadRequest(
"Consumer usage exceeds Bluetooth report range".into(),
));
}
self.peripheral
.send(Report::Consumer, event.usage.to_le_bytes().to_vec())
.await
.map_err(error)
}
async fn reset(&self) -> Result<()> {
*self.input.lock().await = InputState::default();
self.peripheral.action(Action::Reset).await.map_err(error)
}
async fn shutdown(&self) -> Result<()> {
let result = self.peripheral.shutdown().await.map_err(error);
if let Some(worker) = self.worker.lock().await.take() {
worker.abort();
}
result
}
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
let state = self.peripheral.status();
HidBackendRuntimeSnapshot {
initialized: state.initialized,
online: state.ready,
supports_absolute_mouse: false,
keyboard_leds_enabled: true,
led_state: LedState {
num_lock: state.leds & 1 != 0,
caps_lock: state.leds & 2 != 0,
scroll_lock: state.leds & 4 != 0,
compose: state.leds & 8 != 0,
kana: state.leds & 16 != 0,
},
device: Some(state.peer.unwrap_or(state.adapter)),
screen_resolution: None,
error_code: state.error.as_ref().map(|_| "bluetooth_error".into()),
error: state.error,
}
}
fn subscribe_runtime(&self) -> watch::Receiver<()> {
self.runtime.subscribe()
}
async fn bluetooth_status(&self) -> Result<serde_json::Value> {
serde_json::to_value(self.peripheral.status()).map_err(|e| error(e.to_string()))
}
async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> {
if action == "pair" && !(10..=300).contains(&seconds) {
return Err(AppError::BadRequest(
"Pairing window must be 10300 seconds".into(),
));
}
let action = match action {
"pair" => Action::Pair(seconds),
"close" => Action::ClosePairing,
"forget" => Action::Forget,
"disconnect" => Action::Disconnect,
_ => return Err(AppError::BadRequest("Unknown Bluetooth action".into())),
};
self.peripheral.action(action).await.map_err(error)
}
}
fn apply_key(report: &mut KeyboardReport, event: &KeyboardEvent) {
if let Some(bit) = event.key.modifier_bit() {
match event.event_type {
KeyEventType::Down => report.modifiers |= bit,
KeyEventType::Up => report.modifiers &= !bit,
}
} else {
report.modifiers = event.modifiers.to_hid_byte();
let usage = event.key.to_hid_usage();
match event.event_type {
KeyEventType::Down if !report.keys.contains(&usage) => {
report.add_key(usage);
}
KeyEventType::Up => report.remove_key(usage),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hid::{CanonicalKey, KeyboardModifiers};
#[test]
fn repeated_keydown_does_not_leave_stuck_keys() {
let mut report = KeyboardReport::default();
let event = KeyboardEvent {
key: CanonicalKey::KeyA,
event_type: KeyEventType::Down,
modifiers: KeyboardModifiers::default(),
};
apply_key(&mut report, &event);
apply_key(&mut report, &event);
assert_eq!(report.keys.iter().filter(|&&key| key == 4).count(), 1);
apply_key(
&mut report,
&KeyboardEvent {
event_type: KeyEventType::Up,
..event
},
);
assert_eq!(report.to_bytes(), [0; 8]);
}
#[test]
fn modifier_press_and_release() {
let mut report = KeyboardReport::default();
let event = KeyboardEvent {
key: CanonicalKey::ShiftRight,
event_type: KeyEventType::Down,
modifiers: KeyboardModifiers::default(),
};
apply_key(&mut report, &event);
assert_eq!(report.modifiers, 0x20);
apply_key(
&mut report,
&KeyboardEvent {
event_type: KeyEventType::Up,
..event
},
);
assert_eq!(report.modifiers, 0);
}
}

View File

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

View File

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

View File

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

256
src/hid/macos_drag.rs Normal file
View File

@@ -0,0 +1,256 @@
//! Experimental absolute-button / relative-drag routing shared by USB backends.
//! Relative counts are subject to host acceleration; they are not screen pixels.
use super::{MouseEvent, MouseEventType};
#[derive(Debug, PartialEq)]
pub(super) enum MouseReport {
Absolute {
buttons: u8,
x: u16,
y: u16,
},
Relative {
buttons: u8,
dx: i8,
dy: i8,
wheel: i8,
},
}
#[derive(Default)]
pub(super) struct MacosDrag {
position: (u16, u16),
remainder: (i64, i64),
extent: (u32, u32),
relative_input: bool,
absolute_drag: bool,
}
impl MacosDrag {
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn plan(
&mut self,
event: MouseEvent,
buttons: u8,
extent: (u32, u32),
) -> (u8, Vec<MouseReport>) {
let mut reports = Vec::new();
let mut next_buttons = buttons;
if extent != self.extent || buttons == 0 {
self.remainder = (0, 0);
self.extent = extent;
}
match event.event_type {
MouseEventType::MoveAbs => {
let position = (
event.x.clamp(0, 32767) as u16,
event.y.clamp(0, 32767) as u16,
);
self.relative_input = false;
if buttons != 0 {
let dx =
Self::delta(position.0, self.position.0, extent.0, &mut self.remainder.0);
let dy =
Self::delta(position.1, self.position.1, extent.1, &mut self.remainder.1);
Self::motion(&mut reports, buttons, dx, dy);
} else {
reports.push(MouseReport::Absolute {
buttons: 0,
x: position.0,
y: position.1,
});
}
self.position = position;
}
MouseEventType::Move => {
self.relative_input = true;
Self::motion(
&mut reports,
buttons,
i64::from(event.x.clamp(-127, 127)),
i64::from(event.y.clamp(-127, 127)),
);
}
MouseEventType::Down | MouseEventType::Up => {
if let Some(button) = event.button {
let down = event.event_type == MouseEventType::Down;
next_buttons = if down {
buttons | button.to_hid_bit()
} else {
buttons & !button.to_hid_bit()
};
if down && buttons == 0 {
self.absolute_drag = !self.relative_input;
}
// Latch the button route until all buttons are released, even
// if the client changes pointer mode during the drag.
if self.absolute_drag {
if !down {
// Relative drag reports also carry buttons. Clear their
// state before releasing the absolute collection.
reports.push(MouseReport::Relative {
buttons: next_buttons,
dx: 0,
dy: 0,
wheel: 0,
});
}
reports.push(MouseReport::Absolute {
buttons: next_buttons,
x: self.position.0,
y: self.position.1,
});
} else {
reports.push(MouseReport::Relative {
buttons: next_buttons,
dx: 0,
dy: 0,
wheel: 0,
});
}
if next_buttons == 0 {
self.absolute_drag = false;
self.remainder = (0, 0);
}
}
}
MouseEventType::Scroll => {
reports.push(MouseReport::Relative {
buttons,
dx: 0,
dy: 0,
wheel: event.scroll,
});
}
}
(next_buttons, reports)
}
fn delta(current: u16, previous: u16, extent: u32, remainder: &mut i64) -> i64 {
// Keep the original 15-bit input precision and carry fractional counts.
let scaled =
(i64::from(current) - i64::from(previous)) * i64::from(extent.max(1)) + *remainder;
*remainder = scaled % 32768;
scaled / 32768
}
fn motion(reports: &mut Vec<MouseReport>, buttons: u8, dx: i64, dy: i64) {
// Distribute both axes over the same packets to preserve diagonal paths.
let count = (dx.abs().max(dy.abs()) + 126) / 127;
let mut previous = (0, 0);
for index in 1..=count {
let position = (dx * index / count, dy * index / count);
reports.push(MouseReport::Relative {
buttons,
dx: (position.0 - previous.0) as i8,
dy: (position.1 - previous.1) as i8,
wheel: 0,
});
previous = position;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hid::MouseButton;
fn drag(steps: &[i32], extent: (u32, u32)) -> (i64, i64) {
let mut state = MacosDrag::default();
state.plan(MouseEvent::move_abs(8000, 8000), 0, extent);
state.plan(MouseEvent::button_down(MouseButton::Left), 0, extent);
let mut total = (0, 0);
for &x in steps {
for report in state.plan(MouseEvent::move_abs(x, x), 1, extent).1 {
if let MouseReport::Relative { dx, dy, .. } = report {
total.0 += i64::from(dx);
total.1 += i64::from(dy);
} else {
panic!("absolute movement during drag");
}
}
}
total
}
#[test]
fn preserves_total_across_event_splitting_and_large_moves() {
let steps: Vec<_> = (8001..=16000).collect();
assert_eq!(drag(&[16000], (1920, 1080)), (468, 263));
assert_eq!(drag(&steps, (1920, 1080)), (468, 263));
assert_eq!(drag(&[16000, 8000], (1920, 1080)), (0, 0));
assert_eq!(drag(&[16000], (3840, 2160)), (937, 527));
}
#[test]
fn scroll_and_mode_switch_keep_absolute_drag_release() {
let mut state = MacosDrag::default();
let extent = (1920, 1080);
state.plan(MouseEvent::move_abs(8000, 8000), 0, extent);
state.plan(MouseEvent::button_down(MouseButton::Left), 0, extent);
let mut wheel = MouseEvent::move_abs(0, 0);
wheel.event_type = MouseEventType::Scroll;
wheel.scroll = 1;
assert_eq!(
state.plan(wheel, 1, extent).1,
vec![MouseReport::Relative {
buttons: 1,
dx: 0,
dy: 0,
wheel: 1
}]
);
state.plan(MouseEvent::move_rel(10, 0), 1, extent);
assert_eq!(
state
.plan(MouseEvent::button_up(MouseButton::Left), 1, extent)
.1,
vec![
MouseReport::Relative {
buttons: 0,
dx: 0,
dy: 0,
wheel: 0
},
MouseReport::Absolute {
buttons: 0,
x: 8000,
y: 8000
},
]
);
}
#[test]
fn native_relative_clicks_and_reset() {
let mut state = MacosDrag::default();
state.plan(MouseEvent::move_rel(1, 1), 0, (1920, 1080));
assert_eq!(
state
.plan(MouseEvent::button_down(MouseButton::Left), 0, (1920, 1080))
.1,
vec![MouseReport::Relative {
buttons: 1,
dx: 0,
dy: 0,
wheel: 0
}]
);
state.reset();
assert_eq!(
state
.plan(MouseEvent::button_down(MouseButton::Left), 0, (1920, 1080))
.1,
vec![MouseReport::Absolute {
buttons: 1,
x: 0,
y: 0
}]
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,11 @@ use tracing::{debug, info, warn};
use super::image::ImageManager;
use super::monitor::MsdHealthMonitor;
use super::types::{
DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia,
MountedMediaKind, MsdState,
DiskMode, DownloadProgress, DownloadStatus, DriveFileAccess, DriveInfo, ImageInfo,
MountedMedia, MountedMediaKind, MsdState,
};
use crate::error::{AppError, Result};
use super::ventoy_drive::VentoyDrive;
use crate::error::{AppError, MsdErrorCode, Result};
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
pub struct MsdController {
@@ -62,17 +63,15 @@ impl MsdController {
),
}
if let Err(e) = std::fs::create_dir_all(&self.images_path) {
warn!("Failed to create images directory: {}", e);
}
if let Err(e) = std::fs::create_dir_all(&self.ventoy_dir) {
warn!("Failed to create ventoy directory: {}", e);
}
tokio::fs::create_dir_all(&self.images_path).await?;
tokio::fs::create_dir_all(&self.ventoy_dir).await?;
info!("Fetching MSD function from OtgService");
let msd_func = self.otg_service.msd_function().await.ok_or_else(|| {
AppError::Internal("MSD function is not active in OtgService".to_string())
})?;
let msd_func = self
.otg_service
.msd_function()
.await
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
*self.msd_function.write().await = Some(msd_func);
@@ -85,14 +84,9 @@ impl MsdController {
state.available = true;
if self.drive_path.exists() {
if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
let drive_info = DriveInfo {
size: metadata.len(),
used: 0,
free: metadata.len(),
initialized: true,
path: self.drive_path.clone(),
};
if let Ok(drive_info) =
VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown)
{
state.drive_info = Some(drive_info.clone());
debug!(
"Found existing virtual drive: {}",
@@ -148,7 +142,7 @@ impl MsdController {
read_only: bool,
requested_lun: Option<u8>,
) -> Result<()> {
let _op_guard = self.operation_lock.write().await;
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
let previous_state = state.clone();
@@ -159,7 +153,7 @@ impl MsdController {
self.monitor
.report_error(&error_msg, "image_not_found")
.await;
return Err(AppError::Internal(error_msg));
return Err(MsdErrorCode::MsdResourceNotFound.into());
}
if state
@@ -167,7 +161,7 @@ impl MsdController {
.iter()
.any(|media| media.kind == MountedMediaKind::Image && media.id == image.id)
{
return Err(AppError::BadRequest("Image is already mounted".to_string()));
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
}
let lun = Self::select_lun(&state, requested_lun)?;
@@ -195,50 +189,38 @@ impl MsdController {
}
pub async fn mount_drive(&self) -> Result<()> {
let _op_guard = self.operation_lock.write().await;
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
let previous_state = state.clone();
self.assert_available(&state).await?;
if !self.drive_path.exists() {
let err =
AppError::Internal("Virtual drive not initialized. Call init first.".to_string());
self.monitor
.report_error("Virtual drive not initialized", "drive_not_found")
.await;
return Err(err);
}
let drive_info = state.drive_info.clone().or_else(|| {
std::fs::metadata(&self.drive_path)
.ok()
.map(|metadata| DriveInfo {
size: metadata.len(),
used: 0,
free: metadata.len(),
initialized: true,
path: self.drive_path.clone(),
})
});
if state.drive_info.is_none() {
state.drive_info = drive_info.clone();
}
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
{
return Err(AppError::BadRequest(
"Virtual drive is already mounted".to_string(),
));
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
}
let drive_info = drive_info
.ok_or_else(|| AppError::Internal("Virtual drive info is unavailable".to_string()))?;
let drive_info = match self.drive_mount_info() {
Ok(info) => info,
Err(error) => {
if matches!(
&error,
AppError::Msd(msd) if msd.code() == MsdErrorCode::MsdDriveNotInitialized
) {
self.monitor
.report_error("Virtual drive not initialized", "drive_not_found")
.await;
}
return Err(error);
}
};
state.drive_info = Some(drive_info.clone());
let lun = Self::lowest_free_lun(&state)
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?;
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?;
let media = MountedMedia::drive(lun, &drive_info);
if let Err(e) = self.configure_media(&media).await {
@@ -246,6 +228,8 @@ impl MsdController {
return Err(e);
}
state.mounted_media.push(media);
state.drive_info =
Some(drive_info.with_file_access(DriveFileAccess::BlockedWhileConnected));
info!(
"Mounted virtual drive on LUN {}: {}",
@@ -260,12 +244,21 @@ impl MsdController {
Ok(())
}
fn drive_mount_info(&self) -> Result<DriveInfo> {
VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown)
}
pub async fn set_drive_info(&self, drive_info: Option<DriveInfo>) {
self.state.write().await.drive_info = drive_info;
self.mark_device_info_dirty().await;
}
async fn assert_available(&self, state: &MsdState) -> Result<()> {
if !state.available {
self.monitor
.report_error("MSD not available", "not_available")
.await;
return Err(AppError::Internal("MSD not available".to_string()));
return Err(MsdErrorCode::MsdUnavailable.into());
}
Ok(())
}
@@ -286,31 +279,35 @@ impl MsdController {
fn select_lun(state: &MsdState, requested_lun: Option<u8>) -> Result<u8> {
let Some(lun) = requested_lun else {
return Self::lowest_free_lun(state)
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()));
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull));
};
if lun >= state.disk_mode.capacity() {
return Err(AppError::BadRequest(format!(
"Media slot {} is outside the current disk mode capacity",
lun + 1
)));
return Err(MsdErrorCode::MsdInvalidRequest.into());
}
if state.mounted_media.iter().any(|media| media.lun == lun) {
return Err(AppError::BadRequest(format!(
"Media slot {} is already occupied",
lun + 1
)));
return Err(MsdErrorCode::MsdMediaSlotsFull.into());
}
Ok(lun)
}
fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) {
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
{
state.drive_info = state
.drive_info
.take()
.map(|info| info.with_file_access(DriveFileAccess::Unknown));
}
state.disk_mode = disk_mode;
state.mounted_media.clear();
}
pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result<bool> {
let _op_guard = self.operation_lock.write().await;
let _op_guard = self.try_operation()?;
let previous_state = {
let mut state = self.state.write().await;
self.assert_available(&state).await?;
@@ -327,9 +324,10 @@ impl MsdController {
self.otg_service
.set_msd_lun_capacity(disk_mode.capacity())
.await?;
self.otg_service.msd_function().await.ok_or_else(|| {
AppError::Internal("MSD function missing after OTG rebuild".to_string())
})
self.otg_service
.msd_function()
.await
.ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed))
}
.await;
@@ -349,7 +347,7 @@ impl MsdController {
.report_error(&error_msg, "disk_mode_rollback_failed")
.await;
self.mark_device_info_dirty().await;
return Err(AppError::Internal(error_msg));
return Err(MsdErrorCode::MsdOperationFailed.into());
}
let mut state = self.state.write().await;
@@ -360,7 +358,7 @@ impl MsdController {
.report_error(&error_msg, "disk_mode_switch_failed")
.await;
self.mark_device_info_dirty().await;
return Err(AppError::Internal(error_msg));
return Err(MsdErrorCode::MsdOperationFailed.into());
}
};
*self.msd_function.write().await = Some(msd_function);
@@ -397,7 +395,7 @@ impl MsdController {
where
F: Fn(&MountedMedia) -> bool,
{
let _op_guard = self.operation_lock.write().await;
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
let Some(index) = state.mounted_media.iter().position(predicate) else {
@@ -408,6 +406,12 @@ impl MsdController {
self.disconnect_lun(media.lun).await?;
state.mounted_media.remove(index);
if media.kind == MountedMediaKind::Drive {
state.drive_info = state
.drive_info
.take()
.map(|info| info.with_file_access(DriveFileAccess::Unknown));
}
info!("Unmounted media");
drop(state);
@@ -419,25 +423,22 @@ impl MsdController {
}
async fn configure_media(&self, media: &MountedMedia) -> Result<()> {
let gadget_path = self.active_gadget_path().await?;
let msd_hold = self.msd_function.read().await;
let Some(ref msd) = *msd_hold else {
self.monitor
.report_error("MSD function not initialized", "not_initialized")
.await;
return Err(AppError::Internal(
"MSD function not initialized".to_string(),
));
};
if let Err(e) = msd
.configure_lun_async(&gadget_path, media.lun, &Self::media_config(media))
if let Err(e) = self
.otg_service
.configure_msd_lun(media.lun, &Self::media_config(media))
.await
{
let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e);
self.monitor
.report_error(&error_msg, "configfs_error")
.await;
return Err(e);
return Err(match e {
AppError::Msd(error) => AppError::Msd(error),
error => {
warn!(%error, "Unclassified MSD media configuration failure");
MsdErrorCode::MsdOperationFailed.into()
}
});
}
Ok(())
}
@@ -447,7 +448,7 @@ impl MsdController {
let msd_hold = self.msd_function.read().await;
let msd = msd_hold
.as_ref()
.ok_or_else(|| AppError::Internal("MSD function not initialized".to_string()))?;
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
msd.disconnect_lun_async(&gadget_path, lun).await
}
@@ -455,9 +456,11 @@ impl MsdController {
self.otg_service
.set_msd_lun_capacity(previous_state.disk_mode.capacity())
.await?;
let msd_function = self.otg_service.msd_function().await.ok_or_else(|| {
AppError::Internal("MSD function missing after OTG rollback".to_string())
})?;
let msd_function = self
.otg_service
.msd_function()
.await
.ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed))?;
*self.msd_function.write().await = Some(msd_function);
for media in &previous_state.mounted_media {
self.configure_media(media).await?;
@@ -473,7 +476,7 @@ impl MsdController {
}
pub async fn disconnect(&self) -> Result<()> {
let _op_guard = self.operation_lock.write().await;
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
if state.mounted_media.is_empty() {
@@ -488,10 +491,13 @@ impl MsdController {
for prior in &disconnected {
if let Err(restore_error) = self.configure_media(prior).await {
state.available = false;
return Err(AppError::Internal(format!(
"Failed to disconnect LUN {}: {error}; restore failed: {restore_error}",
media.lun
)));
warn!(
lun = media.lun,
disconnect_error = %error,
%restore_error,
"Failed to restore MSD media after disconnect failure"
);
return Err(MsdErrorCode::MsdDisconnectFailed.into());
}
}
return Err(error);
@@ -499,6 +505,16 @@ impl MsdController {
disconnected.push(media.clone());
}
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
{
state.drive_info = state
.drive_info
.take()
.map(|info| info.with_file_access(DriveFileAccess::Unknown));
}
state.mounted_media.clear();
info!("Disconnected all mounted media");
@@ -520,16 +536,14 @@ impl MsdController {
}
pub async fn delete_image(&self, image_id: &str) -> Result<()> {
let _op_guard = self.operation_lock.write().await;
let _op_guard = self.try_operation()?;
let state = self.state.read().await;
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
{
return Err(AppError::BadRequest(
"Cannot delete image while it is mounted".to_string(),
));
return Err(MsdErrorCode::MsdMediaInUse.into());
}
ImageManager::new(self.images_path.clone()).delete(image_id)
@@ -540,6 +554,12 @@ impl MsdController {
url: String,
filename: Option<String>,
) -> Result<DownloadProgress> {
let parsed_url =
reqwest::Url::parse(&url).map_err(|_| AppError::from(MsdErrorCode::MsdInvalidUrl))?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(MsdErrorCode::MsdInvalidUrl.into());
}
let download_id = uuid::Uuid::new_v4().to_string();
let cancel_token = CancellationToken::new();
@@ -560,7 +580,7 @@ impl MsdController {
total_bytes: None,
progress_pct: None,
status: DownloadStatus::Started,
error: None,
error_code: None,
};
self.publish_event(crate::events::SystemEvent::MsdDownloadProgress {
@@ -571,6 +591,7 @@ impl MsdController {
total_bytes: None,
progress_pct: None,
status: "started".to_string(),
error_code: None,
})
.await;
@@ -600,6 +621,7 @@ impl MsdController {
total_bytes: total,
progress_pct,
status: "in_progress".to_string(),
error_code: None,
});
}
};
@@ -624,11 +646,16 @@ impl MsdController {
total_bytes: Some(image_info.size),
progress_pct: Some(100.0),
status: "completed".to_string(),
error_code: None,
});
}
}
Err(e) => {
warn!("Download failed: {}", e);
warn!(error = %e, "MSD image download failed");
let code = match e {
AppError::Msd(error) => error.code(),
_ => MsdErrorCode::MsdOperationFailed,
};
if let Some(ref bus) = events {
bus.publish(crate::events::SystemEvent::MsdDownloadProgress {
download_id: download_id_clone,
@@ -637,7 +664,8 @@ impl MsdController {
bytes_downloaded: 0,
total_bytes: None,
progress_pct: None,
status: format!("failed: {}", e),
status: "failed".to_string(),
error_code: Some(code.as_str().to_string()),
});
}
}
@@ -655,10 +683,7 @@ impl MsdController {
info!("Download cancelled: {}", download_id);
Ok(())
} else {
Err(AppError::NotFound(format!(
"Download not found: {}",
download_id
)))
Err(MsdErrorCode::MsdResourceNotFound.into())
}
}
@@ -666,7 +691,13 @@ impl MsdController {
self.otg_service
.gadget_path()
.await
.ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string()))
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))
}
fn try_operation(&self) -> Result<tokio::sync::RwLockWriteGuard<'_, ()>> {
self.operation_lock
.try_write()
.map_err(|_| MsdErrorCode::MsdOperationInProgress.into())
}
pub async fn shutdown(&self) -> Result<()> {
@@ -712,6 +743,18 @@ mod tests {
assert!(controller.drive_path.ends_with("ventoy.img"));
}
#[tokio::test]
async fn concurrent_operations_have_a_stable_error_code() {
let temp_dir = TempDir::new().unwrap();
let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path());
let _guard = controller.operation_lock.write().await;
assert!(matches!(
controller.try_operation().unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdOperationInProgress
));
}
#[tokio::test]
async fn test_state_default() {
let temp_dir = TempDir::new().unwrap();
@@ -726,6 +769,29 @@ mod tests {
assert!(state.mounted_media.is_empty());
}
#[tokio::test]
async fn drive_mount_metadata_ignores_cached_drive_info() {
let temp_dir = TempDir::new().unwrap();
let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path());
std::fs::create_dir_all(&controller.ventoy_dir).unwrap();
std::fs::write(&controller.drive_path, vec![0u8; 128]).unwrap();
controller.state.write().await.drive_info = Some(DriveInfo::from_raw(
controller.drive_path.clone(),
64,
DriveFileAccess::Available,
));
std::fs::write(&controller.drive_path, vec![0u8; 256]).unwrap();
let info = controller.drive_mount_info().unwrap();
assert_eq!(info.size, 256);
assert_eq!(info.used, None);
assert_eq!(info.file_access, DriveFileAccess::Unknown);
let media = MountedMedia::drive(0, &info);
let config = MsdController::media_config(&media);
assert_eq!(config.file, controller.drive_path);
}
#[test]
fn single_disk_mode_only_exposes_lun_zero() {
let mut state = MsdState::default();
@@ -783,14 +849,14 @@ mod tests {
.push(MountedMedia::image(3, &image, false, true));
assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5);
assert!(MsdController::select_lun(&state, Some(3))
.unwrap_err()
.to_string()
.contains("already occupied"));
assert!(MsdController::select_lun(&state, Some(8))
.unwrap_err()
.to_string()
.contains("outside"));
assert!(matches!(
MsdController::select_lun(&state, Some(3)).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdMediaSlotsFull
));
assert!(matches!(
MsdController::select_lun(&state, Some(8)).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdInvalidRequest
));
}
#[test]
@@ -824,13 +890,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let drive_path = temp_dir.path().join("ventoy.img");
std::fs::write(&drive_path, b"drive").unwrap();
let drive = DriveInfo {
size: 5,
used: 0,
free: 5,
initialized: true,
path: drive_path,
};
let drive = DriveInfo::from_raw(drive_path, 5, DriveFileAccess::Unknown);
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state.mounted_media.push(MountedMedia::drive(0, &drive));
@@ -882,13 +942,11 @@ mod tests {
let image_path = temp_dir.path().join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
let drive = DriveInfo {
size: 5,
used: 0,
free: 5,
initialized: true,
path: temp_dir.path().join("ventoy.img"),
};
let drive = DriveInfo::from_raw(
temp_dir.path().join("ventoy.img"),
5,
DriveFileAccess::Unknown,
);
let mut state = MsdState::default();
state
.mounted_media

View File

@@ -6,10 +6,10 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use time::OffsetDateTime;
use tokio::io::AsyncWriteExt;
use tracing::info;
use tracing::{info, warn};
use super::types::ImageInfo;
use crate::error::{AppError, Result};
use crate::error::{AppError, MsdErrorCode, Result};
const MAX_IMAGE_SIZE: u64 = 32 * 1024 * 1024 * 1024;
@@ -28,7 +28,7 @@ impl ImageManager {
pub fn ensure_dir(&self) -> Result<()> {
fs::create_dir_all(&self.images_path)
.map_err(|e| AppError::Internal(format!("Failed to create images directory: {}", e)))?;
.map_err(|error| storage_io_error("create images directory", error))?;
Ok(())
}
@@ -38,11 +38,9 @@ impl ImageManager {
let mut images = Vec::new();
for entry in fs::read_dir(&self.images_path)
.map_err(|e| AppError::Internal(format!("Failed to read images directory: {}", e)))?
.map_err(|error| storage_io_error("read images directory", error))?
{
let entry = entry.map_err(|e| {
AppError::Internal(format!("Failed to read directory entry: {}", e))
})?;
let entry = entry.map_err(|error| storage_io_error("read image entry", error))?;
let path = entry.path();
if path.is_file() {
@@ -88,13 +86,13 @@ impl ImageManager {
return Ok(image);
}
}
Err(AppError::NotFound(format!("Image not found: {}", id)))
Err(MsdErrorCode::MsdResourceNotFound.into())
}
pub fn get_by_name(&self, name: &str) -> Result<ImageInfo> {
let path = self.images_path.join(name);
self.get_image_info(&path)
.ok_or_else(|| AppError::NotFound(format!("Image not found: {}", name)))
.ok_or_else(|| MsdErrorCode::MsdResourceNotFound.into())
}
#[cfg(test)]
@@ -103,30 +101,24 @@ impl ImageManager {
let name = sanitize_filename(name);
if name.is_empty() {
return Err(AppError::Internal("Invalid filename".to_string()));
return Err(MsdErrorCode::MsdInvalidRequest.into());
}
if data.len() as u64 > MAX_IMAGE_SIZE {
return Err(AppError::Internal(format!(
"Image too large. Maximum size: {} GB",
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
)));
return Err(MsdErrorCode::MsdImageTooLarge.into());
}
let path = self.images_path.join(&name);
if path.exists() {
return Err(AppError::Internal(format!(
"Image already exists: {}",
name
)));
return Err(MsdErrorCode::MsdResourceAlreadyExists.into());
}
let mut file = fs::File::create(&path)
.map_err(|e| AppError::Internal(format!("Failed to create image file: {}", e)))?;
let mut file =
fs::File::create(&path).map_err(|error| storage_io_error("create image", error))?;
file.write_all(data).map_err(|e| {
file.write_all(data).map_err(|error| {
let _ = fs::remove_file(&path);
AppError::Internal(format!("Failed to write image data: {}", e))
storage_io_error("write image", error)
})?;
info!("Created image: {} ({} bytes)", name, data.len());
@@ -143,7 +135,7 @@ impl ImageManager {
let name = sanitize_filename(name);
if name.is_empty() {
return Err(AppError::Internal("Invalid filename".to_string()));
return Err(MsdErrorCode::MsdInvalidRequest.into());
}
let temp_name = format!(".upload_{}", uuid::Uuid::new_v4());
@@ -151,48 +143,41 @@ impl ImageManager {
let final_path = self.images_path.join(&name);
if final_path.exists() {
return Err(AppError::Internal(format!(
"Image already exists: {}",
name
)));
return Err(MsdErrorCode::MsdResourceAlreadyExists.into());
}
let mut file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
.map_err(|error| storage_io_error("create image upload", error))?;
let mut bytes_written: u64 = 0;
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))?
{
while let Some(chunk) = field.chunk().await.map_err(|error| {
warn!(%error, "Failed to read MSD image upload chunk");
AppError::from(MsdErrorCode::MsdOperationFailed)
})? {
bytes_written += chunk.len() as u64;
if bytes_written > MAX_IMAGE_SIZE {
drop(file);
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(AppError::Internal(format!(
"Image too large. Maximum size: {} GB",
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
)));
return Err(MsdErrorCode::MsdImageTooLarge.into());
}
file.write_all(&chunk)
.await
.map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?;
.map_err(|error| storage_io_error("write image upload", error))?;
}
file.flush()
.await
.map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?;
.map_err(|error| storage_io_error("flush image upload", error))?;
drop(file);
tokio::fs::rename(&temp_path, &final_path)
.await
.map_err(|e| {
.map_err(|error| {
let _ = std::fs::remove_file(&temp_path);
AppError::Internal(format!("Failed to rename temp file: {}", e))
storage_io_error("commit image upload", error)
})?;
info!(
@@ -206,8 +191,7 @@ impl ImageManager {
pub fn delete(&self, id: &str) -> Result<()> {
let image = self.get(id)?;
fs::remove_file(&image.path)
.map_err(|e| AppError::Internal(format!("Failed to delete image: {}", e)))?;
fs::remove_file(&image.path).map_err(|error| storage_io_error("delete image", error))?;
info!("Deleted image: {}", image.name);
Ok(())
@@ -224,8 +208,11 @@ impl ImageManager {
{
self.ensure_dir()?;
let parsed_url = reqwest::Url::parse(url)
.map_err(|e| AppError::BadRequest(format!("Invalid URL: {}", e)))?;
let parsed_url =
reqwest::Url::parse(url).map_err(|_| AppError::from(MsdErrorCode::MsdInvalidUrl))?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(MsdErrorCode::MsdInvalidUrl.into());
}
info!("Starting download from: {}", url);
@@ -233,19 +220,17 @@ impl ImageManager {
.timeout(std::time::Duration::from_secs(3600))
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| AppError::Internal(format!("Failed to create HTTP client: {}", e)))?;
.map_err(|error| remote_download_error("create HTTP client", error))?;
let head_response = client
.head(url)
.send()
.await
.map_err(|e| AppError::Internal(format!("Failed to connect: {}", e)))?;
.map_err(|error| remote_download_error("send HEAD request", error))?;
if !head_response.status().is_success() {
return Err(AppError::Internal(format!(
"Server returned error: {}",
head_response.status()
)));
warn!(status = %head_response.status(), "MSD image HEAD request failed");
return Err(MsdErrorCode::MsdRemoteDownloadFailed.into());
}
let total_size = head_response
@@ -256,11 +241,7 @@ impl ImageManager {
if let Some(size) = total_size {
if size > MAX_IMAGE_SIZE {
return Err(AppError::BadRequest(format!(
"File too large: {} bytes (max {} GB)",
size,
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
)));
return Err(MsdErrorCode::MsdImageTooLarge.into());
}
}
@@ -284,17 +265,12 @@ impl ImageManager {
};
if final_filename.is_empty() {
return Err(AppError::BadRequest(
"Could not determine filename".to_string(),
));
return Err(MsdErrorCode::MsdInvalidRequest.into());
}
let final_path = self.images_path.join(&final_filename);
if final_path.exists() {
return Err(AppError::BadRequest(format!(
"Image already exists: {}",
final_filename
)));
return Err(MsdErrorCode::MsdResourceAlreadyExists.into());
}
let temp_filename = format!(".download_{}", uuid::Uuid::new_v4());
@@ -304,13 +280,11 @@ impl ImageManager {
.get(url)
.send()
.await
.map_err(|e| AppError::Internal(format!("Download failed: {}", e)))?;
.map_err(|error| remote_download_error("send GET request", error))?;
if !response.status().is_success() {
return Err(AppError::Internal(format!(
"Download failed: HTTP {}",
response.status()
)));
warn!(status = %response.status(), "MSD image GET request failed");
return Err(MsdErrorCode::MsdRemoteDownloadFailed.into());
}
let content_length = response
@@ -322,7 +296,7 @@ impl ImageManager {
let mut file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
.map_err(|error| storage_io_error("create image download", error))?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
@@ -334,11 +308,11 @@ impl ImageManager {
while let Some(chunk_result) = stream.next().await {
let chunk =
chunk_result.map_err(|e| AppError::Internal(format!("Download error: {}", e)))?;
chunk_result.map_err(|error| remote_download_error("read response body", error))?;
file.write_all(&chunk).await.map_err(|e| {
file.write_all(&chunk).await.map_err(|error| {
let _ = std::fs::remove_file(&temp_path);
AppError::Internal(format!("Failed to write data: {}", e))
storage_io_error("write image download", error)
})?;
downloaded += chunk.len() as u64;
@@ -360,29 +334,29 @@ impl ImageManager {
file.flush()
.await
.map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?;
.map_err(|error| storage_io_error("flush image download", error))?;
drop(file);
let metadata = tokio::fs::metadata(&temp_path)
.await
.map_err(|e| AppError::Internal(format!("Failed to read file metadata: {}", e)))?;
.map_err(|error| storage_io_error("read downloaded image metadata", error))?;
if let Some(expected) = content_length {
if metadata.len() != expected {
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(AppError::Internal(format!(
"Download incomplete: got {} bytes, expected {}",
metadata.len(),
expected
)));
warn!(
actual = metadata.len(),
expected, "MSD image download was incomplete"
);
return Err(MsdErrorCode::MsdDownloadIncomplete.into());
}
}
tokio::fs::rename(&temp_path, &final_path)
.await
.map_err(|e| {
.map_err(|error| {
let _ = std::fs::remove_file(&temp_path);
AppError::Internal(format!("Failed to move file: {}", e))
storage_io_error("commit image download", error)
})?;
info!(
@@ -395,6 +369,26 @@ impl ImageManager {
}
}
fn storage_io_error(operation: &'static str, error: std::io::Error) -> AppError {
warn!(operation, %error, "MSD storage operation failed");
#[cfg(unix)]
let code = match error.raw_os_error() {
Some(libc::EFBIG) => MsdErrorCode::MsdImageTooLarge,
Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull,
Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly,
Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied,
_ => MsdErrorCode::MsdOperationFailed,
};
#[cfg(not(unix))]
let code = MsdErrorCode::MsdOperationFailed;
code.into()
}
fn remote_download_error(operation: &'static str, error: reqwest::Error) -> AppError {
warn!(operation, %error, "MSD remote download failed");
MsdErrorCode::MsdRemoteDownloadFailed.into()
}
fn stable_image_id_from_filename(name: &str) -> String {
let mut hash: u64 = 0;
for (i, byte) in name.bytes().enumerate() {
@@ -490,4 +484,18 @@ mod tests {
assert!(manager.list().unwrap().is_empty());
}
#[test]
fn classifies_storage_io_errors() {
for (errno, expected) in [
(libc::EFBIG, MsdErrorCode::MsdImageTooLarge),
(libc::ENOSPC, MsdErrorCode::MsdStorageFull),
(libc::EROFS, MsdErrorCode::MsdStorageReadOnly),
(libc::EACCES, MsdErrorCode::MsdStoragePermissionDenied),
(libc::EPERM, MsdErrorCode::MsdStoragePermissionDenied),
] {
let error = storage_io_error("test", std::io::Error::from_raw_os_error(errno));
assert!(matches!(error, AppError::Msd(error) if error.code() == expected));
}
}
}

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::info;
use tracing::{info, warn};
use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage};
use super::types::{DriveFile, DriveInfo};
use crate::error::{AppError, Result};
use super::types::{DriveFile, DriveFileAccess, DriveInfo};
use crate::error::{AppError, MsdErrorCode, Result};
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
@@ -35,19 +35,15 @@ impl VentoyDrive {
&self.path
}
/// Returns just the raw file size without attempting to parse the filesystem.
/// Used as a fallback when the image has been reformatted to an unsupported
/// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open.
pub fn raw_size(&self) -> Option<u64> {
std::fs::metadata(&self.path).ok().map(|m| m.len())
/// Read and validate only the backing file metadata, without parsing its
/// partition table or filesystem.
pub fn raw_info(&self, file_access: DriveFileAccess) -> Result<DriveInfo> {
raw_drive_info(&self.path, file_access)
}
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
if size_mb < MIN_DRIVE_SIZE_MB {
return Err(AppError::BadRequest(format!(
"Drive size must be at least {} MB",
MIN_DRIVE_SIZE_MB
)));
return Err(MsdErrorCode::MsdDriveSizeInvalid.into());
}
let size_str = format!("{}M", size_mb);
let path = self.path.clone();
@@ -59,38 +55,42 @@ impl VentoyDrive {
VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(drive_init_error)?;
let metadata = std::fs::metadata(&path)
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
.map_err(|error| drive_io_error("read initialized drive metadata", error))?;
Ok::<DriveInfo, AppError>(DriveInfo {
size: metadata.len(),
used: 0,
free: metadata.len(),
used: Some(0),
free: Some(metadata.len()),
initialized: true,
file_access: DriveFileAccess::Available,
path,
})
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??;
.map_err(|error| task_error("initialize virtual drive", error))??;
info!("Ventoy drive created successfully");
Ok(info)
}
pub async fn info(&self) -> Result<DriveInfo> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
}
let path = self.path.clone();
let _lock = self.lock.read().await;
tokio::task::spawn_blocking(move || {
let metadata = std::fs::metadata(&path)
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
let raw = raw_drive_info(&path, DriveFileAccess::Unsupported)?;
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
let image = match VentoyImage::open(&path) {
Ok(image) => image,
Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw),
Err(error) => return Err(ventoy_to_app_error(error)),
};
let files = image.list_files_recursive().map_err(ventoy_to_app_error)?;
let files = match image.list_files_recursive() {
Ok(files) => files,
Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw),
Err(error) => return Err(ventoy_to_app_error(error)),
};
let used: u64 = files
.iter()
@@ -98,24 +98,25 @@ impl VentoyDrive {
.map(|f| f.size)
.sum();
let size = metadata.len();
let size = raw.size;
let free = size.saturating_sub(used);
Ok(DriveInfo {
size,
used,
free,
used: Some(used),
free: Some(free),
initialized: true,
file_access: DriveFileAccess::Available,
path,
})
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
.map_err(|error| task_error("read virtual drive info", error))?
}
pub async fn list_files(&self, dir_path: &str) -> Result<Vec<DriveFile>> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let path = self.path.clone();
@@ -138,7 +139,7 @@ impl VentoyDrive {
.collect())
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
.map_err(|error| task_error("list virtual drive files", error))?
}
pub async fn write_file_from_multipart_field(
@@ -147,7 +148,7 @@ impl VentoyDrive {
mut field: axum::extract::multipart::Field<'_>,
) -> Result<u64> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let temp_dir = self.path.parent().unwrap_or(Path::new("/tmp"));
@@ -156,24 +157,23 @@ impl VentoyDrive {
let mut temp_file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
.map_err(|error| drive_io_error("create virtual drive upload", error))?;
let mut bytes_written: u64 = 0;
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))?
{
while let Some(chunk) = field.chunk().await.map_err(|error| {
warn!(%error, "Failed to read virtual drive upload chunk");
AppError::from(MsdErrorCode::MsdOperationFailed)
})? {
bytes_written += chunk.len() as u64;
tokio::io::AsyncWriteExt::write_all(&mut temp_file, &chunk)
.await
.map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?;
.map_err(|error| drive_io_error("write virtual drive upload", error))?;
}
tokio::io::AsyncWriteExt::flush(&mut temp_file)
.await
.map_err(|e| AppError::Internal(format!("Failed to flush temp file: {}", e)))?;
.map_err(|error| drive_io_error("flush virtual drive upload", error))?;
drop(temp_file);
let path = self.path.clone();
@@ -191,7 +191,7 @@ impl VentoyDrive {
Ok::<(), AppError>(())
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?;
.map_err(|error| task_error("write virtual drive file", error))?;
let _ = tokio::fs::remove_file(&temp_path).await;
@@ -202,7 +202,7 @@ impl VentoyDrive {
#[cfg(test)]
pub async fn read_file(&self, file_path: &str) -> Result<Vec<u8>> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let path = self.path.clone();
@@ -215,12 +215,12 @@ impl VentoyDrive {
image.read_file(&file_path).map_err(ventoy_to_app_error)
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
.map_err(|error| task_error("read virtual drive file", error))?
}
pub async fn get_file_info(&self, file_path: &str) -> Result<Option<DriveFile>> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let path = self.path.clone();
@@ -234,7 +234,7 @@ impl VentoyDrive {
.map_err(ventoy_to_app_error)
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??;
.map_err(|error| task_error("read virtual drive file information", error))??;
Ok(info.map(|f| DriveFile {
name: f.name,
@@ -253,19 +253,16 @@ impl VentoyDrive {
tokio::sync::mpsc::Receiver<std::result::Result<bytes::Bytes, std::io::Error>>,
)> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let file_info = self
.get_file_info(file_path)
.await?
.ok_or_else(|| AppError::NotFound(format!("File not found: {}", file_path)))?;
.ok_or_else(|| AppError::from(MsdErrorCode::MsdResourceNotFound))?;
if file_info.is_dir {
return Err(AppError::BadRequest(format!(
"'{}' is a directory",
file_path
)));
return Err(MsdErrorCode::MsdInvalidRequest.into());
}
let file_size = file_info.size;
@@ -300,7 +297,7 @@ impl VentoyDrive {
pub async fn mkdir(&self, dir_path: &str) -> Result<()> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let path = self.path.clone();
@@ -315,12 +312,12 @@ impl VentoyDrive {
.map_err(ventoy_to_app_error)
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
.map_err(|error| task_error("create virtual drive directory", error))?
}
pub async fn delete(&self, path_to_delete: &str) -> Result<()> {
if !self.exists() {
return Err(AppError::Internal("Drive not initialized".to_string()));
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
}
let path = self.path.clone();
@@ -335,22 +332,52 @@ impl VentoyDrive {
.map_err(ventoy_to_app_error)
})
.await
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
.map_err(|error| task_error("delete virtual drive resource", error))?
}
}
fn ventoy_to_app_error(err: VentoyError) -> AppError {
match err {
VentoyError::Io(e) => AppError::Io(e),
VentoyError::InvalidSize(s) => AppError::BadRequest(format!("Invalid size: {}", s)),
VentoyError::SizeParseError(s) => AppError::BadRequest(format!("Size parse error: {}", s)),
VentoyError::FilesystemError(s) => AppError::Internal(format!("Filesystem error: {}", s)),
VentoyError::ImageError(s) => AppError::Internal(format!("Image error: {}", s)),
VentoyError::FileNotFound(s) => AppError::NotFound(format!("File not found: {}", s)),
VentoyError::ResourceNotFound(s) => {
AppError::Internal(format!("Resource not found: {}", s))
fn raw_drive_info(path: &Path, file_access: DriveFileAccess) -> Result<DriveInfo> {
let metadata = std::fs::metadata(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
AppError::from(MsdErrorCode::MsdDriveNotInitialized)
} else {
drive_io_error("read drive metadata", error)
}
})?;
if !metadata.is_file() || metadata.len() == 0 {
return Err(MsdErrorCode::MsdDriveSizeInvalid.into());
}
Ok(DriveInfo::from_raw(
path.to_path_buf(),
metadata.len(),
file_access,
))
}
fn is_unsupported_filesystem_error(error: &VentoyError) -> bool {
matches!(
error,
VentoyError::FilesystemError(_)
| VentoyError::ImageError(_)
| VentoyError::PartitionError(_)
)
}
fn ventoy_to_app_error(err: VentoyError) -> AppError {
warn!(%err, "Virtual drive filesystem operation failed");
match err {
VentoyError::Io(error) => drive_io_error("access virtual drive", error),
VentoyError::InvalidSize(_) | VentoyError::SizeParseError(_) => {
MsdErrorCode::MsdDriveSizeInvalid.into()
}
VentoyError::FilesystemError(_)
| VentoyError::ImageError(_)
| VentoyError::PartitionError(_) => MsdErrorCode::MsdDriveFilesystemUnsupported.into(),
VentoyError::FileNotFound(_) | VentoyError::ResourceNotFound(_) => {
MsdErrorCode::MsdResourceNotFound.into()
}
VentoyError::PartitionError(s) => AppError::Internal(format!("Partition error: {}", s)),
}
}
@@ -361,21 +388,35 @@ fn drive_init_error(err: VentoyError) -> AppError {
#[cfg(unix)]
match error.raw_os_error() {
Some(libc::EFBIG) => AppError::BadRequest(
"MSD directory filesystem does not support a virtual drive file of this size".into(),
),
Some(libc::ENOSPC) => AppError::BadRequest(
"MSD directory does not have enough free space for the virtual drive".into(),
),
Some(libc::EROFS) => AppError::BadRequest("MSD directory filesystem is read-only".into()),
Some(libc::EACCES | libc::EPERM) => AppError::BadRequest(
"One-KVM does not have permission to write to the MSD directory".into(),
),
_ => AppError::Io(error),
Some(libc::EFBIG) => MsdErrorCode::MsdDriveSizeInvalid.into(),
Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull.into(),
Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly.into(),
Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied.into(),
_ => drive_io_error("initialize virtual drive", error),
}
#[cfg(not(unix))]
AppError::Io(error)
drive_io_error("initialize virtual drive", error)
}
fn drive_io_error(operation: &'static str, error: std::io::Error) -> AppError {
warn!(operation, %error, "Virtual drive storage operation failed");
#[cfg(unix)]
let code = match error.raw_os_error() {
Some(libc::EFBIG) => MsdErrorCode::MsdImageTooLarge,
Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull,
Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly,
Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied,
_ => MsdErrorCode::MsdOperationFailed,
};
#[cfg(not(unix))]
let code = MsdErrorCode::MsdOperationFailed;
code.into()
}
fn task_error(operation: &'static str, error: tokio::task::JoinError) -> AppError {
warn!(operation, %error, "Virtual drive task failed");
MsdErrorCode::MsdOperationFailed.into()
}
fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile {
@@ -470,16 +511,35 @@ mod tests {
#[test]
fn classifies_drive_creation_io_errors() {
for (errno, expected) in [
(libc::EFBIG, "does not support"),
(libc::ENOSPC, "enough free space"),
(libc::EROFS, "read-only"),
(libc::EACCES, "permission"),
(libc::EFBIG, MsdErrorCode::MsdDriveSizeInvalid),
(libc::ENOSPC, MsdErrorCode::MsdStorageFull),
(libc::EROFS, MsdErrorCode::MsdStorageReadOnly),
(libc::EACCES, MsdErrorCode::MsdStoragePermissionDenied),
(libc::EPERM, MsdErrorCode::MsdStoragePermissionDenied),
] {
let error = drive_init_error(VentoyError::Io(std::io::Error::from_raw_os_error(errno)));
assert!(matches!(error, AppError::BadRequest(message) if message.contains(expected)));
assert!(matches!(error, AppError::Msd(error) if error.code() == expected));
}
}
#[test]
fn classifies_ventoy_filesystem_and_resource_errors() {
for error in [
VentoyError::FilesystemError("details".into()),
VentoyError::ImageError("details".into()),
VentoyError::PartitionError("details".into()),
] {
assert!(matches!(
ventoy_to_app_error(error),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported
));
}
assert!(matches!(
ventoy_to_app_error(VentoyError::FileNotFound("details".into())),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdResourceNotFound
));
}
fn init_ventoy_resources() -> bool {
static INIT: OnceLock<bool> = OnceLock::new();
*INIT.get_or_init(|| {
@@ -548,9 +608,71 @@ mod tests {
let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
assert!(info.initialized);
assert_eq!(info.file_access, DriveFileAccess::Available);
assert_eq!(info.used, Some(0));
assert!(info.free.is_some());
assert!(drive.exists());
}
#[tokio::test]
async fn raw_bytes_are_reported_as_unsupported_with_capacity() {
let temp_dir = TempDir::new().unwrap();
let drive_path = temp_dir.path().join("custom.img");
std::fs::write(&drive_path, vec![0x5a; 1024 * 1024]).unwrap();
let drive = VentoyDrive::new(drive_path);
let info = drive.info().await.unwrap();
assert_eq!(info.size, 1024 * 1024);
assert_eq!(info.used, None);
assert_eq!(info.free, None);
assert_eq!(info.file_access, DriveFileAccess::Unsupported);
assert!(matches!(
drive.list_files("/").await.unwrap_err(),
AppError::Msd(error)
if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported
));
}
#[test]
fn raw_metadata_rejects_missing_empty_and_non_file_paths() {
let temp_dir = TempDir::new().unwrap();
let missing = VentoyDrive::new(temp_dir.path().join("missing.img"));
assert!(matches!(
missing.raw_info(DriveFileAccess::Unknown).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveNotInitialized
));
let empty_path = temp_dir.path().join("empty.img");
std::fs::write(&empty_path, []).unwrap();
let empty = VentoyDrive::new(empty_path);
assert!(matches!(
empty.raw_info(DriveFileAccess::Unknown).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid
));
let directory = VentoyDrive::new(temp_dir.path().to_path_buf());
assert!(matches!(
directory.raw_info(DriveFileAccess::Unknown).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid
));
}
#[tokio::test]
async fn supported_drive_info_has_space_values() {
if !ensure_resources() {
return;
}
let temp_dir = TempDir::new().unwrap();
let drive = VentoyDrive::new(temp_dir.path().join("supported.img"));
drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
let info = drive.info().await.unwrap();
assert_eq!(info.file_access, DriveFileAccess::Available);
assert!(info.used.is_some());
assert!(info.free.is_some());
}
#[tokio::test]
async fn test_drive_mkdir() {
if !ensure_resources() {

View File

@@ -67,8 +67,14 @@ struct BridgeJournal {
existing_bridge: bool,
original_connection_uuid: Option<String>,
bridge_profile_uuid: Option<String>,
#[serde(default)]
bridge_profile_name: Option<String>,
uplink_profile_uuid: Option<String>,
#[serde(default)]
uplink_profile_name: Option<String>,
usb_profile_uuid: String,
#[serde(default)]
usb_profile_name: Option<String>,
}
#[derive(Debug)]
@@ -87,11 +93,11 @@ impl TransactionProfiles {
let suffix = &transaction[..12];
Self {
bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"),
bridge_uuid: Uuid::new_v4().to_string(),
bridge_uuid: String::new(),
uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"),
uplink_uuid: Uuid::new_v4().to_string(),
uplink_uuid: String::new(),
usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"),
usb_uuid: Uuid::new_v4().to_string(),
usb_uuid: String::new(),
}
}
}
@@ -151,15 +157,18 @@ impl NetworkBridgeRuntime {
.trim()
.to_string();
let profiles = TransactionProfiles::new();
let journal = BridgeJournal {
let mut profiles = TransactionProfiles::new();
let mut journal = BridgeJournal {
version: JOURNAL_VERSION,
uplink: uplink.to_string(),
existing_bridge: false,
original_connection_uuid: Some(original_connection_uuid.clone()),
bridge_profile_uuid: Some(profiles.bridge_uuid.clone()),
uplink_profile_uuid: Some(profiles.uplink_uuid.clone()),
usb_profile_uuid: profiles.usb_uuid.clone(),
bridge_profile_uuid: None,
bridge_profile_name: Some(profiles.bridge_name.clone()),
uplink_profile_uuid: None,
uplink_profile_name: Some(profiles.uplink_name.clone()),
usb_profile_uuid: String::new(),
usb_profile_name: Some(profiles.usb_name.clone()),
};
write_journal(&journal)?;
@@ -174,9 +183,10 @@ impl NetworkBridgeRuntime {
BRIDGE_IF,
"con-name",
&profiles.bridge_name,
"connection.uuid",
&profiles.bridge_uuid,
])?;
profiles.bridge_uuid = connection_value(&profiles.bridge_name, "connection.uuid")?;
journal.bridge_profile_uuid = Some(profiles.bridge_uuid.clone());
write_journal(&journal)?;
run_nmcli(&[
"connection",
"modify",
@@ -220,8 +230,6 @@ impl NetworkBridgeRuntime {
uplink,
"con-name",
&profiles.uplink_name,
"connection.uuid",
&profiles.uplink_uuid,
"master",
BRIDGE_IF,
"slave-type",
@@ -229,6 +237,9 @@ impl NetworkBridgeRuntime {
"connection.autoconnect",
"no",
])?;
profiles.uplink_uuid = connection_value(&profiles.uplink_name, "connection.uuid")?;
journal.uplink_profile_uuid = Some(profiles.uplink_uuid.clone());
write_journal(&journal)?;
run_nmcli(&[
"connection",
"add",
@@ -238,8 +249,6 @@ impl NetworkBridgeRuntime {
usb_interface,
"con-name",
&profiles.usb_name,
"connection.uuid",
&profiles.usb_uuid,
"master",
BRIDGE_IF,
"slave-type",
@@ -247,6 +256,9 @@ impl NetworkBridgeRuntime {
"connection.autoconnect",
"no",
])?;
profiles.usb_uuid = connection_value(&profiles.usb_name, "connection.uuid")?;
journal.usb_profile_uuid = profiles.usb_uuid.clone();
write_journal(&journal)?;
Ok(())
})();
if let Err(error) = prepare_result {
@@ -449,17 +461,27 @@ fn select_bridge_candidate<'a>(
fn restore_from_journal(journal: &BridgeJournal) -> Result<()> {
let mut errors = Vec::new();
for (kind, profile_uuid) in [
("USB", Some(journal.usb_profile_uuid.as_str())),
("uplink", journal.uplink_profile_uuid.as_deref()),
("bridge", journal.bridge_profile_uuid.as_deref()),
for (kind, profile_uuid, profile_name) in [
(
"USB",
(!journal.usb_profile_uuid.is_empty()).then_some(journal.usb_profile_uuid.as_str()),
journal.usb_profile_name.as_deref(),
),
(
"uplink",
journal.uplink_profile_uuid.as_deref(),
journal.uplink_profile_name.as_deref(),
),
(
"bridge",
journal.bridge_profile_uuid.as_deref(),
journal.bridge_profile_name.as_deref(),
),
] {
let Some(profile_uuid) = profile_uuid else {
continue;
};
if let Err(error) = delete_connection(profile_uuid) {
if let Err(error) = delete_owned_connection(profile_uuid, profile_name) {
let profile = profile_uuid.or(profile_name).unwrap_or("unknown");
errors.push(format!(
"failed to remove owned {kind} profile {profile_uuid}: {error}"
"failed to remove owned {kind} profile {profile}: {error}"
));
}
}
@@ -627,12 +649,23 @@ fn connection_uuids() -> Result<Vec<String>> {
.collect())
}
fn delete_connection(profile_uuid: &str) -> Result<()> {
if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) {
fn delete_owned_connection(profile_uuid: Option<&str>, profile_name: Option<&str>) -> Result<()> {
if let Some(profile_uuid) = profile_uuid {
if connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) {
return run_nmcli(&["connection", "delete", "uuid", profile_uuid]).map(|_| ());
}
}
let Some(profile_name) = profile_name else {
return Ok(());
};
let output = run_nmcli(&["-t", "--escape", "no", "-f", "NAME", "connection", "show"])?;
if !String::from_utf8_lossy(&output.stdout)
.lines()
.any(|name| name == profile_name)
{
return Ok(());
}
run_nmcli(&["connection", "delete", "uuid", profile_uuid])?;
Ok(())
run_nmcli(&["connection", "delete", "id", profile_name]).map(|_| ())
}
fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> {
@@ -834,8 +867,11 @@ mod tests {
existing_bridge: false,
original_connection_uuid: Some("original-uuid".to_string()),
bridge_profile_uuid: Some("bridge-uuid".to_string()),
bridge_profile_name: Some("bridge-name".to_string()),
uplink_profile_uuid: Some("uplink-uuid".to_string()),
uplink_profile_name: Some("uplink-name".to_string()),
usb_profile_uuid: "usb-uuid".to_string(),
usb_profile_name: Some("usb-name".to_string()),
};
let value = serde_json::to_string(&journal).unwrap();
let decoded: BridgeJournal = serde_json::from_str(&value).unwrap();
@@ -844,13 +880,31 @@ mod tests {
}
#[test]
fn transaction_profiles_use_unique_names_and_uuids() {
fn bridge_journal_accepts_legacy_entries_without_profile_names() {
let value = r#"{
"version": 2,
"uplink": "eth0",
"existing_bridge": false,
"original_connection_uuid": "original-uuid",
"bridge_profile_uuid": "bridge-uuid",
"uplink_profile_uuid": "uplink-uuid",
"usb_profile_uuid": "usb-uuid"
}"#;
let decoded: BridgeJournal = serde_json::from_str(value).unwrap();
assert_eq!(decoded.bridge_profile_name, None);
assert_eq!(decoded.uplink_profile_name, None);
assert_eq!(decoded.usb_profile_name, None);
}
#[test]
fn transaction_profiles_use_unique_names() {
let first = TransactionProfiles::new();
let second = TransactionProfiles::new();
assert_ne!(first.bridge_name, second.bridge_name);
assert_ne!(first.bridge_uuid, second.bridge_uuid);
assert!(first.usb_name.starts_with(PROFILE_PREFIX));
assert!(Uuid::parse_str(&first.usb_uuid).is_ok());
assert!(first.bridge_uuid.is_empty());
assert!(first.uplink_uuid.is_empty());
assert!(first.usb_uuid.is_empty());
}
#[test]

View File

@@ -106,6 +106,16 @@ pub fn write_file(path: &Path, content: &str) -> Result<()> {
Ok(())
}
/// Write an optional configfs/sysfs attribute when the running kernel exposes it.
/// This keeps newer kernel enhancements compatible with older kernels.
pub fn write_file_if_exists(path: &Path, content: &str) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
write_file(path, content)?;
Ok(true)
}
pub fn write_bytes(path: &Path, data: &[u8]) -> Result<()> {
let mut file = File::create(path)
.map_err(|e| AppError::Internal(format!("Failed to create {}: {}", path.display(), e)))?;

View File

@@ -3,6 +3,7 @@ use tracing::debug;
use super::configfs::{
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
write_file_if_exists,
};
use super::function::GadgetFunction;
use super::report_desc::{
@@ -143,6 +144,10 @@ impl GadgetFunction for HidFunction {
self.func_type.report_desc(self.keyboard_leds),
)?;
// Supported by the PiKVM HID kernel patch. Older kernels simply do
// not expose this ConfigFS attribute.
let _ = write_file_if_exists(&func_path.join("wakeup_on_write"), "1")?;
debug!(
"Created HID function: {} at {}",
self.name(),

View File

@@ -4,12 +4,12 @@ use tracing::{debug, error, info, warn};
use super::configfs::{
configfs_path, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir,
remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
remove_file, write_file, write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE,
DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
};
use super::function::GadgetFunction;
use super::hid::HidFunction;
use super::msd::MsdFunction;
use super::msd::{MsdFunction, MsdInquiryStrings};
use super::network::NetworkFunction;
use crate::config::OtgNetworkConfig;
use crate::error::{AppError, Result};
@@ -47,6 +47,7 @@ pub struct OtgGadgetManager {
hid_instance: u8,
msd_instance: u8,
network_instance: u8,
uac_instance: u8,
functions: Vec<Box<dyn GadgetFunction>>,
bound_udc: Option<String>,
created_by_us: bool,
@@ -73,6 +74,7 @@ impl OtgGadgetManager {
hid_instance: 0,
msd_instance: 0,
network_instance: 0,
uac_instance: 0,
functions: Vec::with_capacity(4),
bound_udc: None,
created_by_us: false,
@@ -132,8 +134,12 @@ impl OtgGadgetManager {
Ok(device_path)
}
pub fn add_msd(&mut self, lun_capacity: u8) -> Result<MsdFunction> {
let func = MsdFunction::new(self.msd_instance, lun_capacity)?;
pub fn add_msd(
&mut self,
lun_capacity: u8,
inquiry_strings: MsdInquiryStrings,
) -> Result<MsdFunction> {
let func = MsdFunction::new(self.msd_instance, lun_capacity, inquiry_strings)?;
let func_clone = func.clone();
self.add_function(Box::new(func))?;
self.msd_instance += 1;
@@ -148,6 +154,14 @@ impl OtgGadgetManager {
Ok(func_clone)
}
pub fn add_uac(&mut self, sample_rate: u32, channels: u8) -> Result<super::uac::UacFunction> {
let func = super::uac::UacFunction::new(self.uac_instance, sample_rate, channels)?;
let func_clone = func.clone();
self.add_function(Box::new(func))?;
self.uac_instance += 1;
Ok(func_clone)
}
fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> {
self.functions.push(func);
Ok(())
@@ -186,6 +200,22 @@ impl OtgGadgetManager {
func.link(&self.config_path, &self.gadget_path)?;
}
// A host only enables USB remote wakeup when the configuration
// descriptor advertises it. Enable the descriptor bit only when the
// running kernel supports the HID wakeup_on_write attribute.
let hid_wakeup_supported = self.functions.iter().any(|func| {
func.name().starts_with("hid.")
&& self
.gadget_path
.join("functions")
.join(func.name())
.join("wakeup_on_write")
.exists()
});
if hid_wakeup_supported {
let _ = write_file_if_exists(&self.config_path.join("bmAttributes"), "0xA0")?;
}
debug!("OTG USB Gadget setup complete");
Ok(())
}

View File

@@ -18,6 +18,8 @@ pub mod report_desc;
pub mod self_check;
#[cfg(unix)]
pub mod service;
#[cfg(unix)]
pub mod uac;
#[cfg(unix)]
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
@@ -27,6 +29,8 @@ pub use msd::{MsdFunction, MsdLunConfig};
pub use network::NetworkFunction;
#[cfg(unix)]
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
#[cfg(unix)]
pub use uac::UacFunction;
/// List USB Device Controller names exposed by sysfs.
pub fn list_udc_devices() -> Vec<String> {

View File

@@ -1,10 +1,14 @@
use std::fs;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use super::configfs::{create_dir, create_symlink, remove_dir, remove_file, write_file};
use super::function::GadgetFunction;
use crate::error::{AppError, Result};
use crate::config::{MsdConfig, DEFAULT_CDROM_INQUIRY_STRING, DEFAULT_FLASH_INQUIRY_STRING};
use crate::error::{AppError, MsdErrorCode, Result};
const MEDIA_TYPE_REBIND_DELAY_MS: u64 = 300;
#[derive(Debug, Clone)]
pub struct MsdLunConfig {
@@ -53,14 +57,39 @@ impl MsdLunConfig {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MsdInquiryStrings {
pub flash: String,
pub cdrom: String,
}
impl Default for MsdInquiryStrings {
fn default() -> Self {
Self {
flash: DEFAULT_FLASH_INQUIRY_STRING.to_string(),
cdrom: DEFAULT_CDROM_INQUIRY_STRING.to_string(),
}
}
}
impl From<&MsdConfig> for MsdInquiryStrings {
fn from(config: &MsdConfig) -> Self {
Self {
flash: config.flash_inquiry_string.clone(),
cdrom: config.cdrom_inquiry_string.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct MsdFunction {
name: String,
lun_capacity: u8,
inquiry_strings: MsdInquiryStrings,
}
impl MsdFunction {
pub fn new(instance: u8, lun_capacity: u8) -> Result<Self> {
pub fn new(instance: u8, lun_capacity: u8, inquiry_strings: MsdInquiryStrings) -> Result<Self> {
if lun_capacity != 1 && lun_capacity != 8 {
return Err(AppError::BadRequest(format!(
"MSD LUN capacity must be 1 or 8, got {lun_capacity}"
@@ -70,6 +99,7 @@ impl MsdFunction {
Ok(Self {
name: format!("mass_storage.usb{}", instance),
lun_capacity,
inquiry_strings,
})
}
@@ -150,6 +180,88 @@ impl MsdFunction {
)));
}
let current_cdrom = fs::read_to_string(lun_path.join("cdrom"))
.unwrap_or_default()
.trim()
.to_string();
let rebind_required = Self::media_type_rebind_required(&current_cdrom, config);
let udc_path = gadget_path.join("UDC");
let bound_udc = if rebind_required && udc_path.exists() {
fs::read_to_string(&udc_path)
.map_err(|error| {
AppError::Internal(format!(
"Failed to read bound UDC before changing LUN {lun} media type: {error}"
))
})?
.trim()
.to_string()
} else {
String::new()
};
if !bound_udc.is_empty() {
info!(
"LUN {} media type is changing; temporarily unbinding UDC {}",
lun, bound_udc
);
write_file(&udc_path, "")?;
std::thread::sleep(std::time::Duration::from_millis(MEDIA_TYPE_REBIND_DELAY_MS));
}
let configure_result = self.configure_lun_attributes(&lun_path, lun, config);
let rebind_result = if bound_udc.is_empty() {
Ok(())
} else {
let result = write_file(&udc_path, &bound_udc);
if result.is_ok() {
std::thread::sleep(std::time::Duration::from_millis(MEDIA_TYPE_REBIND_DELAY_MS));
info!(
"Rebound UDC {} after changing LUN {} media type",
bound_udc, lun
);
}
result
};
match (configure_result, rebind_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(configure_error), Ok(())) => Err(configure_error),
(Ok(()), Err(rebind_error)) => Err(AppError::Internal(format!(
"Configured LUN {lun}, but failed to rebind UDC {bound_udc}: {rebind_error}"
))),
(Err(configure_error), Err(rebind_error)) => Err(AppError::Internal(format!(
"Failed to configure LUN {lun}: {configure_error}; also failed to rebind UDC {bound_udc}: {rebind_error}"
))),
}
}
fn media_type_rebind_required(current_cdrom: &str, config: &MsdLunConfig) -> bool {
current_cdrom != if config.cdrom { "1" } else { "0" }
}
fn inquiry_string_path(lun_path: &Path, cdrom: bool) -> Option<PathBuf> {
let cdrom_path = lun_path.join("inquiry_string_cdrom");
if cdrom && cdrom_path.exists() {
return Some(cdrom_path);
}
let generic_path = lun_path.join("inquiry_string");
generic_path.exists().then_some(generic_path)
}
fn inquiry_string(&self, cdrom: bool) -> &str {
if cdrom {
&self.inquiry_strings.cdrom
} else {
&self.inquiry_strings.flash
}
}
fn configure_lun_attributes(
&self,
lun_path: &Path,
lun: u8,
config: &MsdLunConfig,
) -> Result<()> {
let read_attr = |attr: &str| -> String {
fs::read_to_string(lun_path.join(attr))
.unwrap_or_default()
@@ -161,7 +273,6 @@ impl MsdFunction {
let current_ro = read_attr("ro");
let current_removable = read_attr("removable");
let current_nofua = read_attr("nofua");
let new_cdrom = if config.cdrom { "1" } else { "0" };
let new_ro = if config.ro { "1" } else { "0" };
let new_removable = if config.removable { "1" } else { "0" };
@@ -170,20 +281,26 @@ impl MsdFunction {
let forced_eject_path = lun_path.join("forced_eject");
if forced_eject_path.exists() {
debug!("Using forced_eject to clear LUN {}", lun);
let _ = write_file(&forced_eject_path, "1");
if let Err(error) = write_file(&forced_eject_path, "1") {
warn!(
"LUN {} forced_eject failed while changing media: {}; clearing file instead",
lun, error
);
write_file(&lun_path.join("file"), "")?;
}
} else {
let _ = write_file(&lun_path.join("file"), "");
write_file(&lun_path.join("file"), "")?;
}
std::thread::sleep(std::time::Duration::from_millis(50));
let cdrom_changed = current_cdrom != new_cdrom;
if cdrom_changed {
if current_cdrom != new_cdrom {
debug!(
"Updating LUN {} cdrom: {} -> {}",
lun, current_cdrom, new_cdrom
);
write_file(&lun_path.join("cdrom"), new_cdrom)?;
self.write_inquiry_string(lun_path, config.cdrom)?;
}
if current_ro != new_ro {
debug!("Updating LUN {} ro: {} -> {}", lun, current_ro, new_ro);
@@ -204,11 +321,6 @@ impl MsdFunction {
write_file(&lun_path.join("nofua"), new_nofua)?;
}
if cdrom_changed {
debug!("CDROM mode changed, brief yield for USB host");
std::thread::sleep(std::time::Duration::from_millis(50));
}
if config.file.exists() {
let file_path = config.file.to_string_lossy();
let mut last_error = None;
@@ -225,10 +337,9 @@ impl MsdFunction {
);
return Ok(());
}
Err(e) => {
let is_busy = e.to_string().contains("Device or resource busy")
|| e.to_string().contains("os error 16");
Err(error) => {
let is_busy = error.to_string().contains("Device or resource busy")
|| error.to_string().contains("os error 16");
if is_busy && attempt < 4 {
warn!(
"LUN {} file write busy, retrying (attempt {}/5)",
@@ -236,17 +347,16 @@ impl MsdFunction {
attempt + 1
);
std::thread::sleep(std::time::Duration::from_millis(50 << attempt));
last_error = Some(e);
last_error = Some(error);
continue;
}
return Err(e);
return Err(error);
}
}
}
if let Some(e) = last_error {
return Err(e);
if let Some(error) = last_error {
return Err(error);
}
} else if !config.file.as_os_str().is_empty() {
warn!("LUN {} file does not exist: {}", lun, config.file.display());
@@ -255,6 +365,26 @@ impl MsdFunction {
Ok(())
}
fn write_inquiry_string(&self, lun_path: &Path, cdrom: bool) -> Result<()> {
if let Some(path) = Self::inquiry_string_path(lun_path, cdrom) {
write_file(&path, self.inquiry_string(cdrom))?;
}
Ok(())
}
fn write_inquiry_strings(&self, lun_path: &Path) -> Result<()> {
let generic_path = lun_path.join("inquiry_string");
if generic_path.exists() {
write_file(&generic_path, &self.inquiry_strings.flash)?;
}
let cdrom_path = lun_path.join("inquiry_string_cdrom");
if cdrom_path.exists() {
write_file(&cdrom_path, &self.inquiry_strings.cdrom)?;
}
Ok(())
}
pub async fn disconnect_lun_async(&self, gadget_path: &Path, lun: u8) -> Result<()> {
let gadget_path = gadget_path.to_path_buf();
let this = self.clone();
@@ -276,6 +406,52 @@ impl MsdFunction {
self.disconnect_lun_path(&lun_path, lun as u16)
}
fn medium_removal_was_prevented(error: &std::io::Error) -> bool {
error.raw_os_error() == Some(libc::EBUSY)
}
fn clear_lun_file(file_path: &Path, lun: u16) -> Result<()> {
let mut file = OpenOptions::new()
.write(true)
.open(file_path)
.map_err(|error| {
warn!(
lun,
path = %file_path.display(),
%error,
"Failed to open MSD LUN backing-file attribute while disconnecting"
);
AppError::from(MsdErrorCode::MsdDisconnectFailed)
})?;
// An empty configfs value is represented by a newline. Keep this as one
// write operation so EBUSY can be attributed to fsg_store_file().
if let Err(error) = file.write_all(b"\n") {
warn!(
lun,
path = %file_path.display(),
errno = error.raw_os_error(),
%error,
"Kernel rejected MSD LUN disconnect"
);
return if Self::medium_removal_was_prevented(&error) {
Err(MsdErrorCode::MsdMediumRemovalPrevented.into())
} else {
Err(MsdErrorCode::MsdDisconnectFailed.into())
};
}
file.flush().map_err(|error| {
warn!(
lun,
path = %file_path.display(),
%error,
"Failed to flush MSD LUN backing-file attribute while disconnecting"
);
MsdErrorCode::MsdDisconnectFailed.into()
})
}
fn disconnect_lun_path(&self, lun_path: &Path, lun: u16) -> Result<()> {
if lun_path.exists() {
let forced_eject_path = lun_path.join("forced_eject");
@@ -293,14 +469,14 @@ impl MsdFunction {
);
let file_path = lun_path.join("file");
if file_path.exists() {
write_file(&file_path, "")?;
Self::clear_lun_file(&file_path, lun)?;
}
}
}
} else {
let file_path = lun_path.join("file");
if file_path.exists() {
write_file(&file_path, "")?;
Self::clear_lun_file(&file_path, lun)?;
}
}
info!("LUN {} disconnected", lun);
@@ -344,6 +520,7 @@ impl GadgetFunction for MsdFunction {
for lun in 0..self.lun_capacity {
self.clear_lun_unbound(gadget_path, lun)?;
self.write_inquiry_strings(&self.lun_path(gadget_path, lun))?;
}
debug!("Created MSD function: {}", self.name());
@@ -415,6 +592,10 @@ mod tests {
use super::*;
use tempfile::TempDir;
fn test_msd(capacity: u8) -> MsdFunction {
MsdFunction::new(0, capacity, MsdInquiryStrings::default()).unwrap()
}
#[test]
fn test_lun_config_cdrom() {
let config = MsdLunConfig::cdrom(PathBuf::from("/tmp/test.iso"));
@@ -431,21 +612,154 @@ mod tests {
assert!(config.removable);
}
#[test]
fn inquiry_string_uses_cdrom_attribute_with_stock_fallback() {
let msd = MsdFunction::new(
0,
1,
MsdInquiryStrings {
flash: "Custom Flash".into(),
cdrom: "Custom Optical".into(),
},
)
.unwrap();
let patched = TempDir::new().unwrap();
std::fs::write(patched.path().join("inquiry_string"), b"generic\n").unwrap();
std::fs::write(patched.path().join("inquiry_string_cdrom"), b"cdrom\n").unwrap();
msd.write_inquiry_strings(patched.path()).unwrap();
assert_eq!(
std::fs::read_to_string(patched.path().join("inquiry_string_cdrom"))
.unwrap()
.trim(),
"Custom Optical"
);
assert_eq!(
std::fs::read_to_string(patched.path().join("inquiry_string"))
.unwrap()
.trim(),
"Custom Flash"
);
let stock = TempDir::new().unwrap();
std::fs::write(stock.path().join("inquiry_string"), b"generic\n").unwrap();
msd.write_inquiry_string(stock.path(), true).unwrap();
assert_eq!(
std::fs::read_to_string(stock.path().join("inquiry_string"))
.unwrap()
.trim(),
"Custom Optical"
);
}
#[test]
fn test_msd_function_name() {
let msd = MsdFunction::new(0, 1).unwrap();
let msd = test_msd(1);
assert_eq!(msd.name(), "mass_storage.usb0");
assert_eq!(msd.lun_capacity, 1);
let multi = MsdFunction::new(0, 8).unwrap();
let multi = test_msd(8);
assert_eq!(multi.lun_capacity, 8);
}
#[test]
fn test_msd_function_rejects_invalid_capacity() {
assert!(MsdFunction::new(0, 0).is_err());
assert!(MsdFunction::new(0, 2).is_err());
assert!(MsdFunction::new(0, 9).is_err());
assert!(MsdFunction::new(0, 0, MsdInquiryStrings::default()).is_err());
assert!(MsdFunction::new(0, 2, MsdInquiryStrings::default()).is_err());
assert!(MsdFunction::new(0, 9, MsdInquiryStrings::default()).is_err());
}
#[test]
fn only_ebusy_means_the_host_prevented_medium_removal() {
let busy = std::io::Error::from_raw_os_error(libc::EBUSY);
let io = std::io::Error::from_raw_os_error(libc::EIO);
assert!(MsdFunction::medium_removal_was_prevented(&busy));
assert!(!MsdFunction::medium_removal_was_prevented(&io));
}
#[test]
fn disconnect_lun_prefers_forced_eject() {
let temp_dir = TempDir::new().unwrap();
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
std::fs::create_dir_all(&lun_path).unwrap();
std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap();
std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap();
let msd = test_msd(1);
msd.disconnect_lun(temp_dir.path(), 0).unwrap();
assert_eq!(
std::fs::read(lun_path.join("forced_eject")).unwrap(),
b"1\n"
);
assert_eq!(
std::fs::read(lun_path.join("file")).unwrap(),
b"backing.img\n"
);
}
#[test]
fn disconnect_lun_without_forced_eject_clears_file() {
let temp_dir = TempDir::new().unwrap();
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
std::fs::create_dir_all(&lun_path).unwrap();
std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap();
let msd = test_msd(1);
msd.disconnect_lun(temp_dir.path(), 0).unwrap();
assert!(std::fs::read(lun_path.join("file"))
.unwrap()
.starts_with(b"\n"));
}
#[test]
fn disconnect_lun_falls_back_when_forced_eject_write_fails() {
let temp_dir = TempDir::new().unwrap();
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
std::fs::create_dir_all(lun_path.join("forced_eject")).unwrap();
std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap();
let msd = test_msd(1);
msd.disconnect_lun(temp_dir.path(), 0).unwrap();
assert!(std::fs::read(lun_path.join("file"))
.unwrap()
.starts_with(b"\n"));
}
#[test]
fn disconnect_lun_only_changes_the_selected_lun() {
let temp_dir = TempDir::new().unwrap();
let function_path = temp_dir.path().join("functions/mass_storage.usb0");
for lun in 0..2 {
let lun_path = function_path.join(format!("lun.{lun}"));
std::fs::create_dir_all(&lun_path).unwrap();
std::fs::write(lun_path.join("file"), format!("backing-{lun}.img\n")).unwrap();
std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap();
}
let msd = test_msd(8);
msd.disconnect_lun(temp_dir.path(), 1).unwrap();
assert_eq!(
std::fs::read(function_path.join("lun.0/forced_eject")).unwrap(),
b"0\n"
);
assert_eq!(
std::fs::read(function_path.join("lun.1/forced_eject")).unwrap(),
b"1\n"
);
assert_eq!(
std::fs::read(function_path.join("lun.0/file")).unwrap(),
b"backing-0.img\n"
);
assert_eq!(
std::fs::read(function_path.join("lun.1/file")).unwrap(),
b"backing-1.img\n"
);
}
#[test]
@@ -453,7 +767,7 @@ mod tests {
for capacity in [1, 8] {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir_all(temp_dir.path().join("functions")).unwrap();
let msd = MsdFunction::new(0, capacity).unwrap();
let msd = test_msd(capacity);
msd.create(temp_dir.path()).unwrap();
@@ -475,7 +789,7 @@ mod tests {
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
let image_path = temp_dir.path().join("test.img");
std::fs::write(&image_path, b"image").unwrap();
let msd = MsdFunction::new(0, 1).unwrap();
let msd = test_msd(1);
msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::disk(image_path, false))
.unwrap();
@@ -486,6 +800,81 @@ mod tests {
);
}
#[test]
fn media_type_changes_require_udc_rebind() {
let iso = MsdLunConfig::cdrom(PathBuf::from("/tmp/test.iso"));
let disk = MsdLunConfig::disk(PathBuf::from("/tmp/test.img"), false);
assert!(MsdFunction::media_type_rebind_required("0", &iso));
assert!(!MsdFunction::media_type_rebind_required("1", &iso));
assert!(MsdFunction::media_type_rebind_required("1", &disk));
assert!(!MsdFunction::media_type_rebind_required("0", &disk));
}
#[test]
fn configure_cdrom_restores_bound_udc() {
let temp_dir = TempDir::new().unwrap();
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
std::fs::create_dir_all(&lun_path).unwrap();
for attr in ["file", "cdrom", "ro", "removable", "nofua"] {
std::fs::write(lun_path.join(attr), b"0\n").unwrap();
}
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
let image_path = temp_dir.path().join("test.iso");
std::fs::write(&image_path, b"iso").unwrap();
let msd = test_msd(1);
msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::cdrom(image_path.clone()))
.unwrap();
assert_eq!(
std::fs::read_to_string(temp_dir.path().join("UDC"))
.unwrap()
.trim(),
"test.udc"
);
assert_eq!(
std::fs::read_to_string(lun_path.join("cdrom"))
.unwrap()
.trim(),
"1"
);
assert_eq!(
std::fs::read_to_string(lun_path.join("ro")).unwrap().trim(),
"1"
);
assert_eq!(
std::fs::read_to_string(lun_path.join("file"))
.unwrap()
.trim(),
image_path.to_string_lossy()
);
}
#[test]
fn configure_failure_still_restores_bound_udc() {
let temp_dir = TempDir::new().unwrap();
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
std::fs::create_dir_all(lun_path.join("file")).unwrap();
for attr in ["cdrom", "ro", "removable", "nofua"] {
std::fs::write(lun_path.join(attr), b"0\n").unwrap();
}
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
let image_path = temp_dir.path().join("test.iso");
std::fs::write(&image_path, b"iso").unwrap();
let msd = test_msd(1);
assert!(msd
.configure_lun(temp_dir.path(), 0, &MsdLunConfig::cdrom(image_path),)
.is_err());
assert_eq!(
std::fs::read_to_string(temp_dir.path().join("UDC"))
.unwrap()
.trim(),
"test.udc"
);
}
#[test]
fn cleanup_removes_all_dynamic_luns_including_stale_capacity() {
let temp_dir = TempDir::new().unwrap();
@@ -493,13 +882,37 @@ mod tests {
for lun in 1..8 {
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
}
let msd = MsdFunction::new(0, 1).unwrap();
let msd = test_msd(1);
msd.cleanup(temp_dir.path()).unwrap();
assert!(!func_path.exists());
}
#[test]
fn cleanup_forced_ejects_every_existing_lun() {
let temp_dir = TempDir::new().unwrap();
let func_path = temp_dir.path().join("functions/mass_storage.usb0");
for lun in 0..3 {
let lun_path = func_path.join(format!("lun.{lun}"));
std::fs::create_dir_all(&lun_path).unwrap();
std::fs::write(lun_path.join("file"), format!("backing-{lun}.img\n")).unwrap();
std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap();
}
let msd = test_msd(1);
// Ordinary files do not disappear with configfs groups, so cleanup is
// expected to report directory-removal failures in this test fixture.
assert!(msd.cleanup(temp_dir.path()).is_err());
for lun in 0..3 {
assert_eq!(
std::fs::read(func_path.join(format!("lun.{lun}/forced_eject"))).unwrap(),
b"1\n"
);
}
}
#[test]
fn cleanup_reports_when_non_configfs_cannot_release_default_lun() {
let temp_dir = TempDir::new().unwrap();
@@ -507,7 +920,7 @@ mod tests {
for lun in 0..2 {
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
}
let msd = MsdFunction::new(0, 1).unwrap();
let msd = test_msd(1);
let error = msd.cleanup(temp_dir.path()).unwrap_err();

View File

@@ -6,9 +6,10 @@ use typeshare::typeshare;
use super::bridge::NetworkBridgeRuntime;
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::MsdFunction;
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
use crate::config::{
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
UacConfig,
};
use crate::error::{AppError, Result};
@@ -61,7 +62,9 @@ pub(crate) struct OtgDesiredState {
pub keyboard_leds: bool,
pub msd_enabled: bool,
pub msd_lun_capacity: u8,
pub msd_inquiry_strings: MsdInquiryStrings,
pub network: OtgNetworkConfig,
pub uac: UacConfig,
}
impl Default for OtgDesiredState {
@@ -73,7 +76,9 @@ impl Default for OtgDesiredState {
keyboard_leds: false,
msd_enabled: false,
msd_lun_capacity: 1,
msd_inquiry_strings: MsdInquiryStrings::default(),
network: OtgNetworkConfig::default(),
uac: UacConfig::default(),
}
}
}
@@ -83,8 +88,11 @@ impl OtgDesiredState {
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &UacConfig,
) -> Result<Self> {
network.validate()?;
uac.validate()?;
msd.validate()?;
let hid_functions = if hid.backend == HidBackend::Otg {
let functions = hid.constrained_otg_functions();
Some(functions)
@@ -93,7 +101,7 @@ impl OtgDesiredState {
};
hid.validate_otg_functions()?;
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled;
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled;
let udc = if needs_udc {
hid.otg_udc
.as_ref()
@@ -110,7 +118,13 @@ impl OtgDesiredState {
keyboard_leds: hid.effective_otg_keyboard_leds(),
msd_enabled: msd.enabled,
msd_lun_capacity: 1,
msd_inquiry_strings: MsdInquiryStrings::from(msd),
network: network.clone(),
uac: if uac.enabled {
uac.clone()
} else {
UacConfig::default()
},
})
}
@@ -132,7 +146,9 @@ struct OtgServiceState {
pub hid_enabled: bool,
pub msd_enabled: bool,
pub msd_lun_capacity: u8,
pub msd_inquiry_strings: MsdInquiryStrings,
pub network: OtgNetworkConfig,
pub uac: UacConfig,
pub configured_udc: Option<String>,
pub hid_paths: Option<HidDevicePaths>,
pub hid_functions: Option<OtgHidFunctions>,
@@ -149,7 +165,9 @@ impl Default for OtgServiceState {
hid_enabled: false,
msd_enabled: false,
msd_lun_capacity: 1,
msd_inquiry_strings: MsdInquiryStrings::default(),
network: OtgNetworkConfig::default(),
uac: UacConfig::default(),
configured_udc: None,
hid_paths: None,
hid_functions: None,
@@ -202,6 +220,27 @@ impl OtgService {
self.desired.read().await.msd_lun_capacity
}
pub async fn configure_msd_lun(&self, lun: u8, config: &MsdLunConfig) -> Result<()> {
// Keep the manager locked across a possible UDC rebind so an OTG
// reconcile cannot replace the gadget halfway through the media-type
// transition.
let manager = self.manager.lock().await;
let gadget_path = manager
.as_ref()
.map(|value| value.gadget_path().clone())
.ok_or_else(|| AppError::Internal("OTG gadget is not active".to_string()))?;
let function = self
.msd_function
.read()
.await
.clone()
.ok_or_else(|| AppError::Internal("MSD function is not active".to_string()))?;
function
.configure_lun_async(&gadget_path, lun, config)
.await
}
pub async fn network_status(&self) -> OtgNetworkStatus {
let state = self.state.read().await;
OtgNetworkStatus {
@@ -215,6 +254,7 @@ impl OtgService {
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &UacConfig,
) -> Result<()> {
if !self.recovery_checked.load(Ordering::SeqCst) {
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
@@ -226,7 +266,7 @@ impl OtgService {
}
let previous = self.desired.read().await.clone();
let desired = self
.desired_from_config_preserving_runtime(hid, msd, network)
.desired_from_config_preserving_runtime(hid, msd, network, uac)
.await?;
{
let mut state = self.state.write().await;
@@ -269,8 +309,9 @@ impl OtgService {
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &UacConfig,
) -> Result<OtgDesiredState> {
let mut desired = OtgDesiredState::from_config(hid, msd, network)?;
let mut desired = OtgDesiredState::from_config(hid, msd, network, uac)?;
desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity;
Ok(desired)
}
@@ -305,10 +346,11 @@ impl OtgService {
let desired = self.desired.read().await.clone();
debug!(
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UDC={:?}",
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UAC={}, UDC={:?}",
desired.hid_enabled(),
desired.msd_enabled,
desired.network_enabled(),
desired.uac.enabled,
desired.udc
);
@@ -319,7 +361,9 @@ impl OtgService {
&& state.hid_enabled == desired.hid_enabled()
&& state.msd_enabled == desired.msd_enabled
&& state.msd_lun_capacity == desired.msd_lun_capacity
&& state.msd_inquiry_strings == desired.msd_inquiry_strings
&& state.network == desired.network
&& state.uac == desired.uac
&& state.configured_udc == desired.udc
&& state.hid_functions == desired.hid_functions
&& state.keyboard_leds_enabled == desired.keyboard_leds
@@ -358,7 +402,9 @@ impl OtgService {
state.hid_enabled = false;
state.msd_enabled = false;
state.msd_lun_capacity = 1;
state.msd_inquiry_strings = MsdInquiryStrings::default();
state.network = OtgNetworkConfig::default();
state.uac = UacConfig::default();
state.configured_udc = None;
state.hid_paths = None;
state.hid_functions = None;
@@ -367,7 +413,11 @@ impl OtgService {
state.error = None;
}
if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() {
if !desired.hid_enabled()
&& !desired.msd_enabled
&& !desired.network_enabled()
&& !desired.uac.enabled
{
info!("OTG desired state is empty, gadget removed");
return Ok(());
}
@@ -393,6 +443,20 @@ impl OtgService {
);
let mut hid_paths = None;
// Add UAC BEFORE HID so the isochronous endpoint gets a
// lower hardware endpoint number. DWC3 seems to have
// trouble with isochronous transfers on higher-numbered
// endpoints when they follow interrupt endpoints.
let _uac_func = if desired.uac.enabled {
Some(
manager
.add_uac(desired.uac.sample_rate, desired.uac.channels)
.map_err(|e| AppError::Internal(format!("Failed to add UAC function: {e}")))?,
)
} else {
None
};
if let Some(hid_functions) = desired.hid_functions.clone() {
let mut paths = HidDevicePaths {
udc: Some(udc.clone()),
@@ -449,7 +513,10 @@ impl OtgService {
}
let msd_func = if desired.msd_enabled {
match manager.add_msd(desired.msd_lun_capacity) {
match manager.add_msd(
desired.msd_lun_capacity,
desired.msd_inquiry_strings.clone(),
) {
Ok(func) => {
debug!("MSD function added to gadget");
Some(func)
@@ -536,7 +603,9 @@ impl OtgService {
state.hid_enabled = desired.hid_enabled();
state.msd_enabled = desired.msd_enabled;
state.msd_lun_capacity = desired.msd_lun_capacity;
state.msd_inquiry_strings = desired.msd_inquiry_strings.clone();
state.network = desired.network.clone();
state.uac = desired.uac.clone();
state.configured_udc = Some(udc);
state.hid_paths = hid_paths;
state.hid_functions = desired.hid_functions;
@@ -642,6 +711,7 @@ mod tests {
&HidConfig::default(),
&MsdConfig::default(),
&OtgNetworkConfig::default(),
&UacConfig::default(),
)
.await
.unwrap();
@@ -657,6 +727,14 @@ mod tests {
assert_ne!(single, multi);
}
#[test]
fn inquiry_strings_participate_in_desired_state_equality() {
let original = OtgDesiredState::default();
let mut changed = original.clone();
changed.msd_inquiry_strings.flash = "Custom Flash".to_string();
assert_ne!(original, changed);
}
#[test]
fn onecloud_full_composite_is_not_rejected_before_configfs() {
let hid = HidConfig {
@@ -673,7 +751,8 @@ mod tests {
..OtgNetworkConfig::default()
};
let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap();
let desired =
OtgDesiredState::from_config(&hid, &msd, &network, &UacConfig::default()).unwrap();
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));

153
src/otg/uac.rs Normal file
View File

@@ -0,0 +1,153 @@
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use super::configfs::{create_dir, create_symlink, remove_dir, write_file};
use super::function::GadgetFunction;
use crate::error::{AppError, Result};
/// USB Audio Class 1.0 gadget function.
///
/// Creates a virtual USB microphone that the USB host sees as a standard
/// USB audio input device. Audio written to the PCM playback device on the
/// gadget side appears as microphone input on the host.
#[derive(Debug, Clone)]
pub struct UacFunction {
name: String,
sample_rate: u32,
channels: u8,
}
impl UacFunction {
/// Create a new UAC1 function instance.
///
/// `instance` is a zero-based index to avoid name collisions
/// (e.g. `uac2.usb0`).
pub fn new(instance: u8, sample_rate: u32, channels: u8) -> Result<Self> {
if sample_rate == 0 || sample_rate > 384_000 {
return Err(AppError::BadRequest(format!(
"invalid UAC sample rate: {sample_rate}"
)));
}
if channels == 0 || channels > 8 {
return Err(AppError::BadRequest(format!(
"invalid UAC channel count: {channels}"
)));
}
Ok(Self {
name: format!("uac1.usb{instance}"),
sample_rate,
channels,
})
}
fn function_path(&self, gadget_path: &Path) -> PathBuf {
gadget_path.join("functions").join(&self.name)
}
}
impl GadgetFunction for UacFunction {
fn name(&self) -> &str {
&self.name
}
fn create(&self, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path);
create_dir(&func_path)?;
// Playback direction (gadget → host): the controlled machine sees
// this as a microphone/line-in input.
let chmask: u32 = (1u32 << self.channels) - 1;
write_file(&func_path.join("p_chmask"), &chmask.to_string())?;
write_file(&func_path.join("p_srate"), &self.sample_rate.to_string())?;
// 16-bit S16LE.
write_file(&func_path.join("p_ssize"), "2")?;
// One decibel per step. The kernel default is 1/256 dB, which creates
// 25,600 control values and triggers a UAC volume-range warning.
write_file(&func_path.join("p_volume_res"), "256")?;
// UAC1 does not need p_hs_bint — Windows has native built-in
// UAC1 drivers and handles isochronous streaming automatically.
// Only enable playback direction (gadget → host = mic).
// Disabling capture saves one isochronous endpoint.
write_file(&func_path.join("c_chmask"), "0")?;
write_file(&func_path.join("c_volume_present"), "0")?;
// req_number=4: explicitly allocate 4 USB requests for the
// isochronous endpoint. Default (0 = auto) may not be enough
// for composite gadgets on DWC3.
let _ = write_file(&func_path.join("req_number"), "4");
debug!(
"UAC1 function {} created: {}ch {}Hz",
&self.name, self.channels, self.sample_rate
);
Ok(())
}
fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path);
let link_path = config_path.join(&self.name);
create_symlink(&func_path, &link_path)?;
debug!("UAC1 function {} linked into configuration", &self.name);
Ok(())
}
fn unlink(&self, config_path: &Path) -> Result<()> {
let link_path = config_path.join(&self.name);
if link_path.exists() {
std::fs::remove_file(&link_path).map_err(|e| {
AppError::Internal(format!(
"Failed to unlink UAC1 function {}: {}",
&self.name, e
))
})?;
debug!("UAC1 function {} unlinked", &self.name);
}
Ok(())
}
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path);
if func_path.exists() {
remove_dir(&func_path).map_err(|e| {
AppError::Internal(format!(
"Failed to remove UAC1 function {}: {}",
&self.name, e
))
})?;
info!("UAC1 function {} removed", &self.name);
}
Ok(())
}
}
/// Return the ALSA PCM device name that the kernel assigns to a UAC1
/// gadget after binding. The device appears as a playback-only PCM on
/// the gadget side.
pub fn uac_pcm_device() -> String {
// The kernel assigns the card name based on the gadget name.
// The PCM name is typically "playback" for UAC1.
"hw:UAC1Gadget,0".to_string()
}
/// Resolve the actual PCM device name for a UAC1 playback device
/// by scanning /proc/asound/ for the gadget audio card.
pub fn find_uac_pcm_device() -> Option<String> {
for entry in std::fs::read_dir("/proc/asound").ok()? {
let entry = entry.ok()?;
let name = entry.file_name();
let name = name.to_str()?;
if !name.starts_with("card") {
continue;
}
let card_path = entry.path().join("id");
if let Ok(id) = std::fs::read_to_string(&card_path) {
if id.trim().starts_with("UAC1Gadget") || id.trim().starts_with("gadget") {
let card_num = name.strip_prefix("card")?;
return Some(format!("hw:{card_num},0"));
}
}
}
None
}

View File

@@ -88,10 +88,7 @@ mod tests {
#[test]
fn only_service_discovery_and_session_creation_are_public() {
assert!(is_redfish_public_endpoint("/v1/", &Method::GET));
assert!(is_redfish_public_endpoint(
"/v1/$metadata",
&Method::GET
));
assert!(is_redfish_public_endpoint("/v1/$metadata", &Method::GET));
assert!(is_redfish_public_endpoint(
"/v1/SessionService/Sessions",
&Method::POST

View File

@@ -9,8 +9,8 @@ use std::sync::Arc;
use tracing::{info, warn};
use super::super::schema::*;
use super::{empty_collection, resource_not_found, service_unavailable, validate_id};
use crate::error::AppError;
use super::{empty_collection, resource_not_found, validate_id};
use crate::error::{AppError, MsdErrorCode};
use crate::msd::{ImageInfo, ImageManager, MountedMedia, MountedMediaKind};
use crate::state::AppState;
@@ -46,7 +46,7 @@ async fn virtual_media_collection(
let capacity = {
let guard = state.msd.read().await;
let Some(msd) = guard.as_ref() else {
return service_unavailable("MSD not available");
return msd_error_response(MsdErrorCode::MsdUnavailable);
};
msd.state().await.disk_mode.capacity()
};
@@ -81,7 +81,7 @@ async fn virtual_media_detail(
let (msd_state, lun) = {
let guard = state.msd.read().await;
let Some(msd) = guard.as_ref() else {
return service_unavailable("MSD not available");
return msd_error_response(MsdErrorCode::MsdUnavailable);
};
let msd_state = msd.state().await;
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
@@ -164,17 +164,14 @@ async fn virtual_media_insert(
let lun = {
let guard = state.msd.read().await;
let Some(msd) = guard.as_ref() else {
return service_unavailable("MSD not available");
return msd_error_response(MsdErrorCode::MsdUnavailable);
};
let msd_state = msd.state().await;
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
return resource_not_found();
};
if msd_state.mounted_media.iter().any(|media| media.lun == lun) {
return redfish_error(
StatusCode::CONFLICT,
"Virtual media slot is already occupied",
);
return msd_error_response(MsdErrorCode::MsdMediaSlotsFull);
}
lun
};
@@ -194,7 +191,7 @@ async fn virtual_media_insert(
let result = {
let guard = state.msd.read().await;
let Some(msd) = guard.as_ref() else {
return service_unavailable("MSD not available");
return msd_error_response(MsdErrorCode::MsdUnavailable);
};
msd.mount_image_at_lun(&image, cdrom, read_only, lun).await
};
@@ -222,7 +219,7 @@ async fn virtual_media_eject(
let lun = {
let guard = state.msd.read().await;
let Some(msd) = guard.as_ref() else {
return service_unavailable("MSD not available");
return msd_error_response(MsdErrorCode::MsdUnavailable);
};
let capacity = msd.state().await.disk_mode.capacity();
let Some(lun) = parse_slot_id(&media_id, capacity) else {
@@ -234,7 +231,7 @@ async fn virtual_media_eject(
let result = {
let guard = state.msd.read().await;
let Some(msd) = guard.as_ref() else {
return service_unavailable("MSD not available");
return msd_error_response(MsdErrorCode::MsdUnavailable);
};
msd.unmount_lun(lun).await
};
@@ -352,6 +349,9 @@ async fn resolve_image(
}
fn app_error_response(error: AppError) -> Response {
if let AppError::Msd(error) = error {
return msd_error_response(error.code());
}
let status = match &error {
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::NotFound(_) => StatusCode::NOT_FOUND,
@@ -361,6 +361,35 @@ fn app_error_response(error: AppError) -> Response {
redfish_error(status, &error.to_string())
}
fn msd_error_response(code: MsdErrorCode) -> Response {
use MsdErrorCode::*;
let status = match code {
MsdUnavailable => StatusCode::SERVICE_UNAVAILABLE,
MsdResourceNotFound | MsdDriveNotInitialized => StatusCode::NOT_FOUND,
MsdOperationInProgress
| MsdResourceAlreadyExists
| MsdMediaSlotsFull
| MsdMediaAlreadyMounted
| MsdMediaInUse
| MsdDriveConnected
| MsdMediumRemovalPrevented => StatusCode::CONFLICT,
MsdInvalidRequest
| MsdImageTooLarge
| MsdInvalidUrl
| MsdDriveFilesystemUnsupported
| MsdDriveSizeInvalid
| MsdStorageSpaceUnavailable
| MsdStorageFull
| MsdStorageReadOnly
| MsdStoragePermissionDenied => StatusCode::BAD_REQUEST,
MsdOperationFailed
| MsdRemoteDownloadFailed
| MsdDownloadIncomplete
| MsdDisconnectFailed => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(RedfishError::msd(code))).into_response()
}
fn redfish_error(status: StatusCode, message: &str) -> Response {
(status, Json(RedfishError::general_error(message))).into_response()
}
@@ -421,4 +450,45 @@ mod tests {
not_inserted.inserted = Some(false);
assert!(validate_insert_request(&not_inserted).is_err());
}
#[test]
fn msd_redfish_errors_use_the_one_kvm_registry_shape() {
for code in MsdErrorCode::ALL {
let body = RedfishError::msd(code);
let expected = format!("OneKVM.1.0.{}", code.redfish_key());
assert_eq!(body.error.code, expected);
assert_eq!(body.error.message, code.message());
assert_eq!(body.error.extended_info.len(), 1);
let info = &body.error.extended_info[0];
assert_eq!(info.message_id, expected);
assert_eq!(info.message, code.message());
assert_eq!(info.severity, code.severity());
assert_eq!(info.resolution, code.resolution());
}
}
#[tokio::test]
async fn msd_and_validation_errors_keep_separate_redfish_registries() {
let response = msd_error_response(MsdErrorCode::MsdStoragePermissionDenied);
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["error"]["code"],
"OneKVM.1.0.MsdStoragePermissionDenied"
);
assert_eq!(
json["error"]["@Message.ExtendedInfo"][0]["MessageId"],
"OneKVM.1.0.MsdStoragePermissionDenied"
);
let validation = app_error_response(AppError::BadRequest("invalid property".into()));
let body = axum::body::to_bytes(validation.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"], "Base.1.18.GeneralError");
}
}

View File

@@ -1,3 +1,4 @@
use crate::error::MsdErrorCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -546,6 +547,23 @@ pub struct RedfishExtendedInfo {
}
impl RedfishError {
pub fn msd(code: MsdErrorCode) -> Self {
let message_id = format!("OneKVM.1.0.{}", code.redfish_key());
Self {
error: RedfishErrorBody {
code: message_id.clone(),
message: code.message().to_string(),
extended_info: vec![RedfishExtendedInfo {
odata_type: "#Message.v1_2_1.Message".to_string(),
message_id,
message: code.message().to_string(),
severity: code.severity().to_string(),
resolution: code.resolution().to_string(),
}],
},
}
}
pub fn general_error(message: &str) -> Self {
Self {
error: RedfishErrorBody {

594
src/runtime/builder.rs Normal file
View File

@@ -0,0 +1,594 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::broadcast;
use crate::atx::AtxController;
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
use crate::auth::{SessionStore, TwoFactorService, UserStore};
use crate::computer_use::ComputerUseManager;
use crate::config::{self, AppConfig, ConfigStore};
use crate::db::{open_database_pool, DatabasePool};
use crate::events::EventBus;
use crate::extensions::ExtensionManager;
use crate::hid::{HidBackendType, HidController};
#[cfg(unix)]
use crate::msd::MsdController;
#[cfg(unix)]
use crate::otg::OtgService;
use crate::state::{AppState, ShutdownAction};
use crate::update::UpdateService;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::{Streamer, VideoStreamManager};
use crate::webrtc::{WebRtcStreamer, WebRtcStreamerConfig};
use super::supervisor::RuntimeSupervisor;
#[derive(Debug, Clone, Default)]
pub struct WebConfigOverrides {
pub address: Option<String>,
pub http_port: Option<u16>,
pub https_port: Option<u16>,
pub enable_https: bool,
pub ssl_cert: Option<PathBuf>,
pub ssl_key: Option<PathBuf>,
}
impl WebConfigOverrides {
fn apply(self, config: &mut AppConfig) {
if let Some(address) = self.address {
config.web.bind_address = address.clone();
config.web.bind_addresses = vec![address];
}
if let Some(port) = self.http_port {
config.web.http_port = port;
}
if let Some(port) = self.https_port {
config.web.https_port = port;
}
if self.enable_https {
config.web.https_enabled = true;
}
if let Some(path) = self.ssl_cert {
config.web.ssl_cert_path = Some(path.to_string_lossy().to_string());
}
if let Some(path) = self.ssl_key {
config.web.ssl_key_path = Some(path.to_string_lossy().to_string());
}
}
}
pub struct RuntimeBuilder {
data_dir: PathBuf,
web_overrides: WebConfigOverrides,
}
impl RuntimeBuilder {
pub fn new(data_dir: PathBuf) -> Self {
Self {
data_dir,
web_overrides: WebConfigOverrides::default(),
}
}
pub fn with_web_overrides(mut self, overrides: WebConfigOverrides) -> Self {
self.web_overrides = overrides;
self
}
pub async fn build(self) -> anyhow::Result<ApplicationRuntime> {
let Self {
data_dir,
web_overrides,
} = self;
let (db, config_store, mut config) = load_runtime_config(&data_dir).await?;
web_overrides.apply(&mut config);
let sessions = SessionStore::new(config.auth.session_timeout_secs as i64);
let users = UserStore::new(db.clone_pool());
let two_factor = TwoFactorService::new(db.clone_pool());
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
let events = Arc::new(EventBus::new());
tracing::info!("Event bus initialized");
let (video_format, video_resolution) = parse_video_config(&config);
let streamer = build_streamer(&config, &events, video_format, video_resolution).await;
let webrtc = build_webrtc(&config, video_format, video_resolution);
#[cfg(unix)]
let otg_service = build_otg(&config).await;
let hid_backend = hid_backend_type(&config);
#[cfg(unix)]
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
#[cfg(not(unix))]
let hid = Arc::new(HidController::new(hid_backend));
#[cfg(target_os = "linux")]
hid.set_bond_store(config_store.hid_bonds());
hid.set_event_bus(events.clone()).await;
hid.set_screen_resolution(video_resolution.width, video_resolution.height)
.await;
if let Err(error) = hid.init().await {
tracing::warn!("Failed to initialize HID backend: {}", error);
}
#[cfg(unix)]
let msd = build_msd(&config, &data_dir, &otg_service, &events).await;
let atx = build_atx(&config).await;
let audio = build_audio(&config, &events).await;
let extensions = Arc::new(ExtensionManager::new());
tracing::info!("Extension manager initialized");
webrtc.set_hid_controller(hid.clone()).await;
webrtc.set_audio_controller(audio.clone()).await;
if config.audio.enabled {
if let Err(error) = webrtc.set_audio_enabled(true).await {
tracing::warn!("Failed to enable WebRTC audio: {}", error);
} else {
tracing::debug!("WebRTC audio enabled");
}
}
let stream_manager = VideoStreamManager::with_webrtc_streamer(
streamer.clone(),
webrtc.clone() as Arc<dyn crate::video::traits::VideoOutput>,
);
stream_manager.set_event_bus(events.clone()).await;
stream_manager.set_config_store(config_store.clone()).await;
connect_audio_recovery(&audio, &stream_manager).await;
let initial_mode = config.stream.mode.clone();
if let Err(error) = stream_manager.init_with_mode(initial_mode.clone()).await {
tracing::warn!(
"Failed to initialize stream manager with mode {:?}: {}",
initial_mode,
error
);
} else {
tracing::info!(
"Video stream manager initialized with mode: {:?}",
initial_mode
);
}
let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone());
let state = AppState::new(
db,
config_store.clone(),
sessions,
users,
two_factor,
#[cfg(unix)]
otg_service,
stream_manager,
webrtc,
hid,
computer_use,
#[cfg(unix)]
msd,
atx,
audio,
extensions.clone(),
events.clone(),
Arc::new(UpdateService::new()),
shutdown_tx,
data_dir.clone(),
);
start_uac_playback(&state, &config).await;
start_watchdog(&state, &config).await;
extensions.set_event_bus(events.clone()).await;
state.remote_access.start_configured(&config).await;
let extension_config = config_store.get();
extensions.start_enabled(&extension_config.extensions).await;
state.publish_device_info().await;
let supervisor = RuntimeSupervisor::start(state.clone(), events, extensions, config_store);
Ok(ApplicationRuntime {
state,
config,
data_dir,
supervisor,
})
}
}
pub struct ApplicationRuntime {
state: Arc<AppState>,
config: AppConfig,
data_dir: PathBuf,
supervisor: RuntimeSupervisor,
}
impl ApplicationRuntime {
pub fn state(&self) -> &Arc<AppState> {
&self.state
}
pub fn config(&self) -> &AppConfig {
&self.config
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn router(&self) -> axum::Router {
crate::web::create_router(self.state.clone())
}
pub async fn shutdown(&mut self) {
self.supervisor.shutdown(&self.state).await;
}
}
async fn load_runtime_config(
data_dir: &Path,
) -> anyhow::Result<(DatabasePool, ConfigStore, AppConfig)> {
let db = open_database_pool(data_dir).await?;
let config_store = ConfigStore::new(db.clone_pool());
config_store.load().await?;
let mut config = (*config_store.get()).clone();
config.apply_platform_defaults();
normalize_msd_config(data_dir, &config_store, &mut config).await?;
Ok((db, config_store, config))
}
#[cfg(unix)]
async fn normalize_msd_config(
data_dir: &Path,
config_store: &ConfigStore,
config: &mut AppConfig,
) -> anyhow::Result<()> {
let mut msd_dir_updated = false;
if config.msd.msd_dir.trim().is_empty() {
config.msd.msd_dir = data_dir.join("msd").to_string_lossy().to_string();
msd_dir_updated = true;
} else if !PathBuf::from(&config.msd.msd_dir).is_absolute() {
let msd_dir = data_dir.join(&config.msd.msd_dir);
tracing::warn!(
"MSD directory is relative, rebasing to {}",
msd_dir.display()
);
config.msd.msd_dir = msd_dir.to_string_lossy().to_string();
msd_dir_updated = true;
}
if msd_dir_updated {
config_store.set(config.clone()).await?;
}
Ok(())
}
#[cfg(not(unix))]
async fn normalize_msd_config(
_data_dir: &Path,
_config_store: &ConfigStore,
_config: &mut AppConfig,
) -> anyhow::Result<()> {
Ok(())
}
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
let format = config
.video
.format
.as_ref()
.and_then(|format| format.parse::<PixelFormat>().ok())
.unwrap_or(PixelFormat::Mjpeg);
(
format,
Resolution::new(config.video.width, config.video.height),
)
}
async fn build_streamer(
config: &AppConfig,
events: &Arc<EventBus>,
format: PixelFormat,
resolution: Resolution,
) -> Arc<Streamer> {
tracing::debug!(
"Parsed video config: {} @ {}x{}",
format,
resolution.width,
resolution.height
);
let streamer = Streamer::new();
streamer.set_event_bus(events.clone()).await;
if let Some(device_path) = config.video.device.as_ref() {
if let Err(error) = streamer
.apply_video_config(device_path, format, resolution, config.video.fps)
.await
{
tracing::warn!(
"Failed to initialize video with config: {}, will auto-detect",
error
);
} else {
tracing::info!(
"Video configured: {} @ {}x{} {}",
device_path,
resolution.width,
resolution.height,
format
);
}
}
streamer
}
fn build_webrtc(
config: &AppConfig,
input_format: PixelFormat,
resolution: Resolution,
) -> Arc<WebRtcStreamer> {
let webrtc = WebRtcStreamer::with_config(WebRtcStreamerConfig {
resolution,
input_format,
fps: config.video.fps,
bitrate_preset: config.stream.bitrate_preset,
encoder_backend: crate::stream_encoder::encoder_type_to_backend(
config.stream.encoder.clone(),
),
webrtc: build_ice_config(config),
..Default::default()
});
tracing::info!("WebRTC streamer created");
webrtc
}
fn build_ice_config(config: &AppConfig) -> crate::webrtc::config::WebRtcConfig {
let mut stun_servers = Vec::new();
let mut turn_servers = Vec::new();
let has_custom_stun = config
.stream
.stun_server
.as_ref()
.is_some_and(|server| !server.is_empty());
let has_custom_turn = config
.stream
.turn_server
.as_ref()
.is_some_and(|server| !server.is_empty());
if !has_custom_stun && !has_custom_turn {
let stun = crate::webrtc::config::public_ice::stun_server().to_string();
tracing::info!("Using public STUN server: {}", stun);
stun_servers.push(stun);
} else {
if let Some(stun) = config
.stream
.stun_server
.as_ref()
.filter(|server| !server.is_empty())
{
tracing::info!("Using custom STUN server: {}", stun);
stun_servers.push(stun.clone());
}
if let Some(turn) = config
.stream
.turn_server
.as_ref()
.filter(|server| !server.is_empty())
{
let username = config.stream.turn_username.clone().unwrap_or_default();
let credential = config.stream.turn_password.clone().unwrap_or_default();
turn_servers.push(crate::webrtc::config::TurnServer::new(
turn.clone(),
username.clone(),
credential,
));
tracing::info!("Using custom TURN server: {} (user: {})", turn, username);
}
}
crate::webrtc::config::WebRtcConfig {
stun_servers,
turn_servers,
..Default::default()
}
}
#[cfg(unix)]
async fn build_otg(config: &AppConfig) -> Arc<OtgService> {
let service = Arc::new(OtgService::new());
tracing::info!("OTG Service created");
if let Err(error) = service
.apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac)
.await
{
tracing::warn!("Failed to apply OTG config: {}", error);
}
service
}
fn hid_backend_type(config: &AppConfig) -> HidBackendType {
match config.hid.backend {
config::HidBackend::Otg => HidBackendType::Otg {
macos_drag: config.hid.mouse_macos_drag,
},
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
port: config.hid.ch9329_port.clone(),
baud_rate: config.hid.ch9329_baudrate,
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
macos_drag: config.hid.mouse_macos_drag,
},
config::HidBackend::None => HidBackendType::None,
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
config: config.hid.bluetooth.clone(),
},
}
}
#[cfg(unix)]
async fn build_msd(
config: &AppConfig,
data_dir: &Path,
otg: &Arc<OtgService>,
events: &Arc<EventBus>,
) -> Option<MsdController> {
if !config.msd.enabled {
tracing::info!("MSD disabled in configuration");
return None;
}
let controller = MsdController::new(otg.clone(), config.msd.msd_dir_path());
if let Err(error) = controller.init(&data_dir.join("ventoy")).await {
tracing::warn!("Failed to initialize MSD controller: {}", error);
return None;
}
controller.set_event_bus(events.clone()).await;
Some(controller)
}
async fn build_atx(config: &AppConfig) -> Option<AtxController> {
if !config.atx.enabled {
tracing::info!("ATX disabled in configuration");
return None;
}
let controller = AtxController::new(config.atx.to_controller_config());
if let Err(error) = controller.init().await {
tracing::warn!("Failed to initialize ATX controller: {}", error);
return None;
}
Some(controller)
}
async fn build_audio(config: &AppConfig, events: &Arc<EventBus>) -> Arc<AudioController> {
let quality = config
.audio
.quality
.parse::<AudioQuality>()
.unwrap_or_else(|error| {
tracing::warn!(
"Invalid audio quality in config (value={:?}): {}, using balanced",
config.audio.quality,
error
);
AudioQuality::Balanced
});
let controller = Arc::new(AudioController::new(AudioControllerConfig {
enabled: config.audio.enabled,
device: config.audio.device.clone(),
quality,
}));
controller.set_event_bus(events.clone()).await;
if config.audio.enabled {
tracing::info!(
"Audio enabled: {}, quality={}",
config.audio.device,
config.audio.quality
);
if let Err(error) = controller.start_streaming().await {
tracing::warn!("Failed to start audio streaming: {}", error);
}
} else {
tracing::info!("Audio disabled in configuration");
}
controller
}
async fn connect_audio_recovery(
audio: &Arc<AudioController>,
stream_manager: &Arc<VideoStreamManager>,
) {
let stream_manager = Arc::downgrade(stream_manager);
audio
.set_recovered_callback(Arc::new(move || {
if let Some(stream_manager) = stream_manager.upgrade() {
tokio::spawn(async move {
stream_manager.reconnect_webrtc_audio_sources().await;
});
}
}))
.await;
}
#[cfg(unix)]
async fn start_uac_playback(state: &Arc<AppState>, config: &AppConfig) {
if !config.uac.enabled {
return;
}
let playback_config = crate::audio::uac::UacPlaybackConfig {
sample_rate: config.uac.sample_rate,
channels: config.uac.channels as u16,
..Default::default()
};
match crate::audio::uac::UacPlayback::start(playback_config) {
Ok(writer) => {
*state.uac_playback.write().await = Some(writer);
tracing::info!("UAC playback writer started");
}
Err(error) => tracing::warn!("Failed to start UAC playback writer: {}", error),
}
}
#[cfg(not(unix))]
async fn start_uac_playback(_state: &Arc<AppState>, _config: &AppConfig) {}
async fn start_watchdog(state: &Arc<AppState>, config: &AppConfig) {
if !config.watchdog.enabled {
return;
}
if let Err(error) = state.watchdog.enable().await {
tracing::error!(
"Configured hardware watchdog failed to start; web service will continue: {}",
error
);
} else {
tracing::info!("Hardware watchdog started");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_overrides_only_replace_explicit_values() {
let mut config = AppConfig::default();
let original_https_port = config.web.https_port;
WebConfigOverrides {
address: Some("127.0.0.1".to_string()),
http_port: Some(9000),
enable_https: true,
..Default::default()
}
.apply(&mut config);
assert_eq!(config.web.bind_address, "127.0.0.1");
assert_eq!(config.web.bind_addresses, ["127.0.0.1"]);
assert_eq!(config.web.http_port, 9000);
assert_eq!(config.web.https_port, original_https_port);
assert!(config.web.https_enabled);
}
#[cfg(unix)]
#[tokio::test]
async fn normalizing_disabled_msd_does_not_create_module_directories() {
let temp_dir = tempfile::tempdir().unwrap();
let data_dir = temp_dir.path().join("data");
let msd_dir = temp_dir.path().join("disabled-msd");
let db = open_database_pool(&data_dir).await.unwrap();
let config_store = ConfigStore::new(db.clone_pool());
config_store.load().await.unwrap();
let mut config = (*config_store.get()).clone();
config.msd.enabled = false;
config.msd.msd_dir = msd_dir.to_string_lossy().into_owned();
normalize_msd_config(&data_dir, &config_store, &mut config)
.await
.unwrap();
assert!(!msd_dir.join("images").exists());
assert!(!msd_dir.join("ventoy").exists());
}
}

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