Compare commits

..

18 Commits

Author SHA1 Message Date
mofeng
8b17a0c48b chore: 更新版本号到 v0.1.3 2026-01-31 02:10:39 +08:00
mofeng
e75f0500e2 fix: 默认跳过晶晨 /dev/video0 vdec 探测,仅在设置环境变量时允许,修复内核 BUG 带来的软件卡死 2026-01-31 02:04:52 +08:00
mofeng
a14e0fb26d chore: 更新版本号到 v0.1.3 2026-01-30 15:32:04 +08:00
mofeng
d649c1ac20 feat: 引入统一配置 store 并迁移 Console/Settings 配置读写;自动显示当前使用的配置 2026-01-30 15:24:26 +08:00
mofeng
6a110258b9 feat: 支持 ipv4/ipv6 双栈访问 2026-01-30 14:47:41 +08:00
mofeng
58f9020192 feat: RustDesk 扩展支持相对鼠标模式,上报设备标识修改为 Windows 2026-01-30 13:10:55 +08:00
mofeng
f3b42e2aaf fix: 引导流程结束后,再次访问引导页面自动跳转 2026-01-30 13:06:32 +08:00
mofeng
b2b99115ec fix: 修复 USB HID 端点异常时鼠标事件写入会阻塞把服务端音视频线程拖死的问题 2026-01-30 12:33:19 +08:00
mofeng
1a0b285fe6 fix: 初步修复移动端 UI 错乱 2026-01-29 22:43:47 +08:00
mofeng
d9daeb211a fix: 修复 /dev/videoX 设备非视频设备 v4l2 不返回内容,导致主程序挂起的问题 2026-01-29 21:23:48 +08:00
mofeng
78aca25722 fix: 修复部分资源未授权访问,删除冗余 Admin 判断逻辑 2026-01-29 20:16:53 +08:00
mofeng
9cb0dd146e fix: 修复适配全志平台 OTG 低端点情况 2026-01-29 20:00:40 +08:00
mofeng
2938af32a9 fix: 将会话失效处理集中到路由并避免在登录页刷新循环 2026-01-28 21:52:12 +08:00
mofeng
ece0bbdcef fix: 修复 rustdesk 扩展前端校验逻辑,自动补全端口 2026-01-28 21:44:45 +08:00
mofeng
2dc055a5b2 dcos: 更新 README.md 2026-01-27 23:31:09 +08:00
mofeng
46c2edeb96 fix: 完善前端 USB OTG 动态配置功能的选项 2026-01-27 22:16:45 +08:00
mofeng
9d5451f588 fix: 完善 rustdsk 丢帧策略 2026-01-27 17:32:28 +08:00
mofeng
9193c54f86 fix: mpp 性能优化和修复
- mjpeg-->h265 mpp 编码速度优化
- 修复 mpp 编码后的视频 rustdesk 无法解码问题
- 更新版本号为 v0.1.2
2026-01-27 17:06:47 +08:00
59 changed files with 3521 additions and 943 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.1.1"
version = "0.1.4"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"

101
README.md
View File

