mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef136241a | ||
|
|
78af9f67de | ||
|
|
1967d22dff | ||
|
|
0cba8ab127 | ||
|
|
d002226acb | ||
|
|
1db572e020 | ||
|
|
5de5ee42c2 | ||
|
|
2c19208094 | ||
|
|
3014edffbb | ||
|
|
ece4f303ce | ||
|
|
db9d79554a | ||
|
|
620fe0be54 | ||
|
|
79a4dcf2b0 | ||
|
|
a8ee9552a0 | ||
|
|
3749bafae0 | ||
|
|
b573f478fc | ||
|
|
d67a3ab7f8 | ||
|
|
dbe5672df4 | ||
|
|
48fd64319e | ||
|
|
ccf821ab22 | ||
|
|
b074aa5779 | ||
|
|
95a1fdf42d | ||
|
|
e678ec394d | ||
|
|
628909a192 | ||
|
|
5cab585115 | ||
|
|
dcfa3eadaf | ||
|
|
1647e70243 | ||
|
|
9c6d8a614c | ||
|
|
db7a845d3b | ||
|
|
1d48d45592 | ||
|
|
d613739b06 | ||
|
|
6e1194fe4c | ||
|
|
f101ddb918 | ||
|
|
0054100414 | ||
|
|
486f3887c2 | ||
|
|
37af369b43 | ||
|
|
827d24fde4 | ||
|
|
b8ea38b5f3 | ||
|
|
4530d8663d | ||
|
|
a4073d64cb | ||
|
|
16400df182 | ||
|
|
47af17bebc | ||
|
|
814f23a27c | ||
|
|
6bbe137f09 | ||
|
|
6fdcf5c7c9 | ||
|
|
ce1712ff2e | ||
|
|
8b09824e04 | ||
|
|
9fb23476ac | ||
|
|
971c263bf8 | ||
|
|
cb3fd3e646 | ||
|
|
d414557d74 | ||
|
|
fbcd95b617 | ||
|
|
887f29096f | ||
|
|
e0bddc2faa | ||
|
|
4f770a1eea | ||
|
|
376dc97134 | ||
|
|
27c8da9a75 | ||
|
|
5963dfa01a | ||
|
|
f86cba6ce5 | ||
|
|
e1b82519b9 | ||
|
|
32d8a62fec | ||
|
|
4cd622ff07 | ||
|
|
6ec4361f43 | ||
|
|
e9bed3688f | ||
|
|
8d5366444b | ||
|
|
4e32b05124 | ||
|
|
e0055bf491 |
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 \
|
||||
&& \
|
||||
|
||||
@@ -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 \
|
||||
&& \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
24
docs/macos-drag-compatibility.md
Normal file
24
docs/macos-drag-compatibility.md
Normal 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 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。
|
||||
16
libs/bluetooth-hid/Cargo.toml
Normal file
16
libs/bluetooth-hid/Cargo.toml
Normal 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"
|
||||
49
libs/bluetooth-hid/examples/pairing_probe.rs
Normal file
49
libs/bluetooth-hid/examples/pairing_probe.rs
Normal 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)
|
||||
}
|
||||
24
libs/bluetooth-hid/examples/probe.rs
Normal file
24
libs/bluetooth-hid/examples/probe.rs
Normal 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(())
|
||||
}
|
||||
236
libs/bluetooth-hid/src/agent.rs
Normal file
236
libs/bluetooth-hid/src/agent.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
145
libs/bluetooth-hid/src/bonds.rs
Normal file
145
libs/bluetooth-hid/src/bonds.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
135
libs/bluetooth-hid/src/controller.rs
Normal file
135
libs/bluetooth-hid/src/controller.rs
Normal 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("; "))
|
||||
}
|
||||
}
|
||||
}
|
||||
964
libs/bluetooth-hid/src/lib.rs
Normal file
964
libs/bluetooth-hid/src/lib.rs
Normal 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 1–64 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 10–300 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)
|
||||
}
|
||||
267
libs/bluetooth-hid/src/protocol.rs
Normal file
267
libs/bluetooth-hid/src/protocol.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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 {}",
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h
Normal file
38
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dma_jpeg.h
Normal 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;
|
||||
}
|
||||
329
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp
Normal file
329
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf.cpp
Normal 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; }
|
||||
31
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h
Normal file
31
libs/hwcodec/cpp/ffmpeg_hw/rkmpp_dmabuf_ffi.h
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
193
libs/hwcodec/src/rkmpp_dmabuf.rs
Normal file
193
libs/hwcodec/src/rkmpp_dmabuf.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms))
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
59
src/audio/uac/decoder.rs
Normal 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
9
src/audio/uac/mod.rs
Normal 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
509
src/audio/uac/playback.rs
Normal 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(¶ms))
|
||||
.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(¶ms))
|
||||
.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
98
src/audio/uac/protocol.rs
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 1–64 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ pub enum EncoderType {
|
||||
Qsv,
|
||||
Amf,
|
||||
Rkmpp,
|
||||
#[serde(alias = "amlogic")]
|
||||
V4l2m2m,
|
||||
}
|
||||
|
||||
|
||||
78
src/config/schema/uac.rs
Normal file
78
src/config/schema/uac.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
80
src/db/hid_bonds.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
194
src/db/pool.rs
194
src/db/pool.rs
@@ -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
39
src/db/wol_history.rs
Normal 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?)
|
||||
}
|
||||
}
|
||||
368
src/error.rs
368
src/error.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
95
src/extensions/protected_config.rs
Normal file
95
src/extensions/protected_config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
119
src/extensions/validation.rs
Normal file
119
src/extensions/validation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
283
src/hid/bluetooth.rs
Normal 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 10–300 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
256
src/hid/macos_drag.rs
Normal 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
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
225
src/hid/mod.rs
225
src/hid/mod.rs
@@ -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,
|
||||
¤t,
|
||||
"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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
873
src/main.rs
873
src/main.rs
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
170
src/msd/image.rs
170
src/msd/image.rs
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)))?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
479
src/otg/msd.rs
479
src/otg/msd.rs
@@ -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(¤t_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();
|
||||
|
||||
|
||||
@@ -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
153
src/otg/uac.rs
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(¬_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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
594
src/runtime/builder.rs
Normal 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
Reference in New Issue
Block a user