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 (compile-time configuration)
secrets.toml secrets.toml
.env

View File

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

101
README.md
View File

@@ -1,5 +1,4 @@
<div align="center"> <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> <h1>One-KVM</h1>
<p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p> <p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p>
@@ -19,16 +18,6 @@
--- ---
## 📋 目录
- [项目概述](#项目概述)
- [迁移说明](#迁移说明)
- [功能介绍](#功能介绍)
- [快速开始](#快速开始)
- [贡献与反馈](#贡献与反馈)
- [致谢](#致谢)
- [许可证](#许可证)
## 📖 项目概述 ## 📖 项目概述
**One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。 **One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。
@@ -74,85 +63,14 @@
- Web UI 配置,多语言支持(中文/英文) - Web UI 配置,多语言支持(中文/英文)
- 内置 Web 终端ttyd内网穿透支持gostc、P2P 组网支持EasyTier、RustDesk 协议集成(用于跨平台远程访问能力扩展) - 内置 Web 终端ttyd内网穿透支持gostc、P2P 组网支持EasyTier、RustDesk 协议集成(用于跨平台远程访问能力扩展)
## ⚡ 快速开始 ## ⚡ 安装使用
安装方式Docker / DEB 软件包 / 飞牛 NASFPK 可以访问 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/) 获取详细信息
### 方式一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`
## 报告问题 ## 报告问题
如果您发现了问题,请: 如果您发现了问题,请:
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告 1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告,或加入 QQ 群聊反馈。
2. 提供详细的错误信息和复现步骤 2. 提供详细的错误信息和复现步骤
3. 包含您的硬件配置和系统信息 3. 包含您的硬件配置和系统信息
@@ -269,6 +187,14 @@ sudo apt install ./one-kvm_*_*.deb
- -
- MaxZ
- 爱发电用户_c5f33
- 爱发电用户_09386
- 爱发电用户_JT6c
- ...... - ......
</details> </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)** - 提供免登录下载服务 - **[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 --chmod=755 init.sh /init.sh
# Copy binaries (these are placed by the build script) # Copy binaries (these are placed by the build script)
COPY --chmod=755 one-kvm ttyd gostc easytier-core /usr/bin/ COPY --chmod=755 one-kvm ttyd /usr/bin/
# Copy ventoy resources if they exist # Copy ventoy resources if they exist
COPY ventoy/ /etc/one-kvm/ventoy/ COPY ventoy/ /etc/one-kvm/ventoy/

View File

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

View File

@@ -419,10 +419,10 @@ mod ffmpeg {
builder.include(rkrga_dir.join("im2d_api")); 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 { } else {
println!( 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 target_arch
); );
} }

View File

@@ -1,12 +1,16 @@
#include "linux.h" #include "linux.h"
#include "../../log.h" #include "../../log.h"
#include <algorithm>
#include <cctype>
#include <cstring> #include <cstring>
#include <dlfcn.h> #include <dlfcn.h>
#include <errno.h> #include <errno.h>
#include <fstream>
#include <signal.h> #include <signal.h>
#include <sys/prctl.h> #include <sys/prctl.h>
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
#include <string>
// Check for NVIDIA driver support by loading CUDA libraries // Check for NVIDIA driver support by loading CUDA libraries
int linux_support_nv() int linux_support_nv()
@@ -106,6 +110,57 @@ int linux_support_rkmpp() {
// Check for V4L2 Memory-to-Memory (M2M) codec support // Check for V4L2 Memory-to-Memory (M2M) codec support
// Returns 0 if a M2M capable device is found, -1 otherwise // Returns 0 if a M2M capable device is found, -1 otherwise
int linux_support_v4l2m2m() { 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 // Check common V4L2 M2M device paths used by various ARM SoCs
const char *m2m_devices[] = { const char *m2m_devices[] = {
"/dev/video10", // Common M2M encoder device "/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++) { for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {
if (access(m2m_devices[i], F_OK) == 0) { 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 // Device exists, check if it's an M2M device by trying to open it
int fd = open(m2m_devices[i], O_RDWR | O_NONBLOCK); int fd = open(m2m_devices[i], O_RDWR | O_NONBLOCK);
if (fd >= 0) { if (fd >= 0) {

View File

@@ -6,9 +6,11 @@
extern "C" { extern "C" {
#endif #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, const char* enc_name,
int width, int width,
int height, int height,
@@ -17,7 +19,8 @@ FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
int gop, int gop,
int thread_count); 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, const uint8_t* data,
int len, int len,
int64_t pts_ms, int64_t pts_ms,
@@ -25,16 +28,21 @@ int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* ctx,
int* out_len, int* out_len,
int* out_keyframe); 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 bitrate_kbps,
int gop); 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); void ffmpeg_hw_packet_free(uint8_t* data);
// Get last error message (thread-local).
const char* ffmpeg_hw_last_error(void); const char* ffmpeg_hw_last_error(void);
#ifdef __cplusplus #ifdef __cplusplus

View File

@@ -35,7 +35,7 @@ static const char* pix_fmt_name(AVPixelFormat fmt) {
return name ? name : "unknown"; return name ? name : "unknown";
} }
struct FfmpegHwMjpegH264Ctx { struct FfmpegHwMjpegH26xCtx {
AVCodecContext *dec_ctx = nullptr; AVCodecContext *dec_ctx = nullptr;
AVCodecContext *enc_ctx = nullptr; AVCodecContext *enc_ctx = nullptr;
AVPacket *dec_pkt = nullptr; AVPacket *dec_pkt = nullptr;
@@ -48,6 +48,8 @@ struct FfmpegHwMjpegH264Ctx {
std::string enc_name; std::string enc_name;
int width = 0; int width = 0;
int height = 0; int height = 0;
int aligned_width = 0;
int aligned_height = 0;
int fps = 30; int fps = 30;
int bitrate_kbps = 2000; int bitrate_kbps = 2000;
int gop = 60; int gop = 60;
@@ -57,7 +59,7 @@ struct FfmpegHwMjpegH264Ctx {
static enum AVPixelFormat get_hw_format(AVCodecContext *ctx, static enum AVPixelFormat get_hw_format(AVCodecContext *ctx,
const enum AVPixelFormat *pix_fmts) { 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) { if (self && self->hw_pixfmt != AV_PIX_FMT_NONE) {
const enum AVPixelFormat *p; const enum AVPixelFormat *p;
for (p = pix_fmts; *p != AV_PIX_FMT_NONE; 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]; 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()); const AVCodec *dec = avcodec_find_decoder_by_name(ctx->dec_name.c_str());
if (!dec) { if (!dec) {
set_last_error("Decoder not found: " + ctx->dec_name); set_last_error("Decoder not found: " + ctx->dec_name);
@@ -127,7 +129,7 @@ static int init_decoder(FfmpegHwMjpegH264Ctx *ctx) {
return 0; 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()); const AVCodec *enc = avcodec_find_encoder_by_name(ctx->enc_name.c_str());
if (!enc) { if (!enc) {
set_last_error("Encoder not found: " + ctx->enc_name); 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->width = ctx->width;
ctx->enc_ctx->height = ctx->height; 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->time_base = AVRational{1, 1000};
ctx->enc_ctx->framerate = AVRational{ctx->fps, 1}; ctx->enc_ctx->framerate = AVRational{ctx->fps, 1};
ctx->enc_ctx->bit_rate = (int64_t)ctx->bitrate_kbps * 1000; 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) { if (hwfc) {
ctx->enc_ctx->pix_fmt = static_cast<AVPixelFormat>(hwfc->format); ctx->enc_ctx->pix_fmt = static_cast<AVPixelFormat>(hwfc->format);
ctx->enc_ctx->sw_pix_fmt = static_cast<AVPixelFormat>(hwfc->sw_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->width > 0) {
if (hwfc->height > 0) ctx->enc_ctx->height = hwfc->height; 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->hw_frames_ctx = av_buffer_ref(frames_ctx);
ctx->enc_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; AVDictionary *opts = nullptr;
av_dict_set(&opts, "rc_mode", "CBR", 0); 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_init", 23, 0);
av_dict_set_int(&opts, "qp_max", 48, 0); av_dict_set_int(&opts, "qp_max", 48, 0);
av_dict_set_int(&opts, "qp_min", 0, 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; return 0;
} }
static void free_encoder(FfmpegHwMjpegH264Ctx *ctx) { static void free_encoder(FfmpegHwMjpegH26xCtx *ctx) {
if (ctx->enc_ctx) { if (ctx->enc_ctx) {
avcodec_free_context(&ctx->enc_ctx); avcodec_free_context(&ctx->enc_ctx);
ctx->enc_ctx = nullptr; ctx->enc_ctx = nullptr;
@@ -208,7 +224,7 @@ static void free_encoder(FfmpegHwMjpegH264Ctx *ctx) {
} // namespace } // 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, const char* enc_name,
int width, int width,
int height, int height,
@@ -217,11 +233,11 @@ extern "C" FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
int gop, int gop,
int thread_count) { int thread_count) {
if (!dec_name || !enc_name || width <= 0 || height <= 0) { 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; return nullptr;
} }
auto *ctx = new FfmpegHwMjpegH264Ctx(); auto *ctx = new FfmpegHwMjpegH26xCtx();
ctx->dec_name = dec_name; ctx->dec_name = dec_name;
ctx->enc_name = enc_name; ctx->enc_name = enc_name;
ctx->width = width; 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; ctx->thread_count = thread_count > 0 ? thread_count : 1;
if (init_decoder(ctx) != 0) { 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 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, const uint8_t* data,
int len, int len,
int64_t pts_ms, int64_t pts_ms,
@@ -251,7 +267,7 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
return -1; return -1;
} }
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle); auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
*out_data = nullptr; *out_data = nullptr;
*out_len = 0; *out_len = 0;
*out_keyframe = 0; *out_keyframe = 0;
@@ -310,6 +326,14 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
ctx->force_keyframe = false; 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 send_frame->pts = pts_ms; // time_base is ms
ret = avcodec_send_frame(ctx->enc_ctx, send_frame); 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 bitrate_kbps,
int gop) { int gop) {
if (!handle) { if (!handle) {
set_last_error("Invalid handle for reconfigure"); set_last_error("Invalid handle for reconfigure");
return -1; return -1;
} }
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle); auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
if (!ctx->enc_ctx || !ctx->hw_frames_ctx) { if (!ctx->enc_ctx || !ctx->hw_frames_ctx) {
set_last_error("Encoder not initialized for reconfigure"); set_last_error("Encoder not initialized for reconfigure");
return -1; return -1;
@@ -407,18 +431,18 @@ extern "C" int ffmpeg_hw_mjpeg_h264_reconfigure(FfmpegHwMjpegH264* handle,
return 0; 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) { if (!handle) {
set_last_error("Invalid handle for request_keyframe"); set_last_error("Invalid handle for request_keyframe");
return -1; return -1;
} }
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle); auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
ctx->force_keyframe = true; ctx->force_keyframe = true;
return 0; return 0;
} }
extern "C" void ffmpeg_hw_mjpeg_h264_free(FfmpegHwMjpegH264* handle) { extern "C" void ffmpeg_hw_mjpeg_h26x_free(FfmpegHwMjpegH26x* handle) {
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle); auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
if (!ctx) return; if (!ctx) return;
if (ctx->dec_pkt) av_packet_free(&ctx->dec_pkt); 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")); include!(concat!(env!("OUT_DIR"), "/ffmpeg_hw_ffi.rs"));
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HwMjpegH264Config { pub struct HwMjpegH26xConfig {
pub decoder: String, pub decoder: String,
pub encoder: String, pub encoder: String,
pub width: i32, pub width: i32,
@@ -21,19 +21,19 @@ pub struct HwMjpegH264Config {
pub thread_count: i32, pub thread_count: i32,
} }
pub struct HwMjpegH264Pipeline { pub struct HwMjpegH26xPipeline {
ctx: *mut FfmpegHwMjpegH264, ctx: *mut FfmpegHwMjpegH26x,
config: HwMjpegH264Config, config: HwMjpegH26xConfig,
} }
unsafe impl Send for HwMjpegH264Pipeline {} unsafe impl Send for HwMjpegH26xPipeline {}
impl HwMjpegH264Pipeline { impl HwMjpegH26xPipeline {
pub fn new(config: HwMjpegH264Config) -> Result<Self, String> { pub fn new(config: HwMjpegH26xConfig) -> Result<Self, String> {
unsafe { unsafe {
let dec = CString::new(config.decoder.as_str()).map_err(|_| "decoder name invalid".to_string())?; 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 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(), dec.as_ptr(),
enc.as_ptr(), enc.as_ptr(),
config.width, config.width,
@@ -55,7 +55,7 @@ impl HwMjpegH264Pipeline {
let mut out_data: *mut u8 = std::ptr::null_mut(); let mut out_data: *mut u8 = std::ptr::null_mut();
let mut out_len: c_int = 0; let mut out_len: c_int = 0;
let mut out_key: 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, self.ctx,
data.as_ptr(), data.as_ptr(),
data.len() as c_int, data.len() as c_int,
@@ -80,7 +80,7 @@ impl HwMjpegH264Pipeline {
pub fn reconfigure(&mut self, bitrate_kbps: i32, gop: i32) -> Result<(), String> { pub fn reconfigure(&mut self, bitrate_kbps: i32, gop: i32) -> Result<(), String> {
unsafe { 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 { if ret != 0 {
return Err(last_error_message()); return Err(last_error_message());
} }
@@ -92,15 +92,15 @@ impl HwMjpegH264Pipeline {
pub fn request_keyframe(&mut self) { pub fn request_keyframe(&mut self) {
unsafe { 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) { fn drop(&mut self) {
unsafe { unsafe {
ffmpeg_hw_mjpeg_h264_free(self.ctx); ffmpeg_hw_mjpeg_h26x_free(self.ctx);
} }
self.ctx = std::ptr::null_mut(); self.ctx = std::ptr::null_mut();
} }

View File

@@ -40,20 +40,20 @@ pub async fn auth_middleware(
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> 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 // Check if system is initialized
if !state.config.is_initialized() { if !state.config.is_initialized() {
// Allow access to setup endpoints when not initialized // Allow only setup-related endpoints when not initialized
let path = request.uri().path(); if is_setup_public_endpoint(path) {
if path.starts_with("/api/setup")
|| path == "/api/info"
|| path.starts_with("/") && !path.starts_with("/api/")
{
return Ok(next.run(request).await); return Ok(next.run(request).await);
} }
} }
// Public endpoints that don't require auth // Public endpoints that don't require auth
let path = request.uri().path();
if is_public_endpoint(path) { if is_public_endpoint(path) {
return Ok(next.run(request).await); return Ok(next.run(request).await);
} }
@@ -89,21 +89,14 @@ fn unauthorized_response(message: &str) -> Response {
/// Check if endpoint is public (no auth required) /// Check if endpoint is public (no auth required)
fn is_public_endpoint(path: &str) -> bool { 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!( matches!(
path, path,
"/" "/"
| "/auth/login" | "/auth/login"
| "/info"
| "/health" | "/health"
| "/setup" | "/setup"
| "/setup/init" | "/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("/assets/")
|| path.starts_with("/static/") || path.starts_with("/static/")
|| path.ends_with(".js") || path.ends_with(".js")
@@ -112,3 +105,11 @@ fn is_public_endpoint(path: &str) -> bool {
|| path.ends_with(".png") || path.ends_with(".png")
|| path.ends_with(".svg") || 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 { pub enum OtgHidProfile {
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) /// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
Full, 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 /// Legacy profile: only keyboard
LegacyKeyboard, LegacyKeyboard,
/// Legacy profile: only relative mouse /// 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 { pub fn legacy_keyboard() -> Self {
Self { Self {
keyboard: true, keyboard: true,
@@ -234,6 +249,9 @@ impl OtgHidProfile {
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions { pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
match self { match self {
Self::Full => OtgHidFunctions::full(), 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::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(), Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
Self::Custom => custom.clone(), Self::Custom => custom.clone(),
@@ -558,7 +576,9 @@ pub struct WebConfig {
pub http_port: u16, pub http_port: u16,
/// HTTPS port /// HTTPS port
pub https_port: u16, pub https_port: u16,
/// Bind address /// Bind addresses (preferred)
pub bind_addresses: Vec<String>,
/// Bind address (legacy)
pub bind_address: String, pub bind_address: String,
/// Enable HTTPS /// Enable HTTPS
pub https_enabled: bool, pub https_enabled: bool,
@@ -573,6 +593,7 @@ impl Default for WebConfig {
Self { Self {
http_port: 8080, http_port: 8080,
https_port: 8443, https_port: 8443,
bind_addresses: Vec::new(),
bind_address: "0.0.0.0".to_string(), bind_address: "0.0.0.0".to_string(),
https_enabled: false, https_enabled: false,
ssl_cert_path: None, ssl_cert_path: None,

View File

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

View File

@@ -43,24 +43,52 @@ pub struct HidInfo {
} }
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::otg::OtgService; 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 /// HID controller managing keyboard and mouse input
pub struct HidController { pub struct HidController {
/// OTG Service reference (only used when backend is OTG) /// OTG Service reference (only used when backend is OTG)
otg_service: Option<Arc<OtgService>>, otg_service: Option<Arc<OtgService>>,
/// Active backend /// Active backend
backend: Arc<RwLock<Option<Box<dyn HidBackend>>>>, backend: Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
/// Backend type (mutable for reload) /// Backend type (mutable for reload)
backend_type: RwLock<HidBackendType>, backend_type: Arc<RwLock<HidBackendType>>,
/// Event bus for broadcasting state changes (optional) /// Event bus for broadcasting state changes (optional)
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>, events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
/// Health monitor for error tracking and recovery /// Health monitor for error tracking and recovery
monitor: Arc<HidHealthMonitor>, 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 { impl HidController {
@@ -68,12 +96,19 @@ impl HidController {
/// ///
/// For OTG backend, otg_service should be provided to support hot-reload /// For OTG backend, otg_service should be provided to support hot-reload
pub fn new(backend_type: HidBackendType, otg_service: Option<Arc<OtgService>>) -> Self { pub fn new(backend_type: HidBackendType, otg_service: Option<Arc<OtgService>>) -> Self {
let (hid_tx, hid_rx) = mpsc::channel(HID_EVENT_QUEUE_CAPACITY);
Self { Self {
otg_service, otg_service,
backend: Arc::new(RwLock::new(None)), 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), events: tokio::sync::RwLock::new(None),
monitor: Arc::new(HidHealthMonitor::with_defaults()), 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 /// Initialize the HID backend
pub async fn init(&self) -> Result<()> { pub async fn init(&self) -> Result<()> {
let backend_type = self.backend_type.read().await.clone(); 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 => { HidBackendType::Otg => {
// Request HID functions from OtgService // Request HID functions from OtgService
let otg_service = self let otg_service = self
@@ -100,7 +135,7 @@ impl HidController {
// Create OtgBackend from handles (no longer manages gadget itself) // Create OtgBackend from handles (no longer manages gadget itself)
info!("Creating OTG HID backend from device paths"); info!("Creating OTG HID backend from device paths");
Box::new(otg::OtgBackend::from_handles(handles)?) Arc::new(otg::OtgBackend::from_handles(handles)?)
} }
HidBackendType::Ch9329 { HidBackendType::Ch9329 {
ref port, ref port,
@@ -110,7 +145,7 @@ impl HidController {
"Initializing CH9329 HID backend on {} @ {} baud", "Initializing CH9329 HID backend on {} @ {} baud",
port, baud_rate 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 => { HidBackendType::None => {
warn!("HID backend disabled"); warn!("HID backend disabled");
@@ -120,6 +155,10 @@ impl HidController {
backend.init().await?; backend.init().await?;
*self.backend.write().await = Some(backend); *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); info!("HID backend initialized: {:?}", backend_type);
Ok(()) Ok(())
@@ -131,6 +170,7 @@ impl HidController {
// Close the backend // Close the backend
*self.backend.write().await = None; *self.backend.write().await = None;
self.backend_available.store(false, Ordering::Release);
// If OTG backend, notify OtgService to disable HID // If OTG backend, notify OtgService to disable HID
let backend_type = self.backend_type.read().await.clone(); let backend_type = self.backend_type.read().await.clone();
@@ -147,125 +187,47 @@ impl HidController {
/// Send keyboard event /// Send keyboard event
pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
let backend = self.backend.read().await; if !self.backend_available.load(Ordering::Acquire) {
match backend.as_ref() { return Err(AppError::BadRequest(
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(
"HID backend not available".to_string(), "HID backend not available".to_string(),
)), ));
} }
self.enqueue_event(HidEvent::Keyboard(event)).await
} }
/// Send mouse event /// Send mouse event
pub async fn send_mouse(&self, event: MouseEvent) -> Result<()> { pub async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
let backend = self.backend.read().await; if !self.backend_available.load(Ordering::Acquire) {
match backend.as_ref() { return Err(AppError::BadRequest(
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(
"HID backend not available".to_string(), "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) /// Send consumer control event (multimedia keys)
pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
let backend = self.backend.read().await; if !self.backend_available.load(Ordering::Acquire) {
match backend.as_ref() { return Err(AppError::BadRequest(
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(
"HID backend not available".to_string(), "HID backend not available".to_string(),
)), ));
} }
self.enqueue_event(HidEvent::Consumer(event)).await
} }
/// Reset all keys (release all pressed keys) /// Reset all keys (release all pressed keys)
pub async fn reset(&self) -> Result<()> { pub async fn reset(&self) -> Result<()> {
let backend = self.backend.read().await; if !self.backend_available.load(Ordering::Acquire) {
match backend.as_ref() { return Ok(());
Some(b) => b.reset().await,
None => Ok(()),
} }
// Reset is important but best-effort; enqueue to avoid blocking
self.enqueue_event(HidEvent::Reset).await
} }
/// Check if backend is available /// Check if backend is available
@@ -332,6 +294,7 @@ impl HidController {
/// Reload the HID backend with new type /// Reload the HID backend with new type
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type); info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release);
// Shutdown existing backend first // Shutdown existing backend first
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -341,7 +304,7 @@ impl HidController {
} }
// Create and initialize new backend // 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 => { HidBackendType::Otg => {
info!("Initializing OTG HID backend"); info!("Initializing OTG HID backend");
@@ -362,11 +325,11 @@ impl HidController {
// Create OtgBackend from handles // Create OtgBackend from handles
match otg::OtgBackend::from_handles(handles) { match otg::OtgBackend::from_handles(handles) {
Ok(backend) => { Ok(backend) => {
let boxed: Box<dyn HidBackend> = Box::new(backend); let backend = Arc::new(backend);
match boxed.init().await { match backend.init().await {
Ok(_) => { Ok(_) => {
info!("OTG backend initialized successfully"); info!("OTG backend initialized successfully");
Some(boxed) Some(backend)
} }
Err(e) => { Err(e) => {
warn!("Failed to initialize OTG backend: {}", e); warn!("Failed to initialize OTG backend: {}", e);
@@ -407,9 +370,9 @@ impl HidController {
); );
match ch9329::Ch9329Backend::with_baud_rate(port, baud_rate) { match ch9329::Ch9329Backend::with_baud_rate(port, baud_rate) {
Ok(b) => { Ok(b) => {
let boxed = Box::new(b); let backend = Arc::new(b);
match boxed.init().await { match backend.init().await {
Ok(_) => Some(boxed), Ok(_) => Some(backend),
Err(e) => { Err(e) => {
warn!("Failed to initialize CH9329 backend: {}", e); warn!("Failed to initialize CH9329 backend: {}", e);
None None
@@ -432,6 +395,8 @@ impl HidController {
if self.backend.read().await.is_some() { if self.backend.read().await.is_some() {
info!("HID backend reloaded successfully: {:?}", new_backend_type); 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 // Update backend_type on success
*self.backend_type.write().await = new_backend_type.clone(); *self.backend_type.write().await = new_backend_type.clone();
@@ -452,6 +417,7 @@ impl HidController {
Ok(()) Ok(())
} else { } else {
warn!("HID backend reload resulted in no active backend"); 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) // Update backend_type even on failure (to reflect the attempted change)
*self.backend_type.write().await = new_backend_type.clone(); *self.backend_type.write().await = new_backend_type.clone();
@@ -477,6 +443,148 @@ impl HidController {
events.publish(event); 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 { impl Default for HidController {

View File

@@ -145,7 +145,7 @@ pub struct OtgBackend {
} }
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout) /// 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 { impl OtgBackend {
/// Create OTG backend from device paths provided by OtgService /// 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::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use axum_server::tls_rustls::RustlsConfig; use axum_server::tls_rustls::RustlsConfig;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider}; use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -16,9 +18,10 @@ use one_kvm::events::EventBus;
use one_kvm::extensions::ExtensionManager; use one_kvm::extensions::ExtensionManager;
use one_kvm::hid::{HidBackendType, HidController}; use one_kvm::hid::{HidBackendType, HidController};
use one_kvm::msd::MsdController; use one_kvm::msd::MsdController;
use one_kvm::otg::OtgService; use one_kvm::otg::{configfs, OtgService};
use one_kvm::rustdesk::RustDeskService; use one_kvm::rustdesk::RustDeskService;
use one_kvm::state::AppState; use one_kvm::state::AppState;
use one_kvm::utils::bind_tcp_listener;
use one_kvm::video::format::{PixelFormat, Resolution}; use one_kvm::video::format::{PixelFormat, Resolution};
use one_kvm::video::{Streamer, VideoStreamManager}; use one_kvm::video::{Streamer, VideoStreamManager};
use one_kvm::web; use one_kvm::web;
@@ -134,7 +137,8 @@ async fn main() -> anyhow::Result<()> {
// Apply CLI argument overrides to config (only if explicitly specified) // Apply CLI argument overrides to config (only if explicitly specified)
if let Some(addr) = args.address { 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 { if let Some(port) = args.http_port {
config.web.http_port = 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()); config.web.ssl_key_path = Some(key_path.to_string_lossy().to_string());
} }
// Log final configuration let bind_ips = resolve_bind_addresses(&config.web)?;
if config.web.https_enabled { let scheme = if config.web.https_enabled { "https" } else { "http" };
tracing::info!( let bind_port = if config.web.https_enabled {
"Server will listen on: https://{}:{}", config.web.https_port
config.web.bind_address,
config.web.https_port
);
} else { } else {
tracing::info!( config.web.http_port
"Server will listen on: http://{}:{}", };
config.web.bind_address,
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 // Initialize session store
@@ -312,6 +315,19 @@ async fn main() -> anyhow::Result<()> {
let will_use_msd = config.msd.enabled; let will_use_msd = config.msd.enabled;
if will_use_otg_hid { 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 { if let Err(e) = otg_service.enable_hid().await {
tracing::warn!("Failed to pre-enable HID: {}", e); tracing::warn!("Failed to pre-enable HID: {}", e);
} }
@@ -585,12 +601,8 @@ async fn main() -> anyhow::Result<()> {
// Create router // Create router
let app = web::create_router(state.clone()); let app = web::create_router(state.clone());
// Determine bind address based on HTTPS setting // Bind sockets for configured addresses
let bind_addr: SocketAddr = if config.web.https_enabled { let listeners = bind_tcp_listeners(&bind_ips, bind_port)?;
format!("{}:{}", config.web.bind_address, config.web.https_port).parse()?
} else {
format!("{}:{}", config.web.bind_address, config.web.http_port).parse()?
};
// Setup graceful shutdown // Setup graceful shutdown
let shutdown_signal = async move { let shutdown_signal = async move {
@@ -627,33 +639,44 @@ async fn main() -> anyhow::Result<()> {
RustlsConfig::from_pem_file(&cert_path, &key_path).await? 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! { tokio::select! {
_ = shutdown_signal => { _ = shutdown_signal => {
cleanup(&state).await; cleanup(&state).await;
} }
result = server => { result = servers.next() => {
if let Err(e) = result { if let Some(Err(e)) = result {
tracing::error!("HTTPS server error: {}", e); tracing::error!("HTTPS server error: {}", e);
} }
cleanup(&state).await; cleanup(&state).await;
} }
} }
} else { } 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 listener = tokio::net::TcpListener::from_std(listener)?;
let server = axum::serve(listener, app); let server = axum::serve(listener, app.clone());
servers.push(async move { server.await });
}
tokio::select! { tokio::select! {
_ = shutdown_signal => { _ = shutdown_signal => {
cleanup(&state).await; cleanup(&state).await;
} }
result = server => { result = servers.next() => {
if let Err(e) = result { if let Some(Err(e)) = result {
tracing::error!("HTTP server error: {}", e); tracing::error!("HTTP server error: {}", e);
} }
cleanup(&state).await; cleanup(&state).await;
@@ -706,6 +729,47 @@ fn get_data_dir() -> PathBuf {
PathBuf::from("/etc/one-kvm") 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) /// Parse video format and resolution from config (avoids code duplication)
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) { fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
let format = config let format = config

View File

@@ -43,6 +43,23 @@ pub fn find_udc() -> Option<String> {
.next() .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 /// Write string content to a file
/// ///
/// For sysfs files, this function appends a newline and flushes /// 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 tracing::{debug, error, info, warn};
use super::configfs::{ use super::configfs::{
create_dir, find_udc, is_configfs_available, remove_dir, write_file, CONFIGFS_PATH, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir, remove_file,
DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, write_file, CONFIGFS_PATH, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
USB_BCD_USB, DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
}; };
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS}; use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
use super::function::{FunctionMeta, GadgetFunction}; use super::function::{FunctionMeta, GadgetFunction};
@@ -16,6 +16,8 @@ use super::hid::HidFunction;
use super::msd::MsdFunction; use super::msd::MsdFunction;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
const REBIND_DELAY_MS: u64 = 300;
/// USB Gadget device descriptor configuration /// USB Gadget device descriptor configuration
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GadgetDescriptor { pub struct GadgetDescriptor {
@@ -249,9 +251,15 @@ impl OtgGadgetManager {
AppError::Internal("No USB Device Controller (UDC) found".to_string()) 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); info!("Binding gadget to UDC: {}", udc);
write_file(&self.gadget_path.join("UDC"), &udc)?; write_file(&self.gadget_path.join("UDC"), &udc)?;
self.bound_udc = Some(udc); self.bound_udc = Some(udc);
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
Ok(()) Ok(())
} }
@@ -262,6 +270,7 @@ impl OtgGadgetManager {
write_file(&self.gadget_path.join("UDC"), "")?; write_file(&self.gadget_path.join("UDC"), "")?;
self.bound_udc = None; self.bound_udc = None;
info!("Unbound gadget from UDC"); info!("Unbound gadget from UDC");
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
} }
Ok(()) Ok(())
} }
@@ -382,6 +391,47 @@ impl OtgGadgetManager {
pub fn gadget_path(&self) -> &PathBuf { pub fn gadget_path(&self) -> &PathBuf {
&self.gadget_path &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 { impl Default for OtgGadgetManager {

View File

@@ -47,6 +47,11 @@ const DEFAULT_SCREEN_HEIGHT: u32 = 1080;
/// Default mouse event throttle interval (16ms ≈ 60Hz) /// Default mouse event throttle interval (16ms ≈ 60Hz)
const DEFAULT_MOUSE_THROTTLE_MS: u64 = 16; 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 /// Input event throttler
/// ///
/// Limits the rate of input events sent to HID devices to prevent EAGAIN errors. /// 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_test_delay_sent: Option<Instant>,
/// Last known CapsLock state from RustDesk modifiers (for detecting toggle) /// Last known CapsLock state from RustDesk modifiers (for detecting toggle)
last_caps_lock: bool, last_caps_lock: bool,
/// Whether relative mouse mode is currently active for this connection
relative_mouse_active: bool,
} }
/// Messages sent to connection handler /// Messages sent to connection handler
@@ -241,6 +248,7 @@ impl Connection {
last_delay: 0, last_delay: 0,
last_test_delay_sent: None, last_test_delay_sent: None,
last_caps_lock: false, last_caps_lock: false,
relative_mouse_active: false,
}; };
(conn, rx) (conn, rx)
@@ -623,7 +631,7 @@ impl Connection {
self.negotiated_codec = Some(negotiated); self.negotiated_codec = Some(negotiated);
info!("Negotiated video codec: {:?}", 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 let response_bytes = response
.write_to_bytes() .write_to_bytes()
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
@@ -673,7 +681,11 @@ impl Connection {
Some(misc::Union::RefreshVideo(refresh)) => { Some(misc::Union::RefreshVideo(refresh)) => {
if *refresh { if *refresh {
debug!("Video refresh requested"); 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)) => { Some(misc::Union::VideoReceived(received)) => {
@@ -1064,7 +1076,7 @@ impl Connection {
} }
/// Create login response with dynamically detected encoder capabilities /// 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 { if success {
// Dynamically detect available encoders // Dynamically detect available encoders
let registry = EncoderRegistry::global(); let registry = EncoderRegistry::global();
@@ -1080,11 +1092,21 @@ impl Connection {
h264_available, h265_available, vp8_available, vp9_available 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(); let mut display_info = DisplayInfo::new();
display_info.x = 0; display_info.x = 0;
display_info.y = 0; display_info.y = 0;
display_info.width = 1920; display_info.width = display_width as i32;
display_info.height = 1080; display_info.height = display_height as i32;
display_info.name = "KVM Display".to_string(); display_info.name = "KVM Display".to_string();
display_info.online = true; display_info.online = true;
display_info.cursor_embedded = false; display_info.cursor_embedded = false;
@@ -1099,11 +1121,11 @@ impl Connection {
let mut peer_info = PeerInfo::new(); let mut peer_info = PeerInfo::new();
peer_info.username = "one-kvm".to_string(); peer_info.username = "one-kvm".to_string();
peer_info.hostname = get_hostname(); 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.displays.push(display_info);
peer_info.current_display = 0; peer_info.current_display = 0;
peer_info.sas_enabled = false; 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); peer_info.encoding = protobuf::MessageField::some(encoding);
let mut login_response = LoginResponse::new(); let mut login_response = LoginResponse::new();
@@ -1310,9 +1332,16 @@ impl Connection {
async fn handle_mouse_event(&mut self, me: &MouseEvent) -> anyhow::Result<()> { async fn handle_mouse_event(&mut self, me: &MouseEvent) -> anyhow::Result<()> {
// Parse RustDesk mask format: (button << 3) | event_type // Parse RustDesk mask format: (button << 3) | event_type
let event_type = me.mask & 0x07; 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) // 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 // For pure move events, apply throttling
if is_pure_move && !self.input_throttler.should_send_mouse_move() { 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); debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask);
// Convert RustDesk mouse event to One-KVM mouse events // 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 // Send to HID controller if available
if let Some(ref hid) = self.hid { 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 shutdown_rx = shutdown_tx.subscribe();
let mut encoded_count: u64 = 0; let mut encoded_count: u64 = 0;
let mut last_log_time = Instant::now(); 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!( info!(
"Started shared video streaming for connection {} (codec: {:?})", "Started shared video streaming for connection {} (codec: {:?})",
@@ -1582,6 +1615,9 @@ async fn run_video_streaming(
config.bitrate_preset 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 // Inner loop: receives frames from current subscription
loop { 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 // Convert EncodedVideoFrame to RustDesk VideoFrame message
// Use zero-copy version: Bytes.clone() only increments refcount // Use zero-copy version: Bytes.clone() only increments refcount
let msg_bytes = video_adapter.encode_frame_bytes_zero_copy( let msg_bytes = video_adapter.encode_frame_bytes_zero_copy(
@@ -1617,12 +1677,13 @@ async fn run_video_streaming(
frame.pts_ms as u64, frame.pts_ms as u64,
); );
// Send to connection (blocks if channel is full, providing backpressure) // Send to connection (backpressure instead of dropping)
if video_tx.try_send(msg_bytes).is_err() { if video_tx.send(msg_bytes).await.is_err() {
// Drop when channel is full to avoid backpressure debug!("Video channel closed for connection {}", conn_id);
continue; break 'subscribe_loop;
} }
last_sequence = Some(frame.sequence);
encoded_count += 1; encoded_count += 1;
// Log stats periodically // Log stats periodically

View File

@@ -42,6 +42,9 @@ pub struct VideoFrameAdapter {
seq: u32, seq: u32,
/// Timestamp offset /// Timestamp offset
timestamp_base: u64, timestamp_base: u64,
/// Cached H264 SPS/PPS (Annex B NAL without start code)
h264_sps: Option<Bytes>,
h264_pps: Option<Bytes>,
} }
impl VideoFrameAdapter { impl VideoFrameAdapter {
@@ -51,6 +54,8 @@ impl VideoFrameAdapter {
codec, codec,
seq: 0, seq: 0,
timestamp_base: 0, timestamp_base: 0,
h264_sps: None,
h264_pps: None,
} }
} }
@@ -68,6 +73,7 @@ impl VideoFrameAdapter {
is_keyframe: bool, is_keyframe: bool,
timestamp_ms: u64, timestamp_ms: u64,
) -> Message { ) -> Message {
let data = self.prepare_h264_frame(data, is_keyframe);
// Calculate relative timestamp // Calculate relative timestamp
if self.seq == 0 { if self.seq == 0 {
self.timestamp_base = timestamp_ms; self.timestamp_base = timestamp_ms;
@@ -100,6 +106,41 @@ impl VideoFrameAdapter {
msg 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 /// Convert encoded video data to RustDesk Message
pub fn encode_frame(&mut self, data: &[u8], is_keyframe: bool, timestamp_ms: u64) -> 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) 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 UP: i32 = 2;
pub const WHEEL: i32 = 3; pub const WHEEL: i32 = 3;
pub const TRACKPAD: i32 = 4; pub const TRACKPAD: i32 = 4;
pub const MOVE_RELATIVE: i32 = 5;
} }
/// Mouse button IDs from RustDesk protocol (before left shift by 3) /// Mouse button IDs from RustDesk protocol (before left shift by 3)
@@ -36,23 +37,25 @@ pub fn convert_mouse_event(
event: &MouseEvent, event: &MouseEvent,
screen_width: u32, screen_width: u32,
screen_height: u32, screen_height: u32,
relative_mode: bool,
) -> Vec<OneKvmMouseEvent> { ) -> Vec<OneKvmMouseEvent> {
let mut events = Vec::new(); 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 // Parse RustDesk mask format: (button << 3) | event_type
let event_type = event.mask & 0x07; let event_type = event.mask & 0x07;
let button_id = event.mask >> 3; let button_id = event.mask >> 3;
let include_abs_move = !relative_mode;
match event_type { match event_type {
mouse_type::MOVE => { 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) // Move event - may have button held down (button_id > 0 means dragging)
// Just send move, button state is tracked separately by HID backend // Just send move, button state is tracked separately by HID backend
events.push(OneKvmMouseEvent { events.push(OneKvmMouseEvent {
@@ -63,55 +66,83 @@ pub fn convert_mouse_event(
scroll: 0, scroll: 0,
}); });
} }
mouse_type::DOWN => { mouse_type::MOVE_RELATIVE => {
// Button down - first move, then press // Relative movement uses delta values directly (dx, dy).
events.push(OneKvmMouseEvent { events.push(OneKvmMouseEvent {
event_type: MouseEventType::MoveAbs, event_type: MouseEventType::Move,
x: abs_x, x: event.x,
y: abs_y, y: event.y,
button: None, button: None,
scroll: 0, 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) { if let Some(button) = button_id_to_button(button_id) {
events.push(OneKvmMouseEvent { events.push(OneKvmMouseEvent {
event_type: MouseEventType::Down, event_type: MouseEventType::Down,
x: abs_x, x: 0,
y: abs_y, y: 0,
button: Some(button), button: Some(button),
scroll: 0, scroll: 0,
}); });
} }
} }
mouse_type::UP => { mouse_type::UP => {
// Button up - first move, then release if include_abs_move {
events.push(OneKvmMouseEvent { // Button up - first move, then release
event_type: MouseEventType::MoveAbs, let x = event.x.max(0) as u32;
x: abs_x, let y = event.y.max(0) as u32;
y: abs_y, let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
button: None, let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
scroll: 0, 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) { if let Some(button) = button_id_to_button(button_id) {
events.push(OneKvmMouseEvent { events.push(OneKvmMouseEvent {
event_type: MouseEventType::Up, event_type: MouseEventType::Up,
x: abs_x, x: 0,
y: abs_y, y: 0,
button: Some(button), button: Some(button),
scroll: 0, scroll: 0,
}); });
} }
} }
mouse_type::WHEEL => { mouse_type::WHEEL => {
// Scroll event - move first, then scroll if include_abs_move {
events.push(OneKvmMouseEvent { // Scroll event - move first, then scroll
event_type: MouseEventType::MoveAbs, let x = event.x.max(0) as u32;
x: abs_x, let y = event.y.max(0) as u32;
y: abs_y, let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
button: None, let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
scroll: 0, 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 // RustDesk encodes scroll direction in the y coordinate
// Positive y = scroll up, Negative y = scroll down // 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 }; let scroll = if event.y > 0 { 1i8 } else { -1i8 };
events.push(OneKvmMouseEvent { events.push(OneKvmMouseEvent {
event_type: MouseEventType::Scroll, event_type: MouseEventType::Scroll,
x: abs_x, x: 0,
y: abs_y, y: 0,
button: None, button: None,
scroll, scroll,
}); });
} }
_ => { _ => {
// Unknown event type, just move if include_abs_move {
events.push(OneKvmMouseEvent { // Unknown event type, just move
event_type: MouseEventType::MoveAbs, let x = event.x.max(0) as u32;
x: abs_x, let y = event.y.max(0) as u32;
y: abs_y, let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
button: None, let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
scroll: 0, 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.y = 300;
event.mask = mouse_type::MOVE; // Pure move event 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!(!events.is_empty());
assert_eq!(events[0].event_type, MouseEventType::MoveAbs); assert_eq!(events[0].event_type, MouseEventType::MoveAbs);
} }
@@ -534,7 +571,7 @@ mod tests {
event.y = 300; event.y = 300;
event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN; 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); assert!(events.len() >= 2);
// Should have a button down event // Should have a button down event
assert!(events assert!(events
@@ -542,6 +579,20 @@ mod tests {
.any(|e| e.event_type == MouseEventType::Down && e.button == Some(MouseButton::Left))); .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] #[test]
fn test_convert_key_event() { fn test_convert_key_event() {
use protobuf::EnumOrUnknown; use protobuf::EnumOrUnknown;

View File

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

View File

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

View File

@@ -3,5 +3,7 @@
//! This module contains common utilities used across the codebase. //! This module contains common utilities used across the codebase.
pub mod throttle; pub mod throttle;
pub mod net;
pub use throttle::LogThrottler; 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::AVPixelFormat;
use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder}; use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder};
use tracing::warn; use tracing::{info, warn};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::video::convert::Nv12Converter; use crate::video::convert::Nv12Converter;
@@ -72,6 +72,9 @@ impl MjpegRkmppDecoder {
); );
} }
} else { } 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); self.last_pixfmt = Some(frame.pixfmt);
} }

View File

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

View File

@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use v4l::capability::Flags; use v4l::capability::Flags;
use v4l::prelude::*; use v4l::prelude::*;
@@ -12,6 +14,8 @@ use v4l::FourCC;
use super::format::{PixelFormat, Resolution}; use super::format::{PixelFormat, Resolution};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
const DEVICE_PROBE_TIMEOUT_MS: u64 = 400;
/// Information about a video device /// Information about a video device
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoDeviceInfo { pub struct VideoDeviceInfo {
@@ -401,32 +405,29 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
debug!("Found video device: {:?}", path); debug!("Found video device: {:?}", path);
// Try to open and query the device if !sysfs_maybe_capture(&path) {
match VideoDevice::open(&path) { debug!("Skipping non-capture candidate (sysfs): {:?}", path);
Ok(device) => { continue;
match device.info() { }
Ok(info) => {
// Only include devices with video capture capability // Try to open and query the device (with timeout)
if info.capabilities.video_capture || info.capabilities.video_capture_mplane match probe_device_with_timeout(&path, Duration::from_millis(DEVICE_PROBE_TIMEOUT_MS)) {
{ Some(info) => {
info!( // Only include devices with video capture capability
"Found capture device: {} ({}) - {} formats", if info.capabilities.video_capture || info.capabilities.video_capture_mplane {
info.name, info!(
info.driver, "Found capture device: {} ({}) - {} formats",
info.formats.len() info.name,
); info.driver,
devices.push(info); info.formats.len()
} else { );
debug!("Skipping non-capture device: {:?}", path); devices.push(info);
} } else {
} debug!("Skipping non-capture device: {:?}", path);
Err(e) => {
debug!("Failed to get info for {:?}: {}", path, e);
}
} }
} }
Err(e) => { None => {
debug!("Failed to open {:?}: {}", path, e); debug!("Failed to probe {:?}", path);
} }
} }
} }
@@ -438,6 +439,104 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
Ok(devices) 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 /// Find the best video device for KVM use
pub fn find_best_device() -> Result<VideoDeviceInfo> { pub fn find_best_device() -> Result<VideoDeviceInfo> {
let devices = enumerate_devices()?; let devices = enumerate_devices()?;

View File

@@ -7,7 +7,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock; use std::sync::OnceLock;
use tracing::{debug, info}; use tracing::{debug, info, warn};
use hwcodec::common::{DataFormat, Quality, RateControl}; use hwcodec::common::{DataFormat, Quality, RateControl};
use hwcodec::ffmpeg::AVPixelFormat; use hwcodec::ffmpeg::AVPixelFormat;
@@ -255,8 +255,33 @@ impl EncoderRegistry {
thread_count: 1, thread_count: 1,
}; };
// Get all available encoders from hwcodec const DETECT_TIMEOUT_MS: u64 = 5000;
let all_encoders = HwEncoder::available_encoders(ctx, None);
// 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()); 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::error::{AppError, Result};
use crate::video::convert::{Nv12Converter, PixelConverter}; use crate::video::convert::{Nv12Converter, PixelConverter};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use crate::video::decoder::MjpegRkmppDecoder;
use crate::video::decoder::MjpegTurboDecoder; use crate::video::decoder::MjpegTurboDecoder;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[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::buffer::Type as BufferType;
use v4l::io::traits::CaptureStream; use v4l::io::traits::CaptureStream;
use v4l::prelude::*; use v4l::prelude::*;
@@ -177,7 +175,7 @@ struct EncoderThreadState {
yuv420p_converter: Option<PixelConverter>, yuv420p_converter: Option<PixelConverter>,
encoder_needs_yuv420p: bool, encoder_needs_yuv420p: bool,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] #[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"))] #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_enabled: bool, ffmpeg_hw_enabled: bool,
fps: u32, fps: u32,
@@ -319,16 +317,12 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
} }
enum MjpegDecoderKind { enum MjpegDecoderKind {
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
Rkmpp(MjpegRkmppDecoder),
Turbo(MjpegTurboDecoder), Turbo(MjpegTurboDecoder),
} }
impl MjpegDecoderKind { impl MjpegDecoderKind {
fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> { fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> {
match self { 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), 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"))] #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if needs_mjpeg_decode && is_rkmpp_encoder && config.output_codec == VideoEncoderType::H264 { let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
info!("Initializing FFmpeg HW MJPEG->H264 pipeline (no fallback)"); #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
let hw_config = HwMjpegH264Config { 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(), decoder: "mjpeg_rkmpp".to_string(),
encoder: selected_codec_name.clone(), encoder: selected_codec_name.clone(),
width: config.resolution.width as i32, width: config.resolution.width as i32,
@@ -530,14 +527,14 @@ impl SharedVideoPipeline {
gop: config.gop_size() as i32, gop: config.gop_size() as i32,
thread_count: 1, 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 }; let detail = if e.is_empty() { ffmpeg_hw_last_error() } else { e };
AppError::VideoError(format!( AppError::VideoError(format!(
"FFmpeg HW MJPEG->H264 init failed: {}", "FFmpeg HW MJPEG->{} init failed: {}",
detail config.output_codec, detail
)) ))
})?; })?;
info!("Using FFmpeg HW MJPEG->H264 pipeline"); info!("Using FFmpeg HW MJPEG->{} pipeline", config.output_codec);
return Ok(EncoderThreadState { return Ok(EncoderThreadState {
encoder: None, encoder: None,
mjpeg_decoder: None, mjpeg_decoder: None,
@@ -555,35 +552,12 @@ impl SharedVideoPipeline {
} }
let pipeline_input_format = if needs_mjpeg_decode { let pipeline_input_format = if needs_mjpeg_decode {
if is_rkmpp_encoder { info!(
info!( "MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
"MJPEG input detected, using RKMPP decoder ({} -> NV12 with NV16 fallback)", config.input_format
config.input_format );
); let decoder = MjpegTurboDecoder::new(config.resolution)?;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] (Some(MjpegDecoderKind::Turbo(decoder)), PixelFormat::Rgb24)
{
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(),
));
}
} else { } else {
(None, config.input_format) (None, config.input_format)
}; };

View File

@@ -794,6 +794,11 @@ impl VideoStreamManager {
self.webrtc_streamer.set_bitrate_preset(preset).await 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 /// Publish event to event bus
async fn publish_event(&self, event: SystemEvent) { async fn publish_event(&self, event: SystemEvent) {
if let Some(ref events) = *self.events.read().await { if let Some(ref events) = *self.events.read().await {

View File

@@ -187,7 +187,23 @@ pub async fn apply_hid_config(
// 检查 OTG 描述符是否变更 // 检查 OTG 描述符是否变更
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let old_hid_functions = old_config.effective_otg_functions(); 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; let hid_functions_changed = old_hid_functions != new_hid_functions;
if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() { if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() {

View File

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

View File

@@ -316,28 +316,11 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
// Build a map of interface name -> IPv4 address // Check which interfaces are up
let mut ipv4_map: std::collections::HashMap<String, String> = std::collections::HashMap::new(); let mut up_ifaces = std::collections::HashSet::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();
let net_dir = match std::fs::read_dir("/sys/class/net") { let net_dir = match std::fs::read_dir("/sys/class/net") {
Ok(dir) => dir, Ok(dir) => dir,
Err(_) => return addresses, Err(_) => return Vec::new(),
}; };
for entry in net_dir.flatten() { for entry in net_dir.flatten() {
@@ -361,12 +344,43 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
continue; continue;
} }
// Get IP from pre-fetched map up_ifaces.insert(iface_name);
if let Some(ip) = ipv4_map.remove(&iface_name) { }
addresses.push(NetworkAddress {
interface: iface_name, let mut addresses = Vec::new();
ip, 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 struct AuthCheckResponse {
pub authenticated: bool, pub authenticated: bool,
pub user: Option<String>, pub user: Option<String>,
pub is_admin: bool,
} }
pub async fn auth_check( pub async fn auth_check(
@@ -481,7 +494,6 @@ pub async fn auth_check(
Json(AuthCheckResponse { Json(AuthCheckResponse {
authenticated: true, authenticated: true,
user: username, user: username,
is_admin: true,
}) })
} }
@@ -521,11 +533,39 @@ pub struct SetupRequest {
pub hid_ch9329_port: Option<String>, pub hid_ch9329_port: Option<String>,
pub hid_ch9329_baudrate: Option<u32>, pub hid_ch9329_baudrate: Option<u32>,
pub hid_otg_udc: Option<String>, pub hid_otg_udc: Option<String>,
pub hid_otg_profile: Option<String>,
// Extension settings // Extension settings
pub ttyd_enabled: Option<bool>, pub ttyd_enabled: Option<bool>,
pub rustdesk_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( pub async fn setup_init(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<SetupRequest>, Json(req): Json<SetupRequest>,
@@ -601,6 +641,33 @@ pub async fn setup_init(
if let Some(udc) = req.hid_otg_udc.clone() { if let Some(udc) = req.hid_otg_udc.clone() {
config.hid.otg_udc = Some(udc); 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 // Extension settings
if let Some(enabled) = req.ttyd_enabled { if let Some(enabled) = req.ttyd_enabled {
@@ -609,12 +676,32 @@ pub async fn setup_init(
if let Some(enabled) = req.rustdesk_enabled { if let Some(enabled) = req.rustdesk_enabled {
config.rustdesk.enabled = enabled; config.rustdesk.enabled = enabled;
} }
normalize_otg_profile_for_low_endpoint(config);
}) })
.await?; .await?;
// Get updated config for HID reload // Get updated config for HID reload
let new_config = state.config.get(); 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!( tracing::info!(
"Extension config after save: ttyd.enabled={}, rustdesk.enabled={}", "Extension config after save: ttyd.enabled={}, rustdesk.enabled={}",
new_config.extensions.ttyd.enabled, new_config.extensions.ttyd.enabled,
@@ -727,6 +814,9 @@ pub async fn update_config(
let new_config: AppConfig = serde_json::from_value(merged) let new_config: AppConfig = serde_json::from_value(merged)
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?; .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 // Apply the validated config
state.config.set(new_config.clone()).await?; 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", get(handlers::setup_status))
.route("/setup/init", post(handlers::setup_init)); .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() let user_routes = Router::new()
.route("/info", get(handlers::system_info)) .route("/info", get(handlers::system_info))
.route("/auth/logout", post(handlers::logout)) .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)) .route("/audio/devices", get(handlers::list_audio_devices))
// Audio WebSocket endpoint // Audio WebSocket endpoint
.route("/ws/audio", any(audio_ws_handler)) .route("/ws/audio", any(audio_ws_handler))
;
// Admin-only routes (require admin privileges)
let admin_routes = Router::new()
// Configuration management (domain-separated endpoints) // Configuration management (domain-separated endpoints)
.route("/config", get(handlers::config::get_all_config)) .route("/config", get(handlers::config::get_all_config))
.route("/config", post(handlers::update_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/", get(handlers::terminal::terminal_index)) .route("/terminal/", get(handlers::terminal::terminal_index))
.route("/terminal/ws", get(handlers::terminal::terminal_ws)) .route("/terminal/ws", get(handlers::terminal::terminal_ws))
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy)) .route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
;
// Combine protected routes (user + admin) // Protected routes (all authenticated users)
let protected_routes = Router::new().merge(user_routes).merge(admin_routes); let protected_routes = user_routes;
// Stream endpoints (accessible with auth, but typically embedded in pages) // Stream endpoints (accessible with auth, but typically embedded in pages)
let stream_routes = Router::new() 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 === // === Audio Management ===
/// Check if audio is enabled /// 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", "name": "web",
"version": "0.1.1", "version": "0.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "web", "name": "web",
"version": "0.1.1", "version": "0.1.4",
"dependencies": { "dependencies": {
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
@@ -7,6 +7,11 @@ import {
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
} from '@/components/ui/hover-card' } from '@/components/ui/hover-card'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next' import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
@@ -28,8 +33,18 @@ const props = withDefaults(defineProps<{
errorMessage?: string errorMessage?: string
details?: StatusDetail[] details?: StatusDetail[]
hoverAlign?: 'start' | 'center' | 'end' // HoverCard alignment hoverAlign?: 'start' | 'center' | 'end' // HoverCard alignment
compact?: boolean
}>(), { }>(), {
hoverAlign: 'start', 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(() => { const statusColor = computed(() => {
@@ -111,19 +126,20 @@ const statusBadgeText = computed(() => {
</script> </script>
<template> <template>
<HoverCard :open-delay="200" :close-delay="100"> <HoverCard v-if="!prefersPopover" :open-delay="200" :close-delay="100">
<HoverCardTrigger as-child> <HoverCardTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom --> <!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div <div
:class="cn( :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', 'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700', 'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800' status === 'error' && 'border-red-300 dark:border-red-800'
)" )"
> >
<!-- Top: Title --> <!-- 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 --> <!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" /> <span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
@@ -208,4 +224,103 @@ const statusBadgeText = computed(() => {
</div> </div>
</HoverCardContent> </HoverCardContent>
</HoverCard> </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> </template>

View File

@@ -20,7 +20,7 @@ import {
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next' import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue' import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo, type BitratePreset } from '@/api' 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' import { useRouter } from 'vue-router'
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9' export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
@@ -43,7 +43,6 @@ interface VideoDevice {
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
videoMode: VideoMode videoMode: VideoMode
isAdmin?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -52,7 +51,7 @@ const emit = defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const systemStore = useSystemStore() const configStore = useConfigStore()
const router = useRouter() const router = useRouter()
// Device list // Device list
@@ -65,7 +64,7 @@ const loadingCodecs = ref(false)
// Backend list // Backend list
const backends = ref<EncoderBackendInfo[]>([]) const backends = ref<EncoderBackendInfo[]>([])
const currentEncoderBackend = ref<string>('auto') const currentEncoderBackend = computed(() => configStore.stream?.encoder || 'auto')
// Browser supported codecs (WebRTC receive capabilities) // Browser supported codecs (WebRTC receive capabilities)
const browserSupportedCodecs = ref<Set<string>>(new Set()) const browserSupportedCodecs = ref<Set<string>>(new Set())
@@ -198,11 +197,11 @@ const applyingBitrate = ref(false)
// Current config from store // Current config from store
const currentConfig = computed(() => ({ const currentConfig = computed(() => ({
device: systemStore.stream?.device || '', device: configStore.video?.device || '',
format: systemStore.stream?.format || '', format: configStore.video?.format || '',
width: systemStore.stream?.resolution?.[0] || 1920, width: configStore.video?.width || 1920,
height: systemStore.stream?.resolution?.[1] || 1080, height: configStore.video?.height || 1080,
fps: systemStore.stream?.targetFps || 30, fps: configStore.video?.fps || 30,
})) }))
// Button display text - simplified to just show label // 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) // Navigate to settings page (video tab)
function goToSettings() { function goToSettings() {
router.push('/settings?tab=video') router.push('/settings?tab=video')
@@ -441,14 +427,12 @@ async function applyVideoConfig() {
applying.value = true applying.value = true
try { try {
await configApi.update({ await configStore.updateVideo({
video: { device: selectedDevice.value,
device: selectedDevice.value, format: selectedFormat.value,
format: selectedFormat.value, width,
width, height,
height, fps: selectedFps.value,
fps: selectedFps.value,
},
}) })
toast.success(t('config.applied')) toast.success(t('config.applied'))
@@ -464,26 +448,32 @@ async function applyVideoConfig() {
// Watch open state // Watch open state
watch(() => props.open, (isOpen) => { watch(() => props.open, (isOpen) => {
if (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 {
isDirty.value = false 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) // Sync selected values when backend config changes (e.g., auto format switch on mode change)
@@ -619,9 +609,7 @@ watch(currentConfig, () => {
</div> </div>
</div> </div>
<!-- Settings Link - Admin only -->
<Button <Button
v-if="props.isAdmin"
variant="ghost" variant="ghost"
size="sm" size="sm"
class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0" class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0"
@@ -632,11 +620,10 @@ watch(currentConfig, () => {
</Button> </Button>
</div> </div>
<!-- Device Settings Section - Admin only --> <!-- Device Settings Section -->
<template v-if="props.isAdmin"> <Separator />
<Separator />
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5> <h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
<Button <Button
@@ -784,8 +771,7 @@ watch(currentConfig, () => {
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" /> <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> <span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button> </Button>
</div> </div>
</template>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </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) // Key display mapping with Unicode symbols (JetKVM style)
const keyDisplayMap = computed<Record<string, string>>(() => { const keyDisplayMap = computed<Record<string, string>>(() => {
// OS-specific Meta key labels // OS-specific Meta key labels
@@ -233,14 +248,15 @@ function switchOsLayout(os: KeyboardOsType) {
// Update keyboard layout based on selected OS // Update keyboard layout based on selected OS
function updateKeyboardLayout() { function updateKeyboardLayout() {
const bottomRow = getBottomRow() const bottomRow = getBottomRow()
const baseLayout = isCompactLayout.value ? compactMainLayout : keyboardLayout.main
const newLayout = { const newLayout = {
...keyboardLayout.main, ...baseLayout,
default: [ default: [
...keyboardLayout.main.default.slice(0, -1), ...baseLayout.default.slice(0, -1),
bottomRow, bottomRow,
], ],
shift: [ shift: [
...keyboardLayout.main.shift.slice(0, -1), ...baseLayout.shift.slice(0, -1),
bottomRow, bottomRow,
], ],
} }
@@ -422,7 +438,7 @@ function initKeyboards() {
// Main keyboard - pass element directly instead of selector string // Main keyboard - pass element directly instead of selector string
mainKeyboard.value = new Keyboard(mainEl, { mainKeyboard.value = new Keyboard(mainEl, {
layout: keyboardLayout.main, layout: isCompactLayout.value ? compactMainLayout : keyboardLayout.main,
layoutName: layoutName.value, layoutName: layoutName.value,
display: keyDisplayMap.value, display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard', theme: 'hg-theme-default hg-layout-default vkb-keyboard',
@@ -471,6 +487,7 @@ function initKeyboards() {
stopMouseUpPropagation: true, stopMouseUpPropagation: true,
}) })
updateKeyboardLayout()
console.log('[VirtualKeyboard] Keyboards initialized:', id) console.log('[VirtualKeyboard] Keyboards initialized:', id)
} }
@@ -570,6 +587,15 @@ onMounted(() => {
selectedOs.value = savedOs 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('mousemove', onDrag)
document.addEventListener('touchmove', onDrag) document.addEventListener('touchmove', onDrag)
document.addEventListener('mouseup', endDrag) document.addEventListener('mouseup', endDrag)
@@ -577,6 +603,9 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (compactLayoutMedia && compactLayoutListener) {
compactLayoutMedia.removeEventListener('change', compactLayoutListener)
}
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('touchmove', onDrag) document.removeEventListener('touchmove', onDrag)
document.removeEventListener('mouseup', endDrag) 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 */ /* Floating mode - slightly smaller keys but still readable */
.vkb--floating .vkb-body { .vkb--floating .vkb-body {
padding: 8px; padding: 8px;

View File

@@ -265,6 +265,10 @@ export default {
// Help tooltips // Help tooltips
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.', 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.', 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.', 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.', videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
// Extensions // Extensions
@@ -476,10 +480,22 @@ export default {
configureHttpPort: 'Configure HTTP server port', configureHttpPort: 'Configure HTTP server port',
// Web server // Web server
webServer: 'Access Address', 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', httpsPort: 'HTTPS Port',
bindAddress: 'Bind Address', bindAddress: 'Bind Address',
bindAddressDesc: 'IP address the server listens on. 0.0.0.0 means all network interfaces.', 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', httpsEnabled: 'Enable HTTPS',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)', httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
restartRequired: 'Restart Required', restartRequired: 'Restart Required',
@@ -584,9 +600,12 @@ export default {
otgHidProfile: 'OTG HID Profile', otgHidProfile: 'OTG HID Profile',
otgHidProfileDesc: 'Select which HID functions are exposed to the host', otgHidProfileDesc: 'Select which HID functions are exposed to the host',
profile: 'Profile', profile: 'Profile',
otgProfileFull: 'Full (keyboard + relative mouse + absolute mouse + consumer)', otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD',
otgProfileLegacyKeyboard: 'Legacy: keyboard only', otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)',
otgProfileLegacyMouseRelative: 'Legacy: relative mouse only', 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', otgProfileCustom: 'Custom',
otgFunctionKeyboard: 'Keyboard', otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device', otgFunctionKeyboardDesc: 'Standard HID keyboard device',
@@ -599,6 +618,7 @@ export default {
otgFunctionMsd: 'Mass Storage (MSD)', otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host', otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device', 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', otgFunctionMinWarning: 'Enable at least one HID function before saving',
// OTG Descriptor // OTG Descriptor
otgDescriptor: 'USB Device Descriptor', otgDescriptor: 'USB Device Descriptor',
@@ -749,10 +769,10 @@ export default {
serverSettings: 'Server Settings', serverSettings: 'Server Settings',
rendezvousServer: 'ID Server', rendezvousServer: 'ID Server',
rendezvousServerPlaceholder: 'hbbs.example.com:21116', 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', relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117', 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', relayKey: 'Relay Key',
relayKeyPlaceholder: 'Enter relay server key', relayKeyPlaceholder: 'Enter relay server key',
relayKeySet: '••••••••', relayKeySet: '••••••••',

View File

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

View File

@@ -1,4 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' 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' import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
@@ -33,29 +36,62 @@ const router = createRouter({
routes, routes,
}) })
let sessionExpiredNotified = false
function t(key: string, params?: Record<string, unknown>): string {
return String(i18n.global.t(key, params as any))
}
// Navigation guard // Navigation guard
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
// Check if system needs setup // Prevent access to setup after initialization
if (!authStore.initialized && to.name !== 'Setup') { const shouldCheckSetup = to.name === 'Setup' || !authStore.initialized
if (shouldCheckSetup) {
try { try {
await authStore.checkSetupStatus() await authStore.checkSetupStatus()
if (authStore.needsSetup) {
return next({ name: 'Setup' })
}
} catch { } catch {
// Continue anyway // 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 // Check authentication for protected routes
if (to.meta.requiresAuth !== false) { if (to.meta.requiresAuth !== false) {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
try { try {
await authStore.checkAuth() await authStore.checkAuth()
} catch { } catch (e) {
// Not authenticated // 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) { if (!authStore.isAuthenticated) {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useWebSocket } from '@/composables/useWebSocket' import { useWebSocket } from '@/composables/useWebSocket'
import { useConsoleEvents } from '@/composables/useConsoleEvents' import { useConsoleEvents } from '@/composables/useConsoleEvents'
@@ -59,6 +60,7 @@ import { setLanguage } from '@/i18n'
const { t, locale } = useI18n() const { t, locale } = useI18n()
const router = useRouter() const router = useRouter()
const systemStore = useSystemStore() const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket() const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
const hidWs = useHidWebSocket() 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) // Cursor visibility (from localStorage, updated via storage event)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false') 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 // Virtual keyboard state
const virtualKeyboardVisible = ref(false) const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true) const virtualKeyboardAttached = ref(true)
@@ -1787,6 +1798,9 @@ onMounted(async () => {
// 4. 其他初始化 // 4. 其他初始化
await systemStore.startStream().catch(() => {}) await systemStore.startStream().catch(() => {})
await systemStore.fetchAllStates() await systemStore.fetchAllStates()
await configStore.refreshHid().then(() => {
syncMouseModeFromConfig()
}).catch(() => {})
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp) window.addEventListener('keyup', handleKeyUp)
@@ -1800,6 +1814,10 @@ onMounted(async () => {
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener) window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.addEventListener('storage', handleMouseSendIntervalStorage) window.addEventListener('storage', handleMouseSendIntervalStorage)
watch(() => configStore.hid?.mouse_absolute, () => {
syncMouseModeFromConfig()
})
// Pointer Lock event listeners // Pointer Lock event listeners
document.addEventListener('pointerlockchange', handlePointerLockChange) document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError) document.addEventListener('pointerlockerror', handlePointerLockError)
@@ -1881,104 +1899,161 @@ onUnmounted(() => {
<template> <template>
<div class="h-screen flex flex-col bg-background"> <div class="h-screen flex flex-col bg-background">
<!-- Header --> <!-- Header -->
<header class="h-14 shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"> <header class="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"> <div class="px-4">
<!-- Left: Logo --> <div class="h-14 flex items-center justify-between">
<div class="flex items-center gap-6"> <!-- 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"> <div class="flex items-center gap-2">
<Monitor class="h-6 w-6 text-primary" /> <div class="hidden md:flex items-center gap-2">
<span class="font-bold text-lg">One-KVM</span> <!-- 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>
</div> </div>
<!-- Right: Status Cards + User Menu --> <!-- Mobile Status Row -->
<div class="flex items-center gap-2"> <div class="md:hidden pb-2">
<!-- Video Status --> <div class="flex items-center gap-2 overflow-x-auto">
<StatusCard <div class="shrink-0">
:title="t('statusCard.video')" <StatusCard
type="video" :title="t('statusCard.video')"
:status="videoStatus" type="video"
:quick-info="videoQuickInfo" :status="videoStatus"
:error-message="videoErrorMessage" :quick-info="videoQuickInfo"
:details="videoDetails" :error-message="videoErrorMessage"
/> :details="videoDetails"
compact
/>
</div>
<!-- Audio Status --> <div v-if="systemStore.audio?.available" class="shrink-0">
<StatusCard <StatusCard
v-if="systemStore.audio?.available" :title="t('statusCard.audio')"
:title="t('statusCard.audio')" type="audio"
type="audio" :status="audioStatus"
:status="audioStatus" :quick-info="audioQuickInfo"
:quick-info="audioQuickInfo" :error-message="audioErrorMessage"
:error-message="audioErrorMessage" :details="audioDetails"
:details="audioDetails" compact
/> />
</div>
<!-- HID Status --> <div class="shrink-0">
<StatusCard <StatusCard
:title="t('statusCard.hid')" :title="t('statusCard.hid')"
type="hid" type="hid"
:status="hidStatus" :status="hidStatus"
:quick-info="hidQuickInfo" :quick-info="hidQuickInfo"
:details="hidDetails" :details="hidDetails"
/> compact
/>
</div>
<!-- MSD Status - Admin only, hidden when CH9329 backend (no USB gadget support) --> <div v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'" class="shrink-0">
<StatusCard <StatusCard
v-if="authStore.isAdmin && systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'" :title="t('statusCard.msd')"
:title="t('statusCard.msd')" type="msd"
type="msd" :status="msdStatus"
:status="msdStatus" :quick-info="msdQuickInfo"
:quick-info="msdQuickInfo" :error-message="msdErrorMessage"
:error-message="msdErrorMessage" :details="msdDetails"
:details="msdDetails" hover-align="end"
hover-align="end" compact
/> />
</div>
<!-- Separator --> </div>
<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>
</div> </div>
</header> </header>
@@ -1988,7 +2063,6 @@ onUnmounted(() => {
:mouse-mode="mouseMode" :mouse-mode="mouseMode"
:video-mode="videoMode" :video-mode="videoMode"
:ttyd-running="ttydStatus?.running" :ttyd-running="ttydStatus?.running"
:is-admin="authStore.isAdmin"
@toggle-fullscreen="toggleFullscreen" @toggle-fullscreen="toggleFullscreen"
@toggle-stats="statsSheetOpen = true" @toggle-stats="statsSheetOpen = true"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard" @toggle-virtual-keyboard="handleToggleVirtualKeyboard"

View File

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

View File

@@ -96,6 +96,9 @@ const hidBackend = ref('ch9329')
const ch9329Port = ref('') const ch9329Port = ref('')
const ch9329Baudrate = ref(9600) const ch9329Baudrate = ref(9600)
const otgUdc = ref('') const otgUdc = ref('')
const hidOtgProfile = ref('full')
const otgProfileTouched = ref(false)
const showAdvancedOtg = ref(false)
// Extension settings // Extension settings
const ttydEnabled = ref(false) const ttydEnabled = ref(false)
@@ -200,6 +203,26 @@ const availableFps = computed(() => {
return resolution?.fps || [] 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 // Common baud rates for CH9329
const baudRates = [9600, 19200, 38400, 57600, 115200] const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -315,6 +338,17 @@ watch(hidBackend, (newBackend) => {
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) { if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || '' otgUdc.value = devices.value.udc[0]?.name || ''
} }
applyOtgProfileDefault()
})
watch(otgUdc, () => {
applyOtgProfileDefault()
})
watch(showAdvancedOtg, (open) => {
if (open) {
applyOtgProfileDefault()
}
}) })
onMounted(async () => { onMounted(async () => {
@@ -336,6 +370,7 @@ onMounted(async () => {
if (result.udc.length > 0 && result.udc[0]) { if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name otgUdc.value = result.udc[0].name
} }
applyOtgProfileDefault()
// Auto-select audio device if available (and no video device to trigger watch) // Auto-select audio device if available (and no video device to trigger watch)
if (result.audio.length > 0 && !audioDevice.value) { if (result.audio.length > 0 && !audioDevice.value) {
@@ -487,6 +522,7 @@ async function handleSetup() {
} }
if (hidBackend.value === 'otg' && otgUdc.value) { if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value
} }
// Encoder backend setting // Encoder backend setting
@@ -520,7 +556,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</script> </script>
<template> <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"> <Card class="w-full max-w-lg relative">
<!-- Language Switcher --> <!-- Language Switcher -->
<div class="absolute top-4 right-4"> <div class="absolute top-4 right-4">
@@ -547,28 +583,28 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</DropdownMenu> </DropdownMenu>
</div> </div>
<CardHeader class="text-center space-y-2 pt-12"> <CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
<div <div
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10" 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" /> <Monitor class="w-8 h-8 text-primary" />
</div> </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> <CardDescription>{{ t('setup.description') }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-5 sm:space-y-6">
<!-- Progress Text --> <!-- Progress Text -->
<p class="text-sm text-muted-foreground text-center"> <p class="text-sm text-muted-foreground text-center">
{{ t('setup.progress', { current: step, total: totalSteps }) }} {{ t('setup.progress', { current: step, total: totalSteps }) }}
</p> </p>
<!-- Step Indicator with Labels --> <!-- 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"> <template v-for="i in totalSteps" :key="i">
<div class="flex flex-col items-center gap-1"> <div class="flex flex-col items-center gap-1">
<div <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=" :class="
step > i step > i
? 'bg-primary border-primary text-primary-foreground scale-100' ? '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' : 'border-muted text-muted-foreground scale-100'
" "
> >
<Check v-if="step > i" 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-5 h-5" /> <component :is="stepIcons[i - 1]" v-else class="w-4 h-4 sm:w-5 sm:h-5" />
</div> </div>
<span <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'" :class="step >= i ? 'text-foreground font-medium' : 'text-muted-foreground'"
> >
{{ stepLabels[i - 1] }} {{ stepLabels[i - 1] }}
@@ -589,7 +625,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</div> </div>
<div <div
v-if="i < totalSteps" 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'" :class="step > i ? 'bg-primary' : 'bg-muted'"
/> />
</template> </template>
@@ -924,6 +960,46 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
{{ t('setup.noUdcDevices') }} {{ t('setup.noUdcDevices') }}
</p> </p>
</div> </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>
</div> </div>