@@ -1,5 +1,4 @@
<div align="center">
<img src="https://github.com/mofeng-git/Build-Armbian/assets/62919083/add9743a-0987-4e8a-b2cb-62121f236582" alt="One-KVM Logo" width="300">
<h1>One-KVM</h1>
<p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p>
@@ -19,16 +18,6 @@
---
## 📋 目录
- [项目概述](#项目概述)
- [迁移说明](#迁移说明)
- [功能介绍](#功能介绍)
- [快速开始](#快速开始)
- [贡献与反馈](#贡献与反馈)
- [致谢](#致谢)
- [许可证](#许可证)
## 📖 项目概述
**One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。
@@ -74,85 +63,14 @@
- Web UI 配置,多语言支持(中文/英文)
- 内置 Web 终端ttyd内网穿透支持gostc、P2P 组网支持EasyTier、RustDesk 协议集成(用于跨平台远程访问能力扩展)
## ⚡ 快速开始
## ⚡ 安装使用
安装方式Docker / DEB 软件包 / 飞牛 NASFPK
### 方式一Docker 安装(推荐)
前提条件:
- Linux 主机已安装 Docker
- 插好 USB HDMI 采集卡
- 启用 USB OTG 或插好 CH340+CH9329 HID 线(用于 HID 模拟)
启动容器:
```bash
docker run --name one-kvm -itd --privileged=true \
-v /dev:/dev -v /sys/:/sys \
--net=host \
silentwind0/one-kvm
```
访问 Web 界面:`http://<设备IP>:8080`首次访问会引导创建管理员账户。默认端口HTTP `8080`;启用 HTTPS 后为 `8443`
#### 常用环境变量Docker
| 变量名 | 默认值 | 说明 |
|------|------|------|
| `ENABLE_HTTPS` | `false` | 是否启用 HTTPS`true/false` |
| `HTTP_PORT` | `8080` | HTTP 端口(`ENABLE_HTTPS=false` 时生效) |
| `HTTPS_PORT` | `8443` | HTTPS 端口(`ENABLE_HTTPS=true` 时生效) |
| `BIND_ADDRESS` | - | 监听地址(如 `0.0.0.0` |
| `VERBOSE` | `0` | 日志详细程度:`1`-v`2`-vv`3`-vvv |
| `DATA_DIR` | `/etc/one-kvm` | 数据目录(等价于 `one-kvm -d <DIR>`,优先级高于 `ONE_KVM_DATA_DIR` |
> 说明:`--privileged=true` 和挂载 `/dev`、`/sys` 是硬件访问所需配置,当前版本不可省略。
>
> 兼容性:同时支持旧变量名 `ONE_KVM_DATA_DIR`。
>
> HTTPS未提供证书时会自动生成默认自签名证书。
>
> Ventoy若修改 `DATA_DIR`,请确保 Ventoy 资源文件位于 `${DATA_DIR}/ventoy``boot.img`、`core.img`、`ventoy.disk.img`)。
### 方式二DEB 软件包安装
前提条件:
- Debian 11+ / Ubuntu 22+
- 插好 USB HDMI 采集卡、HID 线OTG 或 CH340+CH9329
安装步骤:
1. 从 GitHub Releases 下载适合架构的 `one-kvm_*.deb`[Releases](https://github.com/mofeng-git/One-KVM/releases)
2. 安装:
```bash
sudo apt update
sudo apt install ./one-kvm_*_*.deb
```
访问 Web 界面:`http://<设备IP>:8080`
### 方式三:飞牛 NASFPK安装
前提条件:
- 飞牛 NAS 系统(目前仅支持 x86_64 架构)
- 插好 USB HDMI 采集卡、CH340+CH9329 HID 线
安装步骤:
1. 从 GitHub Releases 下载 `*.fpk` 软件包:[Releases](https://github.com/mofeng-git/One-KVM/releases)
2. 在飞牛应用商店选择“手动安装”,导入 `*.fpk`
访问 Web 界面:`http://<设备IP>:8420`
可以访问 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/) 获取详细信息
## 报告问题
如果您发现了问题,请:
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告,或加入 QQ 群聊反馈。
2. 提供详细的错误信息和复现步骤
3. 包含您的硬件配置和系统信息
@@ -269,6 +187,14 @@ sudo apt install ./one-kvm_*_*.deb
-
- MaxZ
- 爱发电用户_c5f33
- 爱发电用户_09386
- 爱发电用户_JT6c
- ......
</details>
@@ -277,11 +203,6 @@ sudo apt install ./one-kvm_*_*.deb
本项目得到以下赞助商的支持:
**CDN 加速及安全防护:**
- **[Tencent EdgeOne](https://edgeone.ai/zh?from=github)** - 提供 CDN 加速及安全防护服务
![Tencent EdgeOne](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)
**文件存储服务:**
- **[Huang1111公益计划](https://pan.huang1111.cn/s/mxkx3T1)** - 提供免登录下载服务

View File

@@ -39,7 +39,7 @@ RUN apt-get update && \
COPY --chmod=755 init.sh /init.sh
# Copy binaries (these are placed by the build script)
COPY --chmod=755 one-kvm ttyd gostc easytier-core /usr/bin/
COPY --chmod=755 one-kvm ttyd /usr/bin/
# Copy ventoy resources if they exist
COPY ventoy/ /etc/one-kvm/ventoy/

View File

@@ -0,0 +1,48 @@
# One-KVM Runtime Image (full)
# This Dockerfile only packages pre-compiled binaries (no compilation)
# Used after cross-compiling with `cross build`
# Using Debian 11 for maximum compatibility (GLIBC 2.31)
ARG TARGETPLATFORM=linux/amd64
FROM debian:11-slim
ARG TARGETPLATFORM
# Install runtime dependencies in a single layer
# All codec libraries (libx264, libx265, libopus) are now statically linked
# Only hardware acceleration drivers and core system libraries remain dynamic
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# Core runtime (all platforms) - no codec libs needed
ca-certificates \
libudev1 \
libasound2 \
# v4l2 is handled by kernel, minimal userspace needed
libv4l-0 \
&& \
# Platform-specific hardware acceleration
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
apt-get install -y --no-install-recommends \
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
apt-get install -y --no-install-recommends \
libdrm2 libva2; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
apt-get install -y --no-install-recommends \
libdrm2 libva2; \
fi && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /etc/one-kvm/ventoy
# Copy init script
COPY --chmod=755 init.sh /init.sh
# Copy binaries (these are placed by the build script)
COPY --chmod=755 one-kvm ttyd gostc easytier-core /usr/bin/
# Copy ventoy resources if they exist
COPY ventoy/ /etc/one-kvm/ventoy/
# Entrypoint
CMD ["/init.sh"]

View File

@@ -25,11 +25,13 @@ echo_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Configuration
REGISTRY="${REGISTRY:-}" # e.g., docker.io/username or ghcr.io/username
IMAGE_NAME="${IMAGE_NAME:-one-kvm}"
IMAGE_NAME="${IMAGE_NAME:-}"
TAG="${TAG:-latest}"
VARIANT="${VARIANT:-minimal}"
INCLUDE_THIRD_PARTY=false
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
STAGING_DIR="$PROJECT_ROOT/build-staging"
BASE_STAGING_DIR="$PROJECT_ROOT/build-staging"
# Full image name with registry
get_full_image_name() {
@@ -77,6 +79,18 @@ while [[ $# -gt 0 ]]; do
REGISTRY="$2"
shift 2
;;
--image-name)
IMAGE_NAME="$2"
shift 2
;;
--variant)
VARIANT="$2"
shift 2
;;
--full)
VARIANT="full"
shift
;;
--build)
BUILD_BINARY=true
shift
@@ -91,9 +105,12 @@ while [[ $# -gt 0 ]]; do
echo " Use comma to specify multiple: linux/amd64,linux/arm64"
echo " Default: $DEFAULT_PLATFORM"
echo " --registry REGISTRY Container registry (e.g., docker.io/user, ghcr.io/user)"
echo " --image-name NAME Override image name (default: one-kvm or one-kvm-full)"
echo " --push Push image to registry"
echo " --load Load image to local Docker (single platform only)"
echo " --tag TAG Image tag (default: latest)"
echo " --variant VARIANT Image variant: minimal or full (default: minimal)"
echo " --full Shortcut for --variant full"
echo " --build Also build the binary with cross (optional)"
echo " --help Show this help"
echo ""
@@ -101,6 +118,9 @@ while [[ $# -gt 0 ]]; do
echo " # Build for current platform and load locally"
echo " $0 --platform linux/arm64 --load"
echo ""
echo " # Build full image (includes gostc + easytier)"
echo " $0 --variant full --platform linux/arm64 --load"
echo ""
echo " # Build and push single platform"
echo " $0 --platform linux/arm64 --registry docker.io/user --push"
echo ""
@@ -115,6 +135,28 @@ while [[ $# -gt 0 ]]; do
esac
done
# Normalize variant and image name
case "$VARIANT" in
minimal)
INCLUDE_THIRD_PARTY=false
;;
full)
INCLUDE_THIRD_PARTY=true
;;
*)
echo_error "Unknown variant: $VARIANT (expected: minimal or full)"
exit 1
;;
esac
if [ -z "$IMAGE_NAME" ]; then
if [ "$VARIANT" = "full" ]; then
IMAGE_NAME="one-kvm-full"
else
IMAGE_NAME="one-kvm"
fi
fi
# Default platform
if [ -z "$PLATFORMS" ]; then
PLATFORMS="$DEFAULT_PLATFORM"
@@ -176,21 +218,23 @@ download_tools() {
chmod +x "$staging/ttyd"
fi
# gostc
if [ ! -f "$staging/gostc" ]; then
curl -fsSL "$GOSTC_URL" -o /tmp/gostc.tar.gz
tar -xzf /tmp/gostc.tar.gz -C "$staging"
chmod +x "$staging/gostc"
rm /tmp/gostc.tar.gz
fi
if [ "$INCLUDE_THIRD_PARTY" = true ]; then
# gostc
if [ ! -f "$staging/gostc" ]; then
curl -fsSL "$GOSTC_URL" -o /tmp/gostc.tar.gz
tar -xzf /tmp/gostc.tar.gz -C "$staging"
chmod +x "$staging/gostc"
rm /tmp/gostc.tar.gz
fi
# easytier
if [ ! -f "$staging/easytier-core" ]; then
curl -fsSL "$EASYTIER_URL" -o /tmp/easytier.zip
unzip -o /tmp/easytier.zip -d /tmp/easytier
cp "/tmp/easytier/$EASYTIER_DIR/easytier-core" "$staging/easytier-core"
chmod +x "$staging/easytier-core"
rm -rf /tmp/easytier.zip /tmp/easytier
# easytier
if [ ! -f "$staging/easytier-core" ]; then
curl -fsSL "$EASYTIER_URL" -o /tmp/easytier.zip
unzip -o /tmp/easytier.zip -d /tmp/easytier
cp "/tmp/easytier/$EASYTIER_DIR/easytier-core" "$staging/easytier-core"
chmod +x "$staging/easytier-core"
rm -rf /tmp/easytier.zip /tmp/easytier
fi
fi
}
@@ -198,13 +242,14 @@ download_tools() {
build_for_platform() {
local platform="$1"
local target=$(platform_to_target "$platform")
local staging="$STAGING_DIR/$target"
local staging="$BASE_STAGING_DIR/$VARIANT/$target"
echo_info "=========================================="
echo_info "Processing: $platform ($target)"
echo_info "=========================================="
# Create staging directory
rm -rf "$staging"
mkdir -p "$staging/ventoy"
# Build binary if requested
@@ -252,7 +297,11 @@ build_for_platform() {
fi
# Copy Dockerfile
cp "$PROJECT_ROOT/build/Dockerfile.runtime" "$staging/Dockerfile"
local dockerfile="$PROJECT_ROOT/build/Dockerfile.runtime"
if [ "$INCLUDE_THIRD_PARTY" = true ]; then
dockerfile="$PROJECT_ROOT/build/Dockerfile.runtime-full"
fi
cp "$dockerfile" "$staging/Dockerfile"
# Build Docker image
echo_info "Building Docker image..."
@@ -292,6 +341,7 @@ main() {
echo_info "One-KVM Docker Image Builder"
echo_info "Image: $full_image:$TAG"
echo_info "Variant: $VARIANT"
echo_info "Platforms: $PLATFORMS"
if [ -n "$REGISTRY" ]; then
echo_info "Registry: $REGISTRY"

View File

@@ -419,10 +419,10 @@ mod ffmpeg {
builder.include(rkrga_dir.join("im2d_api"));
}
}
builder.file(ffmpeg_hw_dir.join("ffmpeg_hw_mjpeg_h264.cpp"));
builder.file(ffmpeg_hw_dir.join("ffmpeg_hw_mjpeg_h26x.cpp"));
} else {
println!(
"cargo:info=Skipping ffmpeg_hw_mjpeg_h264.cpp (RKMPP) for arch {}",
"cargo:info=Skipping ffmpeg_hw_mjpeg_h26x.cpp (RKMPP) for arch {}",
target_arch
);
}

View File

@@ -1,12 +1,16 @@
#include "linux.h"
#include "../../log.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <dlfcn.h>
#include <errno.h>
#include <fstream>
#include <signal.h>
#include <sys/prctl.h>
#include <unistd.h>
#include <fcntl.h>
#include <string>
// Check for NVIDIA driver support by loading CUDA libraries
int linux_support_nv()
@@ -106,6 +110,57 @@ int linux_support_rkmpp() {
// Check for V4L2 Memory-to-Memory (M2M) codec support
// Returns 0 if a M2M capable device is found, -1 otherwise
int linux_support_v4l2m2m() {
auto to_lower = [](std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return value;
};
auto read_text_file = [](const char *path, std::string *out) -> bool {
std::ifstream file(path);
if (!file.is_open()) {
return false;
}
std::getline(file, *out);
return !out->empty();
};
auto allow_video0_probe = []() -> bool {
const char *env = std::getenv("ONE_KVM_V4L2M2M_ALLOW_VIDEO0");
if (env == nullptr) {
return false;
}
if (env[0] == '\0') {
return false;
}
return std::strcmp(env, "0") != 0;
};
auto is_amlogic_vdec = [&]() -> bool {
std::string name;
std::string modalias;
if (read_text_file("/sys/class/video4linux/video0/name", &name)) {
const std::string lowered = to_lower(name);
if (lowered.find("meson") != std::string::npos ||
lowered.find("vdec") != std::string::npos ||
lowered.find("decoder") != std::string::npos ||
lowered.find("video-decoder") != std::string::npos) {
return true;
}
}
if (read_text_file("/sys/class/video4linux/video0/device/modalias", &modalias)) {
const std::string lowered = to_lower(modalias);
if (lowered.find("amlogic") != std::string::npos ||
lowered.find("meson") != std::string::npos ||
lowered.find("gxl-vdec") != std::string::npos ||
lowered.find("gx-vdec") != std::string::npos) {
return true;
}
}
return false;
};
// Check common V4L2 M2M device paths used by various ARM SoCs
const char *m2m_devices[] = {
"/dev/video10", // Common M2M encoder device
@@ -115,6 +170,13 @@ int linux_support_v4l2m2m() {
for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {
if (access(m2m_devices[i], F_OK) == 0) {
if (std::strcmp(m2m_devices[i], "/dev/video0") == 0) {
if (!allow_video0_probe() && is_amlogic_vdec()) {
LOG_TRACE(std::string("V4L2 M2M: Skipping /dev/video0 (Amlogic vdec)"));
continue;
}
}
// Device exists, check if it's an M2M device by trying to open it
int fd = open(m2m_devices[i], O_RDWR | O_NONBLOCK);
if (fd >= 0) {

View File

@@ -6,9 +6,11 @@
extern "C" {
#endif
typedef struct FfmpegHwMjpegH264 FfmpegHwMjpegH264;
// MJPEG -> H26x (H.264 / H.265) hardware pipeline
typedef struct FfmpegHwMjpegH26x FfmpegHwMjpegH26x;
FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
// Create a new MJPEG -> H26x pipeline.
FfmpegHwMjpegH26x* ffmpeg_hw_mjpeg_h26x_new(const char* dec_name,
const char* enc_name,
int width,
int height,
@@ -17,7 +19,8 @@ FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
int gop,
int thread_count);
int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* ctx,
// Encode one MJPEG frame. Returns 1 if output produced, 0 if no output, <0 on error.
int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* ctx,
const uint8_t* data,
int len,
int64_t pts_ms,
@@ -25,16 +28,21 @@ int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* ctx,
int* out_len,
int* out_keyframe);
int ffmpeg_hw_mjpeg_h264_reconfigure(FfmpegHwMjpegH264* ctx,
// Reconfigure bitrate/gop (best-effort, may recreate encoder internally).
int ffmpeg_hw_mjpeg_h26x_reconfigure(FfmpegHwMjpegH26x* ctx,
int bitrate_kbps,
int gop);
int ffmpeg_hw_mjpeg_h264_request_keyframe(FfmpegHwMjpegH264* ctx);
// Request next frame to be a keyframe.
int ffmpeg_hw_mjpeg_h26x_request_keyframe(FfmpegHwMjpegH26x* ctx);
void ffmpeg_hw_mjpeg_h264_free(FfmpegHwMjpegH264* ctx);
// Free pipeline resources.
void ffmpeg_hw_mjpeg_h26x_free(FfmpegHwMjpegH26x* ctx);
// Free packet buffer allocated by ffmpeg_hw_mjpeg_h26x_encode.
void ffmpeg_hw_packet_free(uint8_t* data);
// Get last error message (thread-local).
const char* ffmpeg_hw_last_error(void);
#ifdef __cplusplus

View File

@@ -35,7 +35,7 @@ static const char* pix_fmt_name(AVPixelFormat fmt) {
return name ? name : "unknown";
}
struct FfmpegHwMjpegH264Ctx {
struct FfmpegHwMjpegH26xCtx {
AVCodecContext *dec_ctx = nullptr;
AVCodecContext *enc_ctx = nullptr;
AVPacket *dec_pkt = nullptr;
@@ -48,6 +48,8 @@ struct FfmpegHwMjpegH264Ctx {
std::string enc_name;
int width = 0;
int height = 0;
int aligned_width = 0;
int aligned_height = 0;
int fps = 30;
int bitrate_kbps = 2000;
int gop = 60;
@@ -57,7 +59,7 @@ struct FfmpegHwMjpegH264Ctx {
static enum AVPixelFormat get_hw_format(AVCodecContext *ctx,
const enum AVPixelFormat *pix_fmts) {
auto *self = reinterpret_cast<FfmpegHwMjpegH264Ctx *>(ctx->opaque);
auto *self = reinterpret_cast<FfmpegHwMjpegH26xCtx *>(ctx->opaque);
if (self && self->hw_pixfmt != AV_PIX_FMT_NONE) {
const enum AVPixelFormat *p;
for (p = pix_fmts; *p != AV_PIX_FMT_NONE; p++) {
@@ -69,7 +71,7 @@ static enum AVPixelFormat get_hw_format(AVCodecContext *ctx,
return pix_fmts[0];
}
static int init_decoder(FfmpegHwMjpegH264Ctx *ctx) {
static int init_decoder(FfmpegHwMjpegH26xCtx *ctx) {
const AVCodec *dec = avcodec_find_decoder_by_name(ctx->dec_name.c_str());
if (!dec) {
set_last_error("Decoder not found: " + ctx->dec_name);
@@ -127,7 +129,7 @@ static int init_decoder(FfmpegHwMjpegH264Ctx *ctx) {
return 0;
}
static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
static int init_encoder(FfmpegHwMjpegH26xCtx *ctx, AVBufferRef *frames_ctx) {
const AVCodec *enc = avcodec_find_encoder_by_name(ctx->enc_name.c_str());
if (!enc) {
set_last_error("Encoder not found: " + ctx->enc_name);
@@ -142,6 +144,10 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
ctx->enc_ctx->width = ctx->width;
ctx->enc_ctx->height = ctx->height;
ctx->enc_ctx->coded_width = ctx->width;
ctx->enc_ctx->coded_height = ctx->height;
ctx->aligned_width = ctx->width;
ctx->aligned_height = ctx->height;
ctx->enc_ctx->time_base = AVRational{1, 1000};
ctx->enc_ctx->framerate = AVRational{ctx->fps, 1};
ctx->enc_ctx->bit_rate = (int64_t)ctx->bitrate_kbps * 1000;
@@ -155,8 +161,14 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
if (hwfc) {
ctx->enc_ctx->pix_fmt = static_cast<AVPixelFormat>(hwfc->format);
ctx->enc_ctx->sw_pix_fmt = static_cast<AVPixelFormat>(hwfc->sw_format);
if (hwfc->width > 0) ctx->enc_ctx->width = hwfc->width;
if (hwfc->height > 0) ctx->enc_ctx->height = hwfc->height;
if (hwfc->width > 0) {
ctx->aligned_width = hwfc->width;
ctx->enc_ctx->coded_width = hwfc->width;
}
if (hwfc->height > 0) {
ctx->aligned_height = hwfc->height;
ctx->enc_ctx->coded_height = hwfc->height;
}
}
ctx->hw_frames_ctx = av_buffer_ref(frames_ctx);
ctx->enc_ctx->hw_frames_ctx = av_buffer_ref(frames_ctx);
@@ -167,7 +179,11 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
AVDictionary *opts = nullptr;
av_dict_set(&opts, "rc_mode", "CBR", 0);
av_dict_set(&opts, "profile", "high", 0);
if (enc->id == AV_CODEC_ID_H264) {
av_dict_set(&opts, "profile", "high", 0);
} else if (enc->id == AV_CODEC_ID_HEVC) {
av_dict_set(&opts, "profile", "main", 0);
}
av_dict_set_int(&opts, "qp_init", 23, 0);
av_dict_set_int(&opts, "qp_max", 48, 0);
av_dict_set_int(&opts, "qp_min", 0, 0);
@@ -195,7 +211,7 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
return 0;
}
static void free_encoder(FfmpegHwMjpegH264Ctx *ctx) {
static void free_encoder(FfmpegHwMjpegH26xCtx *ctx) {
if (ctx->enc_ctx) {
avcodec_free_context(&ctx->enc_ctx);
ctx->enc_ctx = nullptr;
@@ -208,7 +224,7 @@ static void free_encoder(FfmpegHwMjpegH264Ctx *ctx) {
} // namespace
extern "C" FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
extern "C" FfmpegHwMjpegH26x* ffmpeg_hw_mjpeg_h26x_new(const char* dec_name,
const char* enc_name,
int width,
int height,
@@ -217,11 +233,11 @@ extern "C" FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
int gop,
int thread_count) {
if (!dec_name || !enc_name || width <= 0 || height <= 0) {
set_last_error("Invalid parameters for ffmpeg_hw_mjpeg_h264_new");
set_last_error("Invalid parameters for ffmpeg_hw_mjpeg_h26x_new");
return nullptr;
}
auto *ctx = new FfmpegHwMjpegH264Ctx();
auto *ctx = new FfmpegHwMjpegH26xCtx();
ctx->dec_name = dec_name;
ctx->enc_name = enc_name;
ctx->width = width;
@@ -232,14 +248,14 @@ extern "C" FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
ctx->thread_count = thread_count > 0 ? thread_count : 1;
if (init_decoder(ctx) != 0) {
ffmpeg_hw_mjpeg_h264_free(reinterpret_cast<FfmpegHwMjpegH264*>(ctx));
ffmpeg_hw_mjpeg_h26x_free(reinterpret_cast<FfmpegHwMjpegH26x*>(ctx));
return nullptr;
}
return reinterpret_cast<FfmpegHwMjpegH264*>(ctx);
return reinterpret_cast<FfmpegHwMjpegH26x*>(ctx);
}
extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
const uint8_t* data,
int len,
int64_t pts_ms,
@@ -251,7 +267,7 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
return -1;
}
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
*out_data = nullptr;
*out_len = 0;
*out_keyframe = 0;
@@ -310,6 +326,14 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
ctx->force_keyframe = false;
}
// Apply visible size crop if aligned buffer is larger than display size
if (ctx->aligned_width > 0 && ctx->width > 0 && ctx->aligned_width > ctx->width) {
send_frame->crop_right = ctx->aligned_width - ctx->width;
}
if (ctx->aligned_height > 0 && ctx->height > 0 && ctx->aligned_height > ctx->height) {
send_frame->crop_bottom = ctx->aligned_height - ctx->height;
}
send_frame->pts = pts_ms; // time_base is ms
ret = avcodec_send_frame(ctx->enc_ctx, send_frame);
@@ -379,14 +403,14 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
}
}
extern "C" int ffmpeg_hw_mjpeg_h264_reconfigure(FfmpegHwMjpegH264* handle,
extern "C" int ffmpeg_hw_mjpeg_h26x_reconfigure(FfmpegHwMjpegH26x* handle,
int bitrate_kbps,
int gop) {
if (!handle) {
set_last_error("Invalid handle for reconfigure");
return -1;
}
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
if (!ctx->enc_ctx || !ctx->hw_frames_ctx) {
set_last_error("Encoder not initialized for reconfigure");
return -1;
@@ -407,18 +431,18 @@ extern "C" int ffmpeg_hw_mjpeg_h264_reconfigure(FfmpegHwMjpegH264* handle,
return 0;
}
extern "C" int ffmpeg_hw_mjpeg_h264_request_keyframe(FfmpegHwMjpegH264* handle) {
extern "C" int ffmpeg_hw_mjpeg_h26x_request_keyframe(FfmpegHwMjpegH26x* handle) {
if (!handle) {
set_last_error("Invalid handle for request_keyframe");
return -1;
}
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
ctx->force_keyframe = true;
return 0;
}
extern "C" void ffmpeg_hw_mjpeg_h264_free(FfmpegHwMjpegH264* handle) {
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
extern "C" void ffmpeg_hw_mjpeg_h26x_free(FfmpegHwMjpegH26x* handle) {
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
if (!ctx) return;
if (ctx->dec_pkt) av_packet_free(&ctx->dec_pkt);

View File

@@ -10,7 +10,7 @@ use std::{
include!(concat!(env!("OUT_DIR"), "/ffmpeg_hw_ffi.rs"));
#[derive(Debug, Clone)]
pub struct HwMjpegH264Config {
pub struct HwMjpegH26xConfig {
pub decoder: String,
pub encoder: String,
pub width: i32,
@@ -21,19 +21,19 @@ pub struct HwMjpegH264Config {
pub thread_count: i32,
}
pub struct HwMjpegH264Pipeline {
ctx: *mut FfmpegHwMjpegH264,
config: HwMjpegH264Config,
pub struct HwMjpegH26xPipeline {
ctx: *mut FfmpegHwMjpegH26x,
config: HwMjpegH26xConfig,
}
unsafe impl Send for HwMjpegH264Pipeline {}
unsafe impl Send for HwMjpegH26xPipeline {}
impl HwMjpegH264Pipeline {
pub fn new(config: HwMjpegH264Config) -> Result<Self, String> {
impl HwMjpegH26xPipeline {
pub fn new(config: HwMjpegH26xConfig) -> Result<Self, String> {
unsafe {
let dec = CString::new(config.decoder.as_str()).map_err(|_| "decoder name invalid".to_string())?;
let enc = CString::new(config.encoder.as_str()).map_err(|_| "encoder name invalid".to_string())?;
let ctx = ffmpeg_hw_mjpeg_h264_new(
let ctx = ffmpeg_hw_mjpeg_h26x_new(
dec.as_ptr(),
enc.as_ptr(),
config.width,
@@ -55,7 +55,7 @@ impl HwMjpegH264Pipeline {
let mut out_data: *mut u8 = std::ptr::null_mut();
let mut out_len: c_int = 0;
let mut out_key: c_int = 0;
let ret = ffmpeg_hw_mjpeg_h264_encode(
let ret = ffmpeg_hw_mjpeg_h26x_encode(
self.ctx,
data.as_ptr(),
data.len() as c_int,
@@ -80,7 +80,7 @@ impl HwMjpegH264Pipeline {
pub fn reconfigure(&mut self, bitrate_kbps: i32, gop: i32) -> Result<(), String> {
unsafe {
let ret = ffmpeg_hw_mjpeg_h264_reconfigure(self.ctx, bitrate_kbps, gop);
let ret = ffmpeg_hw_mjpeg_h26x_reconfigure(self.ctx, bitrate_kbps, gop);
if ret != 0 {
return Err(last_error_message());
}
@@ -92,15 +92,15 @@ impl HwMjpegH264Pipeline {
pub fn request_keyframe(&mut self) {
unsafe {
let _ = ffmpeg_hw_mjpeg_h264_request_keyframe(self.ctx);
let _ = ffmpeg_hw_mjpeg_h26x_request_keyframe(self.ctx);
}
}
}
impl Drop for HwMjpegH264Pipeline {
impl Drop for HwMjpegH26xPipeline {
fn drop(&mut self) {
unsafe {
ffmpeg_hw_mjpeg_h264_free(self.ctx);
ffmpeg_hw_mjpeg_h26x_free(self.ctx);
}
self.ctx = std::ptr::null_mut();
}

View File

@@ -40,20 +40,20 @@ pub async fn auth_middleware(
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let raw_path = request.uri().path();
// When this middleware is mounted under /api, Axum strips the prefix for the inner router.
// Normalize the path so checks work whether it is mounted or not.
let path = raw_path.strip_prefix("/api").unwrap_or(raw_path);
// Check if system is initialized
if !state.config.is_initialized() {
// Allow access to setup endpoints when not initialized
let path = request.uri().path();
if path.starts_with("/api/setup")
|| path == "/api/info"
|| path.starts_with("/") && !path.starts_with("/api/")
{
// Allow only setup-related endpoints when not initialized
if is_setup_public_endpoint(path) {
return Ok(next.run(request).await);
}
}
// Public endpoints that don't require auth
let path = request.uri().path();
if is_public_endpoint(path) {
return Ok(next.run(request).await);
}
@@ -89,21 +89,14 @@ fn unauthorized_response(message: &str) -> Response {
/// Check if endpoint is public (no auth required)
fn is_public_endpoint(path: &str) -> bool {
// Note: paths here are relative to /api since middleware is applied before nest
// Note: paths here are relative to /api since middleware is applied within the nested router
matches!(
path,
"/"
| "/auth/login"
| "/info"
| "/health"
| "/setup"
| "/setup/init"
// Also check with /api prefix for direct access
| "/api/auth/login"
| "/api/info"
| "/api/health"
| "/api/setup"
| "/api/setup/init"
) || path.starts_with("/assets/")
|| path.starts_with("/static/")
|| path.ends_with(".js")
@@ -112,3 +105,11 @@ fn is_public_endpoint(path: &str) -> bool {
|| path.ends_with(".png")
|| path.ends_with(".svg")
}
/// Setup-only endpoints allowed before initialization.
fn is_setup_public_endpoint(path: &str) -> bool {
matches!(
path,
"/setup" | "/setup/init" | "/devices" | "/stream/codecs"
)
}

View File

@@ -166,6 +166,12 @@ impl Default for OtgDescriptorConfig {
pub enum OtgHidProfile {
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
Full,
/// Full HID device set without MSD
FullNoMsd,
/// Full HID device set without consumer control
FullNoConsumer,
/// Full HID device set without consumer control and MSD
FullNoConsumerNoMsd,
/// Legacy profile: only keyboard
LegacyKeyboard,
/// Legacy profile: only relative mouse
@@ -201,6 +207,15 @@ impl OtgHidFunctions {
}
}
pub fn full_no_consumer() -> Self {
Self {
keyboard: true,
mouse_relative: true,
mouse_absolute: true,
consumer: false,
}
}
pub fn legacy_keyboard() -> Self {
Self {
keyboard: true,
@@ -234,6 +249,9 @@ impl OtgHidProfile {
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
match self {
Self::Full => OtgHidFunctions::full(),
Self::FullNoMsd => OtgHidFunctions::full(),
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
Self::Custom => custom.clone(),
@@ -558,7 +576,9 @@ pub struct WebConfig {
pub http_port: u16,
/// HTTPS port
pub https_port: u16,
/// Bind address
/// Bind addresses (preferred)
pub bind_addresses: Vec<String>,
/// Bind address (legacy)
pub bind_address: String,
/// Enable HTTPS
pub https_enabled: bool,
@@ -573,6 +593,7 @@ impl Default for WebConfig {
Self {
http_port: 8080,
https_port: 8443,
bind_addresses: Vec::new(),
bind_address: "0.0.0.0".to_string(),
https_enabled: false,
ssl_cert_path: None,

View File

@@ -395,6 +395,8 @@ pub struct Ch9329Backend {
last_abs_x: AtomicU16,
/// Last absolute mouse Y position (CH9329 coordinate: 0-4095)
last_abs_y: AtomicU16,
/// Whether relative mouse mode is active (set by incoming events)
relative_mouse_active: AtomicBool,
/// Consecutive error count
error_count: AtomicU32,
/// Whether a reset is in progress
@@ -426,6 +428,7 @@ impl Ch9329Backend {
address: DEFAULT_ADDR,
last_abs_x: AtomicU16::new(0),
last_abs_y: AtomicU16::new(0),
relative_mouse_active: AtomicBool::new(false),
error_count: AtomicU32::new(0),
reset_in_progress: AtomicBool::new(false),
last_success: Mutex::new(None),
@@ -1014,12 +1017,14 @@ impl HidBackend for Ch9329Backend {
match event.event_type {
MouseEventType::Move => {
// Relative movement - send delta directly without inversion
self.relative_mouse_active.store(true, Ordering::Relaxed);
let dx = event.x.clamp(-127, 127) as i8;
let dy = event.y.clamp(-127, 127) as i8;
self.send_mouse_relative(buttons, dx, dy, 0)?;
}
MouseEventType::MoveAbs => {
// Absolute movement
self.relative_mouse_active.store(false, Ordering::Relaxed);
// Frontend sends 0-32767 (HID standard), CH9329 expects 0-4095
let x = ((event.x.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
let y = ((event.y.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
@@ -1031,28 +1036,40 @@ impl HidBackend for Ch9329Backend {
MouseEventType::Down => {
if let Some(button) = event.button {
let bit = button.to_hid_bit();
let x = self.last_abs_x.load(Ordering::Relaxed);
let y = self.last_abs_y.load(Ordering::Relaxed);
let new_buttons = self.mouse_buttons.fetch_or(bit, Ordering::Relaxed) | bit;
trace!("Mouse down: {:?} buttons=0x{:02X}", button, new_buttons);
self.send_mouse_absolute(new_buttons, x, y, 0)?;
if self.relative_mouse_active.load(Ordering::Relaxed) {
self.send_mouse_relative(new_buttons, 0, 0, 0)?;
} else {
let x = self.last_abs_x.load(Ordering::Relaxed);
let y = self.last_abs_y.load(Ordering::Relaxed);
self.send_mouse_absolute(new_buttons, x, y, 0)?;
}
}
}
MouseEventType::Up => {
if let Some(button) = event.button {
let bit = button.to_hid_bit();
let x = self.last_abs_x.load(Ordering::Relaxed);
let y = self.last_abs_y.load(Ordering::Relaxed);
let new_buttons = self.mouse_buttons.fetch_and(!bit, Ordering::Relaxed) & !bit;
trace!("Mouse up: {:?} buttons=0x{:02X}", button, new_buttons);
self.send_mouse_absolute(new_buttons, x, y, 0)?;
if self.relative_mouse_active.load(Ordering::Relaxed) {
self.send_mouse_relative(new_buttons, 0, 0, 0)?;
} else {
let x = self.last_abs_x.load(Ordering::Relaxed);
let y = self.last_abs_y.load(Ordering::Relaxed);
self.send_mouse_absolute(new_buttons, x, y, 0)?;
}
}
}
MouseEventType::Scroll => {
// Use absolute mouse for scroll with last position
let x = self.last_abs_x.load(Ordering::Relaxed);
let y = self.last_abs_y.load(Ordering::Relaxed);
self.send_mouse_absolute(buttons, x, y, event.scroll)?;
if self.relative_mouse_active.load(Ordering::Relaxed) {
self.send_mouse_relative(buttons, 0, 0, event.scroll)?;
} else {
// Use absolute mouse for scroll with last position
let x = self.last_abs_x.load(Ordering::Relaxed);
let y = self.last_abs_y.load(Ordering::Relaxed);
self.send_mouse_absolute(buttons, x, y, event.scroll)?;
}
}
}
@@ -1073,6 +1090,7 @@ impl HidBackend for Ch9329Backend {
self.mouse_buttons.store(0, Ordering::Relaxed);
self.last_abs_x.store(0, Ordering::Relaxed);
self.last_abs_y.store(0, Ordering::Relaxed);
self.relative_mouse_active.store(false, Ordering::Relaxed);
self.send_mouse_absolute(0, 0, 0, 0)?;
// Reset media keys

View File

@@ -43,24 +43,52 @@ pub struct HidInfo {
}
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::error::{AppError, Result};
use crate::otg::OtgService;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use std::time::Duration;
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
#[derive(Debug)]
enum HidEvent {
Keyboard(KeyboardEvent),
Mouse(MouseEvent),
Consumer(ConsumerEvent),
Reset,
}
/// HID controller managing keyboard and mouse input
pub struct HidController {
/// OTG Service reference (only used when backend is OTG)
otg_service: Option<Arc<OtgService>>,
/// Active backend
backend: Arc<RwLock<Option<Box<dyn HidBackend>>>>,
backend: Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
/// Backend type (mutable for reload)
backend_type: RwLock<HidBackendType>,
backend_type: Arc<RwLock<HidBackendType>>,
/// Event bus for broadcasting state changes (optional)
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
/// Health monitor for error tracking and recovery
monitor: Arc<HidHealthMonitor>,
/// HID event queue sender (non-blocking)
hid_tx: mpsc::Sender<HidEvent>,
/// HID event queue receiver (moved into worker on first start)
hid_rx: Mutex<Option<mpsc::Receiver<HidEvent>>>,
/// Coalesced mouse move (latest)
pending_move: Arc<parking_lot::Mutex<Option<MouseEvent>>>,
/// Pending move flag (fast path)
pending_move_flag: Arc<AtomicBool>,
/// Worker task handle
hid_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend availability fast flag
backend_available: AtomicBool,
}
impl HidController {
@@ -68,12 +96,19 @@ impl HidController {
///
/// For OTG backend, otg_service should be provided to support hot-reload
pub fn new(backend_type: HidBackendType, otg_service: Option<Arc<OtgService>>) -> Self {
let (hid_tx, hid_rx) = mpsc::channel(HID_EVENT_QUEUE_CAPACITY);
Self {
otg_service,
backend: Arc::new(RwLock::new(None)),
backend_type: RwLock::new(backend_type),
backend_type: Arc::new(RwLock::new(backend_type)),
events: tokio::sync::RwLock::new(None),
monitor: Arc::new(HidHealthMonitor::with_defaults()),
hid_tx,
hid_rx: Mutex::new(Some(hid_rx)),
pending_move: Arc::new(parking_lot::Mutex::new(None)),
pending_move_flag: Arc::new(AtomicBool::new(false)),
hid_worker: Mutex::new(None),
backend_available: AtomicBool::new(false),
}
}
@@ -87,7 +122,7 @@ impl HidController {
/// Initialize the HID backend
pub async fn init(&self) -> Result<()> {
let backend_type = self.backend_type.read().await.clone();
let backend: Box<dyn HidBackend> = match backend_type {
let backend: Arc<dyn HidBackend> = match backend_type {
HidBackendType::Otg => {
// Request HID functions from OtgService
let otg_service = self
@@ -100,7 +135,7 @@ impl HidController {
// Create OtgBackend from handles (no longer manages gadget itself)
info!("Creating OTG HID backend from device paths");
Box::new(otg::OtgBackend::from_handles(handles)?)
Arc::new(otg::OtgBackend::from_handles(handles)?)
}
HidBackendType::Ch9329 {
ref port,
@@ -110,7 +145,7 @@ impl HidController {
"Initializing CH9329 HID backend on {} @ {} baud",
port, baud_rate
);
Box::new(ch9329::Ch9329Backend::with_baud_rate(port, baud_rate)?)
Arc::new(ch9329::Ch9329Backend::with_baud_rate(port, baud_rate)?)
}
HidBackendType::None => {
warn!("HID backend disabled");
@@ -120,6 +155,10 @@ impl HidController {
backend.init().await?;
*self.backend.write().await = Some(backend);
self.backend_available.store(true, Ordering::Release);
// Start HID event worker (once)
self.start_event_worker().await;
info!("HID backend initialized: {:?}", backend_type);
Ok(())
@@ -131,6 +170,7 @@ impl HidController {
// Close the backend
*self.backend.write().await = None;
self.backend_available.store(false, Ordering::Release);
// If OTG backend, notify OtgService to disable HID
let backend_type = self.backend_type.read().await.clone();
@@ -147,125 +187,47 @@ impl HidController {
/// Send keyboard event
pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
let backend = self.backend.read().await;
match backend.as_ref() {
Some(b) => {
match b.send_keyboard(event).await {
Ok(_) => {
// Check if we were in an error state and now recovered
if self.monitor.is_error().await {
let backend_type = self.backend_type.read().await;
self.monitor.report_recovered(backend_type.name_str()).await;
}
Ok(())
}
Err(e) => {
// Report error to monitor, but skip temporary EAGAIN retries
// - "eagain_retry": within threshold, just temporary busy
// - "eagain": exceeded threshold, report as error
if let AppError::HidError {
ref backend,
ref reason,
ref error_code,
} = e
{
if error_code != "eagain_retry" {
self.monitor
.report_error(backend, None, reason, error_code)
.await;
}
}
Err(e)
}
}
}
None => Err(AppError::BadRequest(
if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::BadRequest(
"HID backend not available".to_string(),
)),
));
}
self.enqueue_event(HidEvent::Keyboard(event)).await
}
/// Send mouse event
pub async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
let backend = self.backend.read().await;
match backend.as_ref() {
Some(b) => {
match b.send_mouse(event).await {
Ok(_) => {
// Check if we were in an error state and now recovered
if self.monitor.is_error().await {
let backend_type = self.backend_type.read().await;
self.monitor.report_recovered(backend_type.name_str()).await;
}
Ok(())
}
Err(e) => {
// Report error to monitor, but skip temporary EAGAIN retries
// - "eagain_retry": within threshold, just temporary busy
// - "eagain": exceeded threshold, report as error
if let AppError::HidError {
ref backend,
ref reason,
ref error_code,
} = e
{
if error_code != "eagain_retry" {
self.monitor
.report_error(backend, None, reason, error_code)
.await;
}
}
Err(e)
}
}
}
None => Err(AppError::BadRequest(
if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::BadRequest(
"HID backend not available".to_string(),
)),
));
}
if matches!(event.event_type, MouseEventType::Move | MouseEventType::MoveAbs) {
// Best-effort: drop/merge move events if queue is full
self.enqueue_mouse_move(event)
} else {
self.enqueue_event(HidEvent::Mouse(event)).await
}
}
/// Send consumer control event (multimedia keys)
pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
let backend = self.backend.read().await;
match backend.as_ref() {
Some(b) => match b.send_consumer(event).await {
Ok(_) => {
if self.monitor.is_error().await {
let backend_type = self.backend_type.read().await;
self.monitor.report_recovered(backend_type.name_str()).await;
}
Ok(())
}
Err(e) => {
if let AppError::HidError {
ref backend,
ref reason,
ref error_code,
} = e
{
if error_code != "eagain_retry" {
self.monitor
.report_error(backend, None, reason, error_code)
.await;
}
}
Err(e)
}
},
None => Err(AppError::BadRequest(
if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::BadRequest(
"HID backend not available".to_string(),
)),
));
}
self.enqueue_event(HidEvent::Consumer(event)).await
}
/// Reset all keys (release all pressed keys)
pub async fn reset(&self) -> Result<()> {
let backend = self.backend.read().await;
match backend.as_ref() {
Some(b) => b.reset().await,
None => Ok(()),
if !self.backend_available.load(Ordering::Acquire) {
return Ok(());
}
// Reset is important but best-effort; enqueue to avoid blocking
self.enqueue_event(HidEvent::Reset).await
}
/// Check if backend is available
@@ -332,6 +294,7 @@ impl HidController {
/// Reload the HID backend with new type
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release);
// Shutdown existing backend first
if let Some(backend) = self.backend.write().await.take() {
@@ -341,7 +304,7 @@ impl HidController {
}
// Create and initialize new backend
let new_backend: Option<Box<dyn HidBackend>> = match new_backend_type {
let new_backend: Option<Arc<dyn HidBackend>> = match new_backend_type {
HidBackendType::Otg => {
info!("Initializing OTG HID backend");
@@ -362,11 +325,11 @@ impl HidController {
// Create OtgBackend from handles
match otg::OtgBackend::from_handles(handles) {
Ok(backend) => {
let boxed: Box<dyn HidBackend> = Box::new(backend);
match boxed.init().await {
let backend = Arc::new(backend);
match backend.init().await {
Ok(_) => {
info!("OTG backend initialized successfully");
Some(boxed)
Some(backend)
}
Err(e) => {
warn!("Failed to initialize OTG backend: {}", e);
@@ -407,9 +370,9 @@ impl HidController {
);
match ch9329::Ch9329Backend::with_baud_rate(port, baud_rate) {
Ok(b) => {
let boxed = Box::new(b);
match boxed.init().await {
Ok(_) => Some(boxed),
let backend = Arc::new(b);
match backend.init().await {
Ok(_) => Some(backend),
Err(e) => {
warn!("Failed to initialize CH9329 backend: {}", e);
None
@@ -432,6 +395,8 @@ impl HidController {
if self.backend.read().await.is_some() {
info!("HID backend reloaded successfully: {:?}", new_backend_type);
self.backend_available.store(true, Ordering::Release);
self.start_event_worker().await;
// Update backend_type on success
*self.backend_type.write().await = new_backend_type.clone();
@@ -452,6 +417,7 @@ impl HidController {
Ok(())
} else {
warn!("HID backend reload resulted in no active backend");
self.backend_available.store(false, Ordering::Release);
// Update backend_type even on failure (to reflect the attempted change)
*self.backend_type.write().await = new_backend_type.clone();
@@ -477,6 +443,148 @@ impl HidController {
events.publish(event);
}
}
async fn start_event_worker(&self) {
let mut worker_guard = self.hid_worker.lock().await;
if worker_guard.is_some() {
return;
}
let mut rx_guard = self.hid_rx.lock().await;
let rx = match rx_guard.take() {
Some(rx) => rx,
None => return,
};
let backend = self.backend.clone();
let monitor = self.monitor.clone();
let backend_type = self.backend_type.clone();
let pending_move = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone();
let handle = tokio::spawn(async move {
let mut rx = rx;
loop {
let event = match rx.recv().await {
Some(ev) => ev,
None => break,
};
process_hid_event(
event,
&backend,
&monitor,
&backend_type,
)
.await;
// After each event, flush latest move if pending
if pending_move_flag.swap(false, Ordering::AcqRel) {
let move_event = { pending_move.lock().take() };
if let Some(move_event) = move_event {
process_hid_event(
HidEvent::Mouse(move_event),
&backend,
&monitor,
&backend_type,
)
.await;
}
}
}
});
*worker_guard = Some(handle);
}
fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => {
*self.pending_move.lock() = Some(event);
self.pending_move_flag.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
"HID event queue closed".to_string(),
)),
}
}
async fn enqueue_event(&self, event: HidEvent) -> Result<()> {
match self.hid_tx.try_send(event) {
Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(ev)) => {
// For non-move events, wait briefly to avoid dropping critical input
let tx = self.hid_tx.clone();
let send_result =
tokio::time::timeout(Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS), tx.send(ev))
.await;
if send_result.is_ok() {
Ok(())
} else {
warn!("HID event queue full, dropping event");
Ok(())
}
}
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
"HID event queue closed".to_string(),
)),
}
}
}
async fn process_hid_event(
event: HidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
monitor: &Arc<HidHealthMonitor>,
backend_type: &Arc<RwLock<HidBackendType>>,
) {
let backend_opt = backend.read().await.clone();
let backend = match backend_opt {
Some(b) => b,
None => return,
};
let result = tokio::task::spawn_blocking(move || {
futures::executor::block_on(async move {
match event {
HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await,
HidEvent::Mouse(ev) => backend.send_mouse(ev).await,
HidEvent::Consumer(ev) => backend.send_consumer(ev).await,
HidEvent::Reset => backend.reset().await,
}
})
})
.await;
let result = match result {
Ok(r) => r,
Err(_) => return,
};
match result {
Ok(_) => {
if monitor.is_error().await {
let backend_type = backend_type.read().await;
monitor.report_recovered(backend_type.name_str()).await;
}
}
Err(e) => {
if let AppError::HidError {
ref backend,
ref reason,
ref error_code,
} = e
{
if error_code != "eagain_retry" {
monitor
.report_error(backend, None, reason, error_code)
.await;
}
}
}
}
}
impl Default for HidController {

View File

@@ -145,7 +145,7 @@ pub struct OtgBackend {
}
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
const HID_WRITE_TIMEOUT_MS: i32 = 500;
const HID_WRITE_TIMEOUT_MS: i32 = 20;
impl OtgBackend {
/// Create OTG backend from device paths provided by OtgService

View File

@@ -1,9 +1,11 @@
use std::net::SocketAddr;
use std::collections::HashSet;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use axum_server::tls_rustls::RustlsConfig;
use clap::{Parser, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::broadcast;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -16,9 +18,10 @@ use one_kvm::events::EventBus;
use one_kvm::extensions::ExtensionManager;
use one_kvm::hid::{HidBackendType, HidController};
use one_kvm::msd::MsdController;
use one_kvm::otg::OtgService;
use one_kvm::otg::{configfs, OtgService};
use one_kvm::rustdesk::RustDeskService;
use one_kvm::state::AppState;
use one_kvm::utils::bind_tcp_listener;
use one_kvm::video::format::{PixelFormat, Resolution};
use one_kvm::video::{Streamer, VideoStreamManager};
use one_kvm::web;
@@ -134,7 +137,8 @@ async fn main() -> anyhow::Result<()> {
// Apply CLI argument overrides to config (only if explicitly specified)
if let Some(addr) = args.address {
config.web.bind_address = addr;
config.web.bind_address = addr.clone();
config.web.bind_addresses = vec![addr];
}
if let Some(port) = args.http_port {
config.web.http_port = port;
@@ -153,19 +157,18 @@ async fn main() -> anyhow::Result<()> {
config.web.ssl_key_path = Some(key_path.to_string_lossy().to_string());
}
// Log final configuration
if config.web.https_enabled {
tracing::info!(
"Server will listen on: https://{}:{}",
config.web.bind_address,
config.web.https_port
);
let bind_ips = resolve_bind_addresses(&config.web)?;
let scheme = if config.web.https_enabled { "https" } else { "http" };
let bind_port = if config.web.https_enabled {
config.web.https_port
} else {
tracing::info!(
"Server will listen on: http://{}:{}",
config.web.bind_address,
config.web.http_port
);
config.web.http_port
};
// Log final configuration
for ip in &bind_ips {
let addr = SocketAddr::new(*ip, bind_port);
tracing::info!("Server will listen on: {}://{}", scheme, addr);
}
// Initialize session store
@@ -312,6 +315,19 @@ async fn main() -> anyhow::Result<()> {
let will_use_msd = config.msd.enabled;
if will_use_otg_hid {
let mut hid_functions = config.hid.effective_otg_functions();
if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) {
if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
tracing::warn!(
"UDC {} has low endpoint resources, disabling consumer control",
udc
);
hid_functions.consumer = false;
}
}
if let Err(e) = otg_service.update_hid_functions(hid_functions).await {
tracing::warn!("Failed to apply HID functions: {}", e);
}
if let Err(e) = otg_service.enable_hid().await {
tracing::warn!("Failed to pre-enable HID: {}", e);
}
@@ -585,12 +601,8 @@ async fn main() -> anyhow::Result<()> {
// Create router
let app = web::create_router(state.clone());
// Determine bind address based on HTTPS setting
let bind_addr: SocketAddr = if config.web.https_enabled {
format!("{}:{}", config.web.bind_address, config.web.https_port).parse()?
} else {
format!("{}:{}", config.web.bind_address, config.web.http_port).parse()?
};
// Bind sockets for configured addresses
let listeners = bind_tcp_listeners(&bind_ips, bind_port)?;
// Setup graceful shutdown
let shutdown_signal = async move {
@@ -627,33 +639,44 @@ async fn main() -> anyhow::Result<()> {
RustlsConfig::from_pem_file(&cert_path, &key_path).await?
};
tracing::info!("Starting HTTPS server on {}", bind_addr);
let mut servers = FuturesUnordered::new();
for listener in listeners {
let local_addr = listener.local_addr()?;
tracing::info!("Starting HTTPS server on {}", local_addr);
let server = axum_server::bind_rustls(bind_addr, tls_config).serve(app.into_make_service());
let server = axum_server::from_tcp_rustls(listener, tls_config.clone())?
.serve(app.clone().into_make_service());
servers.push(async move { server.await });
}
tokio::select! {
_ = shutdown_signal => {
cleanup(&state).await;
}
result = server => {
if let Err(e) = result {
result = servers.next() => {
if let Some(Err(e)) = result {
tracing::error!("HTTPS server error: {}", e);
}
cleanup(&state).await;
}
}
} else {
tracing::info!("Starting HTTP server on {}", bind_addr);
let mut servers = FuturesUnordered::new();
for listener in listeners {
let local_addr = listener.local_addr()?;
tracing::info!("Starting HTTP server on {}", local_addr);
let listener = tokio::net::TcpListener::bind(bind_addr).await?;
let server = axum::serve(listener, app);
let listener = tokio::net::TcpListener::from_std(listener)?;
let server = axum::serve(listener, app.clone());
servers.push(async move { server.await });
}
tokio::select! {
_ = shutdown_signal => {
cleanup(&state).await;
}
result = server => {
if let Err(e) = result {
result = servers.next() => {
if let Some(Err(e)) = result {
tracing::error!("HTTP server error: {}", e);
}
cleanup(&state).await;
@@ -706,6 +729,47 @@ fn get_data_dir() -> PathBuf {
PathBuf::from("/etc/one-kvm")
}
/// Resolve bind IPs from config, preferring bind_addresses when set.
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
let raw_addrs = if !web.bind_addresses.is_empty() {
web.bind_addresses.as_slice()
} else {
std::slice::from_ref(&web.bind_address)
};
let mut seen = HashSet::new();
let mut addrs = Vec::new();
for addr in raw_addrs {
let ip: IpAddr = addr
.parse()
.map_err(|_| anyhow::anyhow!("Invalid bind address: {}", addr))?;
if seen.insert(ip) {
addrs.push(ip);
}
}
Ok(addrs)
}
fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result<Vec<std::net::TcpListener>> {
let mut listeners = Vec::new();
for ip in addrs {
let addr = SocketAddr::new(*ip, port);
match bind_tcp_listener(addr) {
Ok(listener) => listeners.push(listener),
Err(err) => {
tracing::warn!("Failed to bind {}: {}", addr, err);
}
}
}
if listeners.is_empty() {
anyhow::bail!("Failed to bind any addresses on port {}", port);
}
Ok(listeners)
}
/// Parse video format and resolution from config (avoids code duplication)
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
let format = config

View File

@@ -43,6 +43,23 @@ pub fn find_udc() -> Option<String> {
.next()
}
/// Check if UDC is known to have low endpoint resources
pub fn is_low_endpoint_udc(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.contains("musb") || name.contains("musb-hdrc")
}
/// Resolve preferred UDC name if available, otherwise auto-detect
pub fn resolve_udc_name(preferred: Option<&str>) -> Option<String> {
if let Some(name) = preferred {
let path = Path::new("/sys/class/udc").join(name);
if path.exists() {
return Some(name.to_string());
}
}
find_udc()
}
/// Write string content to a file
///
/// For sysfs files, this function appends a newline and flushes

View File

@@ -6,9 +6,9 @@ use std::path::PathBuf;
use tracing::{debug, error, info, warn};
use super::configfs::{
create_dir, find_udc, is_configfs_available, remove_dir, write_file, CONFIGFS_PATH,
DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID,
USB_BCD_USB,
create_dir, create_symlink, find_udc, is_configfs_available, remove_dir, remove_file,
write_file, CONFIGFS_PATH, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
};
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
use super::function::{FunctionMeta, GadgetFunction};
@@ -16,6 +16,8 @@ use super::hid::HidFunction;
use super::msd::MsdFunction;
use crate::error::{AppError, Result};
const REBIND_DELAY_MS: u64 = 300;
/// USB Gadget device descriptor configuration
#[derive(Debug, Clone)]
pub struct GadgetDescriptor {
@@ -249,9 +251,15 @@ impl OtgGadgetManager {
AppError::Internal("No USB Device Controller (UDC) found".to_string())
})?;
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind
if let Err(e) = self.recreate_config_links() {
warn!("Failed to recreate gadget config links before bind: {}", e);
}
info!("Binding gadget to UDC: {}", udc);
write_file(&self.gadget_path.join("UDC"), &udc)?;
self.bound_udc = Some(udc);
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
Ok(())
}
@@ -262,6 +270,7 @@ impl OtgGadgetManager {
write_file(&self.gadget_path.join("UDC"), "")?;
self.bound_udc = None;
info!("Unbound gadget from UDC");
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
}
Ok(())
}
@@ -382,6 +391,47 @@ impl OtgGadgetManager {
pub fn gadget_path(&self) -> &PathBuf {
&self.gadget_path
}
/// Recreate config symlinks from functions directory
fn recreate_config_links(&self) -> Result<()> {
let functions_path = self.gadget_path.join("functions");
if !functions_path.exists() || !self.config_path.exists() {
return Ok(());
}
let entries = std::fs::read_dir(&functions_path).map_err(|e| {
AppError::Internal(format!(
"Failed to read functions directory {}: {}",
functions_path.display(),
e
))
})?;
for entry in entries.flatten() {
let name = entry.file_name();
let name = match name.to_str() {
Some(n) => n,
None => continue,
};
if !name.contains(".usb") {
continue;
}
let src = functions_path.join(name);
let dest = self.config_path.join(name);
if dest.exists() {
if let Err(e) = remove_file(&dest) {
warn!("Failed to remove existing config link {}: {}", dest.display(), e);
continue;
}
}
create_symlink(&src, &dest)?;
}
Ok(())
}
}
impl Default for OtgGadgetManager {

View File

@@ -47,6 +47,11 @@ const DEFAULT_SCREEN_HEIGHT: u32 = 1080;
/// Default mouse event throttle interval (16ms ≈ 60Hz)
const DEFAULT_MOUSE_THROTTLE_MS: u64 = 16;
/// Advertised RustDesk version for client compatibility.
const RUSTDESK_COMPAT_VERSION: &str = "1.4.5";
// Advertised platform for RustDesk clients. This affects which UI options are shown.
const RUSTDESK_COMPAT_PLATFORM: &str = "Windows";
/// Input event throttler
///
/// Limits the rate of input events sent to HID devices to prevent EAGAIN errors.
@@ -164,6 +169,8 @@ pub struct Connection {
last_test_delay_sent: Option<Instant>,
/// Last known CapsLock state from RustDesk modifiers (for detecting toggle)
last_caps_lock: bool,
/// Whether relative mouse mode is currently active for this connection
relative_mouse_active: bool,
}
/// Messages sent to connection handler
@@ -241,6 +248,7 @@ impl Connection {
last_delay: 0,
last_test_delay_sent: None,
last_caps_lock: false,
relative_mouse_active: false,
};
(conn, rx)
@@ -623,7 +631,7 @@ impl Connection {
self.negotiated_codec = Some(negotiated);
info!("Negotiated video codec: {:?}", negotiated);
let response = self.create_login_response(true);
let response = self.create_login_response(true).await;
let response_bytes = response
.write_to_bytes()
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
@@ -673,7 +681,11 @@ impl Connection {
Some(misc::Union::RefreshVideo(refresh)) => {
if *refresh {
debug!("Video refresh requested");
// TODO: Request keyframe from encoder
if let Some(ref video_manager) = self.video_manager {
if let Err(e) = video_manager.request_keyframe().await {
warn!("Failed to request keyframe: {}", e);
}
}
}
}
Some(misc::Union::VideoReceived(received)) => {
@@ -1064,7 +1076,7 @@ impl Connection {
}
/// Create login response with dynamically detected encoder capabilities
fn create_login_response(&self, success: bool) -> HbbMessage {
async fn create_login_response(&self, success: bool) -> HbbMessage {
if success {
// Dynamically detect available encoders
let registry = EncoderRegistry::global();
@@ -1080,11 +1092,21 @@ impl Connection {
h264_available, h265_available, vp8_available, vp9_available
);
let mut display_width = self.screen_width;
let mut display_height = self.screen_height;
if let Some(ref video_manager) = self.video_manager {
let video_info = video_manager.get_video_info().await;
if let Some((width, height)) = video_info.resolution {
display_width = width;
display_height = height;
}
}
let mut display_info = DisplayInfo::new();
display_info.x = 0;
display_info.y = 0;
display_info.width = 1920;
display_info.height = 1080;
display_info.width = display_width as i32;
display_info.height = display_height as i32;
display_info.name = "KVM Display".to_string();
display_info.online = true;
display_info.cursor_embedded = false;
@@ -1099,11 +1121,11 @@ impl Connection {
let mut peer_info = PeerInfo::new();
peer_info.username = "one-kvm".to_string();
peer_info.hostname = get_hostname();
peer_info.platform = "Linux".to_string();
peer_info.platform = RUSTDESK_COMPAT_PLATFORM.to_string();
peer_info.displays.push(display_info);
peer_info.current_display = 0;
peer_info.sas_enabled = false;
peer_info.version = env!("CARGO_PKG_VERSION").to_string();
peer_info.version = RUSTDESK_COMPAT_VERSION.to_string();
peer_info.encoding = protobuf::MessageField::some(encoding);
let mut login_response = LoginResponse::new();
@@ -1310,9 +1332,16 @@ impl Connection {
async fn handle_mouse_event(&mut self, me: &MouseEvent) -> anyhow::Result<()> {
// Parse RustDesk mask format: (button << 3) | event_type
let event_type = me.mask & 0x07;
let is_relative_move = event_type == mouse_type::MOVE_RELATIVE;
if is_relative_move {
self.relative_mouse_active = true;
} else if event_type == mouse_type::MOVE {
self.relative_mouse_active = false;
}
// Check if this is a pure move event (no button/scroll)
let is_pure_move = event_type == mouse_type::MOVE;
let is_pure_move = event_type == mouse_type::MOVE || is_relative_move;
// For pure move events, apply throttling
if is_pure_move && !self.input_throttler.should_send_mouse_move() {
@@ -1323,7 +1352,8 @@ impl Connection {
debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask);
// Convert RustDesk mouse event to One-KVM mouse events
let mouse_events = convert_mouse_event(me, self.screen_width, self.screen_height);
let mouse_events =
convert_mouse_event(me, self.screen_width, self.screen_height, self.relative_mouse_active);
// Send to HID controller if available
if let Some(ref hid) = self.hid {
@@ -1543,6 +1573,9 @@ async fn run_video_streaming(
let mut shutdown_rx = shutdown_tx.subscribe();
let mut encoded_count: u64 = 0;
let mut last_log_time = Instant::now();
let mut waiting_for_keyframe = true;
let mut last_sequence: Option<u64> = None;
let mut last_keyframe_request = Instant::now() - Duration::from_secs(1);
info!(
"Started shared video streaming for connection {} (codec: {:?})",
@@ -1582,6 +1615,9 @@ async fn run_video_streaming(
config.bitrate_preset
);
}
if let Err(e) = video_manager.request_keyframe().await {
debug!("Failed to request keyframe for connection {}: {}", conn_id, e);
}
// Inner loop: receives frames from current subscription
loop {
@@ -1609,6 +1645,30 @@ async fn run_video_streaming(
}
};
let gap_detected = if let Some(prev) = last_sequence {
frame.sequence > prev.saturating_add(1)
} else {
false
};
if waiting_for_keyframe || gap_detected {
if frame.is_keyframe {
waiting_for_keyframe = false;
} else {
if gap_detected {
waiting_for_keyframe = true;
}
let now = Instant::now();
if now.duration_since(last_keyframe_request) >= Duration::from_millis(200) {
if let Err(e) = video_manager.request_keyframe().await {
debug!("Failed to request keyframe for connection {}: {}", conn_id, e);
}
last_keyframe_request = now;
}
continue;
}
}
// Convert EncodedVideoFrame to RustDesk VideoFrame message
// Use zero-copy version: Bytes.clone() only increments refcount
let msg_bytes = video_adapter.encode_frame_bytes_zero_copy(
@@ -1617,12 +1677,13 @@ async fn run_video_streaming(
frame.pts_ms as u64,
);
// Send to connection (blocks if channel is full, providing backpressure)
if video_tx.try_send(msg_bytes).is_err() {
// Drop when channel is full to avoid backpressure
continue;
// Send to connection (backpressure instead of dropping)
if video_tx.send(msg_bytes).await.is_err() {
debug!("Video channel closed for connection {}", conn_id);
break 'subscribe_loop;
}
last_sequence = Some(frame.sequence);
encoded_count += 1;
// Log stats periodically

View File

@@ -42,6 +42,9 @@ pub struct VideoFrameAdapter {
seq: u32,
/// Timestamp offset
timestamp_base: u64,
/// Cached H264 SPS/PPS (Annex B NAL without start code)
h264_sps: Option<Bytes>,
h264_pps: Option<Bytes>,
}
impl VideoFrameAdapter {
@@ -51,6 +54,8 @@ impl VideoFrameAdapter {
codec,
seq: 0,
timestamp_base: 0,
h264_sps: None,
h264_pps: None,
}
}
@@ -68,6 +73,7 @@ impl VideoFrameAdapter {
is_keyframe: bool,
timestamp_ms: u64,
) -> Message {
let data = self.prepare_h264_frame(data, is_keyframe);
// Calculate relative timestamp
if self.seq == 0 {
self.timestamp_base = timestamp_ms;
@@ -100,6 +106,41 @@ impl VideoFrameAdapter {
msg
}
fn prepare_h264_frame(&mut self, data: Bytes, is_keyframe: bool) -> Bytes {
if self.codec != VideoCodec::H264 {
return data;
}
// Parse SPS/PPS from Annex B data (without start codes)
let (sps, pps) = crate::webrtc::rtp::extract_sps_pps(&data);
let mut has_sps = false;
let mut has_pps = false;
if let Some(sps) = sps {
self.h264_sps = Some(Bytes::from(sps));
has_sps = true;
}
if let Some(pps) = pps {
self.h264_pps = Some(Bytes::from(pps));
has_pps = true;
}
// Inject cached SPS/PPS before IDR when missing
if is_keyframe && (!has_sps || !has_pps) {
if let (Some(ref sps), Some(ref pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) {
let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len());
out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(sps);
out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(pps);
out.extend_from_slice(&data);
return Bytes::from(out);
}
}
data
}
/// Convert encoded video data to RustDesk Message
pub fn encode_frame(&mut self, data: &[u8], is_keyframe: bool, timestamp_ms: u64) -> Message {
self.encode_frame_from_bytes(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms)

View File

@@ -18,6 +18,7 @@ pub mod mouse_type {
pub const UP: i32 = 2;
pub const WHEEL: i32 = 3;
pub const TRACKPAD: i32 = 4;
pub const MOVE_RELATIVE: i32 = 5;
}
/// Mouse button IDs from RustDesk protocol (before left shift by 3)
@@ -36,23 +37,25 @@ pub fn convert_mouse_event(
event: &MouseEvent,
screen_width: u32,
screen_height: u32,
relative_mode: bool,
) -> Vec<OneKvmMouseEvent> {
let mut events = Vec::new();
// RustDesk uses absolute coordinates
let x = event.x.max(0) as u32;
let y = event.y.max(0) as u32;
// Normalize to 0-32767 range for absolute mouse (USB HID standard)
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
// Parse RustDesk mask format: (button << 3) | event_type
let event_type = event.mask & 0x07;
let button_id = event.mask >> 3;
let include_abs_move = !relative_mode;
match event_type {
mouse_type::MOVE => {
// RustDesk uses absolute coordinates
let x = event.x.max(0) as u32;
let y = event.y.max(0) as u32;
// Normalize to 0-32767 range for absolute mouse (USB HID standard)
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
// Move event - may have button held down (button_id > 0 means dragging)
// Just send move, button state is tracked separately by HID backend
events.push(OneKvmMouseEvent {
@@ -63,55 +66,83 @@ pub fn convert_mouse_event(
scroll: 0,
});
}
mouse_type::DOWN => {
// Button down - first move, then press
mouse_type::MOVE_RELATIVE => {
// Relative movement uses delta values directly (dx, dy).
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
event_type: MouseEventType::Move,
x: event.x,
y: event.y,
button: None,
scroll: 0,
});
}
mouse_type::DOWN => {
if include_abs_move {
// Button down - first move, then press
let x = event.x.max(0) as u32;
let y = event.y.max(0) as u32;
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
button: None,
scroll: 0,
});
}
if let Some(button) = button_id_to_button(button_id) {
events.push(OneKvmMouseEvent {
event_type: MouseEventType::Down,
x: abs_x,
y: abs_y,
x: 0,
y: 0,
button: Some(button),
scroll: 0,
});
}
}
mouse_type::UP => {
// Button up - first move, then release
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
button: None,
scroll: 0,
});
if include_abs_move {
// Button up - first move, then release
let x = event.x.max(0) as u32;
let y = event.y.max(0) as u32;
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
button: None,
scroll: 0,
});
}
if let Some(button) = button_id_to_button(button_id) {
events.push(OneKvmMouseEvent {
event_type: MouseEventType::Up,
x: abs_x,
y: abs_y,
x: 0,
y: 0,
button: Some(button),
scroll: 0,
});
}
}
mouse_type::WHEEL => {
// Scroll event - move first, then scroll
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
button: None,
scroll: 0,
});
if include_abs_move {
// Scroll event - move first, then scroll
let x = event.x.max(0) as u32;
let y = event.y.max(0) as u32;
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
button: None,
scroll: 0,
});
}
// RustDesk encodes scroll direction in the y coordinate
// Positive y = scroll up, Negative y = scroll down
@@ -119,21 +150,27 @@ pub fn convert_mouse_event(
let scroll = if event.y > 0 { 1i8 } else { -1i8 };
events.push(OneKvmMouseEvent {
event_type: MouseEventType::Scroll,
x: abs_x,
y: abs_y,
x: 0,
y: 0,
button: None,
scroll,
});
}
_ => {
// Unknown event type, just move
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
button: None,
scroll: 0,
});
if include_abs_move {
// Unknown event type, just move
let x = event.x.max(0) as u32;
let y = event.y.max(0) as u32;
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs,
x: abs_x,
y: abs_y,
button: None,
scroll: 0,
});
}
}
}
@@ -522,7 +559,7 @@ mod tests {
event.y = 300;
event.mask = mouse_type::MOVE; // Pure move event
let events = convert_mouse_event(&event, 1920, 1080);
let events = convert_mouse_event(&event, 1920, 1080, false);
assert!(!events.is_empty());
assert_eq!(events[0].event_type, MouseEventType::MoveAbs);
}
@@ -534,7 +571,7 @@ mod tests {
event.y = 300;
event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN;
let events = convert_mouse_event(&event, 1920, 1080);
let events = convert_mouse_event(&event, 1920, 1080, false);
assert!(events.len() >= 2);
// Should have a button down event
assert!(events
@@ -542,6 +579,20 @@ mod tests {
.any(|e| e.event_type == MouseEventType::Down && e.button == Some(MouseButton::Left)));
}
#[test]
fn test_convert_mouse_move_relative() {
let mut event = MouseEvent::new();
event.x = -12;
event.y = 8;
event.mask = mouse_type::MOVE_RELATIVE;
let events = convert_mouse_event(&event, 1920, 1080, true);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, MouseEventType::Move);
assert_eq!(events[0].x, -12);
assert_eq!(events[0].y, 8);
}
#[test]
fn test_convert_key_event() {
use protobuf::EnumOrUnknown;

View File

@@ -23,7 +23,7 @@ pub mod protocol;
pub mod punch;
pub mod rendezvous;
use std::net::SocketAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
@@ -37,6 +37,7 @@ use tracing::{debug, error, info, warn};
use crate::audio::AudioController;
use crate::hid::HidController;
use crate::video::stream_manager::VideoStreamManager;
use crate::utils::bind_tcp_listener;
use self::config::RustDeskConfig;
use self::connection::ConnectionManager;
@@ -84,7 +85,7 @@ pub struct RustDeskService {
status: Arc<RwLock<ServiceStatus>>,
rendezvous: Arc<RwLock<Option<Arc<RendezvousMediator>>>>,
rendezvous_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
tcp_listener_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
tcp_listener_handle: Arc<RwLock<Option<Vec<JoinHandle<()>>>>>,
listen_port: Arc<RwLock<u16>>,
connection_manager: Arc<ConnectionManager>,
video_manager: Arc<VideoStreamManager>,
@@ -212,8 +213,8 @@ impl RustDeskService {
// Start TCP listener BEFORE the rendezvous mediator to ensure port is set correctly
// This prevents race condition where mediator starts registration with wrong port
let (tcp_handle, listen_port) = self.start_tcp_listener_with_port().await?;
*self.tcp_listener_handle.write() = Some(tcp_handle);
let (tcp_handles, listen_port) = self.start_tcp_listener_with_port().await?;
*self.tcp_listener_handle.write() = Some(tcp_handles);
// Set the listen port on mediator before starting the registration loop
mediator.set_listen_port(listen_port);
@@ -373,52 +374,83 @@ impl RustDeskService {
/// Start TCP listener for direct peer connections
/// Returns the join handle and the port that was bound
async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(JoinHandle<()>, u16)> {
async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec<JoinHandle<()>>, u16)> {
// Try to bind to the default port, or find an available port
let listener = match TcpListener::bind(format!("0.0.0.0:{}", DIRECT_LISTEN_PORT)).await {
Ok(l) => l,
Err(_) => {
// Try binding to port 0 to get an available port
TcpListener::bind("0.0.0.0:0").await?
let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) {
Ok(result) => result,
Err(err) => {
warn!(
"Failed to bind RustDesk TCP on port {}: {}, falling back to random port",
DIRECT_LISTEN_PORT, err
);
self.bind_direct_listeners(0)?
}
};
let local_addr = listener.local_addr()?;
let listen_port = local_addr.port();
*self.listen_port.write() = listen_port;
info!("RustDesk TCP listener started on {}", local_addr);
let connection_manager = self.connection_manager.clone();
let mut shutdown_rx = self.shutdown_tx.subscribe();
let mut handles = Vec::new();
let handle = tokio::spawn(async move {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, peer_addr)) => {
info!("Accepted direct connection from {}", peer_addr);
let conn_mgr = connection_manager.clone();
tokio::spawn(async move {
if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await {
error!("Failed to handle direct connection from {}: {}", peer_addr, e);
}
});
}
Err(e) => {
error!("TCP accept error: {}", e);
for listener in listeners {
let local_addr = listener.local_addr()?;
info!("RustDesk TCP listener started on {}", local_addr);
let conn_mgr = connection_manager.clone();
let mut shutdown_rx = self.shutdown_tx.subscribe();
let handle = tokio::spawn(async move {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, peer_addr)) => {
info!("Accepted direct connection from {}", peer_addr);
let conn_mgr = conn_mgr.clone();
tokio::spawn(async move {
if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await {
error!("Failed to handle direct connection from {}: {}", peer_addr, e);
}
});
}
Err(e) => {
error!("TCP accept error: {}", e);
}
}
}
}
_ = shutdown_rx.recv() => {
info!("TCP listener shutting down");
break;
_ = shutdown_rx.recv() => {
info!("TCP listener shutting down");
break;
}
}
}
}
});
});
handles.push(handle);
}
Ok((handle, listen_port))
Ok((handles, listen_port))
}
fn bind_direct_listeners(&self, port: u16) -> anyhow::Result<(Vec<TcpListener>, u16)> {
let v4_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
let v4_listener = bind_tcp_listener(v4_addr)?;
let listen_port = v4_listener.local_addr()?.port();
let mut listeners = vec![TcpListener::from_std(v4_listener)?];
let v6_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), listen_port);
match bind_tcp_listener(v6_addr) {
Ok(v6_listener) => {
listeners.push(TcpListener::from_std(v6_listener)?);
}
Err(err) => {
warn!(
"IPv6 listener unavailable on port {}: {}, continuing with IPv4 only",
listen_port, err
);
}
}
Ok((listeners, listen_port))
}
/// Stop the RustDesk service
@@ -446,8 +478,10 @@ impl RustDeskService {
}
// Wait for TCP listener task to finish
if let Some(handle) = self.tcp_listener_handle.write().take() {
handle.abort();
if let Some(handles) = self.tcp_listener_handle.write().take() {
for handle in handles {
handle.abort();
}
}
*self.rendezvous.write() = None;

View File

@@ -4,7 +4,7 @@
//! It registers the device ID and public key, handles punch hole requests,
//! and relay requests.
use std::net::SocketAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -15,6 +15,8 @@ use tokio::sync::broadcast;
use tokio::time::interval;
use tracing::{debug, error, info, warn};
use crate::utils::bind_udp_socket;
use super::config::RustDeskConfig;
use super::crypto::{KeyPair, SigningKeyPair};
use super::protocol::{
@@ -288,8 +290,13 @@ impl RendezvousMediator {
.next()
.ok_or_else(|| anyhow::anyhow!("Failed to resolve {}", addr))?;
// Create UDP socket
let socket = UdpSocket::bind("0.0.0.0:0").await?;
// Create UDP socket (match address family, enforce IPV6_V6ONLY)
let bind_addr = match server_addr {
SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
};
let std_socket = bind_udp_socket(bind_addr)?;
let socket = UdpSocket::from_std(std_socket)?;
socket.connect(server_addr).await?;
info!("Connected to rendezvous server at {}", server_addr);

View File

@@ -3,5 +3,7 @@
//! This module contains common utilities used across the codebase.
pub mod throttle;
pub mod net;
pub use throttle::LogThrottler;
pub use net::{bind_tcp_listener, bind_udp_socket};

84
src/utils/net.rs Normal file
View File

@@ -0,0 +1,84 @@
//! Networking helpers for binding sockets with explicit IPv6-only behavior.
use std::io;
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd};
use nix::sys::socket::{
self, sockopt, AddressFamily, Backlog, SockFlag, SockProtocol, SockType, SockaddrIn,
SockaddrIn6,
};
fn socket_addr_family(addr: &SocketAddr) -> AddressFamily {
match addr {
SocketAddr::V4(_) => AddressFamily::Inet,
SocketAddr::V6(_) => AddressFamily::Inet6,
}
}
/// Bind a TCP listener with IPv6-only set for IPv6 sockets.
pub fn bind_tcp_listener(addr: SocketAddr) -> io::Result<TcpListener> {
let domain = socket_addr_family(&addr);
let fd = socket::socket(
domain,
SockType::Stream,
SockFlag::SOCK_CLOEXEC,
SockProtocol::Tcp,
)
.map_err(io::Error::from)?;
socket::setsockopt(&fd, sockopt::ReuseAddr, &true).map_err(io::Error::from)?;
if matches!(addr, SocketAddr::V6(_)) {
socket::setsockopt(&fd, sockopt::Ipv6V6Only, &true).map_err(io::Error::from)?;
}
match addr {
SocketAddr::V4(v4) => {
let sockaddr = SockaddrIn::from(v4);
socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?;
}
SocketAddr::V6(v6) => {
let sockaddr = SockaddrIn6::from(v6);
socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?;
}
}
socket::listen(&fd, Backlog::MAXCONN).map_err(io::Error::from)?;
let listener = unsafe { TcpListener::from_raw_fd(fd.into_raw_fd()) };
listener.set_nonblocking(true)?;
Ok(listener)
}
/// Bind a UDP socket with IPv6-only set for IPv6 sockets.
pub fn bind_udp_socket(addr: SocketAddr) -> io::Result<UdpSocket> {
let domain = socket_addr_family(&addr);
let fd = socket::socket(
domain,
SockType::Datagram,
SockFlag::SOCK_CLOEXEC,
SockProtocol::Udp,
)
.map_err(io::Error::from)?;
socket::setsockopt(&fd, sockopt::ReuseAddr, &true).map_err(io::Error::from)?;
if matches!(addr, SocketAddr::V6(_)) {
socket::setsockopt(&fd, sockopt::Ipv6V6Only, &true).map_err(io::Error::from)?;
}
match addr {
SocketAddr::V4(v4) => {
let sockaddr = SockaddrIn::from(v4);
socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?;
}
SocketAddr::V6(v6) => {
let sockaddr = SockaddrIn6::from(v6);
socket::bind(fd.as_raw_fd(), &sockaddr).map_err(io::Error::from)?;
}
}
let socket = unsafe { UdpSocket::from_raw_fd(fd.into_raw_fd()) };
socket.set_nonblocking(true)?;
Ok(socket)
}

View File

@@ -2,7 +2,7 @@
use hwcodec::ffmpeg::AVPixelFormat;
use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder};
use tracing::warn;
use tracing::{info, warn};
use crate::error::{AppError, Result};
use crate::video::convert::Nv12Converter;
@@ -72,6 +72,9 @@ impl MjpegRkmppDecoder {
);
}
} else {
if frame.pixfmt == AVPixelFormat::AV_PIX_FMT_NV16 {
info!("mjpeg_rkmpp output pixfmt NV16 on first frame; converting to NV12");
}
self.last_pixfmt = Some(frame.pixfmt);
}

View File

@@ -2,10 +2,6 @@
//!
//! This module provides video decoding capabilities.
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub mod mjpeg_rkmpp;
pub mod mjpeg_turbo;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub use mjpeg_rkmpp::MjpegRkmppDecoder;
pub use mjpeg_turbo::MjpegTurboDecoder;

View File

@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use tracing::{debug, info, warn};
use v4l::capability::Flags;
use v4l::prelude::*;
@@ -12,6 +14,8 @@ use v4l::FourCC;
use super::format::{PixelFormat, Resolution};
use crate::error::{AppError, Result};
const DEVICE_PROBE_TIMEOUT_MS: u64 = 400;
/// Information about a video device
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoDeviceInfo {
@@ -401,32 +405,29 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
debug!("Found video device: {:?}", path);
// Try to open and query the device
match VideoDevice::open(&path) {
Ok(device) => {
match device.info() {
Ok(info) => {
// Only include devices with video capture capability
if info.capabilities.video_capture || info.capabilities.video_capture_mplane
{
info!(
"Found capture device: {} ({}) - {} formats",
info.name,
info.driver,
info.formats.len()
);
devices.push(info);
} else {
debug!("Skipping non-capture device: {:?}", path);
}
}
Err(e) => {
debug!("Failed to get info for {:?}: {}", path, e);
}
if !sysfs_maybe_capture(&path) {
debug!("Skipping non-capture candidate (sysfs): {:?}", path);
continue;
}
// Try to open and query the device (with timeout)
match probe_device_with_timeout(&path, Duration::from_millis(DEVICE_PROBE_TIMEOUT_MS)) {
Some(info) => {
// Only include devices with video capture capability
if info.capabilities.video_capture || info.capabilities.video_capture_mplane {
info!(
"Found capture device: {} ({}) - {} formats",
info.name,
info.driver,
info.formats.len()
);
devices.push(info);
} else {
debug!("Skipping non-capture device: {:?}", path);
}
}
Err(e) => {
debug!("Failed to open {:?}: {}", path, e);
None => {
debug!("Failed to probe {:?}", path);
}
}
}
@@ -438,6 +439,104 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
Ok(devices)
}
fn probe_device_with_timeout(path: &Path, timeout: Duration) -> Option<VideoDeviceInfo> {
let path = path.to_path_buf();
let path_for_thread = path.clone();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = (|| -> Result<VideoDeviceInfo> {
let device = VideoDevice::open(&path_for_thread)?;
device.info()
})();
let _ = tx.send(result);
});
match rx.recv_timeout(timeout) {
Ok(Ok(info)) => Some(info),
Ok(Err(e)) => {
debug!("Failed to get info for {:?}: {}", path, e);
None
}
Err(mpsc::RecvTimeoutError::Timeout) => {
warn!("Timed out probing video device: {:?}", path);
None
}
Err(_) => None,
}
}
fn sysfs_maybe_capture(path: &Path) -> bool {
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => return true,
};
let sysfs_base = Path::new("/sys/class/video4linux").join(name);
let sysfs_name = read_sysfs_string(&sysfs_base.join("name"))
.unwrap_or_default()
.to_lowercase();
let uevent = read_sysfs_string(&sysfs_base.join("device/uevent"))
.unwrap_or_default()
.to_lowercase();
let driver = extract_uevent_value(&uevent, "driver");
let mut maybe_capture = false;
let capture_hints = [
"capture",
"hdmi",
"usb",
"uvc",
"ms2109",
"ms2130",
"macrosilicon",
"tc358743",
"grabber",
];
if capture_hints.iter().any(|hint| sysfs_name.contains(hint)) {
maybe_capture = true;
}
if let Some(driver) = driver {
if driver.contains("uvcvideo") || driver.contains("tc358743") {
maybe_capture = true;
}
}
let skip_hints = [
"codec",
"decoder",
"encoder",
"isp",
"mem2mem",
"m2m",
"vbi",
"radio",
"metadata",
"output",
];
if skip_hints.iter().any(|hint| sysfs_name.contains(hint)) && !maybe_capture {
return false;
}
true
}
fn read_sysfs_string(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|value| value.trim().to_string())
}
fn extract_uevent_value(content: &str, key: &str) -> Option<String> {
let key_upper = key.to_ascii_uppercase();
for line in content.lines() {
if let Some(value) = line.strip_prefix(&format!("{}=", key_upper)) {
return Some(value.to_lowercase());
}
}
None
}
/// Find the best video device for KVM use
pub fn find_best_device() -> Result<VideoDeviceInfo> {
let devices = enumerate_devices()?;

View File

@@ -7,7 +7,7 @@
use std::collections::HashMap;
use std::sync::OnceLock;
use tracing::{debug, info};
use tracing::{debug, info, warn};
use hwcodec::common::{DataFormat, Quality, RateControl};
use hwcodec::ffmpeg::AVPixelFormat;
@@ -255,8 +255,33 @@ impl EncoderRegistry {
thread_count: 1,
};
// Get all available encoders from hwcodec
let all_encoders = HwEncoder::available_encoders(ctx, None);
const DETECT_TIMEOUT_MS: u64 = 5000;
// Get all available encoders from hwcodec with a hard timeout
let all_encoders = {
use std::sync::mpsc;
use std::time::Duration;
info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
let (tx, rx) = mpsc::channel();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let result = HwEncoder::available_encoders(ctx_clone, None);
let _ = tx.send(result);
});
match rx.recv_timeout(Duration::from_millis(DETECT_TIMEOUT_MS)) {
Ok(encoders) => encoders,
Err(_) => {
warn!(
"Encoder detection timed out after {}ms, skipping hardware detection",
DETECT_TIMEOUT_MS
);
Vec::new()
}
}
};
info!("Found {} encoders from hwcodec", all_encoders.len());

View File

@@ -33,11 +33,9 @@ const JPEG_VALIDATE_INTERVAL: u64 = 30;
use crate::error::{AppError, Result};
use crate::video::convert::{Nv12Converter, PixelConverter};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use crate::video::decoder::MjpegRkmppDecoder;
use crate::video::decoder::MjpegTurboDecoder;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use hwcodec::ffmpeg_hw::{last_error_message as ffmpeg_hw_last_error, HwMjpegH264Config, HwMjpegH264Pipeline};
use hwcodec::ffmpeg_hw::{last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline};
use v4l::buffer::Type as BufferType;
use v4l::io::traits::CaptureStream;
use v4l::prelude::*;
@@ -177,7 +175,7 @@ struct EncoderThreadState {
yuv420p_converter: Option<PixelConverter>,
encoder_needs_yuv420p: bool,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_pipeline: Option<HwMjpegH264Pipeline>,
ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_enabled: bool,
fps: u32,
@@ -319,16 +317,12 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
}
enum MjpegDecoderKind {
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
Rkmpp(MjpegRkmppDecoder),
Turbo(MjpegTurboDecoder),
}
impl MjpegDecoderKind {
fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> {
match self {
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
MjpegDecoderKind::Rkmpp(decoder) => decoder.decode_to_nv12(data),
MjpegDecoderKind::Turbo(decoder) => decoder.decode_to_rgb(data),
}
}
@@ -512,15 +506,18 @@ impl SharedVideoPipeline {
}
};
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
let is_software_encoder = selected_codec_name.contains("libx264")
|| selected_codec_name.contains("libx265")
|| selected_codec_name.contains("libvpx");
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if needs_mjpeg_decode && is_rkmpp_encoder && config.output_codec == VideoEncoderType::H264 {
info!("Initializing FFmpeg HW MJPEG->H264 pipeline (no fallback)");
let hw_config = HwMjpegH264Config {
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if needs_mjpeg_decode
&& is_rkmpp_encoder
&& matches!(config.output_codec, VideoEncoderType::H264 | VideoEncoderType::H265)
{
info!(
"Initializing FFmpeg HW MJPEG->{} pipeline (no fallback)",
config.output_codec
);
let hw_config = HwMjpegH26xConfig {
decoder: "mjpeg_rkmpp".to_string(),
encoder: selected_codec_name.clone(),
width: config.resolution.width as i32,
@@ -530,14 +527,14 @@ impl SharedVideoPipeline {
gop: config.gop_size() as i32,
thread_count: 1,
};
let pipeline = HwMjpegH264Pipeline::new(hw_config).map_err(|e| {
let pipeline = HwMjpegH26xPipeline::new(hw_config).map_err(|e| {
let detail = if e.is_empty() { ffmpeg_hw_last_error() } else { e };
AppError::VideoError(format!(
"FFmpeg HW MJPEG->H264 init failed: {}",
detail
"FFmpeg HW MJPEG->{} init failed: {}",
config.output_codec, detail
))
})?;
info!("Using FFmpeg HW MJPEG->H264 pipeline");
info!("Using FFmpeg HW MJPEG->{} pipeline", config.output_codec);
return Ok(EncoderThreadState {
encoder: None,
mjpeg_decoder: None,
@@ -555,35 +552,12 @@ impl SharedVideoPipeline {
}
let pipeline_input_format = if needs_mjpeg_decode {
if is_rkmpp_encoder {
info!(
"MJPEG input detected, using RKMPP decoder ({} -> NV12 with NV16 fallback)",
config.input_format
);
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
{
let decoder = MjpegRkmppDecoder::new(config.resolution)?;
let pipeline_format = PixelFormat::Nv12;
(Some(MjpegDecoderKind::Rkmpp(decoder)), pipeline_format)
}
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
{
return Err(AppError::VideoError(
"RKMPP MJPEG decode is only supported on ARM builds".to_string(),
));
}
} else if is_software_encoder {
info!(
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
config.input_format
);
let decoder = MjpegTurboDecoder::new(config.resolution)?;
(Some(MjpegDecoderKind::Turbo(decoder)), PixelFormat::Rgb24)
} else {
return Err(AppError::VideoError(
"MJPEG input requires RKMPP or software encoder".to_string(),
));
}
info!(
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
config.input_format
);
let decoder = MjpegTurboDecoder::new(config.resolution)?;
(Some(MjpegDecoderKind::Turbo(decoder)), PixelFormat::Rgb24)
} else {
(None, config.input_format)
};

View File

@@ -794,6 +794,11 @@ impl VideoStreamManager {
self.webrtc_streamer.set_bitrate_preset(preset).await
}
/// Request a keyframe from the shared video pipeline
pub async fn request_keyframe(&self) -> crate::error::Result<()> {
self.webrtc_streamer.request_keyframe().await
}
/// Publish event to event bus
async fn publish_event(&self, event: SystemEvent) {
if let Some(ref events) = *self.events.read().await {

View File

@@ -187,7 +187,23 @@ pub async fn apply_hid_config(
// 检查 OTG 描述符是否变更
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let old_hid_functions = old_config.effective_otg_functions();
let new_hid_functions = new_config.effective_otg_functions();
let mut new_hid_functions = new_config.effective_otg_functions();
// Low-endpoint UDCs (e.g., musb) cannot handle consumer control endpoints reliably
if new_config.backend == HidBackend::Otg {
if let Some(udc) =
crate::otg::configfs::resolve_udc_name(new_config.otg_udc.as_deref())
{
if crate::otg::configfs::is_low_endpoint_udc(&udc) && new_hid_functions.consumer {
tracing::warn!(
"UDC {} has low endpoint resources, disabling consumer control",
udc
);
new_hid_functions.consumer = false;
}
}
}
let hid_functions_changed = old_hid_functions != new_hid_functions;
if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() {

View File

@@ -610,6 +610,7 @@ impl RustDeskConfigUpdate {
pub struct WebConfigUpdate {
pub http_port: Option<u16>,
pub https_port: Option<u16>,
pub bind_addresses: Option<Vec<String>>,
pub bind_address: Option<String>,
pub https_enabled: Option<bool>,
}
@@ -626,6 +627,13 @@ impl WebConfigUpdate {
return Err(AppError::BadRequest("HTTPS port cannot be 0".into()));
}
}
if let Some(ref addrs) = self.bind_addresses {
for addr in addrs {
if addr.parse::<std::net::IpAddr>().is_err() {
return Err(AppError::BadRequest("Invalid bind address".into()));
}
}
}
if let Some(ref addr) = self.bind_address {
if addr.parse::<std::net::IpAddr>().is_err() {
return Err(AppError::BadRequest("Invalid bind address".into()));
@@ -641,8 +649,16 @@ impl WebConfigUpdate {
if let Some(port) = self.https_port {
config.https_port = port;
}
if let Some(ref addr) = self.bind_address {
if let Some(ref addrs) = self.bind_addresses {
config.bind_addresses = addrs.clone();
if let Some(first) = addrs.first() {
config.bind_address = first.clone();
}
} else if let Some(ref addr) = self.bind_address {
config.bind_address = addr.clone();
if config.bind_addresses.is_empty() {
config.bind_addresses = vec![addr.clone()];
}
}
if let Some(enabled) = self.https_enabled {
config.https_enabled = enabled;

View File

@@ -316,28 +316,11 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
Err(_) => return Vec::new(),
};
// Build a map of interface name -> IPv4 address
let mut ipv4_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for ifaddr in all_addrs {
// Skip loopback
if ifaddr.interface_name == "lo" {
continue;
}
// Only collect IPv4 addresses (skip if already have one for this interface)
if !ipv4_map.contains_key(&ifaddr.interface_name) {
if let Some(addr) = ifaddr.address {
if let Some(sockaddr_in) = addr.as_sockaddr_in() {
ipv4_map.insert(ifaddr.interface_name.clone(), sockaddr_in.ip().to_string());
}
}
}
}
// Now check which interfaces are up
let mut addresses = Vec::new();
// Check which interfaces are up
let mut up_ifaces = std::collections::HashSet::new();
let net_dir = match std::fs::read_dir("/sys/class/net") {
Ok(dir) => dir,
Err(_) => return addresses,
Err(_) => return Vec::new(),
};
for entry in net_dir.flatten() {
@@ -361,12 +344,43 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
continue;
}
// Get IP from pre-fetched map
if let Some(ip) = ipv4_map.remove(&iface_name) {
addresses.push(NetworkAddress {
interface: iface_name,
ip,
});
up_ifaces.insert(iface_name);
}
let mut addresses = Vec::new();
let mut seen = std::collections::HashSet::new();
for ifaddr in all_addrs {
let iface_name = &ifaddr.interface_name;
if iface_name == "lo" || !up_ifaces.contains(iface_name) {
continue;
}
if let Some(addr) = ifaddr.address {
if let Some(sockaddr_in) = addr.as_sockaddr_in() {
let ip = sockaddr_in.ip();
if ip.is_loopback() {
continue;
}
let ip_str = ip.to_string();
if seen.insert((iface_name.clone(), ip_str.clone())) {
addresses.push(NetworkAddress {
interface: iface_name.clone(),
ip: ip_str,
});
}
} else if let Some(sockaddr_in6) = addr.as_sockaddr_in6() {
let ip = sockaddr_in6.ip();
if ip.is_loopback() || ip.is_unspecified() || ip.is_unicast_link_local() {
continue;
}
let ip_str = ip.to_string();
if seen.insert((iface_name.clone(), ip_str.clone())) {
addresses.push(NetworkAddress {
interface: iface_name.clone(),
ip: ip_str,
});
}
}
}
}
@@ -465,7 +479,6 @@ pub async fn logout(
pub struct AuthCheckResponse {
pub authenticated: bool,
pub user: Option<String>,
pub is_admin: bool,
}
pub async fn auth_check(
@@ -481,7 +494,6 @@ pub async fn auth_check(
Json(AuthCheckResponse {
authenticated: true,
user: username,
is_admin: true,
})
}
@@ -521,11 +533,39 @@ pub struct SetupRequest {
pub hid_ch9329_port: Option<String>,
pub hid_ch9329_baudrate: Option<u32>,
pub hid_otg_udc: Option<String>,
pub hid_otg_profile: Option<String>,
// Extension settings
pub ttyd_enabled: Option<bool>,
pub rustdesk_enabled: Option<bool>,
}
fn normalize_otg_profile_for_low_endpoint(config: &mut AppConfig) {
if !matches!(config.hid.backend, crate::config::HidBackend::Otg) {
return;
}
let udc = crate::otg::configfs::resolve_udc_name(config.hid.otg_udc.as_deref());
let Some(udc) = udc else {
return;
};
if !crate::otg::configfs::is_low_endpoint_udc(&udc) {
return;
}
match config.hid.otg_profile {
crate::config::OtgHidProfile::Full => {
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumer;
}
crate::config::OtgHidProfile::FullNoMsd => {
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumerNoMsd;
}
crate::config::OtgHidProfile::Custom => {
if config.hid.otg_functions.consumer {
config.hid.otg_functions.consumer = false;
}
}
_ => {}
}
}
pub async fn setup_init(
State(state): State<Arc<AppState>>,
Json(req): Json<SetupRequest>,
@@ -601,6 +641,33 @@ pub async fn setup_init(
if let Some(udc) = req.hid_otg_udc.clone() {
config.hid.otg_udc = Some(udc);
}
if let Some(profile) = req.hid_otg_profile.clone() {
config.hid.otg_profile = match profile.as_str() {
"full" => crate::config::OtgHidProfile::Full,
"full_no_msd" => crate::config::OtgHidProfile::FullNoMsd,
"full_no_consumer" => crate::config::OtgHidProfile::FullNoConsumer,
"full_no_consumer_no_msd" => crate::config::OtgHidProfile::FullNoConsumerNoMsd,
"legacy_keyboard" => crate::config::OtgHidProfile::LegacyKeyboard,
"legacy_mouse_relative" => crate::config::OtgHidProfile::LegacyMouseRelative,
"custom" => crate::config::OtgHidProfile::Custom,
_ => config.hid.otg_profile.clone(),
};
if matches!(config.hid.backend, crate::config::HidBackend::Otg) {
match config.hid.otg_profile {
crate::config::OtgHidProfile::Full
| crate::config::OtgHidProfile::FullNoConsumer => {
config.msd.enabled = true;
}
crate::config::OtgHidProfile::FullNoMsd
| crate::config::OtgHidProfile::FullNoConsumerNoMsd
| crate::config::OtgHidProfile::LegacyKeyboard
| crate::config::OtgHidProfile::LegacyMouseRelative => {
config.msd.enabled = false;
}
crate::config::OtgHidProfile::Custom => {}
}
}
}
// Extension settings
if let Some(enabled) = req.ttyd_enabled {
@@ -609,12 +676,32 @@ pub async fn setup_init(
if let Some(enabled) = req.rustdesk_enabled {
config.rustdesk.enabled = enabled;
}
normalize_otg_profile_for_low_endpoint(config);
})
.await?;
// Get updated config for HID reload
let new_config = state.config.get();
if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) {
let mut hid_functions = new_config.hid.effective_otg_functions();
if let Some(udc) =
crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref())
{
if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
tracing::warn!(
"UDC {} has low endpoint resources, disabling consumer control",
udc
);
hid_functions.consumer = false;
}
}
if let Err(e) = state.otg_service.update_hid_functions(hid_functions).await {
tracing::warn!("Failed to apply HID functions during setup: {}", e);
}
}
tracing::info!(
"Extension config after save: ttyd.enabled={}, rustdesk.enabled={}",
new_config.extensions.ttyd.enabled,
@@ -727,6 +814,9 @@ pub async fn update_config(
let new_config: AppConfig = serde_json::from_value(merged)
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
let mut new_config = new_config;
normalize_otg_profile_for_low_endpoint(&mut new_config);
// Apply the validated config
state.config.set(new_config.clone()).await?;

View File

@@ -32,7 +32,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/setup", get(handlers::setup_status))
.route("/setup/init", post(handlers::setup_init));
// User routes (authenticated users - both regular and admin)
// Authenticated routes (all logged-in users)
let user_routes = Router::new()
.route("/info", get(handlers::system_info))
.route("/auth/logout", post(handlers::logout))
@@ -71,10 +71,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/audio/devices", get(handlers::list_audio_devices))
// Audio WebSocket endpoint
.route("/ws/audio", any(audio_ws_handler))
;
// Admin-only routes (require admin privileges)
let admin_routes = Router::new()
// Configuration management (domain-separated endpoints)
.route("/config", get(handlers::config::get_all_config))
.route("/config", post(handlers::update_config))
@@ -199,11 +195,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/terminal", get(handlers::terminal::terminal_index))
.route("/terminal/", get(handlers::terminal::terminal_index))
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy))
;
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
// Combine protected routes (user + admin)
let protected_routes = Router::new().merge(user_routes).merge(admin_routes);
// Protected routes (all authenticated users)
let protected_routes = user_routes;
// Stream endpoints (accessible with auth, but typically embedded in pages)
let stream_routes = Router::new()

View File

@@ -342,6 +342,18 @@ impl WebRtcStreamer {
}
}
/// Request the encoder to generate a keyframe on next encode
pub async fn request_keyframe(&self) -> Result<()> {
if let Some(ref pipeline) = *self.video_pipeline.read().await {
pipeline.request_keyframe().await;
Ok(())
} else {
Err(AppError::VideoError(
"Video pipeline not running".to_string(),
))
}
}
// === Audio Management ===
/// Check if audio is enabled

566
test/bench_kvm.py Normal file
View File

@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""
One-KVM benchmark script (Windows-friendly).
Measures FPS + CPU usage across:
- input pixel formats (capture card formats)
- output codecs (mjpeg/h264/h265/vp8/vp9)
- resolution/FPS matrix
- encoder backends (software/hardware)
Requirements:
pip install requests websockets playwright
playwright install
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import json
import sys
import threading
import time
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Tuple
import requests
import websockets
from playwright.async_api import async_playwright
SESSION_COOKIE = "one_kvm_session"
DEFAULT_MATRIX = [
(1920, 1080, 30),
(1920, 1080, 60),
(1280, 720, 30),
(1280, 720, 60),
]
@dataclass
class Case:
input_format: str
output_codec: str
encoder: Optional[str]
width: int
height: int
fps: int
@dataclass
class Result:
input_format: str
output_codec: str
encoder: str
width: int
height: int
fps: int
avg_fps: float
avg_cpu: float
note: str = ""
class KvmClient:
def __init__(self, base_url: str, username: str, password: str) -> None:
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.login(username, password)
def login(self, username: str, password: str) -> None:
r = self.s.post(f"{self.base}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
def get_cookie(self) -> str:
return self.s.cookies.get(SESSION_COOKIE, "")
def get_video_config(self) -> Dict:
r = self.s.get(f"{self.base}/api/config/video")
r.raise_for_status()
return r.json()
def get_stream_config(self) -> Dict:
r = self.s.get(f"{self.base}/api/config/stream")
r.raise_for_status()
return r.json()
def get_devices(self) -> Dict:
r = self.s.get(f"{self.base}/api/devices")
r.raise_for_status()
return r.json()
def get_codecs(self) -> Dict:
r = self.s.get(f"{self.base}/api/stream/codecs")
r.raise_for_status()
return r.json()
def patch_video(self, device: Optional[str], fmt: str, w: int, h: int, fps: int) -> None:
payload: Dict[str, object] = {"format": fmt, "width": w, "height": h, "fps": fps}
if device:
payload["device"] = device
r = self.s.patch(f"{self.base}/api/config/video", json=payload)
r.raise_for_status()
def patch_stream(self, encoder: Optional[str]) -> None:
if encoder is None:
return
r = self.s.patch(f"{self.base}/api/config/stream", json={"encoder": encoder})
r.raise_for_status()
def set_mode(self, mode: str) -> None:
r = self.s.post(f"{self.base}/api/stream/mode", json={"mode": mode})
r.raise_for_status()
def get_mode(self) -> Dict:
r = self.s.get(f"{self.base}/api/stream/mode")
r.raise_for_status()
return r.json()
def wait_mode_ready(self, mode: str, timeout_sec: int = 20) -> None:
deadline = time.time() + timeout_sec
while time.time() < deadline:
data = self.get_mode()
if not data.get("switching") and data.get("mode") == mode:
return
time.sleep(0.5)
raise RuntimeError(f"mode switch timeout: {mode}")
def start_stream(self) -> None:
r = self.s.post(f"{self.base}/api/stream/start")
r.raise_for_status()
def stop_stream(self) -> None:
r = self.s.post(f"{self.base}/api/stream/stop")
r.raise_for_status()
def cpu_sample(self) -> float:
r = self.s.get(f"{self.base}/api/info")
r.raise_for_status()
return float(r.json()["device_info"]["cpu_usage"])
def close_webrtc_session(self, session_id: str) -> None:
if not session_id:
return
self.s.post(f"{self.base}/api/webrtc/close", json={"session_id": session_id})
class MjpegStream:
def __init__(self, url: str, cookie: str) -> None:
self._stop = threading.Event()
self._resp = requests.get(url, stream=True, headers={"Cookie": f"{SESSION_COOKIE}={cookie}"})
self._thread = threading.Thread(target=self._reader, daemon=True)
self._thread.start()
def _reader(self) -> None:
try:
for chunk in self._resp.iter_content(chunk_size=4096):
if self._stop.is_set():
break
if not chunk:
time.sleep(0.01)
except Exception:
pass
def close(self) -> None:
self._stop.set()
try:
self._resp.close()
except Exception:
pass
def parse_matrix(values: Optional[List[str]]) -> List[Tuple[int, int, int]]:
if not values:
return DEFAULT_MATRIX
result: List[Tuple[int, int, int]] = []
for item in values:
# WIDTHxHEIGHT@FPS
part = item.strip().lower()
if "@" not in part or "x" not in part:
raise ValueError(f"invalid matrix item: {item}")
res_part, fps_part = part.split("@", 1)
w_str, h_str = res_part.split("x", 1)
result.append((int(w_str), int(h_str), int(fps_part)))
return result
def avg(values: Iterable[float]) -> float:
vals = list(values)
return sum(vals) / len(vals) if vals else 0.0
def normalize_format(fmt: str) -> str:
return fmt.strip().upper()
def select_device(devices: Dict, preferred: Optional[str]) -> Optional[Dict]:
video_devices = devices.get("video", [])
if preferred:
for d in video_devices:
if d.get("path") == preferred:
return d
return video_devices[0] if video_devices else None
def build_supported_map(device: Dict) -> Dict[str, Dict[Tuple[int, int], List[int]]]:
supported: Dict[str, Dict[Tuple[int, int], List[int]]] = {}
for fmt in device.get("formats", []):
fmt_name = normalize_format(fmt.get("format", ""))
res_map: Dict[Tuple[int, int], List[int]] = {}
for res in fmt.get("resolutions", []):
key = (int(res.get("width", 0)), int(res.get("height", 0)))
fps_list = [int(f) for f in res.get("fps", [])]
res_map[key] = fps_list
supported[fmt_name] = res_map
return supported
def is_combo_supported(
supported: Dict[str, Dict[Tuple[int, int], List[int]]],
fmt: str,
width: int,
height: int,
fps: int,
) -> bool:
res_map = supported.get(fmt)
if not res_map:
return False
fps_list = res_map.get((width, height), [])
return fps in fps_list
async def mjpeg_sample(
base_url: str,
cookie: str,
client_id: str,
duration_sec: float,
cpu_sample_fn,
) -> Tuple[float, float]:
mjpeg_url = f"{base_url}/api/stream/mjpeg?client_id={client_id}"
stream = MjpegStream(mjpeg_url, cookie)
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/ws"
fps_samples: List[float] = []
cpu_samples: List[float] = []
# discard first cpu sample (needs delta)
cpu_sample_fn()
try:
async with websockets.connect(ws_url, extra_headers={"Cookie": f"{SESSION_COOKIE}={cookie}"}) as ws:
start = time.time()
while time.time() - start < duration_sec:
try:
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
except asyncio.TimeoutError:
msg = None
if msg:
data = json.loads(msg)
if data.get("type") == "stream.stats_update":
clients = data.get("clients_stat", {})
if client_id in clients:
fps = float(clients[client_id].get("fps", 0))
fps_samples.append(fps)
cpu_samples.append(float(cpu_sample_fn()))
finally:
stream.close()
return avg(fps_samples), avg(cpu_samples)
async def webrtc_sample(
base_url: str,
cookie: str,
duration_sec: float,
cpu_sample_fn,
headless: bool,
) -> Tuple[float, float, str]:
fps_samples: List[float] = []
cpu_samples: List[float] = []
session_id = ""
# discard first cpu sample (needs delta)
cpu_sample_fn()
async with async_playwright() as p:
browser = await p.chromium.launch(headless=headless)
context = await browser.new_context()
await context.add_cookies([{
"name": SESSION_COOKIE,
"value": cookie,
"url": base_url,
"path": "/",
}])
page = await context.new_page()
await page.goto(base_url + "/", wait_until="domcontentloaded")
await page.evaluate(
"""
async (base) => {
const pc = new RTCPeerConnection();
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.onicecandidate = async (e) => {
if (e.candidate && window.__sid) {
await fetch(base + "/api/webrtc/ice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: window.__sid, candidate: e.candidate })
});
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(base + "/api/webrtc/offer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp: offer.sdp })
});
const ans = await resp.json();
window.__sid = ans.session_id;
await pc.setRemoteDescription({ type: "answer", sdp: ans.sdp });
(ans.ice_candidates || []).forEach(c => pc.addIceCandidate(c));
window.__kvmStats = { pc, lastTs: 0, lastFrames: 0 };
}
""",
base_url,
)
try:
await page.wait_for_function(
"window.__kvmStats && window.__kvmStats.pc && window.__kvmStats.pc.connectionState === 'connected'",
timeout=15000,
)
except Exception:
pass
start = time.time()
while time.time() - start < duration_sec:
fps = await page.evaluate(
"""
async () => {
const s = window.__kvmStats;
const report = await s.pc.getStats();
let fps = 0;
for (const r of report.values()) {
if (r.type === "inbound-rtp" && r.kind === "video") {
if (r.framesPerSecond) {
fps = r.framesPerSecond;
} else if (r.framesDecoded && s.lastTs) {
const dt = (r.timestamp - s.lastTs) / 1000.0;
const df = r.framesDecoded - s.lastFrames;
fps = dt > 0 ? df / dt : 0;
}
s.lastTs = r.timestamp;
s.lastFrames = r.framesDecoded || s.lastFrames;
break;
}
}
return fps;
}
"""
)
fps_samples.append(float(fps))
cpu_samples.append(float(cpu_sample_fn()))
await asyncio.sleep(1)
session_id = await page.evaluate("window.__sid || ''")
await browser.close()
return avg(fps_samples), avg(cpu_samples), session_id
async def run_case(
client: KvmClient,
device: Optional[str],
case: Case,
duration_sec: float,
warmup_sec: float,
headless: bool,
) -> Result:
client.patch_video(device, case.input_format, case.width, case.height, case.fps)
if case.output_codec != "mjpeg":
client.patch_stream(case.encoder)
client.set_mode(case.output_codec)
client.wait_mode_ready(case.output_codec)
client.start_stream()
time.sleep(warmup_sec)
note = ""
if case.output_codec == "mjpeg":
avg_fps, avg_cpu = await mjpeg_sample(
client.base,
client.get_cookie(),
client_id=f"bench-{int(time.time() * 1000)}",
duration_sec=duration_sec,
cpu_sample_fn=client.cpu_sample,
)
else:
avg_fps, avg_cpu, session_id = await webrtc_sample(
client.base,
client.get_cookie(),
duration_sec=duration_sec,
cpu_sample_fn=client.cpu_sample,
headless=headless,
)
if session_id:
client.close_webrtc_session(session_id)
else:
note = "no-session-id"
client.stop_stream()
return Result(
input_format=case.input_format,
output_codec=case.output_codec,
encoder=case.encoder or "n/a",
width=case.width,
height=case.height,
fps=case.fps,
avg_fps=avg_fps,
avg_cpu=avg_cpu,
note=note,
)
def write_csv(results: List[Result], path: str) -> None:
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["input_format", "output_codec", "encoder", "width", "height", "fps", "avg_fps", "avg_cpu", "note"])
for r in results:
w.writerow([r.input_format, r.output_codec, r.encoder, r.width, r.height, r.fps, f"{r.avg_fps:.2f}", f"{r.avg_cpu:.2f}", r.note])
def write_md(results: List[Result], path: str) -> None:
lines = [
"| input_format | output_codec | encoder | width | height | fps | avg_fps | avg_cpu | note |",
"|---|---|---|---:|---:|---:|---:|---:|---|",
]
for r in results:
lines.append(
f"| {r.input_format} | {r.output_codec} | {r.encoder} | {r.width} | {r.height} | {r.fps} | {r.avg_fps:.2f} | {r.avg_cpu:.2f} | {r.note} |"
)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
def main() -> int:
parser = argparse.ArgumentParser(description="One-KVM benchmark (FPS + CPU)")
parser.add_argument("--base-url", required=True, help="e.g. http://192.168.1.50")
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--device", help="video device path, e.g. /dev/video0")
parser.add_argument("--input-formats", help="comma list, e.g. MJPEG,YUYV,NV12")
parser.add_argument("--output-codecs", help="comma list, e.g. mjpeg,h264,h265,vp8,vp9")
parser.add_argument("--encoder-backends", help="comma list, e.g. software,auto,vaapi,nvenc,qsv,amf,rkmpp,v4l2m2m")
parser.add_argument("--matrix", action="append", help="repeatable WIDTHxHEIGHT@FPS, e.g. 1920x1080@30")
parser.add_argument("--duration", type=float, default=30.0, help="sample duration seconds (default 30)")
parser.add_argument("--warmup", type=float, default=3.0, help="warmup seconds before sampling")
parser.add_argument("--csv", default="bench_results.csv")
parser.add_argument("--md", default="bench_results.md")
parser.add_argument("--headless", action="store_true", help="run browser headless (default: headful)")
args = parser.parse_args()
if sys.platform.startswith("win"):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
base_url = args.base_url.strip()
if not base_url.startswith(("http://", "https://")):
base_url = "http://" + base_url
client = KvmClient(base_url, args.username, args.password)
devices = client.get_devices()
video_cfg = client.get_video_config()
device_path = args.device or video_cfg.get("device")
device_info = select_device(devices, device_path)
if not device_info:
print("No video device found.", file=sys.stderr)
return 2
device_path = device_info.get("path")
supported_map = build_supported_map(device_info)
if args.input_formats:
input_formats = [normalize_format(f) for f in args.input_formats.split(",") if f.strip()]
else:
input_formats = list(supported_map.keys())
matrix = parse_matrix(args.matrix)
codecs_info = client.get_codecs()
available_codecs = {c["id"] for c in codecs_info.get("codecs", []) if c.get("available")}
available_codecs.add("mjpeg")
if args.output_codecs:
output_codecs = [c.strip().lower() for c in args.output_codecs.split(",") if c.strip()]
else:
output_codecs = sorted(list(available_codecs))
if args.encoder_backends:
encoder_backends = [e.strip().lower() for e in args.encoder_backends.split(",") if e.strip()]
else:
encoder_backends = ["software", "auto"]
cases: List[Case] = []
for fmt in input_formats:
for (w, h, fps) in matrix:
if not is_combo_supported(supported_map, fmt, w, h, fps):
continue
for codec in output_codecs:
if codec not in available_codecs:
continue
if codec == "mjpeg":
cases.append(Case(fmt, codec, None, w, h, fps))
else:
for enc in encoder_backends:
cases.append(Case(fmt, codec, enc, w, h, fps))
print(f"Total cases: {len(cases)}")
results: List[Result] = []
for idx, case in enumerate(cases, 1):
print(f"[{idx}/{len(cases)}] {case.input_format} {case.output_codec} {case.encoder or 'n/a'} {case.width}x{case.height}@{case.fps}")
try:
result = asyncio.run(
run_case(
client,
device=device_path,
case=case,
duration_sec=args.duration,
warmup_sec=args.warmup,
headless=args.headless,
)
)
results.append(result)
print(f" -> avg_fps={result.avg_fps:.2f}, avg_cpu={result.avg_cpu:.2f}")
except Exception as exc:
results.append(
Result(
input_format=case.input_format,
output_codec=case.output_codec,
encoder=case.encoder or "n/a",
width=case.width,
height=case.height,
fps=case.fps,
avg_fps=0.0,
avg_cpu=0.0,
note=f"error: {exc}",
)
)
print(f" -> error: {exc}")
write_csv(results, args.csv)
write_md(results, args.md)
print(f"Saved: {args.csv}, {args.md}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "web",
"version": "0.1.1",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "0.1.1",
"version": "0.1.4",
"dependencies": {
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",

View File

@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "0.1.1",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -336,6 +336,7 @@ export const rustdeskConfigApi = {
export interface WebConfig {
http_port: number
https_port: number
bind_addresses: string[]
bind_address: string
https_enabled: boolean
}
@@ -344,6 +345,7 @@ export interface WebConfig {
export interface WebConfigUpdate {
http_port?: number
https_port?: number
bind_addresses?: string[]
bind_address?: string
https_enabled?: boolean
}

View File

@@ -16,7 +16,7 @@ export const authApi = {
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
check: () =>
request<{ authenticated: boolean; user?: string; is_admin?: boolean }>('/auth/check'),
request<{ authenticated: boolean; user?: string }>('/auth/check'),
changePassword: (currentPassword: string, newPassword: string) =>
request<{ success: boolean }>('/auth/password', {
@@ -84,6 +84,7 @@ export const systemApi = {
hid_ch9329_port?: string
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean

View File

@@ -6,7 +6,6 @@ const API_BASE = '/api'
// Toast debounce mechanism - prevent toast spam (5 seconds)
const toastDebounceMap = new Map<string, number>()
const TOAST_DEBOUNCE_TIME = 5000
let sessionExpiredNotified = false
function shouldShowToast(key: string): boolean {
const now = Date.now()
@@ -84,24 +83,10 @@ export async function request<T>(
const message = getErrorMessage(data, `HTTP ${response.status}`)
const normalized = message.toLowerCase()
const isNotAuthenticated = normalized.includes('not authenticated')
if (response.status === 401 && !sessionExpiredNotified) {
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
const isSessionExpired = normalized.includes('session expired')
if (isLoggedInElsewhere || isSessionExpired) {
sessionExpiredNotified = true
const titleKey = isLoggedInElsewhere ? 'auth.loggedInElsewhere' : 'auth.sessionExpired'
if (toastOnError && shouldShowToast('error_session_expired')) {
toast.error(t(titleKey), {
description: message,
duration: 3000,
})
}
setTimeout(() => {
window.location.reload()
}, 1200)
}
}
if (toastOnError && shouldShowToast(toastKey) && !(response.status === 401 && isNotAuthenticated)) {
const isSessionExpired = normalized.includes('session expired')
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
const isAuthIssue = response.status === 401 && (isNotAuthenticated || isSessionExpired || isLoggedInElsewhere)
if (toastOnError && shouldShowToast(toastKey) && !isAuthIssue) {
toast.error(t('api.operationFailed'), {
description: message,
duration: 4000,

View File

@@ -52,14 +52,13 @@ const overflowMenuOpen = ref(false)
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
const showMsd = computed(() => {
return props.isAdmin && !isCh9329Backend.value
return !!systemStore.msd?.available && !isCh9329Backend.value
})
const props = defineProps<{
mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode
ttydRunning?: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -86,25 +85,23 @@ const extensionOpen = ref(false)
<template>
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between gap-2 px-4 py-1.5">
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
<!-- Left side buttons -->
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
<!-- Video Config - Always visible -->
<VideoConfigPopover
v-model:open="videoPopoverOpen"
:video-mode="props.videoMode || 'mjpeg'"
:is-admin="props.isAdmin"
@update:video-mode="emit('update:videoMode', $event)"
/>
<!-- Audio Config - Always visible -->
<AudioConfigPopover v-model:open="audioPopoverOpen" :is-admin="props.isAdmin" />
<AudioConfigPopover v-model:open="audioPopoverOpen" />
<!-- HID Config - Always visible -->
<HidConfigPopover
v-model:open="hidPopoverOpen"
:mouse-mode="mouseMode"
:is-admin="props.isAdmin"
@update:mouse-mode="emit('toggleMouseMode')"
/>
@@ -125,7 +122,7 @@ const extensionOpen = ref(false)
</TooltipProvider>
<!-- ATX Power Control - Hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="atxOpen" class="hidden sm:block">
<Popover v-model:open="atxOpen" class="hidden sm:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" />
@@ -158,9 +155,9 @@ const extensionOpen = ref(false)
</div>
<!-- Right side buttons -->
<div class="flex items-center gap-1.5 shrink-0">
<!-- Extension Menu - Admin only, hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="extensionOpen" class="hidden lg:block">
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
<!-- Extension Menu - Hidden on small screens -->
<Popover v-model:open="extensionOpen" class="hidden lg:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" />
@@ -183,8 +180,8 @@ const extensionOpen = ref(false)
</PopoverContent>
</Popover>
<!-- Settings - Admin only, hidden on small screens -->
<TooltipProvider v-if="props.isAdmin" class="hidden lg:block">
<!-- Settings - Hidden on small screens -->
<TooltipProvider class="hidden lg:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
@@ -270,7 +267,7 @@ const extensionOpen = ref(false)
</DropdownMenuItem>
<!-- ATX - Mobile only -->
<DropdownMenuItem v-if="props.isAdmin" class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<Power class="h-4 w-4 mr-2" />
{{ t('actionbar.power') }}
</DropdownMenuItem>
@@ -291,7 +288,6 @@ const extensionOpen = ref(false)
<!-- Extension - Tablet and below -->
<DropdownMenuItem
v-if="props.isAdmin"
class="lg:hidden"
:disabled="!props.ttydRunning"
@click="emit('openTerminal'); overflowMenuOpen = false"
@@ -301,7 +297,7 @@ const extensionOpen = ref(false)
</DropdownMenuItem>
<!-- Settings - Tablet and below -->
<DropdownMenuItem v-if="props.isAdmin" class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<Settings class="h-4 w-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select'
import { Volume2, RefreshCw, Loader2 } from 'lucide-vue-next'
import { audioApi, configApi } from '@/api'
import { useConfigStore } from '@/stores/config'
import { useSystemStore } from '@/stores/system'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
@@ -30,7 +31,6 @@ interface AudioDevice {
const props = defineProps<{
open: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -38,6 +38,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const configStore = useConfigStore()
const systemStore = useSystemStore()
const unifiedAudio = getUnifiedAudio()
@@ -88,9 +89,9 @@ async function loadDevices() {
// Initialize from current config
function initializeFromCurrent() {
const audio = systemStore.audio
const audio = configStore.audio
if (audio) {
audioEnabled.value = audio.available && audio.streaming
audioEnabled.value = audio.enabled
selectedDevice.value = audio.device || ''
selectedQuality.value = (audio.quality as 'voice' | 'balanced' | 'high') || 'balanced'
}
@@ -105,12 +106,10 @@ async function applyConfig() {
try {
// Update config
await configApi.update({
audio: {
enabled: audioEnabled.value,
device: selectedDevice.value,
quality: selectedQuality.value,
},
await configStore.updateAudio({
enabled: audioEnabled.value,
device: selectedDevice.value,
quality: selectedQuality.value,
})
// If enabled and device is selected, try to start audio stream
@@ -152,12 +151,19 @@ async function applyConfig() {
// Watch popover open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
if (devices.value.length === 0) {
loadDevices()
}
initializeFromCurrent()
if (!isOpen) return
if (devices.value.length === 0) {
loadDevices()
}
configStore.refreshAudio()
.then(() => {
initializeFromCurrent()
})
.catch(() => {
initializeFromCurrent()
})
})
</script>
@@ -203,11 +209,10 @@ watch(() => props.open, (isOpen) => {
</div>
</div>
<!-- Device Settings (requires apply) - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<!-- Device Settings (requires apply) -->
<Separator />
<div class="space-y-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.audioDeviceSettings') }}
@@ -311,7 +316,6 @@ watch(() => props.open, (isOpen) => {
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>

View File

@@ -22,12 +22,13 @@ import {
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi } from '@/api'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { HidBackend } from '@/types/generated'
import type { HidConfigUpdate } from '@/types/generated'
const props = defineProps<{
open: boolean
mouseMode?: 'absolute' | 'relative'
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -36,7 +37,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
@@ -73,7 +74,7 @@ watch(showCursor, (newValue, oldValue) => {
})
// HID Device Settings (requires apply)
const hidBackend = ref<'otg' | 'ch9329' | 'none'>('none')
const hidBackend = ref<HidBackend>(HidBackend.None)
const devicePath = ref<string>('')
const baudrate = ref<number>(9600)
@@ -90,9 +91,9 @@ const buttonText = computed(() => t('actionbar.hidConfig'))
// Available device paths based on backend type
const availableDevicePaths = computed(() => {
if (hidBackend.value === 'ch9329') {
if (hidBackend.value === HidBackend.Ch9329) {
return serialDevices.value
} else if (hidBackend.value === 'otg') {
} else if (hidBackend.value === HidBackend.Otg) {
// For OTG, we show UDC devices
return udcDevices.value.map(udc => ({
path: udc.name,
@@ -125,9 +126,17 @@ function initializeFromCurrent() {
showCursor.value = storedCursor
// Initialize HID device settings from system state
const hid = systemStore.hid
const hid = configStore.hid
if (hid) {
hidBackend.value = (hid.backend as 'otg' | 'ch9329' | 'none') || 'none'
hidBackend.value = hid.backend || HidBackend.None
if (hidBackend.value === HidBackend.Ch9329) {
devicePath.value = hid.ch9329_port || ''
baudrate.value = hid.ch9329_baudrate || 9600
} else if (hidBackend.value === HidBackend.Otg) {
devicePath.value = hid.otg_udc || ''
} else {
devicePath.value = ''
}
}
}
@@ -137,10 +146,8 @@ function toggleMouseMode() {
emit('update:mouseMode', newMode)
// Update backend config
configApi.update({
hid: {
mouse_absolute: newMode === 'absolute',
},
configStore.updateHid({
mouse_absolute: newMode === 'absolute',
}).catch(_e => {
console.info('[HidConfig] Failed to update mouse mode')
toast.error(t('config.updateFailed'))
@@ -163,7 +170,11 @@ function handleThrottleChange(value: number[] | undefined) {
// Handle backend change
function handleBackendChange(backend: unknown) {
if (typeof backend !== 'string') return
hidBackend.value = backend as 'otg' | 'ch9329' | 'none'
if (backend === HidBackend.Otg || backend === HidBackend.Ch9329 || backend === HidBackend.None) {
hidBackend.value = backend
} else {
return
}
// Clear device path when changing backend
devicePath.value = ''
@@ -190,18 +201,18 @@ function handleBaudrateChange(rate: unknown) {
async function applyHidConfig() {
applying.value = true
try {
const config: Record<string, unknown> = {
const config: HidConfigUpdate = {
backend: hidBackend.value,
}
if (hidBackend.value === 'ch9329') {
if (hidBackend.value === HidBackend.Ch9329) {
config.ch9329_port = devicePath.value
config.ch9329_baudrate = baudrate.value
} else if (hidBackend.value === 'otg') {
} else if (hidBackend.value === HidBackend.Otg) {
config.otg_udc = devicePath.value
}
await configApi.update({ hid: config })
await configStore.updateHid(config)
toast.success(t('config.applied'))
@@ -216,14 +227,20 @@ async function applyHidConfig() {
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Load devices on first open
if (serialDevices.value.length === 0) {
loadDevices()
}
// Initialize from current config
initializeFromCurrent()
if (!isOpen) return
// Load devices on first open
if (serialDevices.value.length === 0) {
loadDevices()
}
configStore.refreshHid()
.then(() => {
initializeFromCurrent()
})
.catch(() => {
initializeFromCurrent()
})
})
</script>
@@ -304,11 +321,10 @@ watch(() => props.open, (isOpen) => {
</div>
</div>
<!-- HID Device Settings (Requires Apply) - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<!-- HID Device Settings (Requires Apply) -->
<Separator />
<div class="space-y-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
<Button
@@ -333,15 +349,15 @@ watch(() => props.open, (isOpen) => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="otg" class="text-xs">USB OTG</SelectItem>
<SelectItem value="ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
<SelectItem value="none" class="text-xs">{{ t('common.disabled') }}</SelectItem>
<SelectItem :value="HidBackend.Otg" class="text-xs">USB OTG</SelectItem>
<SelectItem :value="HidBackend.Ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
<SelectItem :value="HidBackend.None" class="text-xs">{{ t('common.disabled') }}</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Device Path (OTG or CH9329) -->
<div v-if="hidBackend !== 'none'" class="space-y-2">
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
<Select
:model-value="devicePath"
@@ -365,7 +381,7 @@ watch(() => props.open, (isOpen) => {
</div>
<!-- Baudrate (CH9329 only) -->
<div v-if="hidBackend === 'ch9329'" class="space-y-2">
<div v-if="hidBackend === HidBackend.Ch9329" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.baudrate') }}</Label>
<Select
:model-value="String(baudrate)"
@@ -393,8 +409,7 @@ watch(() => props.open, (isOpen) => {
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</div>
</PopoverContent>
</Popover>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/lib/utils'
import {
@@ -7,6 +7,11 @@ import {
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
@@ -28,8 +33,18 @@ const props = withDefaults(defineProps<{
errorMessage?: string
details?: StatusDetail[]
hoverAlign?: 'start' | 'center' | 'end' // HoverCard alignment
compact?: boolean
}>(), {
hoverAlign: 'start',
compact: false,
})
const prefersPopover = ref(false)
onMounted(() => {
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const coarsePointer = window.matchMedia?.('(pointer: coarse)')?.matches
prefersPopover.value = hasTouch || !!coarsePointer
})
const statusColor = computed(() => {
@@ -111,19 +126,20 @@ const statusBadgeText = computed(() => {
</script>
<template>
<HoverCard :open-delay="200" :close-delay="100">
<HoverCard v-if="!prefersPopover" :open-delay="200" :close-delay="100">
<HoverCardTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
:class="cn(
'flex flex-col gap-0.5 px-3 py-1.5 rounded-md border text-sm cursor-pointer transition-colors min-w-[100px]',
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs">{{ title }}</span>
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
@@ -208,4 +224,103 @@ const statusBadgeText = computed(() => {
</div>
</HoverCardContent>
</HoverCard>
<Popover v-else>
<PopoverTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</div>
</PopoverTrigger>
<PopoverContent class="w-80" :align="hoverAlign">
<div class="space-y-3">
<!-- Header -->
<div class="flex items-center gap-3">
<div :class="cn(
'p-2 rounded-lg',
status === 'connected' ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
status === 'error' ? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' :
'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
)">
<component :is="StatusIcon" class="h-5 w-5" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-sm">{{ title }}</h4>
<div class="flex items-center gap-1.5 mt-0.5">
<component
v-if="statusIcon"
:is="statusIcon"
:class="cn(
'h-3.5 w-3.5',
status === 'connected' ? 'text-green-500' :
status === 'connecting' ? 'text-yellow-500 animate-spin' :
status === 'error' ? 'text-red-500' :
'text-slate-400'
)"
/>
<Badge
:variant="status === 'connected' ? 'default' : status === 'error' ? 'destructive' : 'secondary'"
class="text-[10px] px-1.5 py-0"
>
{{ statusBadgeText }}
</Badge>
</div>
</div>
</div>
<!-- Error Message -->
<div
v-if="status === 'error' && errorMessage"
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<p class="text-xs text-red-600 dark:text-red-400">
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
{{ errorMessage }}
</p>
</div>
<!-- Details -->
<div v-if="details && details.length > 0" class="space-y-2">
<Separator />
<div class="space-y-1.5">
<div
v-for="(detail, index) in details"
:key="index"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground">{{ detail.label }}</span>
<span
:class="cn(
'font-medium',
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-foreground'
)"
>
{{ detail.value }}
</span>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -20,7 +20,7 @@ import {
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo, type BitratePreset } from '@/api'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useRouter } from 'vue-router'
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
@@ -43,7 +43,6 @@ interface VideoDevice {
const props = defineProps<{
open: boolean
videoMode: VideoMode
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -52,7 +51,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const router = useRouter()
// Device list
@@ -65,7 +64,7 @@ const loadingCodecs = ref(false)
// Backend list
const backends = ref<EncoderBackendInfo[]>([])
const currentEncoderBackend = ref<string>('auto')
const currentEncoderBackend = computed(() => configStore.stream?.encoder || 'auto')
// Browser supported codecs (WebRTC receive capabilities)
const browserSupportedCodecs = ref<Set<string>>(new Set())
@@ -198,11 +197,11 @@ const applyingBitrate = ref(false)
// Current config from store
const currentConfig = computed(() => ({
device: systemStore.stream?.device || '',
format: systemStore.stream?.format || '',
width: systemStore.stream?.resolution?.[0] || 1920,
height: systemStore.stream?.resolution?.[1] || 1080,
fps: systemStore.stream?.targetFps || 30,
device: configStore.video?.device || '',
format: configStore.video?.format || '',
width: configStore.video?.width || 1920,
height: configStore.video?.height || 1080,
fps: configStore.video?.fps || 30,
}))
// Button display text - simplified to just show label
@@ -304,19 +303,6 @@ async function loadCodecs() {
}
}
// Load current encoder backend from config
async function loadEncoderBackend() {
try {
const config = await configApi.get()
// Access nested stream.encoder
const streamConfig = config.stream as { encoder?: string } | undefined
currentEncoderBackend.value = streamConfig?.encoder || 'auto'
} catch (e) {
console.info('[VideoConfig] Failed to load encoder backend config')
currentEncoderBackend.value = 'auto'
}
}
// Navigate to settings page (video tab)
function goToSettings() {
router.push('/settings?tab=video')
@@ -441,14 +427,12 @@ async function applyVideoConfig() {
applying.value = true
try {
await configApi.update({
video: {
device: selectedDevice.value,
format: selectedFormat.value,
width,
height,
fps: selectedFps.value,
},
await configStore.updateVideo({
device: selectedDevice.value,
format: selectedFormat.value,
width,
height,
fps: selectedFps.value,
})
toast.success(t('config.applied'))
@@ -464,26 +448,32 @@ async function applyVideoConfig() {
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Detect browser codec support on first open
if (browserSupportedCodecs.value.size === 0) {
detectBrowserCodecSupport()
}
// Load devices on first open
if (devices.value.length === 0) {
loadDevices()
}
// Load codecs and backends on first open
if (codecs.value.length === 0) {
loadCodecs()
}
// Load encoder backend config
loadEncoderBackend()
// Initialize from current config
initializeFromCurrent()
} else {
if (!isOpen) {
isDirty.value = false
return
}
// Detect browser codec support on first open
if (browserSupportedCodecs.value.size === 0) {
detectBrowserCodecSupport()
}
// Load devices on first open
if (devices.value.length === 0) {
loadDevices()
}
// Load codecs and backends on first open
if (codecs.value.length === 0) {
loadCodecs()
}
Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
]).then(() => {
initializeFromCurrent()
}).catch(() => {
initializeFromCurrent()
})
})
// Sync selected values when backend config changes (e.g., auto format switch on mode change)
@@ -619,9 +609,7 @@ watch(currentConfig, () => {
</div>
</div>
<!-- Settings Link - Admin only -->
<Button
v-if="props.isAdmin"
variant="ghost"
size="sm"
class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0"
@@ -632,11 +620,10 @@ watch(currentConfig, () => {
</Button>
</div>
<!-- Device Settings Section - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<!-- Device Settings Section -->
<Separator />
<div class="space-y-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
<Button
@@ -784,8 +771,7 @@ watch(currentConfig, () => {
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</div>
</PopoverContent>
</Popover>

View File

@@ -114,6 +114,21 @@ const keyboardLayout = {
},
}
const compactMainLayout = {
default: keyboardLayout.main.default.slice(2),
shift: keyboardLayout.main.shift.slice(2),
}
const isCompactLayout = ref(false)
let compactLayoutMedia: MediaQueryList | null = null
let compactLayoutListener: ((event: MediaQueryListEvent) => void) | null = null
function setCompactLayout(active: boolean) {
if (isCompactLayout.value === active) return
isCompactLayout.value = active
updateKeyboardLayout()
}
// Key display mapping with Unicode symbols (JetKVM style)
const keyDisplayMap = computed<Record<string, string>>(() => {
// OS-specific Meta key labels
@@ -233,14 +248,15 @@ function switchOsLayout(os: KeyboardOsType) {
// Update keyboard layout based on selected OS
function updateKeyboardLayout() {
const bottomRow = getBottomRow()
const baseLayout = isCompactLayout.value ? compactMainLayout : keyboardLayout.main
const newLayout = {
...keyboardLayout.main,
...baseLayout,
default: [
...keyboardLayout.main.default.slice(0, -1),
...baseLayout.default.slice(0, -1),
bottomRow,
],
shift: [
...keyboardLayout.main.shift.slice(0, -1),
...baseLayout.shift.slice(0, -1),
bottomRow,
],
}
@@ -422,7 +438,7 @@ function initKeyboards() {
// Main keyboard - pass element directly instead of selector string
mainKeyboard.value = new Keyboard(mainEl, {
layout: keyboardLayout.main,
layout: isCompactLayout.value ? compactMainLayout : keyboardLayout.main,
layoutName: layoutName.value,
display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
@@ -471,6 +487,7 @@ function initKeyboards() {
stopMouseUpPropagation: true,
})
updateKeyboardLayout()
console.log('[VirtualKeyboard] Keyboards initialized:', id)
}
@@ -570,6 +587,15 @@ onMounted(() => {
selectedOs.value = savedOs
}
if (window.matchMedia) {
compactLayoutMedia = window.matchMedia('(max-width: 640px)')
setCompactLayout(compactLayoutMedia.matches)
compactLayoutListener = (event: MediaQueryListEvent) => {
setCompactLayout(event.matches)
}
compactLayoutMedia.addEventListener('change', compactLayoutListener)
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('touchmove', onDrag)
document.addEventListener('mouseup', endDrag)
@@ -577,6 +603,9 @@ onMounted(() => {
})
onUnmounted(() => {
if (compactLayoutMedia && compactLayoutListener) {
compactLayoutMedia.removeEventListener('change', compactLayoutListener)
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('mouseup', endDrag)
@@ -1112,6 +1141,80 @@ html.dark .hg-theme-default .hg-button.down-key,
}
}
@media (max-width: 640px) {
.vkb .simple-keyboard .hg-button {
height: 30px;
font-size: 10px;
padding: 0 4px;
margin: 0 1px 3px 0;
min-width: 26px;
}
.vkb .simple-keyboard .hg-button.combination-key {
font-size: 9px;
height: 24px;
padding: 0 6px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
min-width: 60px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 60px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 80px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"],
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
min-width: 46px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 140px;
}
.kb-control-container .hg-button {
min-width: 44px !important;
}
.kb-arrows-container .hg-button {
min-width: 36px !important;
width: 36px !important;
}
.vkb-media-btn {
padding: 4px 8px;
font-size: 14px;
min-width: 32px;
}
}
/* Floating mode - slightly smaller keys but still readable */
.vkb--floating .vkb-body {
padding: 8px;

View File

@@ -265,6 +265,10 @@ export default {
// Help tooltips
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
otgAdvanced: 'Advanced: OTG Preset',
otgProfile: 'Initial HID Preset',
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
otgLowEndpointHint: 'Detected low-endpoint UDC; multimedia keys will be disabled automatically.',
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
// Extensions
@@ -476,10 +480,22 @@ export default {
configureHttpPort: 'Configure HTTP server port',
// Web server
webServer: 'Access Address',
webServerDesc: 'Configure HTTP/HTTPS ports and bind address. Restart required for changes to take effect.',
webServerDesc: 'Configure HTTP/HTTPS ports and listening addresses. Restart required for changes to take effect.',
httpsPort: 'HTTPS Port',
bindAddress: 'Bind Address',
bindAddressDesc: 'IP address the server listens on. 0.0.0.0 means all network interfaces.',
bindMode: 'Listening Address',
bindModeDesc: 'Choose which addresses the web server binds to.',
bindModeAll: 'All addresses',
bindModeLocal: 'Local only (127.0.0.1)',
bindModeCustom: 'Custom address list',
bindIpv6: 'Enable IPv6',
bindAllDesc: 'Also listen on :: (all IPv6 interfaces).',
bindLocalDesc: 'Also listen on ::1 (IPv6 loopback).',
bindAddressList: 'Address List',
bindAddressListDesc: 'One IP address per line (IPv4 or IPv6).',
addBindAddress: 'Add address',
bindAddressListEmpty: 'Add at least one IP address.',
httpsEnabled: 'Enable HTTPS',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
restartRequired: 'Restart Required',
@@ -584,9 +600,12 @@ export default {
otgHidProfile: 'OTG HID Profile',
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
profile: 'Profile',
otgProfileFull: 'Full (keyboard + relative mouse + absolute mouse + consumer)',
otgProfileLegacyKeyboard: 'Legacy: keyboard only',
otgProfileLegacyMouseRelative: 'Legacy: relative mouse only',
otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD',
otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)',
otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)',
otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)',
otgProfileLegacyKeyboard: 'Keyboard only',
otgProfileLegacyMouseRelative: 'Relative mouse only',
otgProfileCustom: 'Custom',
otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
@@ -599,6 +618,7 @@ export default {
otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
otgLowEndpointHint: 'Low-endpoint UDC detected; multimedia keys will be disabled automatically.',
otgFunctionMinWarning: 'Enable at least one HID function before saving',
// OTG Descriptor
otgDescriptor: 'USB Device Descriptor',
@@ -749,10 +769,10 @@ export default {
serverSettings: 'Server Settings',
rendezvousServer: 'ID Server',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: 'Configure your RustDesk server address',
rendezvousServerHint: 'Configure your RustDesk server address (port optional, defaults to 21116)',
relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: 'Relay server address, auto-derived from ID server if empty',
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
relayKey: 'Relay Key',
relayKeyPlaceholder: 'Enter relay server key',
relayKeySet: '••••••••',

View File

@@ -265,6 +265,10 @@ export default {
// Help tooltips
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
otgAdvanced: '高级OTG 预设',
otgProfile: '初始 HID 预设',
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
// Extensions
@@ -476,10 +480,22 @@ export default {
configureHttpPort: '配置 HTTP 服务器端口',
// Web server
webServer: '访问地址',
webServerDesc: '配置 HTTP/HTTPS 端口和绑定地址,修改后需要重启生效',
webServerDesc: '配置 HTTP/HTTPS 端口和监听地址,修改后需要重启生效',
httpsPort: 'HTTPS 端口',
bindAddress: '绑定地址',
bindAddressDesc: '服务器监听的 IP 地址0.0.0.0 表示监听所有网络接口',
bindMode: '监听地址',
bindModeDesc: '选择 Web 服务监听哪些地址。',
bindModeAll: '所有地址',
bindModeLocal: '仅本地 (127.0.0.1)',
bindModeCustom: '自定义地址列表',
bindIpv6: '启用 IPv6',
bindAllDesc: '同时监听 ::(所有 IPv6 地址)。',
bindLocalDesc: '同时监听 ::1IPv6 本地回环)。',
bindAddressList: '地址列表',
bindAddressListDesc: '每行一个 IPIPv4 或 IPv6。',
addBindAddress: '添加地址',
bindAddressListEmpty: '请至少填写一个 IP 地址。',
httpsEnabled: '启用 HTTPS',
httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签名证书)',
restartRequired: '需要重启',
@@ -584,9 +600,12 @@ export default {
otgHidProfile: 'OTG HID 组合',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
profile: '组合',
otgProfileFull: '完整(键盘 + 相对鼠标 + 绝对鼠标 + 多媒体',
otgProfileLegacyKeyboard: '兼容:仅键盘',
otgProfileLegacyMouseRelative: '兼容:仅相对鼠标',
otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体',
otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)',
otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)',
otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)',
otgProfileLegacyKeyboard: '仅键盘',
otgProfileLegacyMouseRelative: '仅相对鼠标',
otgProfileCustom: '自定义',
otgFunctionKeyboard: '键盘',
otgFunctionKeyboardDesc: '标准 HID 键盘设备',
@@ -596,9 +615,10 @@ export default {
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
otgFunctionConsumer: '多媒体控制',
otgFunctionConsumerDesc: '音量/播放/暂停等按键',
otgFunctionMsd: 'U盘MSD',
otgFunctionMsd: '虚拟媒体MSD',
otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
// OTG Descriptor
otgDescriptor: 'USB 设备描述符',
@@ -749,10 +769,10 @@ export default {
serverSettings: '服务器设置',
rendezvousServer: 'ID 服务器',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: '请配置您的 RustDesk 服务器地址',
rendezvousServerHint: '请配置您的 RustDesk 服务器地址(端口可省略,默认 21116',
relayServer: '中继服务器',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导',
relayServerHint: '中继服务器地址(端口可省略,默认 21117,留空则自动从 ID 服务器推导',
relayKey: '中继密钥',
relayKeyPlaceholder: '输入中继服务器密钥',
relayKeySet: '••••••••',

View File

@@ -1,4 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { toast } from 'vue-sonner'
import i18n from '@/i18n'
import { ApiError } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
@@ -33,29 +36,62 @@ const router = createRouter({
routes,
})
let sessionExpiredNotified = false
function t(key: string, params?: Record<string, unknown>): string {
return String(i18n.global.t(key, params as any))
}
// Navigation guard
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
// Check if system needs setup
if (!authStore.initialized && to.name !== 'Setup') {
// Prevent access to setup after initialization
const shouldCheckSetup = to.name === 'Setup' || !authStore.initialized
if (shouldCheckSetup) {
try {
await authStore.checkSetupStatus()
if (authStore.needsSetup) {
return next({ name: 'Setup' })
}
} catch {
// Continue anyway
}
}
if (authStore.needsSetup) {
if (to.name !== 'Setup') {
return next({ name: 'Setup' })
}
} else if (authStore.initialized && to.name === 'Setup') {
if (!authStore.isAuthenticated) {
try {
await authStore.checkAuth()
} catch {
// Not authenticated
}
}
return next({ name: authStore.isAuthenticated ? 'Console' : 'Login' })
}
// Check authentication for protected routes
if (to.meta.requiresAuth !== false) {
if (!authStore.isAuthenticated) {
try {
await authStore.checkAuth()
} catch {
} catch (e) {
// Not authenticated
if (e instanceof ApiError && e.status === 401 && !sessionExpiredNotified) {
const normalized = e.message.toLowerCase()
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
const isSessionExpired = normalized.includes('session expired')
if (isLoggedInElsewhere || isSessionExpired) {
sessionExpiredNotified = true
const titleKey = isLoggedInElsewhere ? 'auth.loggedInElsewhere' : 'auth.sessionExpired'
toast.error(t(titleKey), {
description: e.message,
duration: 3000,
})
}
}
}
if (!authStore.isAuthenticated) {

View File

@@ -4,7 +4,6 @@ import { authApi, systemApi } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const user = ref<string | null>(null)
const isAdmin = ref(false)
const isAuthenticated = ref(false)
const initialized = ref(false)
const needsSetup = ref(false)
@@ -30,12 +29,14 @@ export const useAuthStore = defineStore('auth', () => {
const result = await authApi.check()
isAuthenticated.value = result.authenticated
user.value = result.user || null
isAdmin.value = result.is_admin ?? false
return result
} catch {
} catch (e) {
isAuthenticated.value = false
user.value = null
isAdmin.value = false
error.value = e instanceof Error ? e.message : 'Not authenticated'
if (e instanceof Error) {
throw e
}
throw new Error('Not authenticated')
}
}
@@ -49,13 +50,6 @@ export const useAuthStore = defineStore('auth', () => {
if (result.success) {
isAuthenticated.value = true
user.value = username
// After login, fetch admin status
try {
const authResult = await authApi.check()
isAdmin.value = authResult.is_admin ?? false
} catch {
isAdmin.value = false
}
return true
} else {
error.value = result.message || 'Login failed'
@@ -75,7 +69,6 @@ export const useAuthStore = defineStore('auth', () => {
} finally {
isAuthenticated.value = false
user.value = null
isAdmin.value = false
}
}
@@ -91,6 +84,7 @@ export const useAuthStore = defineStore('auth', () => {
hid_ch9329_port?: string
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
@@ -119,7 +113,6 @@ export const useAuthStore = defineStore('auth', () => {
return {
user,
isAdmin,
isAuthenticated,
initialized,
needsSetup,

506
web/src/stores/config.ts Normal file
View File

@@ -0,0 +1,506 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
authConfigApi,
atxConfigApi,
audioConfigApi,
hidConfigApi,
msdConfigApi,
rustdeskConfigApi,
streamConfigApi,
videoConfigApi,
webConfigApi,
} from '@/api'
import type {
AtxConfig,
AtxConfigUpdate,
AudioConfig,
AudioConfigUpdate,
AuthConfig,
AuthConfigUpdate,
HidConfig,
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
StreamConfigResponse,
StreamConfigUpdate,
VideoConfig,
VideoConfigUpdate,
WebConfig,
WebConfigUpdate,
} from '@/types/generated'
import type {
RustDeskConfigResponse as ApiRustDeskConfigResponse,
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
RustDeskStatusResponse as ApiRustDeskStatusResponse,
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
} from '@/api'
function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return 'Unknown error'
}
export const useConfigStore = defineStore('config', () => {
const auth = ref<AuthConfig | null>(null)
const video = ref<VideoConfig | null>(null)
const audio = ref<AudioConfig | null>(null)
const hid = ref<HidConfig | null>(null)
const msd = ref<MsdConfig | null>(null)
const stream = ref<StreamConfigResponse | null>(null)
const web = ref<WebConfig | null>(null)
const atx = ref<AtxConfig | null>(null)
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
const authLoading = ref(false)
const videoLoading = ref(false)
const audioLoading = ref(false)
const hidLoading = ref(false)
const msdLoading = ref(false)
const streamLoading = ref(false)
const webLoading = ref(false)
const atxLoading = ref(false)
const rustdeskLoading = ref(false)
const authError = ref<string | null>(null)
const videoError = ref<string | null>(null)
const audioError = ref<string | null>(null)
const hidError = ref<string | null>(null)
const msdError = ref<string | null>(null)
const streamError = ref<string | null>(null)
const webError = ref<string | null>(null)
const atxError = ref<string | null>(null)
const rustdeskError = ref<string | null>(null)
let authPromise: Promise<AuthConfig> | null = null
let videoPromise: Promise<VideoConfig> | null = null
let audioPromise: Promise<AudioConfig> | null = null
let hidPromise: Promise<HidConfig> | null = null
let msdPromise: Promise<MsdConfig> | null = null
let streamPromise: Promise<StreamConfigResponse> | null = null
let webPromise: Promise<WebConfig> | null = null
let atxPromise: Promise<AtxConfig> | null = null
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
async function refreshAuth() {
if (authLoading.value && authPromise) return authPromise
authLoading.value = true
authError.value = null
const request = authConfigApi.get()
.then((response) => {
auth.value = response
return response
})
.catch((error) => {
authError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
authLoading.value = false
authPromise = null
})
authPromise = request
return request
}
async function refreshVideo() {
if (videoLoading.value && videoPromise) return videoPromise
videoLoading.value = true
videoError.value = null
const request = videoConfigApi.get()
.then((response) => {
video.value = response
return response
})
.catch((error) => {
videoError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
videoLoading.value = false
videoPromise = null
})
videoPromise = request
return request
}
async function refreshAudio() {
if (audioLoading.value && audioPromise) return audioPromise
audioLoading.value = true
audioError.value = null
const request = audioConfigApi.get()
.then((response) => {
audio.value = response
return response
})
.catch((error) => {
audioError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
audioLoading.value = false
audioPromise = null
})
audioPromise = request
return request
}
async function refreshHid() {
if (hidLoading.value && hidPromise) return hidPromise
hidLoading.value = true
hidError.value = null
const request = hidConfigApi.get()
.then((response) => {
hid.value = response
return response
})
.catch((error) => {
hidError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
hidLoading.value = false
hidPromise = null
})
hidPromise = request
return request
}
async function refreshMsd() {
if (msdLoading.value && msdPromise) return msdPromise
msdLoading.value = true
msdError.value = null
const request = msdConfigApi.get()
.then((response) => {
msd.value = response
return response
})
.catch((error) => {
msdError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
msdLoading.value = false
msdPromise = null
})
msdPromise = request
return request
}
async function refreshStream() {
if (streamLoading.value && streamPromise) return streamPromise
streamLoading.value = true
streamError.value = null
const request = streamConfigApi.get()
.then((response) => {
stream.value = response
return response
})
.catch((error) => {
streamError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
streamLoading.value = false
streamPromise = null
})
streamPromise = request
return request
}
async function refreshWeb() {
if (webLoading.value && webPromise) return webPromise
webLoading.value = true
webError.value = null
const request = webConfigApi.get()
.then((response) => {
web.value = response
return response
})
.catch((error) => {
webError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
webLoading.value = false
webPromise = null
})
webPromise = request
return request
}
async function refreshAtx() {
if (atxLoading.value && atxPromise) return atxPromise
atxLoading.value = true
atxError.value = null
const request = atxConfigApi.get()
.then((response) => {
atx.value = response
return response
})
.catch((error) => {
atxError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
atxLoading.value = false
atxPromise = null
})
atxPromise = request
return request
}
async function refreshRustdeskConfig() {
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.get()
.then((response) => {
rustdeskConfig.value = response
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskPromise = null
})
rustdeskPromise = request
return request
}
async function refreshRustdeskStatus() {
if (rustdeskLoading.value && rustdeskStatusPromise) return rustdeskStatusPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.getStatus()
.then((response) => {
rustdeskStatus.value = response
rustdeskConfig.value = response.config
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskStatusPromise = null
})
rustdeskStatusPromise = request
return request
}
async function refreshRustdeskPassword() {
if (rustdeskLoading.value && rustdeskPasswordPromise) return rustdeskPasswordPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.getPassword()
.then((response) => {
rustdeskPassword.value = response
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskPasswordPromise = null
})
rustdeskPasswordPromise = request
return request
}
function ensureAuth() {
if (auth.value) return Promise.resolve(auth.value)
return refreshAuth()
}
function ensureVideo() {
if (video.value) return Promise.resolve(video.value)
return refreshVideo()
}
function ensureAudio() {
if (audio.value) return Promise.resolve(audio.value)
return refreshAudio()
}
function ensureHid() {
if (hid.value) return Promise.resolve(hid.value)
return refreshHid()
}
function ensureMsd() {
if (msd.value) return Promise.resolve(msd.value)
return refreshMsd()
}
function ensureStream() {
if (stream.value) return Promise.resolve(stream.value)
return refreshStream()
}
function ensureWeb() {
if (web.value) return Promise.resolve(web.value)
return refreshWeb()
}
function ensureAtx() {
if (atx.value) return Promise.resolve(atx.value)
return refreshAtx()
}
function ensureRustdeskConfig() {
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
return refreshRustdeskConfig()
}
async function updateAuth(update: AuthConfigUpdate) {
const response = await authConfigApi.update(update)
auth.value = response
return response
}
async function updateVideo(update: VideoConfigUpdate) {
const response = await videoConfigApi.update(update)
video.value = response
return response
}
async function updateAudio(update: AudioConfigUpdate) {
const response = await audioConfigApi.update(update)
audio.value = response
return response
}
async function updateHid(update: HidConfigUpdate) {
const response = await hidConfigApi.update(update)
hid.value = response
return response
}
async function updateMsd(update: MsdConfigUpdate) {
const response = await msdConfigApi.update(update)
msd.value = response
return response
}
async function updateStream(update: StreamConfigUpdate) {
const response = await streamConfigApi.update(update)
stream.value = response
return response
}
async function updateWeb(update: WebConfigUpdate) {
const response = await webConfigApi.update(update)
web.value = response
return response
}
async function updateAtx(update: AtxConfigUpdate) {
const response = await atxConfigApi.update(update)
atx.value = response
return response
}
async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
const response = await rustdeskConfigApi.update(update)
rustdeskConfig.value = response
return response
}
async function regenerateRustdeskId() {
const response = await rustdeskConfigApi.regenerateId()
rustdeskConfig.value = response
return response
}
async function regenerateRustdeskPassword() {
const response = await rustdeskConfigApi.regeneratePassword()
rustdeskConfig.value = response
return response
}
return {
auth,
video,
audio,
hid,
msd,
stream,
web,
atx,
rustdeskConfig,
rustdeskStatus,
rustdeskPassword,
authLoading,
videoLoading,
audioLoading,
hidLoading,
msdLoading,
streamLoading,
webLoading,
atxLoading,
rustdeskLoading,
authError,
videoError,
audioError,
hidError,
msdError,
streamError,
webError,
atxError,
rustdeskError,
refreshAuth,
refreshVideo,
refreshAudio,
refreshHid,
refreshMsd,
refreshStream,
refreshWeb,
refreshAtx,
refreshRustdeskConfig,
refreshRustdeskStatus,
refreshRustdeskPassword,
ensureAuth,
ensureVideo,
ensureAudio,
ensureHid,
ensureMsd,
ensureStream,
ensureWeb,
ensureAtx,
ensureRustdeskConfig,
updateAuth,
updateVideo,
updateAudio,
updateHid,
updateMsd,
updateStream,
updateWeb,
updateAtx,
updateRustdesk,
regenerateRustdeskId,
regenerateRustdeskPassword,
}
})

View File

@@ -1,2 +1,3 @@
export { useAuthStore } from './auth'
export { useConfigStore } from './config'
export { useSystemStore } from './system'

View File

@@ -58,6 +58,12 @@ export interface OtgDescriptorConfig {
export enum OtgHidProfile {
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
Full = "full",
/** Full HID device set without MSD */
FullNoMsd = "full_no_msd",
/** Full HID device set without consumer control */
FullNoConsumer = "full_no_consumer",
/** Full HID device set without consumer control and MSD */
FullNoConsumerNoMsd = "full_no_consumer_no_msd",
/** Legacy profile: only keyboard */
LegacyKeyboard = "legacy_keyboard",
/** Legacy profile: only relative mouse */
@@ -276,7 +282,9 @@ export interface WebConfig {
http_port: number;
/** HTTPS port */
https_port: number;
/** Bind address */
/** Bind addresses (preferred) */
bind_addresses: string[];
/** Bind address (legacy) */
bind_address: string;
/** Enable HTTPS */
https_enabled: boolean;
@@ -619,6 +627,7 @@ export interface VideoConfigUpdate {
export interface WebConfigUpdate {
http_port?: number;
https_port?: number;
bind_addresses?: string[];
bind_address?: string;
https_enabled?: boolean;
}

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
import { useWebSocket } from '@/composables/useWebSocket'
import { useConsoleEvents } from '@/composables/useConsoleEvents'
@@ -59,6 +60,7 @@ import { setLanguage } from '@/i18n'
const { t, locale } = useI18n()
const router = useRouter()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
const hidWs = useHidWebSocket()
@@ -134,6 +136,15 @@ let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas be
// Cursor visibility (from localStorage, updated via storage event)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
function syncMouseModeFromConfig() {
const mouseAbsolute = configStore.hid?.mouse_absolute
if (typeof mouseAbsolute !== 'boolean') return
const nextMode: 'absolute' | 'relative' = mouseAbsolute ? 'absolute' : 'relative'
if (mouseMode.value !== nextMode) {
mouseMode.value = nextMode
}
}
// Virtual keyboard state
const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true)
@@ -1787,6 +1798,9 @@ onMounted(async () => {
// 4. 其他初始化
await systemStore.startStream().catch(() => {})
await systemStore.fetchAllStates()
await configStore.refreshHid().then(() => {
syncMouseModeFromConfig()
}).catch(() => {})
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
@@ -1800,6 +1814,10 @@ onMounted(async () => {
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.addEventListener('storage', handleMouseSendIntervalStorage)
watch(() => configStore.hid?.mouse_absolute, () => {
syncMouseModeFromConfig()
})
// Pointer Lock event listeners
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
@@ -1881,104 +1899,161 @@ onUnmounted(() => {
<template>
<div class="h-screen flex flex-col bg-background">
<!-- Header -->
<header class="h-14 shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="h-full px-4 flex items-center justify-between">
<!-- Left: Logo -->
<div class="flex items-center gap-6">
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="px-4">
<div class="h-14 flex items-center justify-between">
<!-- Left: Logo -->
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Monitor class="h-6 w-6 text-primary" />
<span class="font-bold text-lg">One-KVM</span>
</div>
</div>
<!-- Right: Status Cards + User Menu -->
<div class="flex items-center gap-2">
<Monitor class="h-6 w-6 text-primary" />
<span class="font-bold text-lg">One-KVM</span>
<div class="hidden md:flex items-center gap-2">
<!-- Video Status -->
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
/>
<!-- Audio Status -->
<StatusCard
v-if="systemStore.audio?.available"
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
/>
<!-- HID Status -->
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
/>
<!-- MSD Status - Hidden when CH9329 backend (no USB gadget support) -->
<StatusCard
v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
/>
</div>
<!-- Separator -->
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleLanguage">
<Languages class="h-4 w-4" />
</Button>
<!-- User Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem class="md:hidden" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4 mr-2" />
<Moon v-else class="h-4 w-4 mr-2" />
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
</DropdownMenuItem>
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
<Languages class="h-4 w-4 mr-2" />
{{ locale === 'zh-CN' ? 'English' : '中文' }}
</DropdownMenuItem>
<DropdownMenuSeparator class="md:hidden" />
<DropdownMenuItem @click="changePasswordDialogOpen = true">
<KeyRound class="h-4 w-4 mr-2" />
{{ t('auth.changePassword') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('auth.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<!-- Right: Status Cards + User Menu -->
<div class="flex items-center gap-2">
<!-- Video Status -->
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
/>
<!-- Mobile Status Row -->
<div class="md:hidden pb-2">
<div class="flex items-center gap-2 overflow-x-auto">
<div class="shrink-0">
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
compact
/>
</div>
<!-- Audio Status -->
<StatusCard
v-if="systemStore.audio?.available"
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
/>
<div v-if="systemStore.audio?.available" class="shrink-0">
<StatusCard
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
compact
/>
</div>
<!-- HID Status -->
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
/>
<div class="shrink-0">
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
compact
/>
</div>
<!-- MSD Status - Admin only, hidden when CH9329 backend (no USB gadget support) -->
<StatusCard
v-if="authStore.isAdmin && systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
/>
<!-- Separator -->
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleLanguage">
<Languages class="h-4 w-4" />
</Button>
<!-- User Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem class="md:hidden" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4 mr-2" />
<Moon v-else class="h-4 w-4 mr-2" />
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
</DropdownMenuItem>
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
<Languages class="h-4 w-4 mr-2" />
{{ locale === 'zh-CN' ? 'English' : '中文' }}
</DropdownMenuItem>
<DropdownMenuSeparator class="md:hidden" />
<DropdownMenuItem @click="changePasswordDialogOpen = true">
<KeyRound class="h-4 w-4 mr-2" />
{{ t('auth.changePassword') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('auth.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'" class="shrink-0">
<StatusCard
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
compact
/>
</div>
</div>
</div>
</div>
</header>
@@ -1988,7 +2063,6 @@ onUnmounted(() => {
:mouse-mode="mouseMode"
:video-mode="videoMode"
:ttyd-running="ttydStatus?.running"
:is-admin="authStore.isAdmin"
@toggle-fullscreen="toggleFullscreen"
@toggle-stats="statsSheetOpen = true"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"

View File

@@ -2,20 +2,14 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
import {
authApi,
authConfigApi,
configApi,
streamApi,
videoConfigApi,
streamConfigApi,
hidConfigApi,
msdConfigApi,
atxConfigApi,
extensionsApi,
rustdeskConfigApi,
webConfigApi,
systemApi,
type EncoderBackendInfo,
type AuthConfig,
@@ -72,6 +66,7 @@ import {
Square,
ChevronRight,
Plus,
Trash2,
ExternalLink,
Copy,
ScreenShare,
@@ -79,6 +74,7 @@ import {
const { t, locale } = useI18n()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
// Settings state
@@ -196,11 +192,32 @@ const webServerConfig = ref<WebConfig>({
http_port: 8080,
https_port: 8443,
bind_address: '0.0.0.0',
bind_addresses: ['0.0.0.0'],
https_enabled: false,
})
const webServerLoading = ref(false)
const showRestartDialog = ref(false)
const restarting = ref(false)
type BindMode = 'all' | 'loopback' | 'custom'
const bindMode = ref<BindMode>('all')
const bindAllIpv6 = ref(false)
const bindLocalIpv6 = ref(false)
const bindAddressList = ref<string[]>([])
const bindAddressError = computed(() => {
if (bindMode.value !== 'custom') return ''
return normalizeBindAddresses(bindAddressList.value).length
? ''
: t('settings.bindAddressListEmpty')
})
const effectiveBindAddresses = computed(() => {
if (bindMode.value === 'all') {
return bindAllIpv6.value ? ['0.0.0.0', '::'] : ['0.0.0.0']
}
if (bindMode.value === 'loopback') {
return bindLocalIpv6.value ? ['127.0.0.1', '::1'] : ['127.0.0.1']
}
return normalizeBindAddresses(bindAddressList.value)
})
// Config
interface DeviceConfig {
@@ -220,12 +237,14 @@ interface DeviceConfig {
}>
serial: Array<{ path: string; name: string }>
audio: Array<{ name: string; description: string }>
udc: Array<{ name: string }>
}
const devices = ref<DeviceConfig>({
video: [],
serial: [],
audio: [],
udc: [],
})
const config = ref({
@@ -237,6 +256,7 @@ const config = ref({
hid_backend: 'ch9329',
hid_serial_device: '',
hid_serial_baudrate: 9600,
hid_otg_udc: '',
hid_otg_profile: 'full' as OtgHidProfile,
hid_otg_functions: {
keyboard: true,
@@ -246,7 +266,6 @@ const config = ref({
} as OtgHidFunctions,
msd_enabled: false,
msd_dir: '',
network_port: 8080,
encoder_backend: 'auto',
// STUN/TURN settings
stun_server: '',
@@ -257,6 +276,39 @@ const config = ref({
// 跟踪服务器是否已配置 TURN 密码
const hasTurnPassword = ref(false)
const configLoaded = ref(false)
const devicesLoaded = ref(false)
const hidProfileAligned = ref(false)
const isLowEndpointUdc = computed(() => {
if (config.value.hid_otg_udc) {
return /musb/i.test(config.value.hid_otg_udc)
}
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
})
const showLowEndpointHint = computed(() =>
config.value.hid_backend === 'otg' && isLowEndpointUdc.value
)
function alignHidProfileForLowEndpoint() {
if (hidProfileAligned.value) return
if (!configLoaded.value || !devicesLoaded.value) return
if (config.value.hid_backend !== 'otg') {
hidProfileAligned.value = true
return
}
if (!isLowEndpointUdc.value) {
hidProfileAligned.value = true
return
}
if (config.value.hid_otg_profile === 'full') {
config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile
} else if (config.value.hid_otg_profile === 'full_no_msd') {
config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile
}
hidProfileAligned.value = true
}
const isHidFunctionSelectionValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true
@@ -284,6 +336,12 @@ watch(() => config.value.msd_enabled, (enabled) => {
}
})
watch(bindMode, (mode) => {
if (mode === 'custom' && bindAddressList.value.length === 0) {
bindAddressList.value = ['']
}
})
// ATX config state
const atxConfig = ref({
enabled: false,
@@ -519,7 +577,7 @@ async function saveConfig() {
// Video 配置(包括编码器和 WebRTC/STUN/TURN 设置)
if (activeSection.value === 'video') {
savePromises.push(
videoConfigApi.update({
configStore.updateVideo({
device: config.value.video_device || undefined,
format: config.value.video_format || undefined,
width: config.value.video_width,
@@ -529,7 +587,7 @@ async function saveConfig() {
)
// 同时保存 Stream/Encoder 和 STUN/TURN 配置
savePromises.push(
streamConfigApi.update({
configStore.updateStream({
encoder: config.value.encoder_backend as any,
stun_server: config.value.stun_server || undefined,
turn_server: config.value.turn_server || undefined,
@@ -548,6 +606,12 @@ async function saveConfig() {
if (config.value.hid_backend === 'otg') {
if (config.value.hid_otg_profile === 'full') {
desiredMsdEnabled = true
} else if (config.value.hid_otg_profile === 'full_no_msd') {
desiredMsdEnabled = false
} else if (config.value.hid_otg_profile === 'full_no_consumer') {
desiredMsdEnabled = true
} else if (config.value.hid_otg_profile === 'full_no_consumer_no_msd') {
desiredMsdEnabled = false
} else if (
config.value.hid_otg_profile === 'legacy_keyboard'
|| config.value.hid_otg_profile === 'legacy_mouse_relative'
@@ -572,12 +636,12 @@ async function saveConfig() {
hidUpdate.otg_profile = config.value.hid_otg_profile
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
}
savePromises.push(hidConfigApi.update(hidUpdate))
savePromises.push(configStore.updateHid(hidUpdate))
if (config.value.msd_enabled !== desiredMsdEnabled) {
config.value.msd_enabled = desiredMsdEnabled
}
savePromises.push(
msdConfigApi.update({
configStore.updateMsd({
enabled: desiredMsdEnabled,
})
)
@@ -586,7 +650,7 @@ async function saveConfig() {
// MSD 配置
if (activeSection.value === 'msd') {
savePromises.push(
msdConfigApi.update({
configStore.updateMsd({
msd_dir: config.value.msd_dir || undefined,
})
)
@@ -607,10 +671,10 @@ async function loadConfig() {
try {
// 并行加载所有域配置
const [video, stream, hid, msd] = await Promise.all([
videoConfigApi.get(),
streamConfigApi.get(),
hidConfigApi.get(),
msdConfigApi.get(),
configStore.refreshVideo(),
configStore.refreshStream(),
configStore.refreshHid(),
configStore.refreshMsd(),
])
config.value = {
@@ -622,6 +686,7 @@ async function loadConfig() {
hid_backend: hid.backend || 'none',
hid_serial_device: hid.ch9329_port || '',
hid_serial_baudrate: hid.ch9329_baudrate || 9600,
hid_otg_udc: hid.otg_udc || '',
hid_otg_profile: (hid.otg_profile || 'full') as OtgHidProfile,
hid_otg_functions: {
keyboard: hid.otg_functions?.keyboard ?? true,
@@ -631,7 +696,6 @@ async function loadConfig() {
} as OtgHidFunctions,
msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '',
network_port: 8080, // 从旧 API 加载
encoder_backend: stream.encoder || 'auto',
// STUN/TURN settings
stun_server: stream.stun_server || '',
@@ -652,16 +716,11 @@ async function loadConfig() {
otgSerialNumber.value = hid.otg_descriptor.serial_number || ''
}
// 加载 web config仍使用旧 API
try {
const fullConfig = await configApi.get()
const web = fullConfig.web as any || {}
config.value.network_port = web.http_port || 8080
} catch (e) {
console.warn('Failed to load web config:', e)
}
} catch (e) {
console.error('Failed to load config:', e)
} finally {
configLoaded.value = true
alignHidProfileForLowEndpoint()
}
}
@@ -670,6 +729,9 @@ async function loadDevices() {
devices.value = await configApi.listDevices()
} catch (e) {
console.error('Failed to load devices:', e)
} finally {
devicesLoaded.value = true
alignHidProfileForLowEndpoint()
}
}
@@ -686,7 +748,7 @@ async function loadBackends() {
async function loadAuthConfig() {
authConfigLoading.value = true
try {
authConfig.value = await authConfigApi.get()
authConfig.value = await configStore.refreshAuth()
} catch (e) {
console.error('Failed to load auth config:', e)
} finally {
@@ -697,10 +759,9 @@ async function loadAuthConfig() {
async function saveAuthConfig() {
authConfigLoading.value = true
try {
await authConfigApi.update({
authConfig.value = await configStore.updateAuth({
single_user_allow_multiple_sessions: authConfig.value.single_user_allow_multiple_sessions,
})
await loadAuthConfig()
} catch (e) {
console.error('Failed to save auth config:', e)
} finally {
@@ -835,7 +896,7 @@ function removeEasytierPeer(index: number) {
// ATX management functions
async function loadAtxConfig() {
try {
const config = await atxConfigApi.get()
const config = await configStore.refreshAtx()
atxConfig.value = {
enabled: config.enabled,
power: { ...config.power },
@@ -860,7 +921,7 @@ async function saveAtxConfig() {
loading.value = true
saved.value = false
try {
await atxConfigApi.update({
await configStore.updateAtx({
enabled: atxConfig.value.enabled,
power: {
driver: atxConfig.value.power.driver,
@@ -904,10 +965,8 @@ function getAtxDevicesForDriver(driver: string): string[] {
async function loadRustdeskConfig() {
rustdeskLoading.value = true
try {
const [config, status] = await Promise.all([
rustdeskConfigApi.get(),
rustdeskConfigApi.getStatus(),
])
const status = await configStore.refreshRustdeskStatus()
const config = status.config
rustdeskConfig.value = config
rustdeskStatus.value = status
rustdeskLocalConfig.value = {
@@ -925,26 +984,85 @@ async function loadRustdeskConfig() {
async function loadRustdeskPassword() {
try {
rustdeskPassword.value = await rustdeskConfigApi.getPassword()
rustdeskPassword.value = await configStore.refreshRustdeskPassword()
} catch (e) {
console.error('Failed to load RustDesk password:', e)
}
}
function normalizeRustdeskServer(value: string, defaultPort: number): string | undefined {
const trimmed = value.trim()
if (!trimmed) return undefined
if (trimmed.includes(':')) return trimmed
return `${trimmed}:${defaultPort}`
}
function normalizeBindAddresses(addresses: string[]): string[] {
return addresses.map(addr => addr.trim()).filter(Boolean)
}
function applyBindStateFromConfig(config: WebConfig) {
const rawAddrs =
config.bind_addresses && config.bind_addresses.length > 0
? config.bind_addresses
: config.bind_address
? [config.bind_address]
: []
const addrs = normalizeBindAddresses(rawAddrs)
const isAll = addrs.length > 0 && addrs.every(addr => addr === '0.0.0.0' || addr === '::') && addrs.includes('0.0.0.0')
const isLoopback =
addrs.length > 0 &&
addrs.every(addr => addr === '127.0.0.1' || addr === '::1') &&
addrs.includes('127.0.0.1')
if (isAll) {
bindMode.value = 'all'
bindAllIpv6.value = addrs.includes('::')
return
}
if (isLoopback) {
bindMode.value = 'loopback'
bindLocalIpv6.value = addrs.includes('::1')
return
}
bindMode.value = 'custom'
bindAddressList.value = addrs.length ? [...addrs] : ['']
}
function addBindAddress() {
bindAddressList.value.push('')
}
function removeBindAddress(index: number) {
bindAddressList.value.splice(index, 1)
if (bindAddressList.value.length === 0) {
bindAddressList.value.push('')
}
}
// Web server config functions
async function loadWebServerConfig() {
try {
const config = await webConfigApi.get()
const config = await configStore.refreshWeb()
webServerConfig.value = config
applyBindStateFromConfig(config)
} catch (e) {
console.error('Failed to load web server config:', e)
}
}
async function saveWebServerConfig() {
if (bindAddressError.value) return
webServerLoading.value = true
try {
await webConfigApi.update(webServerConfig.value)
const update = {
http_port: webServerConfig.value.http_port,
https_port: webServerConfig.value.https_port,
https_enabled: webServerConfig.value.https_enabled,
bind_addresses: effectiveBindAddresses.value,
}
const updated = await configStore.updateWeb(update)
webServerConfig.value = updated
applyBindStateFromConfig(updated)
showRestartDialog.value = true
} catch (e) {
console.error('Failed to save web server config:', e)
@@ -976,10 +1094,15 @@ async function saveRustdeskConfig() {
loading.value = true
saved.value = false
try {
await rustdeskConfigApi.update({
const rendezvousServer = normalizeRustdeskServer(
rustdeskLocalConfig.value.rendezvous_server,
21116,
)
const relayServer = normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117)
await configStore.updateRustdesk({
enabled: rustdeskLocalConfig.value.enabled,
rendezvous_server: rustdeskLocalConfig.value.rendezvous_server || undefined,
relay_server: rustdeskLocalConfig.value.relay_server || undefined,
rendezvous_server: rendezvousServer,
relay_server: relayServer,
relay_key: rustdeskLocalConfig.value.relay_key || undefined,
})
await loadRustdeskConfig()
@@ -998,7 +1121,7 @@ async function regenerateRustdeskId() {
if (!confirm(t('extensions.rustdesk.confirmRegenerateId'))) return
rustdeskLoading.value = true
try {
await rustdeskConfigApi.regenerateId()
await configStore.regenerateRustdeskId()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch (e) {
@@ -1012,7 +1135,7 @@ async function regenerateRustdeskPassword() {
if (!confirm(t('extensions.rustdesk.confirmRegeneratePassword'))) return
rustdeskLoading.value = true
try {
await rustdeskConfigApi.regeneratePassword()
await configStore.regenerateRustdeskPassword()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch (e) {
@@ -1026,7 +1149,7 @@ async function startRustdesk() {
rustdeskLoading.value = true
try {
// Enable and save config to start the service
await rustdeskConfigApi.update({ enabled: true })
await configStore.updateRustdesk({ enabled: true })
rustdeskLocalConfig.value.enabled = true
await loadRustdeskConfig()
} catch (e) {
@@ -1040,7 +1163,7 @@ async function stopRustdesk() {
rustdeskLoading.value = true
try {
// Disable and save config to stop the service
await rustdeskConfigApi.update({ enabled: false })
await configStore.updateRustdesk({ enabled: false })
rustdeskLocalConfig.value.enabled = false
await loadRustdeskConfig()
} catch (e) {
@@ -1132,13 +1255,11 @@ onMounted(async () => {
<AppLayout>
<div class="flex h-full overflow-hidden">
<!-- Mobile Header -->
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center justify-between px-4 py-3 border-b bg-background">
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center px-4 py-3 border-b bg-background">
<Sheet v-model:open="mobileMenuOpen">
<SheetTrigger as-child>
<Button variant="outline" size="sm">
<Menu class="h-4 w-4 mr-2" />
{{ t('common.menu') }}
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
<Menu class="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="w-72 p-0">
@@ -1167,6 +1288,7 @@ onMounted(async () => {
</div>
</SheetContent>
</Sheet>
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
</div>
<!-- Desktop Sidebar -->
@@ -1321,7 +1443,7 @@ onMounted(async () => {
<option v-for="fmt in availableFormats" :key="fmt.format" :value="fmt.format">{{ fmt.format }} - {{ fmt.description }}</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="video-resolution">{{ t('settings.resolution') }}</Label>
<select id="video-resolution" :value="`${config.video_width}x${config.video_height}`" @change="e => { const parts = (e.target as HTMLSelectElement).value.split('x').map(Number); if (parts[0] && parts[1]) { config.video_width = parts[0]; config.video_height = parts[1]; } }" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_format">
@@ -1389,7 +1511,7 @@ onMounted(async () => {
/>
<p class="text-xs text-muted-foreground">{{ t('settings.turnServerHint') }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="turn-username">{{ t('settings.turnUsername') }}</Label>
<Input
@@ -1477,6 +1599,9 @@ onMounted(async () => {
<Label for="otg-profile">{{ t('settings.profile') }}</Label>
<select id="otg-profile" v-model="config.hid_otg_profile" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="full">{{ t('settings.otgProfileFull') }}</option>
<option value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</option>
<option value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</option>
<option value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</option>
<option value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</option>
<option value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</option>
<option value="custom">{{ t('settings.otgProfileCustom') }}</option>
@@ -1526,6 +1651,9 @@ onMounted(async () => {
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
<p v-if="showLowEndpointHint" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgLowEndpointHint') }}
</p>
</div>
<Separator class="my-4" />
<div class="space-y-4">
@@ -1621,13 +1749,51 @@ onMounted(async () => {
</div>
<div class="space-y-2">
<Label>{{ t('settings.bindAddress') }}</Label>
<Input v-model="webServerConfig.bind_address" placeholder="0.0.0.0" />
<p class="text-sm text-muted-foreground">{{ t('settings.bindAddressDesc') }}</p>
<Label>{{ t('settings.bindMode') }}</Label>
<select v-model="bindMode" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="all">{{ t('settings.bindModeAll') }}</option>
<option value="loopback">{{ t('settings.bindModeLocal') }}</option>
<option value="custom">{{ t('settings.bindModeCustom') }}</option>
</select>
<p class="text-sm text-muted-foreground">{{ t('settings.bindModeDesc') }}</p>
</div>
<div v-if="bindMode === 'all'" class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.bindIpv6') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.bindAllDesc') }}</p>
</div>
<Switch v-model="bindAllIpv6" />
</div>
<div v-if="bindMode === 'loopback'" class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.bindIpv6') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.bindLocalDesc') }}</p>
</div>
<Switch v-model="bindLocalIpv6" />
</div>
<div v-if="bindMode === 'custom'" class="space-y-2">
<Label>{{ t('settings.bindAddressList') }}</Label>
<div class="space-y-2">
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
<Button variant="ghost" size="icon" @click="removeBindAddress(i)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" @click="addBindAddress">
<Plus class="h-4 w-4 mr-1" />
{{ t('settings.addBindAddress') }}
</Button>
</div>
<p class="text-xs text-muted-foreground">{{ t('settings.bindAddressListDesc') }}</p>
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
</div>
<div class="flex justify-end pt-4">
<Button @click="saveWebServerConfig" :disabled="webServerLoading">
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError">
<Save class="h-4 w-4 mr-2" />
{{ t('common.save') }}
</Button>
@@ -1732,7 +1898,7 @@ onMounted(async () => {
<CardDescription>{{ t('settings.atxPowerButtonDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="power-driver">{{ t('settings.atxDriver') }}</Label>
<select id="power-driver" v-model="atxConfig.power.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
@@ -1749,7 +1915,7 @@ onMounted(async () => {
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="power-pin">{{ atxConfig.power.driver === 'usbrelay' ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
<Input id="power-pin" type="number" v-model.number="atxConfig.power.pin" min="0" :disabled="atxConfig.power.driver === 'none'" />
@@ -1772,7 +1938,7 @@ onMounted(async () => {
<CardDescription>{{ t('settings.atxResetButtonDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="reset-driver">{{ t('settings.atxDriver') }}</Label>
<select id="reset-driver" v-model="atxConfig.reset.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
@@ -1789,7 +1955,7 @@ onMounted(async () => {
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="reset-pin">{{ atxConfig.reset.driver === 'usbrelay' ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
<Input id="reset-pin" type="number" v-model.number="atxConfig.reset.pin" min="0" :disabled="atxConfig.reset.driver === 'none'" />
@@ -1824,7 +1990,7 @@ onMounted(async () => {
</div>
<template v-if="atxConfig.led.enabled">
<Separator />
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="led-chip">{{ t('settings.atxLedChip') }}</Label>
<select id="led-chip" v-model="atxConfig.led.gpio_chip" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
@@ -1941,13 +2107,13 @@ onMounted(async () => {
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="extConfig.ttyd.enabled" :disabled="isExtRunning(extensions?.ttyd?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.ttyd.shell') }}</Label>
<Input v-model="extConfig.ttyd.shell" class="col-span-3" placeholder="/bin/bash" :disabled="isExtRunning(extensions?.ttyd?.status)" />
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.ttyd.shell') }}</Label>
<Input v-model="extConfig.ttyd.shell" class="sm:col-span-3" placeholder="/bin/bash" :disabled="isExtRunning(extensions?.ttyd?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.ttyd.credential') }}</Label>
<Input v-model="extConfig.ttyd.credential" class="col-span-3" placeholder="user:password" :disabled="isExtRunning(extensions?.ttyd?.status)" />
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.ttyd.credential') }}</Label>
<Input v-model="extConfig.ttyd.credential" class="sm:col-span-3" placeholder="user:password" :disabled="isExtRunning(extensions?.ttyd?.status)" />
</div>
</div>
<!-- Logs -->
@@ -2029,17 +2195,17 @@ onMounted(async () => {
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="extConfig.gostc.enabled" :disabled="isExtRunning(extensions?.gostc?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.gostc.addr') }}</Label>
<Input v-model="extConfig.gostc.addr" class="col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.gostc.key') }}</Label>
<Input v-model="extConfig.gostc.key" type="password" class="col-span-3" :disabled="isExtRunning(extensions?.gostc?.status)" />
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
<Input v-model="extConfig.gostc.key" type="password" class="sm:col-span-3" :disabled="isExtRunning(extensions?.gostc?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.gostc.tls') }}</Label>
<div class="col-span-3">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.tls') }}</Label>
<div class="sm:col-span-3">
<Switch v-model="extConfig.gostc.tls" :disabled="isExtRunning(extensions?.gostc?.status)" />
</div>
</div>
@@ -2120,17 +2286,17 @@ onMounted(async () => {
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="extConfig.easytier.enabled" :disabled="isExtRunning(extensions?.easytier?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.easytier.networkName') }}</Label>
<Input v-model="extConfig.easytier.network_name" class="col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label>
<Input v-model="extConfig.easytier.network_name" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
<Input v-model="extConfig.easytier.network_secret" type="password" class="col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
<Input v-model="extConfig.easytier.network_secret" type="password" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.easytier.peers') }}</Label>
<div class="col-span-3 space-y-2">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.easytier.peers') }}</Label>
<div class="sm:col-span-3 space-y-2">
<div v-for="(_, i) in extConfig.easytier.peer_urls" :key="i" class="flex gap-2">
<Input v-model="extConfig.easytier.peer_urls[i]" placeholder="tcp://1.2.3.4:11010" :disabled="isExtRunning(extensions?.easytier?.status)" />
<Button variant="ghost" size="icon" @click="removeEasytierPeer(i)" :disabled="isExtRunning(extensions?.easytier?.status)">
@@ -2143,9 +2309,9 @@ onMounted(async () => {
</Button>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.easytier.virtualIp') }}</Label>
<div class="col-span-3 space-y-1">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.easytier.virtualIp') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input v-model="extConfig.easytier.virtual_ip" placeholder="10.0.0.1/24" :disabled="isExtRunning(extensions?.easytier?.status)" />
<p class="text-xs text-muted-foreground">{{ t('extensions.easytier.virtualIpHint') }}</p>
</div>
@@ -2237,9 +2403,9 @@ onMounted(async () => {
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="rustdeskLocalConfig.enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
<div class="col-span-3 space-y-1">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input
v-model="rustdeskLocalConfig.rendezvous_server"
:placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')"
@@ -2247,9 +2413,9 @@ onMounted(async () => {
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.rendezvousServerHint') }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.relayServer') }}</Label>
<div class="col-span-3 space-y-1">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.relayServer') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input
v-model="rustdeskLocalConfig.relay_server"
:placeholder="t('extensions.rustdesk.relayServerPlaceholder')"
@@ -2257,9 +2423,9 @@ onMounted(async () => {
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayServerHint') }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.relayKey') }}</Label>
<div class="col-span-3 space-y-1">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.relayKey') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input
v-model="rustdeskLocalConfig.relay_key"
type="password"
@@ -2276,9 +2442,9 @@ onMounted(async () => {
<h4 class="text-sm font-medium">{{ t('extensions.rustdesk.deviceInfo') }}</h4>
<!-- Device ID -->
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.deviceId') }}</Label>
<div class="col-span-3 flex items-center gap-2">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.deviceId') }}</Label>
<div class="sm:col-span-3 flex items-center gap-2">
<code class="font-mono text-lg bg-muted px-3 py-1 rounded">{{ rustdeskConfig?.device_id || '-' }}</code>
<Button
variant="ghost"
@@ -2298,9 +2464,9 @@ onMounted(async () => {
</div>
<!-- Device Password (直接显示) -->
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.devicePassword') }}</Label>
<div class="col-span-3 flex items-center gap-2">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.devicePassword') }}</Label>
<div class="sm:col-span-3 flex items-center gap-2">
<code class="font-mono text-lg bg-muted px-3 py-1 rounded">{{ rustdeskPassword?.device_password || '-' }}</code>
<Button
variant="ghost"
@@ -2320,9 +2486,9 @@ onMounted(async () => {
</div>
<!-- Keypair Status -->
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.keypairGenerated') }}</Label>
<div class="col-span-3">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.keypairGenerated') }}</Label>
<div class="sm:col-span-3">
<Badge :variant="rustdeskConfig?.has_keypair ? 'default' : 'secondary'">
{{ rustdeskConfig?.has_keypair ? t('common.yes') : t('common.no') }}
</Badge>

View File

@@ -96,6 +96,9 @@ const hidBackend = ref('ch9329')
const ch9329Port = ref('')
const ch9329Baudrate = ref(9600)
const otgUdc = ref('')
const hidOtgProfile = ref('full')
const otgProfileTouched = ref(false)
const showAdvancedOtg = ref(false)
// Extension settings
const ttydEnabled = ref(false)
@@ -200,6 +203,26 @@ const availableFps = computed(() => {
return resolution?.fps || []
})
const isLowEndpointUdc = computed(() => {
if (otgUdc.value) {
return /musb/i.test(otgUdc.value)
}
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
})
function applyOtgProfileDefault() {
if (otgProfileTouched.value) return
if (hidBackend.value !== 'otg') return
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
if (hidOtgProfile.value === preferred) return
hidOtgProfile.value = preferred
}
function onOtgProfileChange(value: unknown) {
hidOtgProfile.value = typeof value === 'string' ? value : 'full'
otgProfileTouched.value = true
}
// Common baud rates for CH9329
const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -315,6 +338,17 @@ watch(hidBackend, (newBackend) => {
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || ''
}
applyOtgProfileDefault()
})
watch(otgUdc, () => {
applyOtgProfileDefault()
})
watch(showAdvancedOtg, (open) => {
if (open) {
applyOtgProfileDefault()
}
})
onMounted(async () => {
@@ -336,6 +370,7 @@ onMounted(async () => {
if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name
}
applyOtgProfileDefault()
// Auto-select audio device if available (and no video device to trigger watch)
if (result.audio.length > 0 && !audioDevice.value) {
@@ -487,6 +522,7 @@ async function handleSetup() {
}
if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value
}
// Encoder backend setting
@@ -520,7 +556,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<div class="min-h-screen flex items-start sm:items-center justify-center bg-background px-4 py-6 sm:py-10">
<Card class="w-full max-w-lg relative">
<!-- Language Switcher -->
<div class="absolute top-4 right-4">
@@ -547,28 +583,28 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</DropdownMenu>
</div>
<CardHeader class="text-center space-y-2 pt-12">
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
<div
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10"
>
<Monitor class="w-8 h-8 text-primary" />
</div>
<CardTitle class="text-2xl">{{ t('setup.welcome') }}</CardTitle>
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
<CardDescription>{{ t('setup.description') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<CardContent class="space-y-5 sm:space-y-6">
<!-- Progress Text -->
<p class="text-sm text-muted-foreground text-center">
{{ t('setup.progress', { current: step, total: totalSteps }) }}
</p>
<!-- Step Indicator with Labels -->
<div class="flex items-center justify-center gap-2 mb-6">
<div class="flex items-center justify-center gap-1.5 sm:gap-2 mb-5 sm:mb-6">
<template v-for="i in totalSteps" :key="i">
<div class="flex flex-col items-center gap-1">
<div
class="flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-300"
class="flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-full border-2 transition-all duration-300"
:class="
step > i
? 'bg-primary border-primary text-primary-foreground scale-100'
@@ -577,11 +613,11 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
: 'border-muted text-muted-foreground scale-100'
"
>
<Check v-if="step > i" class="w-5 h-5" />
<component :is="stepIcons[i - 1]" v-else class="w-5 h-5" />
<Check v-if="step > i" class="w-4 h-4 sm:w-5 sm:h-5" />
<component :is="stepIcons[i - 1]" v-else class="w-4 h-4 sm:w-5 sm:h-5" />
</div>
<span
class="text-xs transition-colors duration-300 max-w-16 text-center leading-tight"
class="text-[10px] sm:text-xs transition-colors duration-300 max-w-14 sm:max-w-16 text-center leading-tight"
:class="step >= i ? 'text-foreground font-medium' : 'text-muted-foreground'"
>
{{ stepLabels[i - 1] }}
@@ -589,7 +625,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</div>
<div
v-if="i < totalSteps"
class="w-8 h-0.5 transition-colors duration-300 mb-6"
class="w-5 sm:w-8 h-0.5 transition-colors duration-300 mb-5 sm:mb-6"
:class="step > i ? 'bg-primary' : 'bg-muted'"
/>
</template>
@@ -924,6 +960,46 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
{{ t('setup.noUdcDevices') }}
</p>
</div>
<div class="mt-2 border rounded-lg">
<button
type="button"
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
@click="showAdvancedOtg = !showAdvancedOtg"
>
<span class="text-sm font-medium">
{{ t('setup.otgAdvanced') }} ({{ t('common.optional') }})
</span>
<ChevronRight
class="h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-90': showAdvancedOtg }"
/>
</button>
<div v-if="showAdvancedOtg" class="px-3 pb-3 space-y-3">
<p class="text-xs text-muted-foreground">
{{ t('setup.otgProfileDesc') }}
</p>
<div class="space-y-2">
<Label for="otgProfile">{{ t('setup.otgProfile') }}</Label>
<Select :model-value="hidOtgProfile" @update:modelValue="onOtgProfileChange">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
<SelectItem value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</SelectItem>
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
<SelectItem value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
</SelectContent>
</Select>
</div>
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('setup.otgLowEndpointHint') }}
</p>
</div>
</div>
</div>
</div>