mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-18 00:46:44 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b17a0c48b | ||
|
|
e75f0500e2 | ||
|
|
a14e0fb26d | ||
|
|
d649c1ac20 | ||
|
|
6a110258b9 | ||
|
|
58f9020192 | ||
|
|
f3b42e2aaf | ||
|
|
b2b99115ec | ||
|
|
1a0b285fe6 | ||
|
|
d9daeb211a | ||
|
|
78aca25722 | ||
|
|
9cb0dd146e | ||
|
|
2938af32a9 | ||
|
|
ece0bbdcef | ||
|
|
2dc055a5b2 | ||
|
|
46c2edeb96 | ||
|
|
9d5451f588 | ||
|
|
9193c54f86 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ CLAUDE.md
|
||||
|
||||
# Secrets (compile-time configuration)
|
||||
secrets.toml
|
||||
.env
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "one-kvm"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
authors = ["SilentWind"]
|
||||
description = "A open and lightweight IP-KVM solution written in Rust"
|
||||
|
||||
101
README.md
101
README.md
@@ -1,5 +1,4 @@
|
||||
<div align="center">
|
||||
<img src="https://github.com/mofeng-git/Build-Armbian/assets/62919083/add9743a-0987-4e8a-b2cb-62121f236582" alt="One-KVM Logo" width="300">
|
||||
<h1>One-KVM</h1>
|
||||
<p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p>
|
||||
|
||||
@@ -19,16 +18,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [项目概述](#项目概述)
|
||||
- [迁移说明](#迁移说明)
|
||||
- [功能介绍](#功能介绍)
|
||||
- [快速开始](#快速开始)
|
||||
- [贡献与反馈](#贡献与反馈)
|
||||
- [致谢](#致谢)
|
||||
- [许可证](#许可证)
|
||||
|
||||
## 📖 项目概述
|
||||
|
||||
**One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。
|
||||
@@ -74,85 +63,14 @@
|
||||
- Web UI 配置,多语言支持(中文/英文)
|
||||
- 内置 Web 终端(ttyd)内网穿透支持(gostc)、P2P 组网支持(EasyTier)、RustDesk 协议集成(用于跨平台远程访问能力扩展)
|
||||
|
||||
## ⚡ 快速开始
|
||||
## ⚡ 安装使用
|
||||
|
||||
安装方式:Docker / DEB 软件包 / 飞牛 NAS(FPK)。
|
||||
|
||||
### 方式一: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`。
|
||||
|
||||
### 方式三:飞牛 NAS(FPK)安装
|
||||
|
||||
前提条件:
|
||||
|
||||
- 飞牛 NAS 系统(目前仅支持 x86_64 架构)
|
||||
- 插好 USB HDMI 采集卡、CH340+CH9329 HID 线
|
||||
|
||||
安装步骤:
|
||||
|
||||
1. 从 GitHub Releases 下载 `*.fpk` 软件包:[Releases](https://github.com/mofeng-git/One-KVM/releases)
|
||||
2. 在飞牛应用商店选择“手动安装”,导入 `*.fpk`
|
||||
|
||||
访问 Web 界面:`http://<设备IP>:8420`。
|
||||
可以访问 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/) 获取详细信息。
|
||||
|
||||
## 报告问题
|
||||
|
||||
如果您发现了问题,请:
|
||||
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告
|
||||
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告,或加入 QQ 群聊反馈。
|
||||
2. 提供详细的错误信息和复现步骤
|
||||
3. 包含您的硬件配置和系统信息
|
||||
|
||||
@@ -269,6 +187,14 @@ sudo apt install ./one-kvm_*_*.deb
|
||||
|
||||
- 葱
|
||||
|
||||
- MaxZ
|
||||
|
||||
- 爱发电用户_c5f33
|
||||
|
||||
- 爱发电用户_09386
|
||||
|
||||
- 爱发电用户_JT6c
|
||||
|
||||
- ......
|
||||
|
||||
</details>
|
||||
@@ -277,11 +203,6 @@ sudo apt install ./one-kvm_*_*.deb
|
||||
|
||||
本项目得到以下赞助商的支持:
|
||||
|
||||
**CDN 加速及安全防护:**
|
||||
- **[Tencent EdgeOne](https://edgeone.ai/zh?from=github)** - 提供 CDN 加速及安全防护服务
|
||||
|
||||

|
||||
|
||||
**文件存储服务:**
|
||||
- **[Huang1111公益计划](https://pan.huang1111.cn/s/mxkx3T1)** - 提供免登录下载服务
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ RUN apt-get update && \
|
||||
COPY --chmod=755 init.sh /init.sh
|
||||
|
||||
# Copy binaries (these are placed by the build script)
|
||||
COPY --chmod=755 one-kvm ttyd gostc easytier-core /usr/bin/
|
||||
COPY --chmod=755 one-kvm ttyd /usr/bin/
|
||||
|
||||
# Copy ventoy resources if they exist
|
||||
COPY ventoy/ /etc/one-kvm/ventoy/
|
||||
|
||||
48
build/Dockerfile.runtime-full
Normal file
48
build/Dockerfile.runtime-full
Normal 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"]
|
||||
@@ -25,11 +25,13 @@ echo_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Configuration
|
||||
REGISTRY="${REGISTRY:-}" # e.g., docker.io/username or ghcr.io/username
|
||||
IMAGE_NAME="${IMAGE_NAME:-one-kvm}"
|
||||
IMAGE_NAME="${IMAGE_NAME:-}"
|
||||
TAG="${TAG:-latest}"
|
||||
VARIANT="${VARIANT:-minimal}"
|
||||
INCLUDE_THIRD_PARTY=false
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
STAGING_DIR="$PROJECT_ROOT/build-staging"
|
||||
BASE_STAGING_DIR="$PROJECT_ROOT/build-staging"
|
||||
|
||||
# Full image name with registry
|
||||
get_full_image_name() {
|
||||
@@ -77,6 +79,18 @@ while [[ $# -gt 0 ]]; do
|
||||
REGISTRY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--image-name)
|
||||
IMAGE_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--variant)
|
||||
VARIANT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--full)
|
||||
VARIANT="full"
|
||||
shift
|
||||
;;
|
||||
--build)
|
||||
BUILD_BINARY=true
|
||||
shift
|
||||
@@ -91,9 +105,12 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " Use comma to specify multiple: linux/amd64,linux/arm64"
|
||||
echo " Default: $DEFAULT_PLATFORM"
|
||||
echo " --registry REGISTRY Container registry (e.g., docker.io/user, ghcr.io/user)"
|
||||
echo " --image-name NAME Override image name (default: one-kvm or one-kvm-full)"
|
||||
echo " --push Push image to registry"
|
||||
echo " --load Load image to local Docker (single platform only)"
|
||||
echo " --tag TAG Image tag (default: latest)"
|
||||
echo " --variant VARIANT Image variant: minimal or full (default: minimal)"
|
||||
echo " --full Shortcut for --variant full"
|
||||
echo " --build Also build the binary with cross (optional)"
|
||||
echo " --help Show this help"
|
||||
echo ""
|
||||
@@ -101,6 +118,9 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " # Build for current platform and load locally"
|
||||
echo " $0 --platform linux/arm64 --load"
|
||||
echo ""
|
||||
echo " # Build full image (includes gostc + easytier)"
|
||||
echo " $0 --variant full --platform linux/arm64 --load"
|
||||
echo ""
|
||||
echo " # Build and push single platform"
|
||||
echo " $0 --platform linux/arm64 --registry docker.io/user --push"
|
||||
echo ""
|
||||
@@ -115,6 +135,28 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Normalize variant and image name
|
||||
case "$VARIANT" in
|
||||
minimal)
|
||||
INCLUDE_THIRD_PARTY=false
|
||||
;;
|
||||
full)
|
||||
INCLUDE_THIRD_PARTY=true
|
||||
;;
|
||||
*)
|
||||
echo_error "Unknown variant: $VARIANT (expected: minimal or full)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$IMAGE_NAME" ]; then
|
||||
if [ "$VARIANT" = "full" ]; then
|
||||
IMAGE_NAME="one-kvm-full"
|
||||
else
|
||||
IMAGE_NAME="one-kvm"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Default platform
|
||||
if [ -z "$PLATFORMS" ]; then
|
||||
PLATFORMS="$DEFAULT_PLATFORM"
|
||||
@@ -176,21 +218,23 @@ download_tools() {
|
||||
chmod +x "$staging/ttyd"
|
||||
fi
|
||||
|
||||
# gostc
|
||||
if [ ! -f "$staging/gostc" ]; then
|
||||
curl -fsSL "$GOSTC_URL" -o /tmp/gostc.tar.gz
|
||||
tar -xzf /tmp/gostc.tar.gz -C "$staging"
|
||||
chmod +x "$staging/gostc"
|
||||
rm /tmp/gostc.tar.gz
|
||||
fi
|
||||
if [ "$INCLUDE_THIRD_PARTY" = true ]; then
|
||||
# gostc
|
||||
if [ ! -f "$staging/gostc" ]; then
|
||||
curl -fsSL "$GOSTC_URL" -o /tmp/gostc.tar.gz
|
||||
tar -xzf /tmp/gostc.tar.gz -C "$staging"
|
||||
chmod +x "$staging/gostc"
|
||||
rm /tmp/gostc.tar.gz
|
||||
fi
|
||||
|
||||
# easytier
|
||||
if [ ! -f "$staging/easytier-core" ]; then
|
||||
curl -fsSL "$EASYTIER_URL" -o /tmp/easytier.zip
|
||||
unzip -o /tmp/easytier.zip -d /tmp/easytier
|
||||
cp "/tmp/easytier/$EASYTIER_DIR/easytier-core" "$staging/easytier-core"
|
||||
chmod +x "$staging/easytier-core"
|
||||
rm -rf /tmp/easytier.zip /tmp/easytier
|
||||
# easytier
|
||||
if [ ! -f "$staging/easytier-core" ]; then
|
||||
curl -fsSL "$EASYTIER_URL" -o /tmp/easytier.zip
|
||||
unzip -o /tmp/easytier.zip -d /tmp/easytier
|
||||
cp "/tmp/easytier/$EASYTIER_DIR/easytier-core" "$staging/easytier-core"
|
||||
chmod +x "$staging/easytier-core"
|
||||
rm -rf /tmp/easytier.zip /tmp/easytier
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -198,13 +242,14 @@ download_tools() {
|
||||
build_for_platform() {
|
||||
local platform="$1"
|
||||
local target=$(platform_to_target "$platform")
|
||||
local staging="$STAGING_DIR/$target"
|
||||
local staging="$BASE_STAGING_DIR/$VARIANT/$target"
|
||||
|
||||
echo_info "=========================================="
|
||||
echo_info "Processing: $platform ($target)"
|
||||
echo_info "=========================================="
|
||||
|
||||
# Create staging directory
|
||||
rm -rf "$staging"
|
||||
mkdir -p "$staging/ventoy"
|
||||
|
||||
# Build binary if requested
|
||||
@@ -252,7 +297,11 @@ build_for_platform() {
|
||||
fi
|
||||
|
||||
# Copy Dockerfile
|
||||
cp "$PROJECT_ROOT/build/Dockerfile.runtime" "$staging/Dockerfile"
|
||||
local dockerfile="$PROJECT_ROOT/build/Dockerfile.runtime"
|
||||
if [ "$INCLUDE_THIRD_PARTY" = true ]; then
|
||||
dockerfile="$PROJECT_ROOT/build/Dockerfile.runtime-full"
|
||||
fi
|
||||
cp "$dockerfile" "$staging/Dockerfile"
|
||||
|
||||
# Build Docker image
|
||||
echo_info "Building Docker image..."
|
||||
@@ -292,6 +341,7 @@ main() {
|
||||
|
||||
echo_info "One-KVM Docker Image Builder"
|
||||
echo_info "Image: $full_image:$TAG"
|
||||
echo_info "Variant: $VARIANT"
|
||||
echo_info "Platforms: $PLATFORMS"
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
echo_info "Registry: $REGISTRY"
|
||||
|
||||
@@ -419,10 +419,10 @@ mod ffmpeg {
|
||||
builder.include(rkrga_dir.join("im2d_api"));
|
||||
}
|
||||
}
|
||||
builder.file(ffmpeg_hw_dir.join("ffmpeg_hw_mjpeg_h264.cpp"));
|
||||
builder.file(ffmpeg_hw_dir.join("ffmpeg_hw_mjpeg_h26x.cpp"));
|
||||
} else {
|
||||
println!(
|
||||
"cargo:info=Skipping ffmpeg_hw_mjpeg_h264.cpp (RKMPP) for arch {}",
|
||||
"cargo:info=Skipping ffmpeg_hw_mjpeg_h26x.cpp (RKMPP) for arch {}",
|
||||
target_arch
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
#include "linux.h"
|
||||
#include "../../log.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fstream>
|
||||
#include <signal.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <string>
|
||||
|
||||
// Check for NVIDIA driver support by loading CUDA libraries
|
||||
int linux_support_nv()
|
||||
@@ -106,6 +110,57 @@ int linux_support_rkmpp() {
|
||||
// Check for V4L2 Memory-to-Memory (M2M) codec support
|
||||
// Returns 0 if a M2M capable device is found, -1 otherwise
|
||||
int linux_support_v4l2m2m() {
|
||||
auto to_lower = [](std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
auto read_text_file = [](const char *path, std::string *out) -> bool {
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open()) {
|
||||
return false;
|
||||
}
|
||||
std::getline(file, *out);
|
||||
return !out->empty();
|
||||
};
|
||||
|
||||
auto allow_video0_probe = []() -> bool {
|
||||
const char *env = std::getenv("ONE_KVM_V4L2M2M_ALLOW_VIDEO0");
|
||||
if (env == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (env[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
return std::strcmp(env, "0") != 0;
|
||||
};
|
||||
|
||||
auto is_amlogic_vdec = [&]() -> bool {
|
||||
std::string name;
|
||||
std::string modalias;
|
||||
if (read_text_file("/sys/class/video4linux/video0/name", &name)) {
|
||||
const std::string lowered = to_lower(name);
|
||||
if (lowered.find("meson") != std::string::npos ||
|
||||
lowered.find("vdec") != std::string::npos ||
|
||||
lowered.find("decoder") != std::string::npos ||
|
||||
lowered.find("video-decoder") != std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (read_text_file("/sys/class/video4linux/video0/device/modalias", &modalias)) {
|
||||
const std::string lowered = to_lower(modalias);
|
||||
if (lowered.find("amlogic") != std::string::npos ||
|
||||
lowered.find("meson") != std::string::npos ||
|
||||
lowered.find("gxl-vdec") != std::string::npos ||
|
||||
lowered.find("gx-vdec") != std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check common V4L2 M2M device paths used by various ARM SoCs
|
||||
const char *m2m_devices[] = {
|
||||
"/dev/video10", // Common M2M encoder device
|
||||
@@ -115,6 +170,13 @@ int linux_support_v4l2m2m() {
|
||||
|
||||
for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {
|
||||
if (access(m2m_devices[i], F_OK) == 0) {
|
||||
if (std::strcmp(m2m_devices[i], "/dev/video0") == 0) {
|
||||
if (!allow_video0_probe() && is_amlogic_vdec()) {
|
||||
LOG_TRACE(std::string("V4L2 M2M: Skipping /dev/video0 (Amlogic vdec)"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Device exists, check if it's an M2M device by trying to open it
|
||||
int fd = open(m2m_devices[i], O_RDWR | O_NONBLOCK);
|
||||
if (fd >= 0) {
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct FfmpegHwMjpegH264 FfmpegHwMjpegH264;
|
||||
// MJPEG -> H26x (H.264 / H.265) hardware pipeline
|
||||
typedef struct FfmpegHwMjpegH26x FfmpegHwMjpegH26x;
|
||||
|
||||
FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
|
||||
// Create a new MJPEG -> H26x pipeline.
|
||||
FfmpegHwMjpegH26x* ffmpeg_hw_mjpeg_h26x_new(const char* dec_name,
|
||||
const char* enc_name,
|
||||
int width,
|
||||
int height,
|
||||
@@ -17,7 +19,8 @@ FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
|
||||
int gop,
|
||||
int thread_count);
|
||||
|
||||
int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* ctx,
|
||||
// Encode one MJPEG frame. Returns 1 if output produced, 0 if no output, <0 on error.
|
||||
int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* ctx,
|
||||
const uint8_t* data,
|
||||
int len,
|
||||
int64_t pts_ms,
|
||||
@@ -25,16 +28,21 @@ int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* ctx,
|
||||
int* out_len,
|
||||
int* out_keyframe);
|
||||
|
||||
int ffmpeg_hw_mjpeg_h264_reconfigure(FfmpegHwMjpegH264* ctx,
|
||||
// Reconfigure bitrate/gop (best-effort, may recreate encoder internally).
|
||||
int ffmpeg_hw_mjpeg_h26x_reconfigure(FfmpegHwMjpegH26x* ctx,
|
||||
int bitrate_kbps,
|
||||
int gop);
|
||||
|
||||
int ffmpeg_hw_mjpeg_h264_request_keyframe(FfmpegHwMjpegH264* ctx);
|
||||
// Request next frame to be a keyframe.
|
||||
int ffmpeg_hw_mjpeg_h26x_request_keyframe(FfmpegHwMjpegH26x* ctx);
|
||||
|
||||
void ffmpeg_hw_mjpeg_h264_free(FfmpegHwMjpegH264* ctx);
|
||||
// Free pipeline resources.
|
||||
void ffmpeg_hw_mjpeg_h26x_free(FfmpegHwMjpegH26x* ctx);
|
||||
|
||||
// Free packet buffer allocated by ffmpeg_hw_mjpeg_h26x_encode.
|
||||
void ffmpeg_hw_packet_free(uint8_t* data);
|
||||
|
||||
// Get last error message (thread-local).
|
||||
const char* ffmpeg_hw_last_error(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -35,7 +35,7 @@ static const char* pix_fmt_name(AVPixelFormat fmt) {
|
||||
return name ? name : "unknown";
|
||||
}
|
||||
|
||||
struct FfmpegHwMjpegH264Ctx {
|
||||
struct FfmpegHwMjpegH26xCtx {
|
||||
AVCodecContext *dec_ctx = nullptr;
|
||||
AVCodecContext *enc_ctx = nullptr;
|
||||
AVPacket *dec_pkt = nullptr;
|
||||
@@ -48,6 +48,8 @@ struct FfmpegHwMjpegH264Ctx {
|
||||
std::string enc_name;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int aligned_width = 0;
|
||||
int aligned_height = 0;
|
||||
int fps = 30;
|
||||
int bitrate_kbps = 2000;
|
||||
int gop = 60;
|
||||
@@ -57,7 +59,7 @@ struct FfmpegHwMjpegH264Ctx {
|
||||
|
||||
static enum AVPixelFormat get_hw_format(AVCodecContext *ctx,
|
||||
const enum AVPixelFormat *pix_fmts) {
|
||||
auto *self = reinterpret_cast<FfmpegHwMjpegH264Ctx *>(ctx->opaque);
|
||||
auto *self = reinterpret_cast<FfmpegHwMjpegH26xCtx *>(ctx->opaque);
|
||||
if (self && self->hw_pixfmt != AV_PIX_FMT_NONE) {
|
||||
const enum AVPixelFormat *p;
|
||||
for (p = pix_fmts; *p != AV_PIX_FMT_NONE; p++) {
|
||||
@@ -69,7 +71,7 @@ static enum AVPixelFormat get_hw_format(AVCodecContext *ctx,
|
||||
return pix_fmts[0];
|
||||
}
|
||||
|
||||
static int init_decoder(FfmpegHwMjpegH264Ctx *ctx) {
|
||||
static int init_decoder(FfmpegHwMjpegH26xCtx *ctx) {
|
||||
const AVCodec *dec = avcodec_find_decoder_by_name(ctx->dec_name.c_str());
|
||||
if (!dec) {
|
||||
set_last_error("Decoder not found: " + ctx->dec_name);
|
||||
@@ -127,7 +129,7 @@ static int init_decoder(FfmpegHwMjpegH264Ctx *ctx) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
|
||||
static int init_encoder(FfmpegHwMjpegH26xCtx *ctx, AVBufferRef *frames_ctx) {
|
||||
const AVCodec *enc = avcodec_find_encoder_by_name(ctx->enc_name.c_str());
|
||||
if (!enc) {
|
||||
set_last_error("Encoder not found: " + ctx->enc_name);
|
||||
@@ -142,6 +144,10 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
|
||||
|
||||
ctx->enc_ctx->width = ctx->width;
|
||||
ctx->enc_ctx->height = ctx->height;
|
||||
ctx->enc_ctx->coded_width = ctx->width;
|
||||
ctx->enc_ctx->coded_height = ctx->height;
|
||||
ctx->aligned_width = ctx->width;
|
||||
ctx->aligned_height = ctx->height;
|
||||
ctx->enc_ctx->time_base = AVRational{1, 1000};
|
||||
ctx->enc_ctx->framerate = AVRational{ctx->fps, 1};
|
||||
ctx->enc_ctx->bit_rate = (int64_t)ctx->bitrate_kbps * 1000;
|
||||
@@ -155,8 +161,14 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
|
||||
if (hwfc) {
|
||||
ctx->enc_ctx->pix_fmt = static_cast<AVPixelFormat>(hwfc->format);
|
||||
ctx->enc_ctx->sw_pix_fmt = static_cast<AVPixelFormat>(hwfc->sw_format);
|
||||
if (hwfc->width > 0) ctx->enc_ctx->width = hwfc->width;
|
||||
if (hwfc->height > 0) ctx->enc_ctx->height = hwfc->height;
|
||||
if (hwfc->width > 0) {
|
||||
ctx->aligned_width = hwfc->width;
|
||||
ctx->enc_ctx->coded_width = hwfc->width;
|
||||
}
|
||||
if (hwfc->height > 0) {
|
||||
ctx->aligned_height = hwfc->height;
|
||||
ctx->enc_ctx->coded_height = hwfc->height;
|
||||
}
|
||||
}
|
||||
ctx->hw_frames_ctx = av_buffer_ref(frames_ctx);
|
||||
ctx->enc_ctx->hw_frames_ctx = av_buffer_ref(frames_ctx);
|
||||
@@ -167,7 +179,11 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
|
||||
|
||||
AVDictionary *opts = nullptr;
|
||||
av_dict_set(&opts, "rc_mode", "CBR", 0);
|
||||
av_dict_set(&opts, "profile", "high", 0);
|
||||
if (enc->id == AV_CODEC_ID_H264) {
|
||||
av_dict_set(&opts, "profile", "high", 0);
|
||||
} else if (enc->id == AV_CODEC_ID_HEVC) {
|
||||
av_dict_set(&opts, "profile", "main", 0);
|
||||
}
|
||||
av_dict_set_int(&opts, "qp_init", 23, 0);
|
||||
av_dict_set_int(&opts, "qp_max", 48, 0);
|
||||
av_dict_set_int(&opts, "qp_min", 0, 0);
|
||||
@@ -195,7 +211,7 @@ static int init_encoder(FfmpegHwMjpegH264Ctx *ctx, AVBufferRef *frames_ctx) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void free_encoder(FfmpegHwMjpegH264Ctx *ctx) {
|
||||
static void free_encoder(FfmpegHwMjpegH26xCtx *ctx) {
|
||||
if (ctx->enc_ctx) {
|
||||
avcodec_free_context(&ctx->enc_ctx);
|
||||
ctx->enc_ctx = nullptr;
|
||||
@@ -208,7 +224,7 @@ static void free_encoder(FfmpegHwMjpegH264Ctx *ctx) {
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
|
||||
extern "C" FfmpegHwMjpegH26x* ffmpeg_hw_mjpeg_h26x_new(const char* dec_name,
|
||||
const char* enc_name,
|
||||
int width,
|
||||
int height,
|
||||
@@ -217,11 +233,11 @@ extern "C" FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
|
||||
int gop,
|
||||
int thread_count) {
|
||||
if (!dec_name || !enc_name || width <= 0 || height <= 0) {
|
||||
set_last_error("Invalid parameters for ffmpeg_hw_mjpeg_h264_new");
|
||||
set_last_error("Invalid parameters for ffmpeg_hw_mjpeg_h26x_new");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto *ctx = new FfmpegHwMjpegH264Ctx();
|
||||
auto *ctx = new FfmpegHwMjpegH26xCtx();
|
||||
ctx->dec_name = dec_name;
|
||||
ctx->enc_name = enc_name;
|
||||
ctx->width = width;
|
||||
@@ -232,14 +248,14 @@ extern "C" FfmpegHwMjpegH264* ffmpeg_hw_mjpeg_h264_new(const char* dec_name,
|
||||
ctx->thread_count = thread_count > 0 ? thread_count : 1;
|
||||
|
||||
if (init_decoder(ctx) != 0) {
|
||||
ffmpeg_hw_mjpeg_h264_free(reinterpret_cast<FfmpegHwMjpegH264*>(ctx));
|
||||
ffmpeg_hw_mjpeg_h26x_free(reinterpret_cast<FfmpegHwMjpegH26x*>(ctx));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return reinterpret_cast<FfmpegHwMjpegH264*>(ctx);
|
||||
return reinterpret_cast<FfmpegHwMjpegH26x*>(ctx);
|
||||
}
|
||||
|
||||
extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
|
||||
extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
||||
const uint8_t* data,
|
||||
int len,
|
||||
int64_t pts_ms,
|
||||
@@ -251,7 +267,7 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
|
||||
return -1;
|
||||
}
|
||||
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
|
||||
*out_data = nullptr;
|
||||
*out_len = 0;
|
||||
*out_keyframe = 0;
|
||||
@@ -310,6 +326,14 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
|
||||
ctx->force_keyframe = false;
|
||||
}
|
||||
|
||||
// Apply visible size crop if aligned buffer is larger than display size
|
||||
if (ctx->aligned_width > 0 && ctx->width > 0 && ctx->aligned_width > ctx->width) {
|
||||
send_frame->crop_right = ctx->aligned_width - ctx->width;
|
||||
}
|
||||
if (ctx->aligned_height > 0 && ctx->height > 0 && ctx->aligned_height > ctx->height) {
|
||||
send_frame->crop_bottom = ctx->aligned_height - ctx->height;
|
||||
}
|
||||
|
||||
send_frame->pts = pts_ms; // time_base is ms
|
||||
|
||||
ret = avcodec_send_frame(ctx->enc_ctx, send_frame);
|
||||
@@ -379,14 +403,14 @@ extern "C" int ffmpeg_hw_mjpeg_h264_encode(FfmpegHwMjpegH264* handle,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" int ffmpeg_hw_mjpeg_h264_reconfigure(FfmpegHwMjpegH264* handle,
|
||||
extern "C" int ffmpeg_hw_mjpeg_h26x_reconfigure(FfmpegHwMjpegH26x* handle,
|
||||
int bitrate_kbps,
|
||||
int gop) {
|
||||
if (!handle) {
|
||||
set_last_error("Invalid handle for reconfigure");
|
||||
return -1;
|
||||
}
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
|
||||
if (!ctx->enc_ctx || !ctx->hw_frames_ctx) {
|
||||
set_last_error("Encoder not initialized for reconfigure");
|
||||
return -1;
|
||||
@@ -407,18 +431,18 @@ extern "C" int ffmpeg_hw_mjpeg_h264_reconfigure(FfmpegHwMjpegH264* handle,
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int ffmpeg_hw_mjpeg_h264_request_keyframe(FfmpegHwMjpegH264* handle) {
|
||||
extern "C" int ffmpeg_hw_mjpeg_h26x_request_keyframe(FfmpegHwMjpegH26x* handle) {
|
||||
if (!handle) {
|
||||
set_last_error("Invalid handle for request_keyframe");
|
||||
return -1;
|
||||
}
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
|
||||
ctx->force_keyframe = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" void ffmpeg_hw_mjpeg_h264_free(FfmpegHwMjpegH264* handle) {
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH264Ctx*>(handle);
|
||||
extern "C" void ffmpeg_hw_mjpeg_h26x_free(FfmpegHwMjpegH26x* handle) {
|
||||
auto *ctx = reinterpret_cast<FfmpegHwMjpegH26xCtx*>(handle);
|
||||
if (!ctx) return;
|
||||
|
||||
if (ctx->dec_pkt) av_packet_free(&ctx->dec_pkt);
|
||||
@@ -10,7 +10,7 @@ use std::{
|
||||
include!(concat!(env!("OUT_DIR"), "/ffmpeg_hw_ffi.rs"));
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HwMjpegH264Config {
|
||||
pub struct HwMjpegH26xConfig {
|
||||
pub decoder: String,
|
||||
pub encoder: String,
|
||||
pub width: i32,
|
||||
@@ -21,19 +21,19 @@ pub struct HwMjpegH264Config {
|
||||
pub thread_count: i32,
|
||||
}
|
||||
|
||||
pub struct HwMjpegH264Pipeline {
|
||||
ctx: *mut FfmpegHwMjpegH264,
|
||||
config: HwMjpegH264Config,
|
||||
pub struct HwMjpegH26xPipeline {
|
||||
ctx: *mut FfmpegHwMjpegH26x,
|
||||
config: HwMjpegH26xConfig,
|
||||
}
|
||||
|
||||
unsafe impl Send for HwMjpegH264Pipeline {}
|
||||
unsafe impl Send for HwMjpegH26xPipeline {}
|
||||
|
||||
impl HwMjpegH264Pipeline {
|
||||
pub fn new(config: HwMjpegH264Config) -> Result<Self, String> {
|
||||
impl HwMjpegH26xPipeline {
|
||||
pub fn new(config: HwMjpegH26xConfig) -> Result<Self, String> {
|
||||
unsafe {
|
||||
let dec = CString::new(config.decoder.as_str()).map_err(|_| "decoder name invalid".to_string())?;
|
||||
let enc = CString::new(config.encoder.as_str()).map_err(|_| "encoder name invalid".to_string())?;
|
||||
let ctx = ffmpeg_hw_mjpeg_h264_new(
|
||||
let ctx = ffmpeg_hw_mjpeg_h26x_new(
|
||||
dec.as_ptr(),
|
||||
enc.as_ptr(),
|
||||
config.width,
|
||||
@@ -55,7 +55,7 @@ impl HwMjpegH264Pipeline {
|
||||
let mut out_data: *mut u8 = std::ptr::null_mut();
|
||||
let mut out_len: c_int = 0;
|
||||
let mut out_key: c_int = 0;
|
||||
let ret = ffmpeg_hw_mjpeg_h264_encode(
|
||||
let ret = ffmpeg_hw_mjpeg_h26x_encode(
|
||||
self.ctx,
|
||||
data.as_ptr(),
|
||||
data.len() as c_int,
|
||||
@@ -80,7 +80,7 @@ impl HwMjpegH264Pipeline {
|
||||
|
||||
pub fn reconfigure(&mut self, bitrate_kbps: i32, gop: i32) -> Result<(), String> {
|
||||
unsafe {
|
||||
let ret = ffmpeg_hw_mjpeg_h264_reconfigure(self.ctx, bitrate_kbps, gop);
|
||||
let ret = ffmpeg_hw_mjpeg_h26x_reconfigure(self.ctx, bitrate_kbps, gop);
|
||||
if ret != 0 {
|
||||
return Err(last_error_message());
|
||||
}
|
||||
@@ -92,15 +92,15 @@ impl HwMjpegH264Pipeline {
|
||||
|
||||
pub fn request_keyframe(&mut self) {
|
||||
unsafe {
|
||||
let _ = ffmpeg_hw_mjpeg_h264_request_keyframe(self.ctx);
|
||||
let _ = ffmpeg_hw_mjpeg_h26x_request_keyframe(self.ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HwMjpegH264Pipeline {
|
||||
impl Drop for HwMjpegH26xPipeline {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
ffmpeg_hw_mjpeg_h264_free(self.ctx);
|
||||
ffmpeg_hw_mjpeg_h26x_free(self.ctx);
|
||||
}
|
||||
self.ctx = std::ptr::null_mut();
|
||||
}
|
||||
|
||||
@@ -40,20 +40,20 @@ pub async fn auth_middleware(
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let raw_path = request.uri().path();
|
||||
// When this middleware is mounted under /api, Axum strips the prefix for the inner router.
|
||||
// Normalize the path so checks work whether it is mounted or not.
|
||||
let path = raw_path.strip_prefix("/api").unwrap_or(raw_path);
|
||||
|
||||
// Check if system is initialized
|
||||
if !state.config.is_initialized() {
|
||||
// Allow access to setup endpoints when not initialized
|
||||
let path = request.uri().path();
|
||||
if path.starts_with("/api/setup")
|
||||
|| path == "/api/info"
|
||||
|| path.starts_with("/") && !path.starts_with("/api/")
|
||||
{
|
||||
// Allow only setup-related endpoints when not initialized
|
||||
if is_setup_public_endpoint(path) {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth
|
||||
let path = request.uri().path();
|
||||
if is_public_endpoint(path) {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
@@ -89,21 +89,14 @@ fn unauthorized_response(message: &str) -> Response {
|
||||
|
||||
/// Check if endpoint is public (no auth required)
|
||||
fn is_public_endpoint(path: &str) -> bool {
|
||||
// Note: paths here are relative to /api since middleware is applied before nest
|
||||
// Note: paths here are relative to /api since middleware is applied within the nested router
|
||||
matches!(
|
||||
path,
|
||||
"/"
|
||||
| "/auth/login"
|
||||
| "/info"
|
||||
| "/health"
|
||||
| "/setup"
|
||||
| "/setup/init"
|
||||
// Also check with /api prefix for direct access
|
||||
| "/api/auth/login"
|
||||
| "/api/info"
|
||||
| "/api/health"
|
||||
| "/api/setup"
|
||||
| "/api/setup/init"
|
||||
) || path.starts_with("/assets/")
|
||||
|| path.starts_with("/static/")
|
||||
|| path.ends_with(".js")
|
||||
@@ -112,3 +105,11 @@ fn is_public_endpoint(path: &str) -> bool {
|
||||
|| path.ends_with(".png")
|
||||
|| path.ends_with(".svg")
|
||||
}
|
||||
|
||||
/// Setup-only endpoints allowed before initialization.
|
||||
fn is_setup_public_endpoint(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/setup" | "/setup/init" | "/devices" | "/stream/codecs"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,12 @@ impl Default for OtgDescriptorConfig {
|
||||
pub enum OtgHidProfile {
|
||||
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
|
||||
Full,
|
||||
/// Full HID device set without MSD
|
||||
FullNoMsd,
|
||||
/// Full HID device set without consumer control
|
||||
FullNoConsumer,
|
||||
/// Full HID device set without consumer control and MSD
|
||||
FullNoConsumerNoMsd,
|
||||
/// Legacy profile: only keyboard
|
||||
LegacyKeyboard,
|
||||
/// Legacy profile: only relative mouse
|
||||
@@ -201,6 +207,15 @@ impl OtgHidFunctions {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn full_no_consumer() -> Self {
|
||||
Self {
|
||||
keyboard: true,
|
||||
mouse_relative: true,
|
||||
mouse_absolute: true,
|
||||
consumer: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn legacy_keyboard() -> Self {
|
||||
Self {
|
||||
keyboard: true,
|
||||
@@ -234,6 +249,9 @@ impl OtgHidProfile {
|
||||
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
|
||||
match self {
|
||||
Self::Full => OtgHidFunctions::full(),
|
||||
Self::FullNoMsd => OtgHidFunctions::full(),
|
||||
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
|
||||
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
|
||||
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
|
||||
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
|
||||
Self::Custom => custom.clone(),
|
||||
@@ -558,7 +576,9 @@ pub struct WebConfig {
|
||||
pub http_port: u16,
|
||||
/// HTTPS port
|
||||
pub https_port: u16,
|
||||
/// Bind address
|
||||
/// Bind addresses (preferred)
|
||||
pub bind_addresses: Vec<String>,
|
||||
/// Bind address (legacy)
|
||||
pub bind_address: String,
|
||||
/// Enable HTTPS
|
||||
pub https_enabled: bool,
|
||||
@@ -573,6 +593,7 @@ impl Default for WebConfig {
|
||||
Self {
|
||||
http_port: 8080,
|
||||
https_port: 8443,
|
||||
bind_addresses: Vec::new(),
|
||||
bind_address: "0.0.0.0".to_string(),
|
||||
https_enabled: false,
|
||||
ssl_cert_path: None,
|
||||
|
||||
@@ -395,6 +395,8 @@ pub struct Ch9329Backend {
|
||||
last_abs_x: AtomicU16,
|
||||
/// Last absolute mouse Y position (CH9329 coordinate: 0-4095)
|
||||
last_abs_y: AtomicU16,
|
||||
/// Whether relative mouse mode is active (set by incoming events)
|
||||
relative_mouse_active: AtomicBool,
|
||||
/// Consecutive error count
|
||||
error_count: AtomicU32,
|
||||
/// Whether a reset is in progress
|
||||
@@ -426,6 +428,7 @@ impl Ch9329Backend {
|
||||
address: DEFAULT_ADDR,
|
||||
last_abs_x: AtomicU16::new(0),
|
||||
last_abs_y: AtomicU16::new(0),
|
||||
relative_mouse_active: AtomicBool::new(false),
|
||||
error_count: AtomicU32::new(0),
|
||||
reset_in_progress: AtomicBool::new(false),
|
||||
last_success: Mutex::new(None),
|
||||
@@ -1014,12 +1017,14 @@ impl HidBackend for Ch9329Backend {
|
||||
match event.event_type {
|
||||
MouseEventType::Move => {
|
||||
// Relative movement - send delta directly without inversion
|
||||
self.relative_mouse_active.store(true, Ordering::Relaxed);
|
||||
let dx = event.x.clamp(-127, 127) as i8;
|
||||
let dy = event.y.clamp(-127, 127) as i8;
|
||||
self.send_mouse_relative(buttons, dx, dy, 0)?;
|
||||
}
|
||||
MouseEventType::MoveAbs => {
|
||||
// Absolute movement
|
||||
self.relative_mouse_active.store(false, Ordering::Relaxed);
|
||||
// Frontend sends 0-32767 (HID standard), CH9329 expects 0-4095
|
||||
let x = ((event.x.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
|
||||
let y = ((event.y.clamp(0, 32767) as u32) * CH9329_MOUSE_RESOLUTION / 32768) as u16;
|
||||
@@ -1031,28 +1036,40 @@ impl HidBackend for Ch9329Backend {
|
||||
MouseEventType::Down => {
|
||||
if let Some(button) = event.button {
|
||||
let bit = button.to_hid_bit();
|
||||
let x = self.last_abs_x.load(Ordering::Relaxed);
|
||||
let y = self.last_abs_y.load(Ordering::Relaxed);
|
||||
let new_buttons = self.mouse_buttons.fetch_or(bit, Ordering::Relaxed) | bit;
|
||||
trace!("Mouse down: {:?} buttons=0x{:02X}", button, new_buttons);
|
||||
self.send_mouse_absolute(new_buttons, x, y, 0)?;
|
||||
if self.relative_mouse_active.load(Ordering::Relaxed) {
|
||||
self.send_mouse_relative(new_buttons, 0, 0, 0)?;
|
||||
} else {
|
||||
let x = self.last_abs_x.load(Ordering::Relaxed);
|
||||
let y = self.last_abs_y.load(Ordering::Relaxed);
|
||||
self.send_mouse_absolute(new_buttons, x, y, 0)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventType::Up => {
|
||||
if let Some(button) = event.button {
|
||||
let bit = button.to_hid_bit();
|
||||
let x = self.last_abs_x.load(Ordering::Relaxed);
|
||||
let y = self.last_abs_y.load(Ordering::Relaxed);
|
||||
let new_buttons = self.mouse_buttons.fetch_and(!bit, Ordering::Relaxed) & !bit;
|
||||
trace!("Mouse up: {:?} buttons=0x{:02X}", button, new_buttons);
|
||||
self.send_mouse_absolute(new_buttons, x, y, 0)?;
|
||||
if self.relative_mouse_active.load(Ordering::Relaxed) {
|
||||
self.send_mouse_relative(new_buttons, 0, 0, 0)?;
|
||||
} else {
|
||||
let x = self.last_abs_x.load(Ordering::Relaxed);
|
||||
let y = self.last_abs_y.load(Ordering::Relaxed);
|
||||
self.send_mouse_absolute(new_buttons, x, y, 0)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventType::Scroll => {
|
||||
// Use absolute mouse for scroll with last position
|
||||
let x = self.last_abs_x.load(Ordering::Relaxed);
|
||||
let y = self.last_abs_y.load(Ordering::Relaxed);
|
||||
self.send_mouse_absolute(buttons, x, y, event.scroll)?;
|
||||
if self.relative_mouse_active.load(Ordering::Relaxed) {
|
||||
self.send_mouse_relative(buttons, 0, 0, event.scroll)?;
|
||||
} else {
|
||||
// Use absolute mouse for scroll with last position
|
||||
let x = self.last_abs_x.load(Ordering::Relaxed);
|
||||
let y = self.last_abs_y.load(Ordering::Relaxed);
|
||||
self.send_mouse_absolute(buttons, x, y, event.scroll)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,6 +1090,7 @@ impl HidBackend for Ch9329Backend {
|
||||
self.mouse_buttons.store(0, Ordering::Relaxed);
|
||||
self.last_abs_x.store(0, Ordering::Relaxed);
|
||||
self.last_abs_y.store(0, Ordering::Relaxed);
|
||||
self.relative_mouse_active.store(false, Ordering::Relaxed);
|
||||
self.send_mouse_absolute(0, 0, 0, 0)?;
|
||||
|
||||
// Reset media keys
|
||||
|
||||
334
src/hid/mod.rs
334
src/hid/mod.rs
@@ -43,24 +43,52 @@ pub struct HidInfo {
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::otg::OtgService;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
|
||||
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum HidEvent {
|
||||
Keyboard(KeyboardEvent),
|
||||
Mouse(MouseEvent),
|
||||
Consumer(ConsumerEvent),
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// HID controller managing keyboard and mouse input
|
||||
pub struct HidController {
|
||||
/// OTG Service reference (only used when backend is OTG)
|
||||
otg_service: Option<Arc<OtgService>>,
|
||||
/// Active backend
|
||||
backend: Arc<RwLock<Option<Box<dyn HidBackend>>>>,
|
||||
backend: Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
|
||||
/// Backend type (mutable for reload)
|
||||
backend_type: RwLock<HidBackendType>,
|
||||
backend_type: Arc<RwLock<HidBackendType>>,
|
||||
/// Event bus for broadcasting state changes (optional)
|
||||
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
|
||||
/// Health monitor for error tracking and recovery
|
||||
monitor: Arc<HidHealthMonitor>,
|
||||
/// HID event queue sender (non-blocking)
|
||||
hid_tx: mpsc::Sender<HidEvent>,
|
||||
/// HID event queue receiver (moved into worker on first start)
|
||||
hid_rx: Mutex<Option<mpsc::Receiver<HidEvent>>>,
|
||||
/// Coalesced mouse move (latest)
|
||||
pending_move: Arc<parking_lot::Mutex<Option<MouseEvent>>>,
|
||||
/// Pending move flag (fast path)
|
||||
pending_move_flag: Arc<AtomicBool>,
|
||||
/// Worker task handle
|
||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
/// Backend availability fast flag
|
||||
backend_available: AtomicBool,
|
||||
}
|
||||
|
||||
impl HidController {
|
||||
@@ -68,12 +96,19 @@ impl HidController {
|
||||
///
|
||||
/// For OTG backend, otg_service should be provided to support hot-reload
|
||||
pub fn new(backend_type: HidBackendType, otg_service: Option<Arc<OtgService>>) -> Self {
|
||||
let (hid_tx, hid_rx) = mpsc::channel(HID_EVENT_QUEUE_CAPACITY);
|
||||
Self {
|
||||
otg_service,
|
||||
backend: Arc::new(RwLock::new(None)),
|
||||
backend_type: RwLock::new(backend_type),
|
||||
backend_type: Arc::new(RwLock::new(backend_type)),
|
||||
events: tokio::sync::RwLock::new(None),
|
||||
monitor: Arc::new(HidHealthMonitor::with_defaults()),
|
||||
hid_tx,
|
||||
hid_rx: Mutex::new(Some(hid_rx)),
|
||||
pending_move: Arc::new(parking_lot::Mutex::new(None)),
|
||||
pending_move_flag: Arc::new(AtomicBool::new(false)),
|
||||
hid_worker: Mutex::new(None),
|
||||
backend_available: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +122,7 @@ impl HidController {
|
||||
/// Initialize the HID backend
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
let backend_type = self.backend_type.read().await.clone();
|
||||
let backend: Box<dyn HidBackend> = match backend_type {
|
||||
let backend: Arc<dyn HidBackend> = match backend_type {
|
||||
HidBackendType::Otg => {
|
||||
// Request HID functions from OtgService
|
||||
let otg_service = self
|
||||
@@ -100,7 +135,7 @@ impl HidController {
|
||||
|
||||
// Create OtgBackend from handles (no longer manages gadget itself)
|
||||
info!("Creating OTG HID backend from device paths");
|
||||
Box::new(otg::OtgBackend::from_handles(handles)?)
|
||||
Arc::new(otg::OtgBackend::from_handles(handles)?)
|
||||
}
|
||||
HidBackendType::Ch9329 {
|
||||
ref port,
|
||||
@@ -110,7 +145,7 @@ impl HidController {
|
||||
"Initializing CH9329 HID backend on {} @ {} baud",
|
||||
port, baud_rate
|
||||
);
|
||||
Box::new(ch9329::Ch9329Backend::with_baud_rate(port, baud_rate)?)
|
||||
Arc::new(ch9329::Ch9329Backend::with_baud_rate(port, baud_rate)?)
|
||||
}
|
||||
HidBackendType::None => {
|
||||
warn!("HID backend disabled");
|
||||
@@ -120,6 +155,10 @@ impl HidController {
|
||||
|
||||
backend.init().await?;
|
||||
*self.backend.write().await = Some(backend);
|
||||
self.backend_available.store(true, Ordering::Release);
|
||||
|
||||
// Start HID event worker (once)
|
||||
self.start_event_worker().await;
|
||||
|
||||
info!("HID backend initialized: {:?}", backend_type);
|
||||
Ok(())
|
||||
@@ -131,6 +170,7 @@ impl HidController {
|
||||
|
||||
// Close the backend
|
||||
*self.backend.write().await = None;
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
|
||||
// If OTG backend, notify OtgService to disable HID
|
||||
let backend_type = self.backend_type.read().await.clone();
|
||||
@@ -147,125 +187,47 @@ impl HidController {
|
||||
|
||||
/// Send keyboard event
|
||||
pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
||||
let backend = self.backend.read().await;
|
||||
match backend.as_ref() {
|
||||
Some(b) => {
|
||||
match b.send_keyboard(event).await {
|
||||
Ok(_) => {
|
||||
// Check if we were in an error state and now recovered
|
||||
if self.monitor.is_error().await {
|
||||
let backend_type = self.backend_type.read().await;
|
||||
self.monitor.report_recovered(backend_type.name_str()).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Report error to monitor, but skip temporary EAGAIN retries
|
||||
// - "eagain_retry": within threshold, just temporary busy
|
||||
// - "eagain": exceeded threshold, report as error
|
||||
if let AppError::HidError {
|
||||
ref backend,
|
||||
ref reason,
|
||||
ref error_code,
|
||||
} = e
|
||||
{
|
||||
if error_code != "eagain_retry" {
|
||||
self.monitor
|
||||
.report_error(backend, None, reason, error_code)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Err(AppError::BadRequest(
|
||||
if !self.backend_available.load(Ordering::Acquire) {
|
||||
return Err(AppError::BadRequest(
|
||||
"HID backend not available".to_string(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
self.enqueue_event(HidEvent::Keyboard(event)).await
|
||||
}
|
||||
|
||||
/// Send mouse event
|
||||
pub async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||
let backend = self.backend.read().await;
|
||||
match backend.as_ref() {
|
||||
Some(b) => {
|
||||
match b.send_mouse(event).await {
|
||||
Ok(_) => {
|
||||
// Check if we were in an error state and now recovered
|
||||
if self.monitor.is_error().await {
|
||||
let backend_type = self.backend_type.read().await;
|
||||
self.monitor.report_recovered(backend_type.name_str()).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Report error to monitor, but skip temporary EAGAIN retries
|
||||
// - "eagain_retry": within threshold, just temporary busy
|
||||
// - "eagain": exceeded threshold, report as error
|
||||
if let AppError::HidError {
|
||||
ref backend,
|
||||
ref reason,
|
||||
ref error_code,
|
||||
} = e
|
||||
{
|
||||
if error_code != "eagain_retry" {
|
||||
self.monitor
|
||||
.report_error(backend, None, reason, error_code)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Err(AppError::BadRequest(
|
||||
if !self.backend_available.load(Ordering::Acquire) {
|
||||
return Err(AppError::BadRequest(
|
||||
"HID backend not available".to_string(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(event.event_type, MouseEventType::Move | MouseEventType::MoveAbs) {
|
||||
// Best-effort: drop/merge move events if queue is full
|
||||
self.enqueue_mouse_move(event)
|
||||
} else {
|
||||
self.enqueue_event(HidEvent::Mouse(event)).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Send consumer control event (multimedia keys)
|
||||
pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||
let backend = self.backend.read().await;
|
||||
match backend.as_ref() {
|
||||
Some(b) => match b.send_consumer(event).await {
|
||||
Ok(_) => {
|
||||
if self.monitor.is_error().await {
|
||||
let backend_type = self.backend_type.read().await;
|
||||
self.monitor.report_recovered(backend_type.name_str()).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
if let AppError::HidError {
|
||||
ref backend,
|
||||
ref reason,
|
||||
ref error_code,
|
||||
} = e
|
||||
{
|
||||
if error_code != "eagain_retry" {
|
||||
self.monitor
|
||||
.report_error(backend, None, reason, error_code)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
},
|
||||
None => Err(AppError::BadRequest(
|
||||
if !self.backend_available.load(Ordering::Acquire) {
|
||||
return Err(AppError::BadRequest(
|
||||
"HID backend not available".to_string(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
self.enqueue_event(HidEvent::Consumer(event)).await
|
||||
}
|
||||
|
||||
/// Reset all keys (release all pressed keys)
|
||||
pub async fn reset(&self) -> Result<()> {
|
||||
let backend = self.backend.read().await;
|
||||
match backend.as_ref() {
|
||||
Some(b) => b.reset().await,
|
||||
None => Ok(()),
|
||||
if !self.backend_available.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
// Reset is important but best-effort; enqueue to avoid blocking
|
||||
self.enqueue_event(HidEvent::Reset).await
|
||||
}
|
||||
|
||||
/// Check if backend is available
|
||||
@@ -332,6 +294,7 @@ impl HidController {
|
||||
/// Reload the HID backend with new type
|
||||
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
||||
info!("Reloading HID backend: {:?}", new_backend_type);
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
|
||||
// Shutdown existing backend first
|
||||
if let Some(backend) = self.backend.write().await.take() {
|
||||
@@ -341,7 +304,7 @@ impl HidController {
|
||||
}
|
||||
|
||||
// Create and initialize new backend
|
||||
let new_backend: Option<Box<dyn HidBackend>> = match new_backend_type {
|
||||
let new_backend: Option<Arc<dyn HidBackend>> = match new_backend_type {
|
||||
HidBackendType::Otg => {
|
||||
info!("Initializing OTG HID backend");
|
||||
|
||||
@@ -362,11 +325,11 @@ impl HidController {
|
||||
// Create OtgBackend from handles
|
||||
match otg::OtgBackend::from_handles(handles) {
|
||||
Ok(backend) => {
|
||||
let boxed: Box<dyn HidBackend> = Box::new(backend);
|
||||
match boxed.init().await {
|
||||
let backend = Arc::new(backend);
|
||||
match backend.init().await {
|
||||
Ok(_) => {
|
||||
info!("OTG backend initialized successfully");
|
||||
Some(boxed)
|
||||
Some(backend)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to initialize OTG backend: {}", e);
|
||||
@@ -407,9 +370,9 @@ impl HidController {
|
||||
);
|
||||
match ch9329::Ch9329Backend::with_baud_rate(port, baud_rate) {
|
||||
Ok(b) => {
|
||||
let boxed = Box::new(b);
|
||||
match boxed.init().await {
|
||||
Ok(_) => Some(boxed),
|
||||
let backend = Arc::new(b);
|
||||
match backend.init().await {
|
||||
Ok(_) => Some(backend),
|
||||
Err(e) => {
|
||||
warn!("Failed to initialize CH9329 backend: {}", e);
|
||||
None
|
||||
@@ -432,6 +395,8 @@ impl HidController {
|
||||
|
||||
if self.backend.read().await.is_some() {
|
||||
info!("HID backend reloaded successfully: {:?}", new_backend_type);
|
||||
self.backend_available.store(true, Ordering::Release);
|
||||
self.start_event_worker().await;
|
||||
|
||||
// Update backend_type on success
|
||||
*self.backend_type.write().await = new_backend_type.clone();
|
||||
@@ -452,6 +417,7 @@ impl HidController {
|
||||
Ok(())
|
||||
} else {
|
||||
warn!("HID backend reload resulted in no active backend");
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
|
||||
// Update backend_type even on failure (to reflect the attempted change)
|
||||
*self.backend_type.write().await = new_backend_type.clone();
|
||||
@@ -477,6 +443,148 @@ impl HidController {
|
||||
events.publish(event);
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_event_worker(&self) {
|
||||
let mut worker_guard = self.hid_worker.lock().await;
|
||||
if worker_guard.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rx_guard = self.hid_rx.lock().await;
|
||||
let rx = match rx_guard.take() {
|
||||
Some(rx) => rx,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let backend = self.backend.clone();
|
||||
let monitor = self.monitor.clone();
|
||||
let backend_type = self.backend_type.clone();
|
||||
let pending_move = self.pending_move.clone();
|
||||
let pending_move_flag = self.pending_move_flag.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = rx;
|
||||
loop {
|
||||
let event = match rx.recv().await {
|
||||
Some(ev) => ev,
|
||||
None => break,
|
||||
};
|
||||
|
||||
process_hid_event(
|
||||
event,
|
||||
&backend,
|
||||
&monitor,
|
||||
&backend_type,
|
||||
)
|
||||
.await;
|
||||
|
||||
// After each event, flush latest move if pending
|
||||
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
||||
let move_event = { pending_move.lock().take() };
|
||||
if let Some(move_event) = move_event {
|
||||
process_hid_event(
|
||||
HidEvent::Mouse(move_event),
|
||||
&backend,
|
||||
&monitor,
|
||||
&backend_type,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*worker_guard = Some(handle);
|
||||
}
|
||||
|
||||
fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
|
||||
match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
*self.pending_move.lock() = Some(event);
|
||||
self.pending_move_flag.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
|
||||
"HID event queue closed".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn enqueue_event(&self, event: HidEvent) -> Result<()> {
|
||||
match self.hid_tx.try_send(event) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Full(ev)) => {
|
||||
// For non-move events, wait briefly to avoid dropping critical input
|
||||
let tx = self.hid_tx.clone();
|
||||
let send_result =
|
||||
tokio::time::timeout(Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS), tx.send(ev))
|
||||
.await;
|
||||
if send_result.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
warn!("HID event queue full, dropping event");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
|
||||
"HID event queue closed".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_hid_event(
|
||||
event: HidEvent,
|
||||
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
|
||||
monitor: &Arc<HidHealthMonitor>,
|
||||
backend_type: &Arc<RwLock<HidBackendType>>,
|
||||
) {
|
||||
let backend_opt = backend.read().await.clone();
|
||||
let backend = match backend_opt {
|
||||
Some(b) => b,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
futures::executor::block_on(async move {
|
||||
match event {
|
||||
HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await,
|
||||
HidEvent::Mouse(ev) => backend.send_mouse(ev).await,
|
||||
HidEvent::Consumer(ev) => backend.send_consumer(ev).await,
|
||||
HidEvent::Reset => backend.reset().await,
|
||||
}
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if monitor.is_error().await {
|
||||
let backend_type = backend_type.read().await;
|
||||
monitor.report_recovered(backend_type.name_str()).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let AppError::HidError {
|
||||
ref backend,
|
||||
ref reason,
|
||||
ref error_code,
|
||||
} = e
|
||||
{
|
||||
if error_code != "eagain_retry" {
|
||||
monitor
|
||||
.report_error(backend, None, reason, error_code)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HidController {
|
||||
|
||||
@@ -145,7 +145,7 @@ pub struct OtgBackend {
|
||||
}
|
||||
|
||||
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
|
||||
const HID_WRITE_TIMEOUT_MS: i32 = 500;
|
||||
const HID_WRITE_TIMEOUT_MS: i32 = 20;
|
||||
|
||||
impl OtgBackend {
|
||||
/// Create OTG backend from device paths provided by OtgService
|
||||
|
||||
124
src/main.rs
124
src/main.rs
@@ -1,9 +1,11 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use clap::{Parser, ValueEnum};
|
||||
use futures::{stream::FuturesUnordered, StreamExt};
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
@@ -16,9 +18,10 @@ use one_kvm::events::EventBus;
|
||||
use one_kvm::extensions::ExtensionManager;
|
||||
use one_kvm::hid::{HidBackendType, HidController};
|
||||
use one_kvm::msd::MsdController;
|
||||
use one_kvm::otg::OtgService;
|
||||
use one_kvm::otg::{configfs, OtgService};
|
||||
use one_kvm::rustdesk::RustDeskService;
|
||||
use one_kvm::state::AppState;
|
||||
use one_kvm::utils::bind_tcp_listener;
|
||||
use one_kvm::video::format::{PixelFormat, Resolution};
|
||||
use one_kvm::video::{Streamer, VideoStreamManager};
|
||||
use one_kvm::web;
|
||||
@@ -134,7 +137,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Apply CLI argument overrides to config (only if explicitly specified)
|
||||
if let Some(addr) = args.address {
|
||||
config.web.bind_address = addr;
|
||||
config.web.bind_address = addr.clone();
|
||||
config.web.bind_addresses = vec![addr];
|
||||
}
|
||||
if let Some(port) = args.http_port {
|
||||
config.web.http_port = port;
|
||||
@@ -153,19 +157,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
config.web.ssl_key_path = Some(key_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Log final configuration
|
||||
if config.web.https_enabled {
|
||||
tracing::info!(
|
||||
"Server will listen on: https://{}:{}",
|
||||
config.web.bind_address,
|
||||
config.web.https_port
|
||||
);
|
||||
let bind_ips = resolve_bind_addresses(&config.web)?;
|
||||
let scheme = if config.web.https_enabled { "https" } else { "http" };
|
||||
let bind_port = if config.web.https_enabled {
|
||||
config.web.https_port
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Server will listen on: http://{}:{}",
|
||||
config.web.bind_address,
|
||||
config.web.http_port
|
||||
);
|
||||
config.web.http_port
|
||||
};
|
||||
|
||||
// Log final configuration
|
||||
for ip in &bind_ips {
|
||||
let addr = SocketAddr::new(*ip, bind_port);
|
||||
tracing::info!("Server will listen on: {}://{}", scheme, addr);
|
||||
}
|
||||
|
||||
// Initialize session store
|
||||
@@ -312,6 +315,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
let will_use_msd = config.msd.enabled;
|
||||
|
||||
if will_use_otg_hid {
|
||||
let mut hid_functions = config.hid.effective_otg_functions();
|
||||
if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) {
|
||||
if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
|
||||
tracing::warn!(
|
||||
"UDC {} has low endpoint resources, disabling consumer control",
|
||||
udc
|
||||
);
|
||||
hid_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = otg_service.update_hid_functions(hid_functions).await {
|
||||
tracing::warn!("Failed to apply HID functions: {}", e);
|
||||
}
|
||||
if let Err(e) = otg_service.enable_hid().await {
|
||||
tracing::warn!("Failed to pre-enable HID: {}", e);
|
||||
}
|
||||
@@ -585,12 +601,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Create router
|
||||
let app = web::create_router(state.clone());
|
||||
|
||||
// Determine bind address based on HTTPS setting
|
||||
let bind_addr: SocketAddr = if config.web.https_enabled {
|
||||
format!("{}:{}", config.web.bind_address, config.web.https_port).parse()?
|
||||
} else {
|
||||
format!("{}:{}", config.web.bind_address, config.web.http_port).parse()?
|
||||
};
|
||||
// Bind sockets for configured addresses
|
||||
let listeners = bind_tcp_listeners(&bind_ips, bind_port)?;
|
||||
|
||||
// Setup graceful shutdown
|
||||
let shutdown_signal = async move {
|
||||
@@ -627,33 +639,44 @@ async fn main() -> anyhow::Result<()> {
|
||||
RustlsConfig::from_pem_file(&cert_path, &key_path).await?
|
||||
};
|
||||
|
||||
tracing::info!("Starting HTTPS server on {}", bind_addr);
|
||||
let mut servers = FuturesUnordered::new();
|
||||
for listener in listeners {
|
||||
let local_addr = listener.local_addr()?;
|
||||
tracing::info!("Starting HTTPS server on {}", local_addr);
|
||||
|
||||
let server = axum_server::bind_rustls(bind_addr, tls_config).serve(app.into_make_service());
|
||||
let server = axum_server::from_tcp_rustls(listener, tls_config.clone())?
|
||||
.serve(app.clone().into_make_service());
|
||||
servers.push(async move { server.await });
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown_signal => {
|
||||
cleanup(&state).await;
|
||||
}
|
||||
result = server => {
|
||||
if let Err(e) = result {
|
||||
result = servers.next() => {
|
||||
if let Some(Err(e)) = result {
|
||||
tracing::error!("HTTPS server error: {}", e);
|
||||
}
|
||||
cleanup(&state).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("Starting HTTP server on {}", bind_addr);
|
||||
let mut servers = FuturesUnordered::new();
|
||||
for listener in listeners {
|
||||
let local_addr = listener.local_addr()?;
|
||||
tracing::info!("Starting HTTP server on {}", local_addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind_addr).await?;
|
||||
let server = axum::serve(listener, app);
|
||||
let listener = tokio::net::TcpListener::from_std(listener)?;
|
||||
let server = axum::serve(listener, app.clone());
|
||||
servers.push(async move { server.await });
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown_signal => {
|
||||
cleanup(&state).await;
|
||||
}
|
||||
result = server => {
|
||||
if let Err(e) = result {
|
||||
result = servers.next() => {
|
||||
if let Some(Err(e)) = result {
|
||||
tracing::error!("HTTP server error: {}", e);
|
||||
}
|
||||
cleanup(&state).await;
|
||||
@@ -706,6 +729,47 @@ fn get_data_dir() -> PathBuf {
|
||||
PathBuf::from("/etc/one-kvm")
|
||||
}
|
||||
|
||||
/// Resolve bind IPs from config, preferring bind_addresses when set.
|
||||
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
|
||||
let raw_addrs = if !web.bind_addresses.is_empty() {
|
||||
web.bind_addresses.as_slice()
|
||||
} else {
|
||||
std::slice::from_ref(&web.bind_address)
|
||||
};
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut addrs = Vec::new();
|
||||
for addr in raw_addrs {
|
||||
let ip: IpAddr = addr
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid bind address: {}", addr))?;
|
||||
if seen.insert(ip) {
|
||||
addrs.push(ip);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(addrs)
|
||||
}
|
||||
|
||||
fn bind_tcp_listeners(addrs: &[IpAddr], port: u16) -> anyhow::Result<Vec<std::net::TcpListener>> {
|
||||
let mut listeners = Vec::new();
|
||||
for ip in addrs {
|
||||
let addr = SocketAddr::new(*ip, port);
|
||||
match bind_tcp_listener(addr) {
|
||||
Ok(listener) => listeners.push(listener),
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to bind {}: {}", addr, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if listeners.is_empty() {
|
||||
anyhow::bail!("Failed to bind any addresses on port {}", port);
|
||||
}
|
||||
|
||||
Ok(listeners)
|
||||
}
|
||||
|
||||
/// Parse video format and resolution from config (avoids code duplication)
|
||||
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
|
||||
let format = config
|
||||
|
||||
@@ -43,6 +43,23 @@ pub fn find_udc() -> Option<String> {
|
||||
.next()
|
||||
}
|
||||
|
||||
/// Check if UDC is known to have low endpoint resources
|
||||
pub fn is_low_endpoint_udc(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.contains("musb") || name.contains("musb-hdrc")
|
||||
}
|
||||
|
||||
/// Resolve preferred UDC name if available, otherwise auto-detect
|
||||
pub fn resolve_udc_name(preferred: Option<&str>) -> Option<String> {
|
||||
if let Some(name) = preferred {
|
||||
let path = Path::new("/sys/class/udc").join(name);
|
||||
if path.exists() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
find_udc()
|
||||
}
|
||||
|
||||
/// Write string content to a file
|
||||
///
|
||||
/// For sysfs files, this function appends a newline and flushes
|
||||
|
||||
@@ -6,9 +6,9 @@ use std::path::PathBuf;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::configfs::{
|
||||
create_dir, find_udc, is_configfs_available, remove_dir, write_file, CONFIGFS_PATH,
|
||||
DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID,
|
||||
USB_BCD_USB,
|
||||
create_dir, create_symlink, find_udc, is_configfs_available, remove_dir, remove_file,
|
||||
write_file, CONFIGFS_PATH, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
|
||||
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
||||
};
|
||||
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
|
||||
use super::function::{FunctionMeta, GadgetFunction};
|
||||
@@ -16,6 +16,8 @@ use super::hid::HidFunction;
|
||||
use super::msd::MsdFunction;
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
const REBIND_DELAY_MS: u64 = 300;
|
||||
|
||||
/// USB Gadget device descriptor configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GadgetDescriptor {
|
||||
@@ -249,9 +251,15 @@ impl OtgGadgetManager {
|
||||
AppError::Internal("No USB Device Controller (UDC) found".to_string())
|
||||
})?;
|
||||
|
||||
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind
|
||||
if let Err(e) = self.recreate_config_links() {
|
||||
warn!("Failed to recreate gadget config links before bind: {}", e);
|
||||
}
|
||||
|
||||
info!("Binding gadget to UDC: {}", udc);
|
||||
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
||||
self.bound_udc = Some(udc);
|
||||
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -262,6 +270,7 @@ impl OtgGadgetManager {
|
||||
write_file(&self.gadget_path.join("UDC"), "")?;
|
||||
self.bound_udc = None;
|
||||
info!("Unbound gadget from UDC");
|
||||
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -382,6 +391,47 @@ impl OtgGadgetManager {
|
||||
pub fn gadget_path(&self) -> &PathBuf {
|
||||
&self.gadget_path
|
||||
}
|
||||
|
||||
/// Recreate config symlinks from functions directory
|
||||
fn recreate_config_links(&self) -> Result<()> {
|
||||
let functions_path = self.gadget_path.join("functions");
|
||||
if !functions_path.exists() || !self.config_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(&functions_path).map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Failed to read functions directory {}: {}",
|
||||
functions_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = match name.to_str() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if !name.contains(".usb") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src = functions_path.join(name);
|
||||
let dest = self.config_path.join(name);
|
||||
|
||||
if dest.exists() {
|
||||
if let Err(e) = remove_file(&dest) {
|
||||
warn!("Failed to remove existing config link {}: {}", dest.display(), e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
create_symlink(&src, &dest)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OtgGadgetManager {
|
||||
|
||||
@@ -47,6 +47,11 @@ const DEFAULT_SCREEN_HEIGHT: u32 = 1080;
|
||||
/// Default mouse event throttle interval (16ms ≈ 60Hz)
|
||||
const DEFAULT_MOUSE_THROTTLE_MS: u64 = 16;
|
||||
|
||||
/// Advertised RustDesk version for client compatibility.
|
||||
const RUSTDESK_COMPAT_VERSION: &str = "1.4.5";
|
||||
// Advertised platform for RustDesk clients. This affects which UI options are shown.
|
||||
const RUSTDESK_COMPAT_PLATFORM: &str = "Windows";
|
||||
|
||||
/// Input event throttler
|
||||
///
|
||||
/// Limits the rate of input events sent to HID devices to prevent EAGAIN errors.
|
||||
@@ -164,6 +169,8 @@ pub struct Connection {
|
||||
last_test_delay_sent: Option<Instant>,
|
||||
/// Last known CapsLock state from RustDesk modifiers (for detecting toggle)
|
||||
last_caps_lock: bool,
|
||||
/// Whether relative mouse mode is currently active for this connection
|
||||
relative_mouse_active: bool,
|
||||
}
|
||||
|
||||
/// Messages sent to connection handler
|
||||
@@ -241,6 +248,7 @@ impl Connection {
|
||||
last_delay: 0,
|
||||
last_test_delay_sent: None,
|
||||
last_caps_lock: false,
|
||||
relative_mouse_active: false,
|
||||
};
|
||||
|
||||
(conn, rx)
|
||||
@@ -623,7 +631,7 @@ impl Connection {
|
||||
self.negotiated_codec = Some(negotiated);
|
||||
info!("Negotiated video codec: {:?}", negotiated);
|
||||
|
||||
let response = self.create_login_response(true);
|
||||
let response = self.create_login_response(true).await;
|
||||
let response_bytes = response
|
||||
.write_to_bytes()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?;
|
||||
@@ -673,7 +681,11 @@ impl Connection {
|
||||
Some(misc::Union::RefreshVideo(refresh)) => {
|
||||
if *refresh {
|
||||
debug!("Video refresh requested");
|
||||
// TODO: Request keyframe from encoder
|
||||
if let Some(ref video_manager) = self.video_manager {
|
||||
if let Err(e) = video_manager.request_keyframe().await {
|
||||
warn!("Failed to request keyframe: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(misc::Union::VideoReceived(received)) => {
|
||||
@@ -1064,7 +1076,7 @@ impl Connection {
|
||||
}
|
||||
|
||||
/// Create login response with dynamically detected encoder capabilities
|
||||
fn create_login_response(&self, success: bool) -> HbbMessage {
|
||||
async fn create_login_response(&self, success: bool) -> HbbMessage {
|
||||
if success {
|
||||
// Dynamically detect available encoders
|
||||
let registry = EncoderRegistry::global();
|
||||
@@ -1080,11 +1092,21 @@ impl Connection {
|
||||
h264_available, h265_available, vp8_available, vp9_available
|
||||
);
|
||||
|
||||
let mut display_width = self.screen_width;
|
||||
let mut display_height = self.screen_height;
|
||||
if let Some(ref video_manager) = self.video_manager {
|
||||
let video_info = video_manager.get_video_info().await;
|
||||
if let Some((width, height)) = video_info.resolution {
|
||||
display_width = width;
|
||||
display_height = height;
|
||||
}
|
||||
}
|
||||
|
||||
let mut display_info = DisplayInfo::new();
|
||||
display_info.x = 0;
|
||||
display_info.y = 0;
|
||||
display_info.width = 1920;
|
||||
display_info.height = 1080;
|
||||
display_info.width = display_width as i32;
|
||||
display_info.height = display_height as i32;
|
||||
display_info.name = "KVM Display".to_string();
|
||||
display_info.online = true;
|
||||
display_info.cursor_embedded = false;
|
||||
@@ -1099,11 +1121,11 @@ impl Connection {
|
||||
let mut peer_info = PeerInfo::new();
|
||||
peer_info.username = "one-kvm".to_string();
|
||||
peer_info.hostname = get_hostname();
|
||||
peer_info.platform = "Linux".to_string();
|
||||
peer_info.platform = RUSTDESK_COMPAT_PLATFORM.to_string();
|
||||
peer_info.displays.push(display_info);
|
||||
peer_info.current_display = 0;
|
||||
peer_info.sas_enabled = false;
|
||||
peer_info.version = env!("CARGO_PKG_VERSION").to_string();
|
||||
peer_info.version = RUSTDESK_COMPAT_VERSION.to_string();
|
||||
peer_info.encoding = protobuf::MessageField::some(encoding);
|
||||
|
||||
let mut login_response = LoginResponse::new();
|
||||
@@ -1310,9 +1332,16 @@ impl Connection {
|
||||
async fn handle_mouse_event(&mut self, me: &MouseEvent) -> anyhow::Result<()> {
|
||||
// Parse RustDesk mask format: (button << 3) | event_type
|
||||
let event_type = me.mask & 0x07;
|
||||
let is_relative_move = event_type == mouse_type::MOVE_RELATIVE;
|
||||
|
||||
if is_relative_move {
|
||||
self.relative_mouse_active = true;
|
||||
} else if event_type == mouse_type::MOVE {
|
||||
self.relative_mouse_active = false;
|
||||
}
|
||||
|
||||
// Check if this is a pure move event (no button/scroll)
|
||||
let is_pure_move = event_type == mouse_type::MOVE;
|
||||
let is_pure_move = event_type == mouse_type::MOVE || is_relative_move;
|
||||
|
||||
// For pure move events, apply throttling
|
||||
if is_pure_move && !self.input_throttler.should_send_mouse_move() {
|
||||
@@ -1323,7 +1352,8 @@ impl Connection {
|
||||
debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask);
|
||||
|
||||
// Convert RustDesk mouse event to One-KVM mouse events
|
||||
let mouse_events = convert_mouse_event(me, self.screen_width, self.screen_height);
|
||||
let mouse_events =
|
||||
convert_mouse_event(me, self.screen_width, self.screen_height, self.relative_mouse_active);
|
||||
|
||||
// Send to HID controller if available
|
||||
if let Some(ref hid) = self.hid {
|
||||
@@ -1543,6 +1573,9 @@ async fn run_video_streaming(
|
||||
let mut shutdown_rx = shutdown_tx.subscribe();
|
||||
let mut encoded_count: u64 = 0;
|
||||
let mut last_log_time = Instant::now();
|
||||
let mut waiting_for_keyframe = true;
|
||||
let mut last_sequence: Option<u64> = None;
|
||||
let mut last_keyframe_request = Instant::now() - Duration::from_secs(1);
|
||||
|
||||
info!(
|
||||
"Started shared video streaming for connection {} (codec: {:?})",
|
||||
@@ -1582,6 +1615,9 @@ async fn run_video_streaming(
|
||||
config.bitrate_preset
|
||||
);
|
||||
}
|
||||
if let Err(e) = video_manager.request_keyframe().await {
|
||||
debug!("Failed to request keyframe for connection {}: {}", conn_id, e);
|
||||
}
|
||||
|
||||
// Inner loop: receives frames from current subscription
|
||||
loop {
|
||||
@@ -1609,6 +1645,30 @@ async fn run_video_streaming(
|
||||
}
|
||||
};
|
||||
|
||||
let gap_detected = if let Some(prev) = last_sequence {
|
||||
frame.sequence > prev.saturating_add(1)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if waiting_for_keyframe || gap_detected {
|
||||
if frame.is_keyframe {
|
||||
waiting_for_keyframe = false;
|
||||
} else {
|
||||
if gap_detected {
|
||||
waiting_for_keyframe = true;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_keyframe_request) >= Duration::from_millis(200) {
|
||||
if let Err(e) = video_manager.request_keyframe().await {
|
||||
debug!("Failed to request keyframe for connection {}: {}", conn_id, e);
|
||||
}
|
||||
last_keyframe_request = now;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert EncodedVideoFrame to RustDesk VideoFrame message
|
||||
// Use zero-copy version: Bytes.clone() only increments refcount
|
||||
let msg_bytes = video_adapter.encode_frame_bytes_zero_copy(
|
||||
@@ -1617,12 +1677,13 @@ async fn run_video_streaming(
|
||||
frame.pts_ms as u64,
|
||||
);
|
||||
|
||||
// Send to connection (blocks if channel is full, providing backpressure)
|
||||
if video_tx.try_send(msg_bytes).is_err() {
|
||||
// Drop when channel is full to avoid backpressure
|
||||
continue;
|
||||
// Send to connection (backpressure instead of dropping)
|
||||
if video_tx.send(msg_bytes).await.is_err() {
|
||||
debug!("Video channel closed for connection {}", conn_id);
|
||||
break 'subscribe_loop;
|
||||
}
|
||||
|
||||
last_sequence = Some(frame.sequence);
|
||||
encoded_count += 1;
|
||||
|
||||
// Log stats periodically
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct VideoFrameAdapter {
|
||||
seq: u32,
|
||||
/// Timestamp offset
|
||||
timestamp_base: u64,
|
||||
/// Cached H264 SPS/PPS (Annex B NAL without start code)
|
||||
h264_sps: Option<Bytes>,
|
||||
h264_pps: Option<Bytes>,
|
||||
}
|
||||
|
||||
impl VideoFrameAdapter {
|
||||
@@ -51,6 +54,8 @@ impl VideoFrameAdapter {
|
||||
codec,
|
||||
seq: 0,
|
||||
timestamp_base: 0,
|
||||
h264_sps: None,
|
||||
h264_pps: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +73,7 @@ impl VideoFrameAdapter {
|
||||
is_keyframe: bool,
|
||||
timestamp_ms: u64,
|
||||
) -> Message {
|
||||
let data = self.prepare_h264_frame(data, is_keyframe);
|
||||
// Calculate relative timestamp
|
||||
if self.seq == 0 {
|
||||
self.timestamp_base = timestamp_ms;
|
||||
@@ -100,6 +106,41 @@ impl VideoFrameAdapter {
|
||||
msg
|
||||
}
|
||||
|
||||
fn prepare_h264_frame(&mut self, data: Bytes, is_keyframe: bool) -> Bytes {
|
||||
if self.codec != VideoCodec::H264 {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Parse SPS/PPS from Annex B data (without start codes)
|
||||
let (sps, pps) = crate::webrtc::rtp::extract_sps_pps(&data);
|
||||
let mut has_sps = false;
|
||||
let mut has_pps = false;
|
||||
|
||||
if let Some(sps) = sps {
|
||||
self.h264_sps = Some(Bytes::from(sps));
|
||||
has_sps = true;
|
||||
}
|
||||
if let Some(pps) = pps {
|
||||
self.h264_pps = Some(Bytes::from(pps));
|
||||
has_pps = true;
|
||||
}
|
||||
|
||||
// Inject cached SPS/PPS before IDR when missing
|
||||
if is_keyframe && (!has_sps || !has_pps) {
|
||||
if let (Some(ref sps), Some(ref pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) {
|
||||
let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len());
|
||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||
out.extend_from_slice(sps);
|
||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||
out.extend_from_slice(pps);
|
||||
out.extend_from_slice(&data);
|
||||
return Bytes::from(out);
|
||||
}
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
/// Convert encoded video data to RustDesk Message
|
||||
pub fn encode_frame(&mut self, data: &[u8], is_keyframe: bool, timestamp_ms: u64) -> Message {
|
||||
self.encode_frame_from_bytes(Bytes::copy_from_slice(data), is_keyframe, timestamp_ms)
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod mouse_type {
|
||||
pub const UP: i32 = 2;
|
||||
pub const WHEEL: i32 = 3;
|
||||
pub const TRACKPAD: i32 = 4;
|
||||
pub const MOVE_RELATIVE: i32 = 5;
|
||||
}
|
||||
|
||||
/// Mouse button IDs from RustDesk protocol (before left shift by 3)
|
||||
@@ -36,23 +37,25 @@ pub fn convert_mouse_event(
|
||||
event: &MouseEvent,
|
||||
screen_width: u32,
|
||||
screen_height: u32,
|
||||
relative_mode: bool,
|
||||
) -> Vec<OneKvmMouseEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// RustDesk uses absolute coordinates
|
||||
let x = event.x.max(0) as u32;
|
||||
let y = event.y.max(0) as u32;
|
||||
|
||||
// Normalize to 0-32767 range for absolute mouse (USB HID standard)
|
||||
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
|
||||
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
|
||||
|
||||
// Parse RustDesk mask format: (button << 3) | event_type
|
||||
let event_type = event.mask & 0x07;
|
||||
let button_id = event.mask >> 3;
|
||||
let include_abs_move = !relative_mode;
|
||||
|
||||
match event_type {
|
||||
mouse_type::MOVE => {
|
||||
// RustDesk uses absolute coordinates
|
||||
let x = event.x.max(0) as u32;
|
||||
let y = event.y.max(0) as u32;
|
||||
|
||||
// Normalize to 0-32767 range for absolute mouse (USB HID standard)
|
||||
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
|
||||
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
|
||||
|
||||
// Move event - may have button held down (button_id > 0 means dragging)
|
||||
// Just send move, button state is tracked separately by HID backend
|
||||
events.push(OneKvmMouseEvent {
|
||||
@@ -63,55 +66,83 @@ pub fn convert_mouse_event(
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
mouse_type::DOWN => {
|
||||
// Button down - first move, then press
|
||||
mouse_type::MOVE_RELATIVE => {
|
||||
// Relative movement uses delta values directly (dx, dy).
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
event_type: MouseEventType::Move,
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
mouse_type::DOWN => {
|
||||
if include_abs_move {
|
||||
// Button down - first move, then press
|
||||
let x = event.x.max(0) as u32;
|
||||
let y = event.y.max(0) as u32;
|
||||
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
|
||||
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(button) = button_id_to_button(button_id) {
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::Down,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
x: 0,
|
||||
y: 0,
|
||||
button: Some(button),
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
mouse_type::UP => {
|
||||
// Button up - first move, then release
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
if include_abs_move {
|
||||
// Button up - first move, then release
|
||||
let x = event.x.max(0) as u32;
|
||||
let y = event.y.max(0) as u32;
|
||||
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
|
||||
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(button) = button_id_to_button(button_id) {
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::Up,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
x: 0,
|
||||
y: 0,
|
||||
button: Some(button),
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
mouse_type::WHEEL => {
|
||||
// Scroll event - move first, then scroll
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
if include_abs_move {
|
||||
// Scroll event - move first, then scroll
|
||||
let x = event.x.max(0) as u32;
|
||||
let y = event.y.max(0) as u32;
|
||||
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
|
||||
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// RustDesk encodes scroll direction in the y coordinate
|
||||
// Positive y = scroll up, Negative y = scroll down
|
||||
@@ -119,21 +150,27 @@ pub fn convert_mouse_event(
|
||||
let scroll = if event.y > 0 { 1i8 } else { -1i8 };
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::Scroll,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
x: 0,
|
||||
y: 0,
|
||||
button: None,
|
||||
scroll,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Unknown event type, just move
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
if include_abs_move {
|
||||
// Unknown event type, just move
|
||||
let x = event.x.max(0) as u32;
|
||||
let y = event.y.max(0) as u32;
|
||||
let abs_x = ((x as u64 * 32767) / screen_width.max(1) as u64) as i32;
|
||||
let abs_y = ((y as u64 * 32767) / screen_height.max(1) as u64) as i32;
|
||||
events.push(OneKvmMouseEvent {
|
||||
event_type: MouseEventType::MoveAbs,
|
||||
x: abs_x,
|
||||
y: abs_y,
|
||||
button: None,
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,7 +559,7 @@ mod tests {
|
||||
event.y = 300;
|
||||
event.mask = mouse_type::MOVE; // Pure move event
|
||||
|
||||
let events = convert_mouse_event(&event, 1920, 1080);
|
||||
let events = convert_mouse_event(&event, 1920, 1080, false);
|
||||
assert!(!events.is_empty());
|
||||
assert_eq!(events[0].event_type, MouseEventType::MoveAbs);
|
||||
}
|
||||
@@ -534,7 +571,7 @@ mod tests {
|
||||
event.y = 300;
|
||||
event.mask = (mouse_button::LEFT << 3) | mouse_type::DOWN;
|
||||
|
||||
let events = convert_mouse_event(&event, 1920, 1080);
|
||||
let events = convert_mouse_event(&event, 1920, 1080, false);
|
||||
assert!(events.len() >= 2);
|
||||
// Should have a button down event
|
||||
assert!(events
|
||||
@@ -542,6 +579,20 @@ mod tests {
|
||||
.any(|e| e.event_type == MouseEventType::Down && e.button == Some(MouseButton::Left)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_mouse_move_relative() {
|
||||
let mut event = MouseEvent::new();
|
||||
event.x = -12;
|
||||
event.y = 8;
|
||||
event.mask = mouse_type::MOVE_RELATIVE;
|
||||
|
||||
let events = convert_mouse_event(&event, 1920, 1080, true);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].event_type, MouseEventType::Move);
|
||||
assert_eq!(events[0].x, -12);
|
||||
assert_eq!(events[0].y, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_key_event() {
|
||||
use protobuf::EnumOrUnknown;
|
||||
|
||||
@@ -23,7 +23,7 @@ pub mod protocol;
|
||||
pub mod punch;
|
||||
pub mod rendezvous;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -37,6 +37,7 @@ use tracing::{debug, error, info, warn};
|
||||
use crate::audio::AudioController;
|
||||
use crate::hid::HidController;
|
||||
use crate::video::stream_manager::VideoStreamManager;
|
||||
use crate::utils::bind_tcp_listener;
|
||||
|
||||
use self::config::RustDeskConfig;
|
||||
use self::connection::ConnectionManager;
|
||||
@@ -84,7 +85,7 @@ pub struct RustDeskService {
|
||||
status: Arc<RwLock<ServiceStatus>>,
|
||||
rendezvous: Arc<RwLock<Option<Arc<RendezvousMediator>>>>,
|
||||
rendezvous_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||
tcp_listener_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||
tcp_listener_handle: Arc<RwLock<Option<Vec<JoinHandle<()>>>>>,
|
||||
listen_port: Arc<RwLock<u16>>,
|
||||
connection_manager: Arc<ConnectionManager>,
|
||||
video_manager: Arc<VideoStreamManager>,
|
||||
@@ -212,8 +213,8 @@ impl RustDeskService {
|
||||
|
||||
// Start TCP listener BEFORE the rendezvous mediator to ensure port is set correctly
|
||||
// This prevents race condition where mediator starts registration with wrong port
|
||||
let (tcp_handle, listen_port) = self.start_tcp_listener_with_port().await?;
|
||||
*self.tcp_listener_handle.write() = Some(tcp_handle);
|
||||
let (tcp_handles, listen_port) = self.start_tcp_listener_with_port().await?;
|
||||
*self.tcp_listener_handle.write() = Some(tcp_handles);
|
||||
|
||||
// Set the listen port on mediator before starting the registration loop
|
||||
mediator.set_listen_port(listen_port);
|
||||
@@ -373,52 +374,83 @@ impl RustDeskService {
|
||||
|
||||
/// Start TCP listener for direct peer connections
|
||||
/// Returns the join handle and the port that was bound
|
||||
async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(JoinHandle<()>, u16)> {
|
||||
async fn start_tcp_listener_with_port(&self) -> anyhow::Result<(Vec<JoinHandle<()>>, u16)> {
|
||||
// Try to bind to the default port, or find an available port
|
||||
let listener = match TcpListener::bind(format!("0.0.0.0:{}", DIRECT_LISTEN_PORT)).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => {
|
||||
// Try binding to port 0 to get an available port
|
||||
TcpListener::bind("0.0.0.0:0").await?
|
||||
let (listeners, listen_port) = match self.bind_direct_listeners(DIRECT_LISTEN_PORT) {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to bind RustDesk TCP on port {}: {}, falling back to random port",
|
||||
DIRECT_LISTEN_PORT, err
|
||||
);
|
||||
self.bind_direct_listeners(0)?
|
||||
}
|
||||
};
|
||||
|
||||
let local_addr = listener.local_addr()?;
|
||||
let listen_port = local_addr.port();
|
||||
*self.listen_port.write() = listen_port;
|
||||
info!("RustDesk TCP listener started on {}", local_addr);
|
||||
|
||||
let connection_manager = self.connection_manager.clone();
|
||||
let mut shutdown_rx = self.shutdown_tx.subscribe();
|
||||
let mut handles = Vec::new();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, peer_addr)) => {
|
||||
info!("Accepted direct connection from {}", peer_addr);
|
||||
let conn_mgr = connection_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await {
|
||||
error!("Failed to handle direct connection from {}: {}", peer_addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("TCP accept error: {}", e);
|
||||
for listener in listeners {
|
||||
let local_addr = listener.local_addr()?;
|
||||
info!("RustDesk TCP listener started on {}", local_addr);
|
||||
|
||||
let conn_mgr = connection_manager.clone();
|
||||
let mut shutdown_rx = self.shutdown_tx.subscribe();
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, peer_addr)) => {
|
||||
info!("Accepted direct connection from {}", peer_addr);
|
||||
let conn_mgr = conn_mgr.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn_mgr.accept_connection(stream, peer_addr).await {
|
||||
error!("Failed to handle direct connection from {}: {}", peer_addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("TCP accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("TCP listener shutting down");
|
||||
break;
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("TCP listener shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
Ok((handle, listen_port))
|
||||
Ok((handles, listen_port))
|
||||
}
|
||||
|
||||
fn bind_direct_listeners(&self, port: u16) -> anyhow::Result<(Vec<TcpListener>, u16)> {
|
||||
let v4_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
|
||||
let v4_listener = bind_tcp_listener(v4_addr)?;
|
||||
let listen_port = v4_listener.local_addr()?.port();
|
||||
|
||||
let mut listeners = vec![TcpListener::from_std(v4_listener)?];
|
||||
|
||||
let v6_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), listen_port);
|
||||
match bind_tcp_listener(v6_addr) {
|
||||
Ok(v6_listener) => {
|
||||
listeners.push(TcpListener::from_std(v6_listener)?);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"IPv6 listener unavailable on port {}: {}, continuing with IPv4 only",
|
||||
listen_port, err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((listeners, listen_port))
|
||||
}
|
||||
|
||||
/// Stop the RustDesk service
|
||||
@@ -446,8 +478,10 @@ impl RustDeskService {
|
||||
}
|
||||
|
||||
// Wait for TCP listener task to finish
|
||||
if let Some(handle) = self.tcp_listener_handle.write().take() {
|
||||
handle.abort();
|
||||
if let Some(handles) = self.tcp_listener_handle.write().take() {
|
||||
for handle in handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
*self.rendezvous.write() = None;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! It registers the device ID and public key, handles punch hole requests,
|
||||
//! and relay requests.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -15,6 +15,8 @@ use tokio::sync::broadcast;
|
||||
use tokio::time::interval;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::utils::bind_udp_socket;
|
||||
|
||||
use super::config::RustDeskConfig;
|
||||
use super::crypto::{KeyPair, SigningKeyPair};
|
||||
use super::protocol::{
|
||||
@@ -288,8 +290,13 @@ impl RendezvousMediator {
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to resolve {}", addr))?;
|
||||
|
||||
// Create UDP socket
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
// Create UDP socket (match address family, enforce IPV6_V6ONLY)
|
||||
let bind_addr = match server_addr {
|
||||
SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
|
||||
SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
|
||||
};
|
||||
let std_socket = bind_udp_socket(bind_addr)?;
|
||||
let socket = UdpSocket::from_std(std_socket)?;
|
||||
socket.connect(server_addr).await?;
|
||||
|
||||
info!("Connected to rendezvous server at {}", server_addr);
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
//! This module contains common utilities used across the codebase.
|
||||
|
||||
pub mod throttle;
|
||||
pub mod net;
|
||||
|
||||
pub use throttle::LogThrottler;
|
||||
pub use net::{bind_tcp_listener, bind_udp_socket};
|
||||
|
||||
84
src/utils/net.rs
Normal file
84
src/utils/net.rs
Normal 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)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder};
|
||||
use tracing::warn;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::convert::Nv12Converter;
|
||||
@@ -72,6 +72,9 @@ impl MjpegRkmppDecoder {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if frame.pixfmt == AVPixelFormat::AV_PIX_FMT_NV16 {
|
||||
info!("mjpeg_rkmpp output pixfmt NV16 on first frame; converting to NV12");
|
||||
}
|
||||
self.last_pixfmt = Some(frame.pixfmt);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
//!
|
||||
//! This module provides video decoding capabilities.
|
||||
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
pub mod mjpeg_rkmpp;
|
||||
pub mod mjpeg_turbo;
|
||||
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
pub use mjpeg_rkmpp::MjpegRkmppDecoder;
|
||||
pub use mjpeg_turbo::MjpegTurboDecoder;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
use v4l::capability::Flags;
|
||||
use v4l::prelude::*;
|
||||
@@ -12,6 +14,8 @@ use v4l::FourCC;
|
||||
use super::format::{PixelFormat, Resolution};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
const DEVICE_PROBE_TIMEOUT_MS: u64 = 400;
|
||||
|
||||
/// Information about a video device
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoDeviceInfo {
|
||||
@@ -401,32 +405,29 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
||||
|
||||
debug!("Found video device: {:?}", path);
|
||||
|
||||
// Try to open and query the device
|
||||
match VideoDevice::open(&path) {
|
||||
Ok(device) => {
|
||||
match device.info() {
|
||||
Ok(info) => {
|
||||
// Only include devices with video capture capability
|
||||
if info.capabilities.video_capture || info.capabilities.video_capture_mplane
|
||||
{
|
||||
info!(
|
||||
"Found capture device: {} ({}) - {} formats",
|
||||
info.name,
|
||||
info.driver,
|
||||
info.formats.len()
|
||||
);
|
||||
devices.push(info);
|
||||
} else {
|
||||
debug!("Skipping non-capture device: {:?}", path);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to get info for {:?}: {}", path, e);
|
||||
}
|
||||
if !sysfs_maybe_capture(&path) {
|
||||
debug!("Skipping non-capture candidate (sysfs): {:?}", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to open and query the device (with timeout)
|
||||
match probe_device_with_timeout(&path, Duration::from_millis(DEVICE_PROBE_TIMEOUT_MS)) {
|
||||
Some(info) => {
|
||||
// Only include devices with video capture capability
|
||||
if info.capabilities.video_capture || info.capabilities.video_capture_mplane {
|
||||
info!(
|
||||
"Found capture device: {} ({}) - {} formats",
|
||||
info.name,
|
||||
info.driver,
|
||||
info.formats.len()
|
||||
);
|
||||
devices.push(info);
|
||||
} else {
|
||||
debug!("Skipping non-capture device: {:?}", path);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to open {:?}: {}", path, e);
|
||||
None => {
|
||||
debug!("Failed to probe {:?}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,6 +439,104 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn probe_device_with_timeout(path: &Path, timeout: Duration) -> Option<VideoDeviceInfo> {
|
||||
let path = path.to_path_buf();
|
||||
let path_for_thread = path.clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Result<VideoDeviceInfo> {
|
||||
let device = VideoDevice::open(&path_for_thread)?;
|
||||
device.info()
|
||||
})();
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(Ok(info)) => Some(info),
|
||||
Ok(Err(e)) => {
|
||||
debug!("Failed to get info for {:?}: {}", path, e);
|
||||
None
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
warn!("Timed out probing video device: {:?}", path);
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sysfs_maybe_capture(path: &Path) -> bool {
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => name,
|
||||
None => return true,
|
||||
};
|
||||
let sysfs_base = Path::new("/sys/class/video4linux").join(name);
|
||||
|
||||
let sysfs_name = read_sysfs_string(&sysfs_base.join("name"))
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let uevent = read_sysfs_string(&sysfs_base.join("device/uevent"))
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let driver = extract_uevent_value(&uevent, "driver");
|
||||
|
||||
let mut maybe_capture = false;
|
||||
let capture_hints = [
|
||||
"capture",
|
||||
"hdmi",
|
||||
"usb",
|
||||
"uvc",
|
||||
"ms2109",
|
||||
"ms2130",
|
||||
"macrosilicon",
|
||||
"tc358743",
|
||||
"grabber",
|
||||
];
|
||||
if capture_hints.iter().any(|hint| sysfs_name.contains(hint)) {
|
||||
maybe_capture = true;
|
||||
}
|
||||
if let Some(driver) = driver {
|
||||
if driver.contains("uvcvideo") || driver.contains("tc358743") {
|
||||
maybe_capture = true;
|
||||
}
|
||||
}
|
||||
|
||||
let skip_hints = [
|
||||
"codec",
|
||||
"decoder",
|
||||
"encoder",
|
||||
"isp",
|
||||
"mem2mem",
|
||||
"m2m",
|
||||
"vbi",
|
||||
"radio",
|
||||
"metadata",
|
||||
"output",
|
||||
];
|
||||
if skip_hints.iter().any(|hint| sysfs_name.contains(hint)) && !maybe_capture {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn read_sysfs_string(path: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_uevent_value(content: &str, key: &str) -> Option<String> {
|
||||
let key_upper = key.to_ascii_uppercase();
|
||||
for line in content.lines() {
|
||||
if let Some(value) = line.strip_prefix(&format!("{}=", key_upper)) {
|
||||
return Some(value.to_lowercase());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the best video device for KVM use
|
||||
pub fn find_best_device() -> Result<VideoDeviceInfo> {
|
||||
let devices = enumerate_devices()?;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
@@ -255,8 +255,33 @@ impl EncoderRegistry {
|
||||
thread_count: 1,
|
||||
};
|
||||
|
||||
// Get all available encoders from hwcodec
|
||||
let all_encoders = HwEncoder::available_encoders(ctx, None);
|
||||
const DETECT_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
// Get all available encoders from hwcodec with a hard timeout
|
||||
let all_encoders = {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let ctx_clone = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = HwEncoder::available_encoders(ctx_clone, None);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(DETECT_TIMEOUT_MS)) {
|
||||
Ok(encoders) => encoders,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Encoder detection timed out after {}ms, skipping hardware detection",
|
||||
DETECT_TIMEOUT_MS
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
info!("Found {} encoders from hwcodec", all_encoders.len());
|
||||
|
||||
|
||||
@@ -33,11 +33,9 @@ const JPEG_VALIDATE_INTERVAL: u64 = 30;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::convert::{Nv12Converter, PixelConverter};
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
use crate::video::decoder::MjpegRkmppDecoder;
|
||||
use crate::video::decoder::MjpegTurboDecoder;
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
use hwcodec::ffmpeg_hw::{last_error_message as ffmpeg_hw_last_error, HwMjpegH264Config, HwMjpegH264Pipeline};
|
||||
use hwcodec::ffmpeg_hw::{last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline};
|
||||
use v4l::buffer::Type as BufferType;
|
||||
use v4l::io::traits::CaptureStream;
|
||||
use v4l::prelude::*;
|
||||
@@ -177,7 +175,7 @@ struct EncoderThreadState {
|
||||
yuv420p_converter: Option<PixelConverter>,
|
||||
encoder_needs_yuv420p: bool,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_pipeline: Option<HwMjpegH264Pipeline>,
|
||||
ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_enabled: bool,
|
||||
fps: u32,
|
||||
@@ -319,16 +317,12 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
|
||||
}
|
||||
|
||||
enum MjpegDecoderKind {
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
Rkmpp(MjpegRkmppDecoder),
|
||||
Turbo(MjpegTurboDecoder),
|
||||
}
|
||||
|
||||
impl MjpegDecoderKind {
|
||||
fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
match self {
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
MjpegDecoderKind::Rkmpp(decoder) => decoder.decode_to_nv12(data),
|
||||
MjpegDecoderKind::Turbo(decoder) => decoder.decode_to_rgb(data),
|
||||
}
|
||||
}
|
||||
@@ -512,15 +506,18 @@ impl SharedVideoPipeline {
|
||||
}
|
||||
};
|
||||
|
||||
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
|
||||
let is_software_encoder = selected_codec_name.contains("libx264")
|
||||
|| selected_codec_name.contains("libx265")
|
||||
|| selected_codec_name.contains("libvpx");
|
||||
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
if needs_mjpeg_decode && is_rkmpp_encoder && config.output_codec == VideoEncoderType::H264 {
|
||||
info!("Initializing FFmpeg HW MJPEG->H264 pipeline (no fallback)");
|
||||
let hw_config = HwMjpegH264Config {
|
||||
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
if needs_mjpeg_decode
|
||||
&& is_rkmpp_encoder
|
||||
&& matches!(config.output_codec, VideoEncoderType::H264 | VideoEncoderType::H265)
|
||||
{
|
||||
info!(
|
||||
"Initializing FFmpeg HW MJPEG->{} pipeline (no fallback)",
|
||||
config.output_codec
|
||||
);
|
||||
let hw_config = HwMjpegH26xConfig {
|
||||
decoder: "mjpeg_rkmpp".to_string(),
|
||||
encoder: selected_codec_name.clone(),
|
||||
width: config.resolution.width as i32,
|
||||
@@ -530,14 +527,14 @@ impl SharedVideoPipeline {
|
||||
gop: config.gop_size() as i32,
|
||||
thread_count: 1,
|
||||
};
|
||||
let pipeline = HwMjpegH264Pipeline::new(hw_config).map_err(|e| {
|
||||
let pipeline = HwMjpegH26xPipeline::new(hw_config).map_err(|e| {
|
||||
let detail = if e.is_empty() { ffmpeg_hw_last_error() } else { e };
|
||||
AppError::VideoError(format!(
|
||||
"FFmpeg HW MJPEG->H264 init failed: {}",
|
||||
detail
|
||||
"FFmpeg HW MJPEG->{} init failed: {}",
|
||||
config.output_codec, detail
|
||||
))
|
||||
})?;
|
||||
info!("Using FFmpeg HW MJPEG->H264 pipeline");
|
||||
info!("Using FFmpeg HW MJPEG->{} pipeline", config.output_codec);
|
||||
return Ok(EncoderThreadState {
|
||||
encoder: None,
|
||||
mjpeg_decoder: None,
|
||||
@@ -555,35 +552,12 @@ impl SharedVideoPipeline {
|
||||
}
|
||||
|
||||
let pipeline_input_format = if needs_mjpeg_decode {
|
||||
if is_rkmpp_encoder {
|
||||
info!(
|
||||
"MJPEG input detected, using RKMPP decoder ({} -> NV12 with NV16 fallback)",
|
||||
config.input_format
|
||||
);
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
{
|
||||
let decoder = MjpegRkmppDecoder::new(config.resolution)?;
|
||||
let pipeline_format = PixelFormat::Nv12;
|
||||
(Some(MjpegDecoderKind::Rkmpp(decoder)), pipeline_format)
|
||||
}
|
||||
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
|
||||
{
|
||||
return Err(AppError::VideoError(
|
||||
"RKMPP MJPEG decode is only supported on ARM builds".to_string(),
|
||||
));
|
||||
}
|
||||
} else if is_software_encoder {
|
||||
info!(
|
||||
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
|
||||
config.input_format
|
||||
);
|
||||
let decoder = MjpegTurboDecoder::new(config.resolution)?;
|
||||
(Some(MjpegDecoderKind::Turbo(decoder)), PixelFormat::Rgb24)
|
||||
} else {
|
||||
return Err(AppError::VideoError(
|
||||
"MJPEG input requires RKMPP or software encoder".to_string(),
|
||||
));
|
||||
}
|
||||
info!(
|
||||
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
|
||||
config.input_format
|
||||
);
|
||||
let decoder = MjpegTurboDecoder::new(config.resolution)?;
|
||||
(Some(MjpegDecoderKind::Turbo(decoder)), PixelFormat::Rgb24)
|
||||
} else {
|
||||
(None, config.input_format)
|
||||
};
|
||||
|
||||
@@ -794,6 +794,11 @@ impl VideoStreamManager {
|
||||
self.webrtc_streamer.set_bitrate_preset(preset).await
|
||||
}
|
||||
|
||||
/// Request a keyframe from the shared video pipeline
|
||||
pub async fn request_keyframe(&self) -> crate::error::Result<()> {
|
||||
self.webrtc_streamer.request_keyframe().await
|
||||
}
|
||||
|
||||
/// Publish event to event bus
|
||||
async fn publish_event(&self, event: SystemEvent) {
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
|
||||
@@ -187,7 +187,23 @@ pub async fn apply_hid_config(
|
||||
// 检查 OTG 描述符是否变更
|
||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
||||
let old_hid_functions = old_config.effective_otg_functions();
|
||||
let new_hid_functions = new_config.effective_otg_functions();
|
||||
let mut new_hid_functions = new_config.effective_otg_functions();
|
||||
|
||||
// Low-endpoint UDCs (e.g., musb) cannot handle consumer control endpoints reliably
|
||||
if new_config.backend == HidBackend::Otg {
|
||||
if let Some(udc) =
|
||||
crate::otg::configfs::resolve_udc_name(new_config.otg_udc.as_deref())
|
||||
{
|
||||
if crate::otg::configfs::is_low_endpoint_udc(&udc) && new_hid_functions.consumer {
|
||||
tracing::warn!(
|
||||
"UDC {} has low endpoint resources, disabling consumer control",
|
||||
udc
|
||||
);
|
||||
new_hid_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hid_functions_changed = old_hid_functions != new_hid_functions;
|
||||
|
||||
if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() {
|
||||
|
||||
@@ -610,6 +610,7 @@ impl RustDeskConfigUpdate {
|
||||
pub struct WebConfigUpdate {
|
||||
pub http_port: Option<u16>,
|
||||
pub https_port: Option<u16>,
|
||||
pub bind_addresses: Option<Vec<String>>,
|
||||
pub bind_address: Option<String>,
|
||||
pub https_enabled: Option<bool>,
|
||||
}
|
||||
@@ -626,6 +627,13 @@ impl WebConfigUpdate {
|
||||
return Err(AppError::BadRequest("HTTPS port cannot be 0".into()));
|
||||
}
|
||||
}
|
||||
if let Some(ref addrs) = self.bind_addresses {
|
||||
for addr in addrs {
|
||||
if addr.parse::<std::net::IpAddr>().is_err() {
|
||||
return Err(AppError::BadRequest("Invalid bind address".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref addr) = self.bind_address {
|
||||
if addr.parse::<std::net::IpAddr>().is_err() {
|
||||
return Err(AppError::BadRequest("Invalid bind address".into()));
|
||||
@@ -641,8 +649,16 @@ impl WebConfigUpdate {
|
||||
if let Some(port) = self.https_port {
|
||||
config.https_port = port;
|
||||
}
|
||||
if let Some(ref addr) = self.bind_address {
|
||||
if let Some(ref addrs) = self.bind_addresses {
|
||||
config.bind_addresses = addrs.clone();
|
||||
if let Some(first) = addrs.first() {
|
||||
config.bind_address = first.clone();
|
||||
}
|
||||
} else if let Some(ref addr) = self.bind_address {
|
||||
config.bind_address = addr.clone();
|
||||
if config.bind_addresses.is_empty() {
|
||||
config.bind_addresses = vec![addr.clone()];
|
||||
}
|
||||
}
|
||||
if let Some(enabled) = self.https_enabled {
|
||||
config.https_enabled = enabled;
|
||||
|
||||
@@ -316,28 +316,11 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Build a map of interface name -> IPv4 address
|
||||
let mut ipv4_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
for ifaddr in all_addrs {
|
||||
// Skip loopback
|
||||
if ifaddr.interface_name == "lo" {
|
||||
continue;
|
||||
}
|
||||
// Only collect IPv4 addresses (skip if already have one for this interface)
|
||||
if !ipv4_map.contains_key(&ifaddr.interface_name) {
|
||||
if let Some(addr) = ifaddr.address {
|
||||
if let Some(sockaddr_in) = addr.as_sockaddr_in() {
|
||||
ipv4_map.insert(ifaddr.interface_name.clone(), sockaddr_in.ip().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now check which interfaces are up
|
||||
let mut addresses = Vec::new();
|
||||
// Check which interfaces are up
|
||||
let mut up_ifaces = std::collections::HashSet::new();
|
||||
let net_dir = match std::fs::read_dir("/sys/class/net") {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return addresses,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
for entry in net_dir.flatten() {
|
||||
@@ -361,12 +344,43 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get IP from pre-fetched map
|
||||
if let Some(ip) = ipv4_map.remove(&iface_name) {
|
||||
addresses.push(NetworkAddress {
|
||||
interface: iface_name,
|
||||
ip,
|
||||
});
|
||||
up_ifaces.insert(iface_name);
|
||||
}
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for ifaddr in all_addrs {
|
||||
let iface_name = &ifaddr.interface_name;
|
||||
if iface_name == "lo" || !up_ifaces.contains(iface_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(addr) = ifaddr.address {
|
||||
if let Some(sockaddr_in) = addr.as_sockaddr_in() {
|
||||
let ip = sockaddr_in.ip();
|
||||
if ip.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
let ip_str = ip.to_string();
|
||||
if seen.insert((iface_name.clone(), ip_str.clone())) {
|
||||
addresses.push(NetworkAddress {
|
||||
interface: iface_name.clone(),
|
||||
ip: ip_str,
|
||||
});
|
||||
}
|
||||
} else if let Some(sockaddr_in6) = addr.as_sockaddr_in6() {
|
||||
let ip = sockaddr_in6.ip();
|
||||
if ip.is_loopback() || ip.is_unspecified() || ip.is_unicast_link_local() {
|
||||
continue;
|
||||
}
|
||||
let ip_str = ip.to_string();
|
||||
if seen.insert((iface_name.clone(), ip_str.clone())) {
|
||||
addresses.push(NetworkAddress {
|
||||
interface: iface_name.clone(),
|
||||
ip: ip_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +479,6 @@ pub async fn logout(
|
||||
pub struct AuthCheckResponse {
|
||||
pub authenticated: bool,
|
||||
pub user: Option<String>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
pub async fn auth_check(
|
||||
@@ -481,7 +494,6 @@ pub async fn auth_check(
|
||||
Json(AuthCheckResponse {
|
||||
authenticated: true,
|
||||
user: username,
|
||||
is_admin: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -521,11 +533,39 @@ pub struct SetupRequest {
|
||||
pub hid_ch9329_port: Option<String>,
|
||||
pub hid_ch9329_baudrate: Option<u32>,
|
||||
pub hid_otg_udc: Option<String>,
|
||||
pub hid_otg_profile: Option<String>,
|
||||
// Extension settings
|
||||
pub ttyd_enabled: Option<bool>,
|
||||
pub rustdesk_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn normalize_otg_profile_for_low_endpoint(config: &mut AppConfig) {
|
||||
if !matches!(config.hid.backend, crate::config::HidBackend::Otg) {
|
||||
return;
|
||||
}
|
||||
let udc = crate::otg::configfs::resolve_udc_name(config.hid.otg_udc.as_deref());
|
||||
let Some(udc) = udc else {
|
||||
return;
|
||||
};
|
||||
if !crate::otg::configfs::is_low_endpoint_udc(&udc) {
|
||||
return;
|
||||
}
|
||||
match config.hid.otg_profile {
|
||||
crate::config::OtgHidProfile::Full => {
|
||||
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumer;
|
||||
}
|
||||
crate::config::OtgHidProfile::FullNoMsd => {
|
||||
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumerNoMsd;
|
||||
}
|
||||
crate::config::OtgHidProfile::Custom => {
|
||||
if config.hid.otg_functions.consumer {
|
||||
config.hid.otg_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn setup_init(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SetupRequest>,
|
||||
@@ -601,6 +641,33 @@ pub async fn setup_init(
|
||||
if let Some(udc) = req.hid_otg_udc.clone() {
|
||||
config.hid.otg_udc = Some(udc);
|
||||
}
|
||||
if let Some(profile) = req.hid_otg_profile.clone() {
|
||||
config.hid.otg_profile = match profile.as_str() {
|
||||
"full" => crate::config::OtgHidProfile::Full,
|
||||
"full_no_msd" => crate::config::OtgHidProfile::FullNoMsd,
|
||||
"full_no_consumer" => crate::config::OtgHidProfile::FullNoConsumer,
|
||||
"full_no_consumer_no_msd" => crate::config::OtgHidProfile::FullNoConsumerNoMsd,
|
||||
"legacy_keyboard" => crate::config::OtgHidProfile::LegacyKeyboard,
|
||||
"legacy_mouse_relative" => crate::config::OtgHidProfile::LegacyMouseRelative,
|
||||
"custom" => crate::config::OtgHidProfile::Custom,
|
||||
_ => config.hid.otg_profile.clone(),
|
||||
};
|
||||
if matches!(config.hid.backend, crate::config::HidBackend::Otg) {
|
||||
match config.hid.otg_profile {
|
||||
crate::config::OtgHidProfile::Full
|
||||
| crate::config::OtgHidProfile::FullNoConsumer => {
|
||||
config.msd.enabled = true;
|
||||
}
|
||||
crate::config::OtgHidProfile::FullNoMsd
|
||||
| crate::config::OtgHidProfile::FullNoConsumerNoMsd
|
||||
| crate::config::OtgHidProfile::LegacyKeyboard
|
||||
| crate::config::OtgHidProfile::LegacyMouseRelative => {
|
||||
config.msd.enabled = false;
|
||||
}
|
||||
crate::config::OtgHidProfile::Custom => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension settings
|
||||
if let Some(enabled) = req.ttyd_enabled {
|
||||
@@ -609,12 +676,32 @@ pub async fn setup_init(
|
||||
if let Some(enabled) = req.rustdesk_enabled {
|
||||
config.rustdesk.enabled = enabled;
|
||||
}
|
||||
|
||||
normalize_otg_profile_for_low_endpoint(config);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Get updated config for HID reload
|
||||
let new_config = state.config.get();
|
||||
|
||||
if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) {
|
||||
let mut hid_functions = new_config.hid.effective_otg_functions();
|
||||
if let Some(udc) =
|
||||
crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref())
|
||||
{
|
||||
if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
|
||||
tracing::warn!(
|
||||
"UDC {} has low endpoint resources, disabling consumer control",
|
||||
udc
|
||||
);
|
||||
hid_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = state.otg_service.update_hid_functions(hid_functions).await {
|
||||
tracing::warn!("Failed to apply HID functions during setup: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Extension config after save: ttyd.enabled={}, rustdesk.enabled={}",
|
||||
new_config.extensions.ttyd.enabled,
|
||||
@@ -727,6 +814,9 @@ pub async fn update_config(
|
||||
let new_config: AppConfig = serde_json::from_value(merged)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
|
||||
|
||||
let mut new_config = new_config;
|
||||
normalize_otg_profile_for_low_endpoint(&mut new_config);
|
||||
|
||||
// Apply the validated config
|
||||
state.config.set(new_config.clone()).await?;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/setup", get(handlers::setup_status))
|
||||
.route("/setup/init", post(handlers::setup_init));
|
||||
|
||||
// User routes (authenticated users - both regular and admin)
|
||||
// Authenticated routes (all logged-in users)
|
||||
let user_routes = Router::new()
|
||||
.route("/info", get(handlers::system_info))
|
||||
.route("/auth/logout", post(handlers::logout))
|
||||
@@ -71,10 +71,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/audio/devices", get(handlers::list_audio_devices))
|
||||
// Audio WebSocket endpoint
|
||||
.route("/ws/audio", any(audio_ws_handler))
|
||||
;
|
||||
|
||||
// Admin-only routes (require admin privileges)
|
||||
let admin_routes = Router::new()
|
||||
// Configuration management (domain-separated endpoints)
|
||||
.route("/config", get(handlers::config::get_all_config))
|
||||
.route("/config", post(handlers::update_config))
|
||||
@@ -199,11 +195,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/terminal", get(handlers::terminal::terminal_index))
|
||||
.route("/terminal/", get(handlers::terminal::terminal_index))
|
||||
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
|
||||
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy))
|
||||
;
|
||||
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
|
||||
|
||||
// Combine protected routes (user + admin)
|
||||
let protected_routes = Router::new().merge(user_routes).merge(admin_routes);
|
||||
// Protected routes (all authenticated users)
|
||||
let protected_routes = user_routes;
|
||||
|
||||
// Stream endpoints (accessible with auth, but typically embedded in pages)
|
||||
let stream_routes = Router::new()
|
||||
|
||||
@@ -342,6 +342,18 @@ impl WebRtcStreamer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the encoder to generate a keyframe on next encode
|
||||
pub async fn request_keyframe(&self) -> Result<()> {
|
||||
if let Some(ref pipeline) = *self.video_pipeline.read().await {
|
||||
pipeline.request_keyframe().await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::VideoError(
|
||||
"Video pipeline not running".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// === Audio Management ===
|
||||
|
||||
/// Check if audio is enabled
|
||||
|
||||
566
test/bench_kvm.py
Normal file
566
test/bench_kvm.py
Normal 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
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "web",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -336,6 +336,7 @@ export const rustdeskConfigApi = {
|
||||
export interface WebConfig {
|
||||
http_port: number
|
||||
https_port: number
|
||||
bind_addresses: string[]
|
||||
bind_address: string
|
||||
https_enabled: boolean
|
||||
}
|
||||
@@ -344,6 +345,7 @@ export interface WebConfig {
|
||||
export interface WebConfigUpdate {
|
||||
http_port?: number
|
||||
https_port?: number
|
||||
bind_addresses?: string[]
|
||||
bind_address?: string
|
||||
https_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const authApi = {
|
||||
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
||||
|
||||
check: () =>
|
||||
request<{ authenticated: boolean; user?: string; is_admin?: boolean }>('/auth/check'),
|
||||
request<{ authenticated: boolean; user?: string }>('/auth/check'),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ success: boolean }>('/auth/password', {
|
||||
@@ -84,6 +84,7 @@ export const systemApi = {
|
||||
hid_ch9329_port?: string
|
||||
hid_ch9329_baudrate?: number
|
||||
hid_otg_udc?: string
|
||||
hid_otg_profile?: string
|
||||
encoder_backend?: string
|
||||
audio_device?: string
|
||||
ttyd_enabled?: boolean
|
||||
|
||||
@@ -6,7 +6,6 @@ const API_BASE = '/api'
|
||||
// Toast debounce mechanism - prevent toast spam (5 seconds)
|
||||
const toastDebounceMap = new Map<string, number>()
|
||||
const TOAST_DEBOUNCE_TIME = 5000
|
||||
let sessionExpiredNotified = false
|
||||
|
||||
function shouldShowToast(key: string): boolean {
|
||||
const now = Date.now()
|
||||
@@ -84,24 +83,10 @@ export async function request<T>(
|
||||
const message = getErrorMessage(data, `HTTP ${response.status}`)
|
||||
const normalized = message.toLowerCase()
|
||||
const isNotAuthenticated = normalized.includes('not authenticated')
|
||||
if (response.status === 401 && !sessionExpiredNotified) {
|
||||
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
|
||||
const isSessionExpired = normalized.includes('session expired')
|
||||
if (isLoggedInElsewhere || isSessionExpired) {
|
||||
sessionExpiredNotified = true
|
||||
const titleKey = isLoggedInElsewhere ? 'auth.loggedInElsewhere' : 'auth.sessionExpired'
|
||||
if (toastOnError && shouldShowToast('error_session_expired')) {
|
||||
toast.error(t(titleKey), {
|
||||
description: message,
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1200)
|
||||
}
|
||||
}
|
||||
if (toastOnError && shouldShowToast(toastKey) && !(response.status === 401 && isNotAuthenticated)) {
|
||||
const isSessionExpired = normalized.includes('session expired')
|
||||
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
|
||||
const isAuthIssue = response.status === 401 && (isNotAuthenticated || isSessionExpired || isLoggedInElsewhere)
|
||||
if (toastOnError && shouldShowToast(toastKey) && !isAuthIssue) {
|
||||
toast.error(t('api.operationFailed'), {
|
||||
description: message,
|
||||
duration: 4000,
|
||||
|
||||
@@ -52,14 +52,13 @@ const overflowMenuOpen = ref(false)
|
||||
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
|
||||
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
|
||||
const showMsd = computed(() => {
|
||||
return props.isAdmin && !isCh9329Backend.value
|
||||
return !!systemStore.msd?.available && !isCh9329Backend.value
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
videoMode?: VideoMode
|
||||
ttydRunning?: boolean
|
||||
isAdmin?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -86,25 +85,23 @@ const extensionOpen = ref(false)
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-1.5">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
|
||||
<!-- Left side buttons -->
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
|
||||
<!-- Video Config - Always visible -->
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
:video-mode="props.videoMode || 'mjpeg'"
|
||||
:is-admin="props.isAdmin"
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
/>
|
||||
|
||||
<!-- Audio Config - Always visible -->
|
||||
<AudioConfigPopover v-model:open="audioPopoverOpen" :is-admin="props.isAdmin" />
|
||||
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
||||
|
||||
<!-- HID Config - Always visible -->
|
||||
<HidConfigPopover
|
||||
v-model:open="hidPopoverOpen"
|
||||
:mouse-mode="mouseMode"
|
||||
:is-admin="props.isAdmin"
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
|
||||
@@ -125,7 +122,7 @@ const extensionOpen = ref(false)
|
||||
</TooltipProvider>
|
||||
|
||||
<!-- ATX Power Control - Hidden on small screens -->
|
||||
<Popover v-if="props.isAdmin" v-model:open="atxOpen" class="hidden sm:block">
|
||||
<Popover v-model:open="atxOpen" class="hidden sm:block">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Power class="h-4 w-4" />
|
||||
@@ -158,9 +155,9 @@ const extensionOpen = ref(false)
|
||||
</div>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<!-- Extension Menu - Admin only, hidden on small screens -->
|
||||
<Popover v-if="props.isAdmin" v-model:open="extensionOpen" class="hidden lg:block">
|
||||
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
|
||||
<!-- Extension Menu - Hidden on small screens -->
|
||||
<Popover v-model:open="extensionOpen" class="hidden lg:block">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Cable class="h-4 w-4" />
|
||||
@@ -183,8 +180,8 @@ const extensionOpen = ref(false)
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Settings - Admin only, hidden on small screens -->
|
||||
<TooltipProvider v-if="props.isAdmin" class="hidden lg:block">
|
||||
<!-- Settings - Hidden on small screens -->
|
||||
<TooltipProvider class="hidden lg:block">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
@@ -270,7 +267,7 @@ const extensionOpen = ref(false)
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- ATX - Mobile only -->
|
||||
<DropdownMenuItem v-if="props.isAdmin" class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
|
||||
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
|
||||
<Power class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.power') }}
|
||||
</DropdownMenuItem>
|
||||
@@ -291,7 +288,6 @@ const extensionOpen = ref(false)
|
||||
|
||||
<!-- Extension - Tablet and below -->
|
||||
<DropdownMenuItem
|
||||
v-if="props.isAdmin"
|
||||
class="lg:hidden"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="emit('openTerminal'); overflowMenuOpen = false"
|
||||
@@ -301,7 +297,7 @@ const extensionOpen = ref(false)
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Settings - Tablet and below -->
|
||||
<DropdownMenuItem v-if="props.isAdmin" class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
|
||||
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
|
||||
<Settings class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.settings') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Volume2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
||||
import { audioApi, configApi } from '@/api'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||
|
||||
@@ -30,7 +31,6 @@ interface AudioDevice {
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
isAdmin?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -38,6 +38,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const configStore = useConfigStore()
|
||||
const systemStore = useSystemStore()
|
||||
const unifiedAudio = getUnifiedAudio()
|
||||
|
||||
@@ -88,9 +89,9 @@ async function loadDevices() {
|
||||
|
||||
// Initialize from current config
|
||||
function initializeFromCurrent() {
|
||||
const audio = systemStore.audio
|
||||
const audio = configStore.audio
|
||||
if (audio) {
|
||||
audioEnabled.value = audio.available && audio.streaming
|
||||
audioEnabled.value = audio.enabled
|
||||
selectedDevice.value = audio.device || ''
|
||||
selectedQuality.value = (audio.quality as 'voice' | 'balanced' | 'high') || 'balanced'
|
||||
}
|
||||
@@ -105,12 +106,10 @@ async function applyConfig() {
|
||||
|
||||
try {
|
||||
// Update config
|
||||
await configApi.update({
|
||||
audio: {
|
||||
enabled: audioEnabled.value,
|
||||
device: selectedDevice.value,
|
||||
quality: selectedQuality.value,
|
||||
},
|
||||
await configStore.updateAudio({
|
||||
enabled: audioEnabled.value,
|
||||
device: selectedDevice.value,
|
||||
quality: selectedQuality.value,
|
||||
})
|
||||
|
||||
// If enabled and device is selected, try to start audio stream
|
||||
@@ -152,12 +151,19 @@ async function applyConfig() {
|
||||
|
||||
// Watch popover open state
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
if (devices.value.length === 0) {
|
||||
loadDevices()
|
||||
}
|
||||
initializeFromCurrent()
|
||||
if (!isOpen) return
|
||||
|
||||
if (devices.value.length === 0) {
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
configStore.refreshAudio()
|
||||
.then(() => {
|
||||
initializeFromCurrent()
|
||||
})
|
||||
.catch(() => {
|
||||
initializeFromCurrent()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -203,11 +209,10 @@ watch(() => props.open, (isOpen) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Settings (requires apply) - Admin only -->
|
||||
<template v-if="props.isAdmin">
|
||||
<Separator />
|
||||
<!-- Device Settings (requires apply) -->
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-xs font-medium text-muted-foreground">
|
||||
{{ t('actionbar.audioDeviceSettings') }}
|
||||
@@ -311,7 +316,6 @@ watch(() => props.open, (isOpen) => {
|
||||
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import { configApi } from '@/api'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { HidBackend } from '@/types/generated'
|
||||
import type { HidConfigUpdate } from '@/types/generated'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
isAdmin?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,7 +37,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const systemStore = useSystemStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
|
||||
|
||||
@@ -73,7 +74,7 @@ watch(showCursor, (newValue, oldValue) => {
|
||||
})
|
||||
|
||||
// HID Device Settings (requires apply)
|
||||
const hidBackend = ref<'otg' | 'ch9329' | 'none'>('none')
|
||||
const hidBackend = ref<HidBackend>(HidBackend.None)
|
||||
const devicePath = ref<string>('')
|
||||
const baudrate = ref<number>(9600)
|
||||
|
||||
@@ -90,9 +91,9 @@ const buttonText = computed(() => t('actionbar.hidConfig'))
|
||||
|
||||
// Available device paths based on backend type
|
||||
const availableDevicePaths = computed(() => {
|
||||
if (hidBackend.value === 'ch9329') {
|
||||
if (hidBackend.value === HidBackend.Ch9329) {
|
||||
return serialDevices.value
|
||||
} else if (hidBackend.value === 'otg') {
|
||||
} else if (hidBackend.value === HidBackend.Otg) {
|
||||
// For OTG, we show UDC devices
|
||||
return udcDevices.value.map(udc => ({
|
||||
path: udc.name,
|
||||
@@ -125,9 +126,17 @@ function initializeFromCurrent() {
|
||||
showCursor.value = storedCursor
|
||||
|
||||
// Initialize HID device settings from system state
|
||||
const hid = systemStore.hid
|
||||
const hid = configStore.hid
|
||||
if (hid) {
|
||||
hidBackend.value = (hid.backend as 'otg' | 'ch9329' | 'none') || 'none'
|
||||
hidBackend.value = hid.backend || HidBackend.None
|
||||
if (hidBackend.value === HidBackend.Ch9329) {
|
||||
devicePath.value = hid.ch9329_port || ''
|
||||
baudrate.value = hid.ch9329_baudrate || 9600
|
||||
} else if (hidBackend.value === HidBackend.Otg) {
|
||||
devicePath.value = hid.otg_udc || ''
|
||||
} else {
|
||||
devicePath.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,10 +146,8 @@ function toggleMouseMode() {
|
||||
emit('update:mouseMode', newMode)
|
||||
|
||||
// Update backend config
|
||||
configApi.update({
|
||||
hid: {
|
||||
mouse_absolute: newMode === 'absolute',
|
||||
},
|
||||
configStore.updateHid({
|
||||
mouse_absolute: newMode === 'absolute',
|
||||
}).catch(_e => {
|
||||
console.info('[HidConfig] Failed to update mouse mode')
|
||||
toast.error(t('config.updateFailed'))
|
||||
@@ -163,7 +170,11 @@ function handleThrottleChange(value: number[] | undefined) {
|
||||
// Handle backend change
|
||||
function handleBackendChange(backend: unknown) {
|
||||
if (typeof backend !== 'string') return
|
||||
hidBackend.value = backend as 'otg' | 'ch9329' | 'none'
|
||||
if (backend === HidBackend.Otg || backend === HidBackend.Ch9329 || backend === HidBackend.None) {
|
||||
hidBackend.value = backend
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear device path when changing backend
|
||||
devicePath.value = ''
|
||||
@@ -190,18 +201,18 @@ function handleBaudrateChange(rate: unknown) {
|
||||
async function applyHidConfig() {
|
||||
applying.value = true
|
||||
try {
|
||||
const config: Record<string, unknown> = {
|
||||
const config: HidConfigUpdate = {
|
||||
backend: hidBackend.value,
|
||||
}
|
||||
|
||||
if (hidBackend.value === 'ch9329') {
|
||||
if (hidBackend.value === HidBackend.Ch9329) {
|
||||
config.ch9329_port = devicePath.value
|
||||
config.ch9329_baudrate = baudrate.value
|
||||
} else if (hidBackend.value === 'otg') {
|
||||
} else if (hidBackend.value === HidBackend.Otg) {
|
||||
config.otg_udc = devicePath.value
|
||||
}
|
||||
|
||||
await configApi.update({ hid: config })
|
||||
await configStore.updateHid(config)
|
||||
|
||||
toast.success(t('config.applied'))
|
||||
|
||||
@@ -216,14 +227,20 @@ async function applyHidConfig() {
|
||||
|
||||
// Watch open state
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
// Load devices on first open
|
||||
if (serialDevices.value.length === 0) {
|
||||
loadDevices()
|
||||
}
|
||||
// Initialize from current config
|
||||
initializeFromCurrent()
|
||||
if (!isOpen) return
|
||||
|
||||
// Load devices on first open
|
||||
if (serialDevices.value.length === 0) {
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
configStore.refreshHid()
|
||||
.then(() => {
|
||||
initializeFromCurrent()
|
||||
})
|
||||
.catch(() => {
|
||||
initializeFromCurrent()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -304,11 +321,10 @@ watch(() => props.open, (isOpen) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HID Device Settings (Requires Apply) - Admin only -->
|
||||
<template v-if="props.isAdmin">
|
||||
<Separator />
|
||||
<!-- HID Device Settings (Requires Apply) -->
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
|
||||
<Button
|
||||
@@ -333,15 +349,15 @@ watch(() => props.open, (isOpen) => {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="otg" class="text-xs">USB OTG</SelectItem>
|
||||
<SelectItem value="ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
|
||||
<SelectItem value="none" class="text-xs">{{ t('common.disabled') }}</SelectItem>
|
||||
<SelectItem :value="HidBackend.Otg" class="text-xs">USB OTG</SelectItem>
|
||||
<SelectItem :value="HidBackend.Ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
|
||||
<SelectItem :value="HidBackend.None" class="text-xs">{{ t('common.disabled') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Device Path (OTG or CH9329) -->
|
||||
<div v-if="hidBackend !== 'none'" class="space-y-2">
|
||||
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
|
||||
<Select
|
||||
:model-value="devicePath"
|
||||
@@ -365,7 +381,7 @@ watch(() => props.open, (isOpen) => {
|
||||
</div>
|
||||
|
||||
<!-- Baudrate (CH9329 only) -->
|
||||
<div v-if="hidBackend === 'ch9329'" class="space-y-2">
|
||||
<div v-if="hidBackend === HidBackend.Ch9329" class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.baudrate') }}</Label>
|
||||
<Select
|
||||
:model-value="String(baudrate)"
|
||||
@@ -393,8 +409,7 @@ watch(() => props.open, (isOpen) => {
|
||||
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
|
||||
@@ -28,8 +33,18 @@ const props = withDefaults(defineProps<{
|
||||
errorMessage?: string
|
||||
details?: StatusDetail[]
|
||||
hoverAlign?: 'start' | 'center' | 'end' // HoverCard alignment
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
hoverAlign: 'start',
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const prefersPopover = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
const coarsePointer = window.matchMedia?.('(pointer: coarse)')?.matches
|
||||
prefersPopover.value = hasTouch || !!coarsePointer
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
@@ -111,19 +126,20 @@ const statusBadgeText = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCard :open-delay="200" :close-delay="100">
|
||||
<HoverCard v-if="!prefersPopover" :open-delay="200" :close-delay="100">
|
||||
<HoverCardTrigger as-child>
|
||||
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
|
||||
<div
|
||||
:class="cn(
|
||||
'flex flex-col gap-0.5 px-3 py-1.5 rounded-md border text-sm cursor-pointer transition-colors min-w-[100px]',
|
||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
|
||||
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||
'border-slate-200 dark:border-slate-700',
|
||||
status === 'error' && 'border-red-300 dark:border-red-800'
|
||||
)"
|
||||
>
|
||||
<!-- Top: Title -->
|
||||
<span class="font-medium text-foreground text-xs">{{ title }}</span>
|
||||
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
||||
<!-- Bottom: Status dot + Quick info -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
|
||||
@@ -208,4 +224,103 @@ const statusBadgeText = computed(() => {
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<Popover v-else>
|
||||
<PopoverTrigger as-child>
|
||||
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
|
||||
<div
|
||||
:class="cn(
|
||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
|
||||
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||
'border-slate-200 dark:border-slate-700',
|
||||
status === 'error' && 'border-red-300 dark:border-red-800'
|
||||
)"
|
||||
>
|
||||
<!-- Top: Title -->
|
||||
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
||||
<!-- Bottom: Status dot + Quick info -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
|
||||
<span class="text-[11px] text-muted-foreground leading-tight truncate">
|
||||
{{ quickInfo || subtitle || statusText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-80" :align="hoverAlign">
|
||||
<div class="space-y-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="cn(
|
||||
'p-2 rounded-lg',
|
||||
status === 'connected' ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
|
||||
status === 'error' ? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' :
|
||||
'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
|
||||
)">
|
||||
<component :is="StatusIcon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-sm">{{ title }}</h4>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<component
|
||||
v-if="statusIcon"
|
||||
:is="statusIcon"
|
||||
:class="cn(
|
||||
'h-3.5 w-3.5',
|
||||
status === 'connected' ? 'text-green-500' :
|
||||
status === 'connecting' ? 'text-yellow-500 animate-spin' :
|
||||
status === 'error' ? 'text-red-500' :
|
||||
'text-slate-400'
|
||||
)"
|
||||
/>
|
||||
<Badge
|
||||
:variant="status === 'connected' ? 'default' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusBadgeText }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="status === 'error' && errorMessage"
|
||||
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<p class="text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div v-if="details && details.length > 0" class="space-y-2">
|
||||
<Separator />
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="(detail, index) in details"
|
||||
:key="index"
|
||||
class="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span class="text-muted-foreground">{{ detail.label }}</span>
|
||||
<span
|
||||
:class="cn(
|
||||
'font-medium',
|
||||
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
||||
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
||||
'text-foreground'
|
||||
)"
|
||||
>
|
||||
{{ detail.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo, type BitratePreset } from '@/api'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
@@ -43,7 +43,6 @@ interface VideoDevice {
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
videoMode: VideoMode
|
||||
isAdmin?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -52,7 +51,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const systemStore = useSystemStore()
|
||||
const configStore = useConfigStore()
|
||||
const router = useRouter()
|
||||
|
||||
// Device list
|
||||
@@ -65,7 +64,7 @@ const loadingCodecs = ref(false)
|
||||
|
||||
// Backend list
|
||||
const backends = ref<EncoderBackendInfo[]>([])
|
||||
const currentEncoderBackend = ref<string>('auto')
|
||||
const currentEncoderBackend = computed(() => configStore.stream?.encoder || 'auto')
|
||||
|
||||
// Browser supported codecs (WebRTC receive capabilities)
|
||||
const browserSupportedCodecs = ref<Set<string>>(new Set())
|
||||
@@ -198,11 +197,11 @@ const applyingBitrate = ref(false)
|
||||
|
||||
// Current config from store
|
||||
const currentConfig = computed(() => ({
|
||||
device: systemStore.stream?.device || '',
|
||||
format: systemStore.stream?.format || '',
|
||||
width: systemStore.stream?.resolution?.[0] || 1920,
|
||||
height: systemStore.stream?.resolution?.[1] || 1080,
|
||||
fps: systemStore.stream?.targetFps || 30,
|
||||
device: configStore.video?.device || '',
|
||||
format: configStore.video?.format || '',
|
||||
width: configStore.video?.width || 1920,
|
||||
height: configStore.video?.height || 1080,
|
||||
fps: configStore.video?.fps || 30,
|
||||
}))
|
||||
|
||||
// Button display text - simplified to just show label
|
||||
@@ -304,19 +303,6 @@ async function loadCodecs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load current encoder backend from config
|
||||
async function loadEncoderBackend() {
|
||||
try {
|
||||
const config = await configApi.get()
|
||||
// Access nested stream.encoder
|
||||
const streamConfig = config.stream as { encoder?: string } | undefined
|
||||
currentEncoderBackend.value = streamConfig?.encoder || 'auto'
|
||||
} catch (e) {
|
||||
console.info('[VideoConfig] Failed to load encoder backend config')
|
||||
currentEncoderBackend.value = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to settings page (video tab)
|
||||
function goToSettings() {
|
||||
router.push('/settings?tab=video')
|
||||
@@ -441,14 +427,12 @@ async function applyVideoConfig() {
|
||||
|
||||
applying.value = true
|
||||
try {
|
||||
await configApi.update({
|
||||
video: {
|
||||
device: selectedDevice.value,
|
||||
format: selectedFormat.value,
|
||||
width,
|
||||
height,
|
||||
fps: selectedFps.value,
|
||||
},
|
||||
await configStore.updateVideo({
|
||||
device: selectedDevice.value,
|
||||
format: selectedFormat.value,
|
||||
width,
|
||||
height,
|
||||
fps: selectedFps.value,
|
||||
})
|
||||
|
||||
toast.success(t('config.applied'))
|
||||
@@ -464,26 +448,32 @@ async function applyVideoConfig() {
|
||||
|
||||
// Watch open state
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
// Detect browser codec support on first open
|
||||
if (browserSupportedCodecs.value.size === 0) {
|
||||
detectBrowserCodecSupport()
|
||||
}
|
||||
// Load devices on first open
|
||||
if (devices.value.length === 0) {
|
||||
loadDevices()
|
||||
}
|
||||
// Load codecs and backends on first open
|
||||
if (codecs.value.length === 0) {
|
||||
loadCodecs()
|
||||
}
|
||||
// Load encoder backend config
|
||||
loadEncoderBackend()
|
||||
// Initialize from current config
|
||||
initializeFromCurrent()
|
||||
} else {
|
||||
if (!isOpen) {
|
||||
isDirty.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Detect browser codec support on first open
|
||||
if (browserSupportedCodecs.value.size === 0) {
|
||||
detectBrowserCodecSupport()
|
||||
}
|
||||
// Load devices on first open
|
||||
if (devices.value.length === 0) {
|
||||
loadDevices()
|
||||
}
|
||||
// Load codecs and backends on first open
|
||||
if (codecs.value.length === 0) {
|
||||
loadCodecs()
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
configStore.refreshVideo(),
|
||||
configStore.refreshStream(),
|
||||
]).then(() => {
|
||||
initializeFromCurrent()
|
||||
}).catch(() => {
|
||||
initializeFromCurrent()
|
||||
})
|
||||
})
|
||||
|
||||
// Sync selected values when backend config changes (e.g., auto format switch on mode change)
|
||||
@@ -619,9 +609,7 @@ watch(currentConfig, () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Link - Admin only -->
|
||||
<Button
|
||||
v-if="props.isAdmin"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0"
|
||||
@@ -632,11 +620,10 @@ watch(currentConfig, () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Device Settings Section - Admin only -->
|
||||
<template v-if="props.isAdmin">
|
||||
<Separator />
|
||||
<!-- Device Settings Section -->
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
|
||||
<Button
|
||||
@@ -784,8 +771,7 @@ watch(currentConfig, () => {
|
||||
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -114,6 +114,21 @@ const keyboardLayout = {
|
||||
},
|
||||
}
|
||||
|
||||
const compactMainLayout = {
|
||||
default: keyboardLayout.main.default.slice(2),
|
||||
shift: keyboardLayout.main.shift.slice(2),
|
||||
}
|
||||
|
||||
const isCompactLayout = ref(false)
|
||||
let compactLayoutMedia: MediaQueryList | null = null
|
||||
let compactLayoutListener: ((event: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
function setCompactLayout(active: boolean) {
|
||||
if (isCompactLayout.value === active) return
|
||||
isCompactLayout.value = active
|
||||
updateKeyboardLayout()
|
||||
}
|
||||
|
||||
// Key display mapping with Unicode symbols (JetKVM style)
|
||||
const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
// OS-specific Meta key labels
|
||||
@@ -233,14 +248,15 @@ function switchOsLayout(os: KeyboardOsType) {
|
||||
// Update keyboard layout based on selected OS
|
||||
function updateKeyboardLayout() {
|
||||
const bottomRow = getBottomRow()
|
||||
const baseLayout = isCompactLayout.value ? compactMainLayout : keyboardLayout.main
|
||||
const newLayout = {
|
||||
...keyboardLayout.main,
|
||||
...baseLayout,
|
||||
default: [
|
||||
...keyboardLayout.main.default.slice(0, -1),
|
||||
...baseLayout.default.slice(0, -1),
|
||||
bottomRow,
|
||||
],
|
||||
shift: [
|
||||
...keyboardLayout.main.shift.slice(0, -1),
|
||||
...baseLayout.shift.slice(0, -1),
|
||||
bottomRow,
|
||||
],
|
||||
}
|
||||
@@ -422,7 +438,7 @@ function initKeyboards() {
|
||||
|
||||
// Main keyboard - pass element directly instead of selector string
|
||||
mainKeyboard.value = new Keyboard(mainEl, {
|
||||
layout: keyboardLayout.main,
|
||||
layout: isCompactLayout.value ? compactMainLayout : keyboardLayout.main,
|
||||
layoutName: layoutName.value,
|
||||
display: keyDisplayMap.value,
|
||||
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
||||
@@ -471,6 +487,7 @@ function initKeyboards() {
|
||||
stopMouseUpPropagation: true,
|
||||
})
|
||||
|
||||
updateKeyboardLayout()
|
||||
console.log('[VirtualKeyboard] Keyboards initialized:', id)
|
||||
}
|
||||
|
||||
@@ -570,6 +587,15 @@ onMounted(() => {
|
||||
selectedOs.value = savedOs
|
||||
}
|
||||
|
||||
if (window.matchMedia) {
|
||||
compactLayoutMedia = window.matchMedia('(max-width: 640px)')
|
||||
setCompactLayout(compactLayoutMedia.matches)
|
||||
compactLayoutListener = (event: MediaQueryListEvent) => {
|
||||
setCompactLayout(event.matches)
|
||||
}
|
||||
compactLayoutMedia.addEventListener('change', compactLayoutListener)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('touchmove', onDrag)
|
||||
document.addEventListener('mouseup', endDrag)
|
||||
@@ -577,6 +603,9 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (compactLayoutMedia && compactLayoutListener) {
|
||||
compactLayoutMedia.removeEventListener('change', compactLayoutListener)
|
||||
}
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('mouseup', endDrag)
|
||||
@@ -1112,6 +1141,80 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vkb .simple-keyboard .hg-button {
|
||||
height: 30px;
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
margin: 0 1px 3px 0;
|
||||
min-width: 26px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button.combination-key {
|
||||
font-size: 9px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"],
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
|
||||
min-width: 46px;
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.kb-control-container .hg-button {
|
||||
min-width: 44px !important;
|
||||
}
|
||||
|
||||
.kb-arrows-container .hg-button {
|
||||
min-width: 36px !important;
|
||||
width: 36px !important;
|
||||
}
|
||||
|
||||
.vkb-media-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
min-width: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating mode - slightly smaller keys but still readable */
|
||||
.vkb--floating .vkb-body {
|
||||
padding: 8px;
|
||||
|
||||
@@ -265,6 +265,10 @@ export default {
|
||||
// Help tooltips
|
||||
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
|
||||
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
|
||||
otgAdvanced: 'Advanced: OTG Preset',
|
||||
otgProfile: 'Initial HID Preset',
|
||||
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
|
||||
otgLowEndpointHint: 'Detected low-endpoint UDC; multimedia keys will be disabled automatically.',
|
||||
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
|
||||
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
|
||||
// Extensions
|
||||
@@ -476,10 +480,22 @@ export default {
|
||||
configureHttpPort: 'Configure HTTP server port',
|
||||
// Web server
|
||||
webServer: 'Access Address',
|
||||
webServerDesc: 'Configure HTTP/HTTPS ports and bind address. Restart required for changes to take effect.',
|
||||
webServerDesc: 'Configure HTTP/HTTPS ports and listening addresses. Restart required for changes to take effect.',
|
||||
httpsPort: 'HTTPS Port',
|
||||
bindAddress: 'Bind Address',
|
||||
bindAddressDesc: 'IP address the server listens on. 0.0.0.0 means all network interfaces.',
|
||||
bindMode: 'Listening Address',
|
||||
bindModeDesc: 'Choose which addresses the web server binds to.',
|
||||
bindModeAll: 'All addresses',
|
||||
bindModeLocal: 'Local only (127.0.0.1)',
|
||||
bindModeCustom: 'Custom address list',
|
||||
bindIpv6: 'Enable IPv6',
|
||||
bindAllDesc: 'Also listen on :: (all IPv6 interfaces).',
|
||||
bindLocalDesc: 'Also listen on ::1 (IPv6 loopback).',
|
||||
bindAddressList: 'Address List',
|
||||
bindAddressListDesc: 'One IP address per line (IPv4 or IPv6).',
|
||||
addBindAddress: 'Add address',
|
||||
bindAddressListEmpty: 'Add at least one IP address.',
|
||||
httpsEnabled: 'Enable HTTPS',
|
||||
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
|
||||
restartRequired: 'Restart Required',
|
||||
@@ -584,9 +600,12 @@ export default {
|
||||
otgHidProfile: 'OTG HID Profile',
|
||||
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
|
||||
profile: 'Profile',
|
||||
otgProfileFull: 'Full (keyboard + relative mouse + absolute mouse + consumer)',
|
||||
otgProfileLegacyKeyboard: 'Legacy: keyboard only',
|
||||
otgProfileLegacyMouseRelative: 'Legacy: relative mouse only',
|
||||
otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD',
|
||||
otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)',
|
||||
otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)',
|
||||
otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)',
|
||||
otgProfileLegacyKeyboard: 'Keyboard only',
|
||||
otgProfileLegacyMouseRelative: 'Relative mouse only',
|
||||
otgProfileCustom: 'Custom',
|
||||
otgFunctionKeyboard: 'Keyboard',
|
||||
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
|
||||
@@ -599,6 +618,7 @@ export default {
|
||||
otgFunctionMsd: 'Mass Storage (MSD)',
|
||||
otgFunctionMsdDesc: 'Expose USB storage to the host',
|
||||
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
|
||||
otgLowEndpointHint: 'Low-endpoint UDC detected; multimedia keys will be disabled automatically.',
|
||||
otgFunctionMinWarning: 'Enable at least one HID function before saving',
|
||||
// OTG Descriptor
|
||||
otgDescriptor: 'USB Device Descriptor',
|
||||
@@ -749,10 +769,10 @@ export default {
|
||||
serverSettings: 'Server Settings',
|
||||
rendezvousServer: 'ID Server',
|
||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||
rendezvousServerHint: 'Configure your RustDesk server address',
|
||||
rendezvousServerHint: 'Configure your RustDesk server address (port optional, defaults to 21116)',
|
||||
relayServer: 'Relay Server',
|
||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||
relayServerHint: 'Relay server address, auto-derived from ID server if empty',
|
||||
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
|
||||
relayKey: 'Relay Key',
|
||||
relayKeyPlaceholder: 'Enter relay server key',
|
||||
relayKeySet: '••••••••',
|
||||
|
||||
@@ -265,6 +265,10 @@ export default {
|
||||
// Help tooltips
|
||||
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
|
||||
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
|
||||
otgAdvanced: '高级:OTG 预设',
|
||||
otgProfile: '初始 HID 预设',
|
||||
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
|
||||
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。',
|
||||
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
|
||||
videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。',
|
||||
// Extensions
|
||||
@@ -476,10 +480,22 @@ export default {
|
||||
configureHttpPort: '配置 HTTP 服务器端口',
|
||||
// Web server
|
||||
webServer: '访问地址',
|
||||
webServerDesc: '配置 HTTP/HTTPS 端口和绑定地址,修改后需要重启生效',
|
||||
webServerDesc: '配置 HTTP/HTTPS 端口和监听地址,修改后需要重启生效',
|
||||
httpsPort: 'HTTPS 端口',
|
||||
bindAddress: '绑定地址',
|
||||
bindAddressDesc: '服务器监听的 IP 地址,0.0.0.0 表示监听所有网络接口',
|
||||
bindMode: '监听地址',
|
||||
bindModeDesc: '选择 Web 服务监听哪些地址。',
|
||||
bindModeAll: '所有地址',
|
||||
bindModeLocal: '仅本地 (127.0.0.1)',
|
||||
bindModeCustom: '自定义地址列表',
|
||||
bindIpv6: '启用 IPv6',
|
||||
bindAllDesc: '同时监听 ::(所有 IPv6 地址)。',
|
||||
bindLocalDesc: '同时监听 ::1(IPv6 本地回环)。',
|
||||
bindAddressList: '地址列表',
|
||||
bindAddressListDesc: '每行一个 IP(IPv4 或 IPv6)。',
|
||||
addBindAddress: '添加地址',
|
||||
bindAddressListEmpty: '请至少填写一个 IP 地址。',
|
||||
httpsEnabled: '启用 HTTPS',
|
||||
httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签名证书)',
|
||||
restartRequired: '需要重启',
|
||||
@@ -584,9 +600,12 @@ export default {
|
||||
otgHidProfile: 'OTG HID 组合',
|
||||
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
|
||||
profile: '组合',
|
||||
otgProfileFull: '完整(键盘 + 相对鼠标 + 绝对鼠标 + 多媒体)',
|
||||
otgProfileLegacyKeyboard: '兼容:仅键盘',
|
||||
otgProfileLegacyMouseRelative: '兼容:仅相对鼠标',
|
||||
otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体',
|
||||
otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)',
|
||||
otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)',
|
||||
otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)',
|
||||
otgProfileLegacyKeyboard: '仅键盘',
|
||||
otgProfileLegacyMouseRelative: '仅相对鼠标',
|
||||
otgProfileCustom: '自定义',
|
||||
otgFunctionKeyboard: '键盘',
|
||||
otgFunctionKeyboardDesc: '标准 HID 键盘设备',
|
||||
@@ -596,9 +615,10 @@ export default {
|
||||
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
|
||||
otgFunctionConsumer: '多媒体控制',
|
||||
otgFunctionConsumerDesc: '音量/播放/暂停等按键',
|
||||
otgFunctionMsd: 'U盘(MSD)',
|
||||
otgFunctionMsd: '虚拟媒体(MSD)',
|
||||
otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
|
||||
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
|
||||
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。',
|
||||
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
|
||||
// OTG Descriptor
|
||||
otgDescriptor: 'USB 设备描述符',
|
||||
@@ -749,10 +769,10 @@ export default {
|
||||
serverSettings: '服务器设置',
|
||||
rendezvousServer: 'ID 服务器',
|
||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||
rendezvousServerHint: '请配置您的 RustDesk 服务器地址',
|
||||
rendezvousServerHint: '请配置您的 RustDesk 服务器地址(端口可省略,默认 21116)',
|
||||
relayServer: '中继服务器',
|
||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||
relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导',
|
||||
relayServerHint: '中继服务器地址(端口可省略,默认 21117),留空则自动从 ID 服务器推导',
|
||||
relayKey: '中继密钥',
|
||||
relayKeyPlaceholder: '输入中继服务器密钥',
|
||||
relayKeySet: '••••••••',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import i18n from '@/i18n'
|
||||
import { ApiError } from '@/api/request'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -33,29 +36,62 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
let sessionExpiredNotified = false
|
||||
|
||||
function t(key: string, params?: Record<string, unknown>): string {
|
||||
return String(i18n.global.t(key, params as any))
|
||||
}
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Check if system needs setup
|
||||
if (!authStore.initialized && to.name !== 'Setup') {
|
||||
// Prevent access to setup after initialization
|
||||
const shouldCheckSetup = to.name === 'Setup' || !authStore.initialized
|
||||
if (shouldCheckSetup) {
|
||||
try {
|
||||
await authStore.checkSetupStatus()
|
||||
if (authStore.needsSetup) {
|
||||
return next({ name: 'Setup' })
|
||||
}
|
||||
} catch {
|
||||
// Continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
if (authStore.needsSetup) {
|
||||
if (to.name !== 'Setup') {
|
||||
return next({ name: 'Setup' })
|
||||
}
|
||||
} else if (authStore.initialized && to.name === 'Setup') {
|
||||
if (!authStore.isAuthenticated) {
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
} catch {
|
||||
// Not authenticated
|
||||
}
|
||||
}
|
||||
|
||||
return next({ name: authStore.isAuthenticated ? 'Console' : 'Login' })
|
||||
}
|
||||
|
||||
// Check authentication for protected routes
|
||||
if (to.meta.requiresAuth !== false) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Not authenticated
|
||||
if (e instanceof ApiError && e.status === 401 && !sessionExpiredNotified) {
|
||||
const normalized = e.message.toLowerCase()
|
||||
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
|
||||
const isSessionExpired = normalized.includes('session expired')
|
||||
if (isLoggedInElsewhere || isSessionExpired) {
|
||||
sessionExpiredNotified = true
|
||||
const titleKey = isLoggedInElsewhere ? 'auth.loggedInElsewhere' : 'auth.sessionExpired'
|
||||
toast.error(t(titleKey), {
|
||||
description: e.message,
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { authApi, systemApi } from '@/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<string | null>(null)
|
||||
const isAdmin = ref(false)
|
||||
const isAuthenticated = ref(false)
|
||||
const initialized = ref(false)
|
||||
const needsSetup = ref(false)
|
||||
@@ -30,12 +29,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const result = await authApi.check()
|
||||
isAuthenticated.value = result.authenticated
|
||||
user.value = result.user || null
|
||||
isAdmin.value = result.is_admin ?? false
|
||||
return result
|
||||
} catch {
|
||||
} catch (e) {
|
||||
isAuthenticated.value = false
|
||||
user.value = null
|
||||
isAdmin.value = false
|
||||
error.value = e instanceof Error ? e.message : 'Not authenticated'
|
||||
if (e instanceof Error) {
|
||||
throw e
|
||||
}
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
}
|
||||
@@ -49,13 +50,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
if (result.success) {
|
||||
isAuthenticated.value = true
|
||||
user.value = username
|
||||
// After login, fetch admin status
|
||||
try {
|
||||
const authResult = await authApi.check()
|
||||
isAdmin.value = authResult.is_admin ?? false
|
||||
} catch {
|
||||
isAdmin.value = false
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
error.value = result.message || 'Login failed'
|
||||
@@ -75,7 +69,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
} finally {
|
||||
isAuthenticated.value = false
|
||||
user.value = null
|
||||
isAdmin.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +84,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
hid_ch9329_port?: string
|
||||
hid_ch9329_baudrate?: number
|
||||
hid_otg_udc?: string
|
||||
hid_otg_profile?: string
|
||||
encoder_backend?: string
|
||||
audio_device?: string
|
||||
ttyd_enabled?: boolean
|
||||
@@ -119,7 +113,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
return {
|
||||
user,
|
||||
isAdmin,
|
||||
isAuthenticated,
|
||||
initialized,
|
||||
needsSetup,
|
||||
|
||||
506
web/src/stores/config.ts
Normal file
506
web/src/stores/config.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useAuthStore } from './auth'
|
||||
export { useConfigStore } from './config'
|
||||
export { useSystemStore } from './system'
|
||||
|
||||
@@ -58,6 +58,12 @@ export interface OtgDescriptorConfig {
|
||||
export enum OtgHidProfile {
|
||||
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
|
||||
Full = "full",
|
||||
/** Full HID device set without MSD */
|
||||
FullNoMsd = "full_no_msd",
|
||||
/** Full HID device set without consumer control */
|
||||
FullNoConsumer = "full_no_consumer",
|
||||
/** Full HID device set without consumer control and MSD */
|
||||
FullNoConsumerNoMsd = "full_no_consumer_no_msd",
|
||||
/** Legacy profile: only keyboard */
|
||||
LegacyKeyboard = "legacy_keyboard",
|
||||
/** Legacy profile: only relative mouse */
|
||||
@@ -276,7 +282,9 @@ export interface WebConfig {
|
||||
http_port: number;
|
||||
/** HTTPS port */
|
||||
https_port: number;
|
||||
/** Bind address */
|
||||
/** Bind addresses (preferred) */
|
||||
bind_addresses: string[];
|
||||
/** Bind address (legacy) */
|
||||
bind_address: string;
|
||||
/** Enable HTTPS */
|
||||
https_enabled: boolean;
|
||||
@@ -619,6 +627,7 @@ export interface VideoConfigUpdate {
|
||||
export interface WebConfigUpdate {
|
||||
http_port?: number;
|
||||
https_port?: number;
|
||||
bind_addresses?: string[];
|
||||
bind_address?: string;
|
||||
https_enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { useConsoleEvents } from '@/composables/useConsoleEvents'
|
||||
@@ -59,6 +60,7 @@ import { setLanguage } from '@/i18n'
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const systemStore = useSystemStore()
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
|
||||
const hidWs = useHidWebSocket()
|
||||
@@ -134,6 +136,15 @@ let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas be
|
||||
// Cursor visibility (from localStorage, updated via storage event)
|
||||
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
|
||||
|
||||
function syncMouseModeFromConfig() {
|
||||
const mouseAbsolute = configStore.hid?.mouse_absolute
|
||||
if (typeof mouseAbsolute !== 'boolean') return
|
||||
const nextMode: 'absolute' | 'relative' = mouseAbsolute ? 'absolute' : 'relative'
|
||||
if (mouseMode.value !== nextMode) {
|
||||
mouseMode.value = nextMode
|
||||
}
|
||||
}
|
||||
|
||||
// Virtual keyboard state
|
||||
const virtualKeyboardVisible = ref(false)
|
||||
const virtualKeyboardAttached = ref(true)
|
||||
@@ -1787,6 +1798,9 @@ onMounted(async () => {
|
||||
// 4. 其他初始化
|
||||
await systemStore.startStream().catch(() => {})
|
||||
await systemStore.fetchAllStates()
|
||||
await configStore.refreshHid().then(() => {
|
||||
syncMouseModeFromConfig()
|
||||
}).catch(() => {})
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
@@ -1800,6 +1814,10 @@ onMounted(async () => {
|
||||
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
|
||||
window.addEventListener('storage', handleMouseSendIntervalStorage)
|
||||
|
||||
watch(() => configStore.hid?.mouse_absolute, () => {
|
||||
syncMouseModeFromConfig()
|
||||
})
|
||||
|
||||
// Pointer Lock event listeners
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
document.addEventListener('pointerlockerror', handlePointerLockError)
|
||||
@@ -1881,104 +1899,161 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-background">
|
||||
<!-- Header -->
|
||||
<header class="h-14 shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="h-full px-4 flex items-center justify-between">
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-6">
|
||||
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="px-4">
|
||||
<div class="h-14 flex items-center justify-between">
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Monitor class="h-6 w-6 text-primary" />
|
||||
<span class="font-bold text-lg">One-KVM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Status Cards + User Menu -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Monitor class="h-6 w-6 text-primary" />
|
||||
<span class="font-bold text-lg">One-KVM</span>
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<!-- Video Status -->
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
:status="videoStatus"
|
||||
:quick-info="videoQuickInfo"
|
||||
:error-message="videoErrorMessage"
|
||||
:details="videoDetails"
|
||||
/>
|
||||
|
||||
<!-- Audio Status -->
|
||||
<StatusCard
|
||||
v-if="systemStore.audio?.available"
|
||||
:title="t('statusCard.audio')"
|
||||
type="audio"
|
||||
:status="audioStatus"
|
||||
:quick-info="audioQuickInfo"
|
||||
:error-message="audioErrorMessage"
|
||||
:details="audioDetails"
|
||||
/>
|
||||
|
||||
<!-- HID Status -->
|
||||
<StatusCard
|
||||
:title="t('statusCard.hid')"
|
||||
type="hid"
|
||||
:status="hidStatus"
|
||||
:quick-info="hidQuickInfo"
|
||||
:details="hidDetails"
|
||||
/>
|
||||
|
||||
<!-- MSD Status - Hidden when CH9329 backend (no USB gadget support) -->
|
||||
<StatusCard
|
||||
v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
|
||||
:title="t('statusCard.msd')"
|
||||
type="msd"
|
||||
:status="msdStatus"
|
||||
:quick-info="msdQuickInfo"
|
||||
:error-message="msdErrorMessage"
|
||||
:details="msdDetails"
|
||||
hover-align="end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleTheme">
|
||||
<Sun v-if="isDark" class="h-4 w-4" />
|
||||
<Moon v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleLanguage">
|
||||
<Languages class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- User Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="gap-1.5">
|
||||
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem class="md:hidden" @click="toggleTheme">
|
||||
<Sun v-if="isDark" class="h-4 w-4 mr-2" />
|
||||
<Moon v-else class="h-4 w-4 mr-2" />
|
||||
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
|
||||
<Languages class="h-4 w-4 mr-2" />
|
||||
{{ locale === 'zh-CN' ? 'English' : '中文' }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator class="md:hidden" />
|
||||
<DropdownMenuItem @click="changePasswordDialogOpen = true">
|
||||
<KeyRound class="h-4 w-4 mr-2" />
|
||||
{{ t('auth.changePassword') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="logout">
|
||||
<LogOut class="h-4 w-4 mr-2" />
|
||||
{{ t('auth.logout') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Status Cards + User Menu -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Video Status -->
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
:status="videoStatus"
|
||||
:quick-info="videoQuickInfo"
|
||||
:error-message="videoErrorMessage"
|
||||
:details="videoDetails"
|
||||
/>
|
||||
<!-- Mobile Status Row -->
|
||||
<div class="md:hidden pb-2">
|
||||
<div class="flex items-center gap-2 overflow-x-auto">
|
||||
<div class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
:status="videoStatus"
|
||||
:quick-info="videoQuickInfo"
|
||||
:error-message="videoErrorMessage"
|
||||
:details="videoDetails"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Audio Status -->
|
||||
<StatusCard
|
||||
v-if="systemStore.audio?.available"
|
||||
:title="t('statusCard.audio')"
|
||||
type="audio"
|
||||
:status="audioStatus"
|
||||
:quick-info="audioQuickInfo"
|
||||
:error-message="audioErrorMessage"
|
||||
:details="audioDetails"
|
||||
/>
|
||||
<div v-if="systemStore.audio?.available" class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.audio')"
|
||||
type="audio"
|
||||
:status="audioStatus"
|
||||
:quick-info="audioQuickInfo"
|
||||
:error-message="audioErrorMessage"
|
||||
:details="audioDetails"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- HID Status -->
|
||||
<StatusCard
|
||||
:title="t('statusCard.hid')"
|
||||
type="hid"
|
||||
:status="hidStatus"
|
||||
:quick-info="hidQuickInfo"
|
||||
:details="hidDetails"
|
||||
/>
|
||||
<div class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.hid')"
|
||||
type="hid"
|
||||
:status="hidStatus"
|
||||
:quick-info="hidQuickInfo"
|
||||
:details="hidDetails"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- MSD Status - Admin only, hidden when CH9329 backend (no USB gadget support) -->
|
||||
<StatusCard
|
||||
v-if="authStore.isAdmin && systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
|
||||
:title="t('statusCard.msd')"
|
||||
type="msd"
|
||||
:status="msdStatus"
|
||||
:quick-info="msdQuickInfo"
|
||||
:error-message="msdErrorMessage"
|
||||
:details="msdDetails"
|
||||
hover-align="end"
|
||||
/>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleTheme">
|
||||
<Sun v-if="isDark" class="h-4 w-4" />
|
||||
<Moon v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleLanguage">
|
||||
<Languages class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- User Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="gap-1.5">
|
||||
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem class="md:hidden" @click="toggleTheme">
|
||||
<Sun v-if="isDark" class="h-4 w-4 mr-2" />
|
||||
<Moon v-else class="h-4 w-4 mr-2" />
|
||||
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
|
||||
<Languages class="h-4 w-4 mr-2" />
|
||||
{{ locale === 'zh-CN' ? 'English' : '中文' }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator class="md:hidden" />
|
||||
<DropdownMenuItem @click="changePasswordDialogOpen = true">
|
||||
<KeyRound class="h-4 w-4 mr-2" />
|
||||
{{ t('auth.changePassword') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="logout">
|
||||
<LogOut class="h-4 w-4 mr-2" />
|
||||
{{ t('auth.logout') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'" class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.msd')"
|
||||
type="msd"
|
||||
:status="msdStatus"
|
||||
:quick-info="msdQuickInfo"
|
||||
:error-message="msdErrorMessage"
|
||||
:details="msdDetails"
|
||||
hover-align="end"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1988,7 +2063,6 @@ onUnmounted(() => {
|
||||
:mouse-mode="mouseMode"
|
||||
:video-mode="videoMode"
|
||||
:ttyd-running="ttydStatus?.running"
|
||||
:is-admin="authStore.isAdmin"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@toggle-stats="statsSheetOpen = true"
|
||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||
|
||||
@@ -2,20 +2,14 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
authApi,
|
||||
authConfigApi,
|
||||
configApi,
|
||||
streamApi,
|
||||
videoConfigApi,
|
||||
streamConfigApi,
|
||||
hidConfigApi,
|
||||
msdConfigApi,
|
||||
atxConfigApi,
|
||||
extensionsApi,
|
||||
rustdeskConfigApi,
|
||||
webConfigApi,
|
||||
systemApi,
|
||||
type EncoderBackendInfo,
|
||||
type AuthConfig,
|
||||
@@ -72,6 +66,7 @@ import {
|
||||
Square,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
ScreenShare,
|
||||
@@ -79,6 +74,7 @@ import {
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const systemStore = useSystemStore()
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Settings state
|
||||
@@ -196,11 +192,32 @@ const webServerConfig = ref<WebConfig>({
|
||||
http_port: 8080,
|
||||
https_port: 8443,
|
||||
bind_address: '0.0.0.0',
|
||||
bind_addresses: ['0.0.0.0'],
|
||||
https_enabled: false,
|
||||
})
|
||||
const webServerLoading = ref(false)
|
||||
const showRestartDialog = ref(false)
|
||||
const restarting = ref(false)
|
||||
type BindMode = 'all' | 'loopback' | 'custom'
|
||||
const bindMode = ref<BindMode>('all')
|
||||
const bindAllIpv6 = ref(false)
|
||||
const bindLocalIpv6 = ref(false)
|
||||
const bindAddressList = ref<string[]>([])
|
||||
const bindAddressError = computed(() => {
|
||||
if (bindMode.value !== 'custom') return ''
|
||||
return normalizeBindAddresses(bindAddressList.value).length
|
||||
? ''
|
||||
: t('settings.bindAddressListEmpty')
|
||||
})
|
||||
const effectiveBindAddresses = computed(() => {
|
||||
if (bindMode.value === 'all') {
|
||||
return bindAllIpv6.value ? ['0.0.0.0', '::'] : ['0.0.0.0']
|
||||
}
|
||||
if (bindMode.value === 'loopback') {
|
||||
return bindLocalIpv6.value ? ['127.0.0.1', '::1'] : ['127.0.0.1']
|
||||
}
|
||||
return normalizeBindAddresses(bindAddressList.value)
|
||||
})
|
||||
|
||||
// Config
|
||||
interface DeviceConfig {
|
||||
@@ -220,12 +237,14 @@ interface DeviceConfig {
|
||||
}>
|
||||
serial: Array<{ path: string; name: string }>
|
||||
audio: Array<{ name: string; description: string }>
|
||||
udc: Array<{ name: string }>
|
||||
}
|
||||
|
||||
const devices = ref<DeviceConfig>({
|
||||
video: [],
|
||||
serial: [],
|
||||
audio: [],
|
||||
udc: [],
|
||||
})
|
||||
|
||||
const config = ref({
|
||||
@@ -237,6 +256,7 @@ const config = ref({
|
||||
hid_backend: 'ch9329',
|
||||
hid_serial_device: '',
|
||||
hid_serial_baudrate: 9600,
|
||||
hid_otg_udc: '',
|
||||
hid_otg_profile: 'full' as OtgHidProfile,
|
||||
hid_otg_functions: {
|
||||
keyboard: true,
|
||||
@@ -246,7 +266,6 @@ const config = ref({
|
||||
} as OtgHidFunctions,
|
||||
msd_enabled: false,
|
||||
msd_dir: '',
|
||||
network_port: 8080,
|
||||
encoder_backend: 'auto',
|
||||
// STUN/TURN settings
|
||||
stun_server: '',
|
||||
@@ -257,6 +276,39 @@ const config = ref({
|
||||
|
||||
// 跟踪服务器是否已配置 TURN 密码
|
||||
const hasTurnPassword = ref(false)
|
||||
const configLoaded = ref(false)
|
||||
const devicesLoaded = ref(false)
|
||||
const hidProfileAligned = ref(false)
|
||||
|
||||
const isLowEndpointUdc = computed(() => {
|
||||
if (config.value.hid_otg_udc) {
|
||||
return /musb/i.test(config.value.hid_otg_udc)
|
||||
}
|
||||
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
|
||||
})
|
||||
|
||||
const showLowEndpointHint = computed(() =>
|
||||
config.value.hid_backend === 'otg' && isLowEndpointUdc.value
|
||||
)
|
||||
|
||||
function alignHidProfileForLowEndpoint() {
|
||||
if (hidProfileAligned.value) return
|
||||
if (!configLoaded.value || !devicesLoaded.value) return
|
||||
if (config.value.hid_backend !== 'otg') {
|
||||
hidProfileAligned.value = true
|
||||
return
|
||||
}
|
||||
if (!isLowEndpointUdc.value) {
|
||||
hidProfileAligned.value = true
|
||||
return
|
||||
}
|
||||
if (config.value.hid_otg_profile === 'full') {
|
||||
config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile
|
||||
} else if (config.value.hid_otg_profile === 'full_no_msd') {
|
||||
config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile
|
||||
}
|
||||
hidProfileAligned.value = true
|
||||
}
|
||||
|
||||
const isHidFunctionSelectionValid = computed(() => {
|
||||
if (config.value.hid_backend !== 'otg') return true
|
||||
@@ -284,6 +336,12 @@ watch(() => config.value.msd_enabled, (enabled) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(bindMode, (mode) => {
|
||||
if (mode === 'custom' && bindAddressList.value.length === 0) {
|
||||
bindAddressList.value = ['']
|
||||
}
|
||||
})
|
||||
|
||||
// ATX config state
|
||||
const atxConfig = ref({
|
||||
enabled: false,
|
||||
@@ -519,7 +577,7 @@ async function saveConfig() {
|
||||
// Video 配置(包括编码器和 WebRTC/STUN/TURN 设置)
|
||||
if (activeSection.value === 'video') {
|
||||
savePromises.push(
|
||||
videoConfigApi.update({
|
||||
configStore.updateVideo({
|
||||
device: config.value.video_device || undefined,
|
||||
format: config.value.video_format || undefined,
|
||||
width: config.value.video_width,
|
||||
@@ -529,7 +587,7 @@ async function saveConfig() {
|
||||
)
|
||||
// 同时保存 Stream/Encoder 和 STUN/TURN 配置
|
||||
savePromises.push(
|
||||
streamConfigApi.update({
|
||||
configStore.updateStream({
|
||||
encoder: config.value.encoder_backend as any,
|
||||
stun_server: config.value.stun_server || undefined,
|
||||
turn_server: config.value.turn_server || undefined,
|
||||
@@ -548,6 +606,12 @@ async function saveConfig() {
|
||||
if (config.value.hid_backend === 'otg') {
|
||||
if (config.value.hid_otg_profile === 'full') {
|
||||
desiredMsdEnabled = true
|
||||
} else if (config.value.hid_otg_profile === 'full_no_msd') {
|
||||
desiredMsdEnabled = false
|
||||
} else if (config.value.hid_otg_profile === 'full_no_consumer') {
|
||||
desiredMsdEnabled = true
|
||||
} else if (config.value.hid_otg_profile === 'full_no_consumer_no_msd') {
|
||||
desiredMsdEnabled = false
|
||||
} else if (
|
||||
config.value.hid_otg_profile === 'legacy_keyboard'
|
||||
|| config.value.hid_otg_profile === 'legacy_mouse_relative'
|
||||
@@ -572,12 +636,12 @@ async function saveConfig() {
|
||||
hidUpdate.otg_profile = config.value.hid_otg_profile
|
||||
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
||||
}
|
||||
savePromises.push(hidConfigApi.update(hidUpdate))
|
||||
savePromises.push(configStore.updateHid(hidUpdate))
|
||||
if (config.value.msd_enabled !== desiredMsdEnabled) {
|
||||
config.value.msd_enabled = desiredMsdEnabled
|
||||
}
|
||||
savePromises.push(
|
||||
msdConfigApi.update({
|
||||
configStore.updateMsd({
|
||||
enabled: desiredMsdEnabled,
|
||||
})
|
||||
)
|
||||
@@ -586,7 +650,7 @@ async function saveConfig() {
|
||||
// MSD 配置
|
||||
if (activeSection.value === 'msd') {
|
||||
savePromises.push(
|
||||
msdConfigApi.update({
|
||||
configStore.updateMsd({
|
||||
msd_dir: config.value.msd_dir || undefined,
|
||||
})
|
||||
)
|
||||
@@ -607,10 +671,10 @@ async function loadConfig() {
|
||||
try {
|
||||
// 并行加载所有域配置
|
||||
const [video, stream, hid, msd] = await Promise.all([
|
||||
videoConfigApi.get(),
|
||||
streamConfigApi.get(),
|
||||
hidConfigApi.get(),
|
||||
msdConfigApi.get(),
|
||||
configStore.refreshVideo(),
|
||||
configStore.refreshStream(),
|
||||
configStore.refreshHid(),
|
||||
configStore.refreshMsd(),
|
||||
])
|
||||
|
||||
config.value = {
|
||||
@@ -622,6 +686,7 @@ async function loadConfig() {
|
||||
hid_backend: hid.backend || 'none',
|
||||
hid_serial_device: hid.ch9329_port || '',
|
||||
hid_serial_baudrate: hid.ch9329_baudrate || 9600,
|
||||
hid_otg_udc: hid.otg_udc || '',
|
||||
hid_otg_profile: (hid.otg_profile || 'full') as OtgHidProfile,
|
||||
hid_otg_functions: {
|
||||
keyboard: hid.otg_functions?.keyboard ?? true,
|
||||
@@ -631,7 +696,6 @@ async function loadConfig() {
|
||||
} as OtgHidFunctions,
|
||||
msd_enabled: msd.enabled || false,
|
||||
msd_dir: msd.msd_dir || '',
|
||||
network_port: 8080, // 从旧 API 加载
|
||||
encoder_backend: stream.encoder || 'auto',
|
||||
// STUN/TURN settings
|
||||
stun_server: stream.stun_server || '',
|
||||
@@ -652,16 +716,11 @@ async function loadConfig() {
|
||||
otgSerialNumber.value = hid.otg_descriptor.serial_number || ''
|
||||
}
|
||||
|
||||
// 加载 web config(仍使用旧 API)
|
||||
try {
|
||||
const fullConfig = await configApi.get()
|
||||
const web = fullConfig.web as any || {}
|
||||
config.value.network_port = web.http_port || 8080
|
||||
} catch (e) {
|
||||
console.warn('Failed to load web config:', e)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load config:', e)
|
||||
} finally {
|
||||
configLoaded.value = true
|
||||
alignHidProfileForLowEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +729,9 @@ async function loadDevices() {
|
||||
devices.value = await configApi.listDevices()
|
||||
} catch (e) {
|
||||
console.error('Failed to load devices:', e)
|
||||
} finally {
|
||||
devicesLoaded.value = true
|
||||
alignHidProfileForLowEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,7 +748,7 @@ async function loadBackends() {
|
||||
async function loadAuthConfig() {
|
||||
authConfigLoading.value = true
|
||||
try {
|
||||
authConfig.value = await authConfigApi.get()
|
||||
authConfig.value = await configStore.refreshAuth()
|
||||
} catch (e) {
|
||||
console.error('Failed to load auth config:', e)
|
||||
} finally {
|
||||
@@ -697,10 +759,9 @@ async function loadAuthConfig() {
|
||||
async function saveAuthConfig() {
|
||||
authConfigLoading.value = true
|
||||
try {
|
||||
await authConfigApi.update({
|
||||
authConfig.value = await configStore.updateAuth({
|
||||
single_user_allow_multiple_sessions: authConfig.value.single_user_allow_multiple_sessions,
|
||||
})
|
||||
await loadAuthConfig()
|
||||
} catch (e) {
|
||||
console.error('Failed to save auth config:', e)
|
||||
} finally {
|
||||
@@ -835,7 +896,7 @@ function removeEasytierPeer(index: number) {
|
||||
// ATX management functions
|
||||
async function loadAtxConfig() {
|
||||
try {
|
||||
const config = await atxConfigApi.get()
|
||||
const config = await configStore.refreshAtx()
|
||||
atxConfig.value = {
|
||||
enabled: config.enabled,
|
||||
power: { ...config.power },
|
||||
@@ -860,7 +921,7 @@ async function saveAtxConfig() {
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
await atxConfigApi.update({
|
||||
await configStore.updateAtx({
|
||||
enabled: atxConfig.value.enabled,
|
||||
power: {
|
||||
driver: atxConfig.value.power.driver,
|
||||
@@ -904,10 +965,8 @@ function getAtxDevicesForDriver(driver: string): string[] {
|
||||
async function loadRustdeskConfig() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
const [config, status] = await Promise.all([
|
||||
rustdeskConfigApi.get(),
|
||||
rustdeskConfigApi.getStatus(),
|
||||
])
|
||||
const status = await configStore.refreshRustdeskStatus()
|
||||
const config = status.config
|
||||
rustdeskConfig.value = config
|
||||
rustdeskStatus.value = status
|
||||
rustdeskLocalConfig.value = {
|
||||
@@ -925,26 +984,85 @@ async function loadRustdeskConfig() {
|
||||
|
||||
async function loadRustdeskPassword() {
|
||||
try {
|
||||
rustdeskPassword.value = await rustdeskConfigApi.getPassword()
|
||||
rustdeskPassword.value = await configStore.refreshRustdeskPassword()
|
||||
} catch (e) {
|
||||
console.error('Failed to load RustDesk password:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRustdeskServer(value: string, defaultPort: number): string | undefined {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (trimmed.includes(':')) return trimmed
|
||||
return `${trimmed}:${defaultPort}`
|
||||
}
|
||||
|
||||
function normalizeBindAddresses(addresses: string[]): string[] {
|
||||
return addresses.map(addr => addr.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function applyBindStateFromConfig(config: WebConfig) {
|
||||
const rawAddrs =
|
||||
config.bind_addresses && config.bind_addresses.length > 0
|
||||
? config.bind_addresses
|
||||
: config.bind_address
|
||||
? [config.bind_address]
|
||||
: []
|
||||
const addrs = normalizeBindAddresses(rawAddrs)
|
||||
const isAll = addrs.length > 0 && addrs.every(addr => addr === '0.0.0.0' || addr === '::') && addrs.includes('0.0.0.0')
|
||||
const isLoopback =
|
||||
addrs.length > 0 &&
|
||||
addrs.every(addr => addr === '127.0.0.1' || addr === '::1') &&
|
||||
addrs.includes('127.0.0.1')
|
||||
if (isAll) {
|
||||
bindMode.value = 'all'
|
||||
bindAllIpv6.value = addrs.includes('::')
|
||||
return
|
||||
}
|
||||
if (isLoopback) {
|
||||
bindMode.value = 'loopback'
|
||||
bindLocalIpv6.value = addrs.includes('::1')
|
||||
return
|
||||
}
|
||||
bindMode.value = 'custom'
|
||||
bindAddressList.value = addrs.length ? [...addrs] : ['']
|
||||
}
|
||||
|
||||
function addBindAddress() {
|
||||
bindAddressList.value.push('')
|
||||
}
|
||||
|
||||
function removeBindAddress(index: number) {
|
||||
bindAddressList.value.splice(index, 1)
|
||||
if (bindAddressList.value.length === 0) {
|
||||
bindAddressList.value.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// Web server config functions
|
||||
async function loadWebServerConfig() {
|
||||
try {
|
||||
const config = await webConfigApi.get()
|
||||
const config = await configStore.refreshWeb()
|
||||
webServerConfig.value = config
|
||||
applyBindStateFromConfig(config)
|
||||
} catch (e) {
|
||||
console.error('Failed to load web server config:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebServerConfig() {
|
||||
if (bindAddressError.value) return
|
||||
webServerLoading.value = true
|
||||
try {
|
||||
await webConfigApi.update(webServerConfig.value)
|
||||
const update = {
|
||||
http_port: webServerConfig.value.http_port,
|
||||
https_port: webServerConfig.value.https_port,
|
||||
https_enabled: webServerConfig.value.https_enabled,
|
||||
bind_addresses: effectiveBindAddresses.value,
|
||||
}
|
||||
const updated = await configStore.updateWeb(update)
|
||||
webServerConfig.value = updated
|
||||
applyBindStateFromConfig(updated)
|
||||
showRestartDialog.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to save web server config:', e)
|
||||
@@ -976,10 +1094,15 @@ async function saveRustdeskConfig() {
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
await rustdeskConfigApi.update({
|
||||
const rendezvousServer = normalizeRustdeskServer(
|
||||
rustdeskLocalConfig.value.rendezvous_server,
|
||||
21116,
|
||||
)
|
||||
const relayServer = normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117)
|
||||
await configStore.updateRustdesk({
|
||||
enabled: rustdeskLocalConfig.value.enabled,
|
||||
rendezvous_server: rustdeskLocalConfig.value.rendezvous_server || undefined,
|
||||
relay_server: rustdeskLocalConfig.value.relay_server || undefined,
|
||||
rendezvous_server: rendezvousServer,
|
||||
relay_server: relayServer,
|
||||
relay_key: rustdeskLocalConfig.value.relay_key || undefined,
|
||||
})
|
||||
await loadRustdeskConfig()
|
||||
@@ -998,7 +1121,7 @@ async function regenerateRustdeskId() {
|
||||
if (!confirm(t('extensions.rustdesk.confirmRegenerateId'))) return
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
await rustdeskConfigApi.regenerateId()
|
||||
await configStore.regenerateRustdeskId()
|
||||
await loadRustdeskConfig()
|
||||
await loadRustdeskPassword()
|
||||
} catch (e) {
|
||||
@@ -1012,7 +1135,7 @@ async function regenerateRustdeskPassword() {
|
||||
if (!confirm(t('extensions.rustdesk.confirmRegeneratePassword'))) return
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
await rustdeskConfigApi.regeneratePassword()
|
||||
await configStore.regenerateRustdeskPassword()
|
||||
await loadRustdeskConfig()
|
||||
await loadRustdeskPassword()
|
||||
} catch (e) {
|
||||
@@ -1026,7 +1149,7 @@ async function startRustdesk() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
// Enable and save config to start the service
|
||||
await rustdeskConfigApi.update({ enabled: true })
|
||||
await configStore.updateRustdesk({ enabled: true })
|
||||
rustdeskLocalConfig.value.enabled = true
|
||||
await loadRustdeskConfig()
|
||||
} catch (e) {
|
||||
@@ -1040,7 +1163,7 @@ async function stopRustdesk() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
// Disable and save config to stop the service
|
||||
await rustdeskConfigApi.update({ enabled: false })
|
||||
await configStore.updateRustdesk({ enabled: false })
|
||||
rustdeskLocalConfig.value.enabled = false
|
||||
await loadRustdeskConfig()
|
||||
} catch (e) {
|
||||
@@ -1132,13 +1255,11 @@ onMounted(async () => {
|
||||
<AppLayout>
|
||||
<div class="flex h-full overflow-hidden">
|
||||
<!-- Mobile Header -->
|
||||
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center justify-between px-4 py-3 border-b bg-background">
|
||||
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
|
||||
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center px-4 py-3 border-b bg-background">
|
||||
<Sheet v-model:open="mobileMenuOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button variant="outline" size="sm">
|
||||
<Menu class="h-4 w-4 mr-2" />
|
||||
{{ t('common.menu') }}
|
||||
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
|
||||
<Menu class="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" class="w-72 p-0">
|
||||
@@ -1167,6 +1288,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
@@ -1321,7 +1443,7 @@ onMounted(async () => {
|
||||
<option v-for="fmt in availableFormats" :key="fmt.format" :value="fmt.format">{{ fmt.format }} - {{ fmt.description }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="video-resolution">{{ t('settings.resolution') }}</Label>
|
||||
<select id="video-resolution" :value="`${config.video_width}x${config.video_height}`" @change="e => { const parts = (e.target as HTMLSelectElement).value.split('x').map(Number); if (parts[0] && parts[1]) { config.video_width = parts[0]; config.video_height = parts[1]; } }" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_format">
|
||||
@@ -1389,7 +1511,7 @@ onMounted(async () => {
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.turnServerHint') }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="turn-username">{{ t('settings.turnUsername') }}</Label>
|
||||
<Input
|
||||
@@ -1477,6 +1599,9 @@ onMounted(async () => {
|
||||
<Label for="otg-profile">{{ t('settings.profile') }}</Label>
|
||||
<select id="otg-profile" v-model="config.hid_otg_profile" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="full">{{ t('settings.otgProfileFull') }}</option>
|
||||
<option value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</option>
|
||||
<option value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</option>
|
||||
<option value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</option>
|
||||
<option value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</option>
|
||||
<option value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</option>
|
||||
<option value="custom">{{ t('settings.otgProfileCustom') }}</option>
|
||||
@@ -1526,6 +1651,9 @@ onMounted(async () => {
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.otgProfileWarning') }}
|
||||
</p>
|
||||
<p v-if="showLowEndpointHint" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.otgLowEndpointHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
@@ -1621,13 +1749,51 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.bindAddress') }}</Label>
|
||||
<Input v-model="webServerConfig.bind_address" placeholder="0.0.0.0" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.bindAddressDesc') }}</p>
|
||||
<Label>{{ t('settings.bindMode') }}</Label>
|
||||
<select v-model="bindMode" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="all">{{ t('settings.bindModeAll') }}</option>
|
||||
<option value="loopback">{{ t('settings.bindModeLocal') }}</option>
|
||||
<option value="custom">{{ t('settings.bindModeCustom') }}</option>
|
||||
</select>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.bindModeDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="bindMode === 'all'" class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAllDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="bindAllIpv6" />
|
||||
</div>
|
||||
|
||||
<div v-if="bindMode === 'loopback'" class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindLocalDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="bindLocalIpv6" />
|
||||
</div>
|
||||
|
||||
<div v-if="bindMode === 'custom'" class="space-y-2">
|
||||
<Label>{{ t('settings.bindAddressList') }}</Label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
|
||||
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
|
||||
<Button variant="ghost" size="icon" @click="removeBindAddress(i)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="addBindAddress">
|
||||
<Plus class="h-4 w-4 mr-1" />
|
||||
{{ t('settings.addBindAddress') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAddressListDesc') }}</p>
|
||||
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading">
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
@@ -1732,7 +1898,7 @@ onMounted(async () => {
|
||||
<CardDescription>{{ t('settings.atxPowerButtonDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="power-driver">{{ t('settings.atxDriver') }}</Label>
|
||||
<select id="power-driver" v-model="atxConfig.power.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
@@ -1749,7 +1915,7 @@ onMounted(async () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="power-pin">{{ atxConfig.power.driver === 'usbrelay' ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
|
||||
<Input id="power-pin" type="number" v-model.number="atxConfig.power.pin" min="0" :disabled="atxConfig.power.driver === 'none'" />
|
||||
@@ -1772,7 +1938,7 @@ onMounted(async () => {
|
||||
<CardDescription>{{ t('settings.atxResetButtonDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-driver">{{ t('settings.atxDriver') }}</Label>
|
||||
<select id="reset-driver" v-model="atxConfig.reset.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
@@ -1789,7 +1955,7 @@ onMounted(async () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-pin">{{ atxConfig.reset.driver === 'usbrelay' ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
|
||||
<Input id="reset-pin" type="number" v-model.number="atxConfig.reset.pin" min="0" :disabled="atxConfig.reset.driver === 'none'" />
|
||||
@@ -1824,7 +1990,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<template v-if="atxConfig.led.enabled">
|
||||
<Separator />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="led-chip">{{ t('settings.atxLedChip') }}</Label>
|
||||
<select id="led-chip" v-model="atxConfig.led.gpio_chip" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
@@ -1941,13 +2107,13 @@ onMounted(async () => {
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="extConfig.ttyd.enabled" :disabled="isExtRunning(extensions?.ttyd?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.ttyd.shell') }}</Label>
|
||||
<Input v-model="extConfig.ttyd.shell" class="col-span-3" placeholder="/bin/bash" :disabled="isExtRunning(extensions?.ttyd?.status)" />
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.ttyd.shell') }}</Label>
|
||||
<Input v-model="extConfig.ttyd.shell" class="sm:col-span-3" placeholder="/bin/bash" :disabled="isExtRunning(extensions?.ttyd?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.ttyd.credential') }}</Label>
|
||||
<Input v-model="extConfig.ttyd.credential" class="col-span-3" placeholder="user:password" :disabled="isExtRunning(extensions?.ttyd?.status)" />
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.ttyd.credential') }}</Label>
|
||||
<Input v-model="extConfig.ttyd.credential" class="sm:col-span-3" placeholder="user:password" :disabled="isExtRunning(extensions?.ttyd?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Logs -->
|
||||
@@ -2029,17 +2195,17 @@ onMounted(async () => {
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="extConfig.gostc.enabled" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.gostc.addr') }}</Label>
|
||||
<Input v-model="extConfig.gostc.addr" class="col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
|
||||
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.gostc.key') }}</Label>
|
||||
<Input v-model="extConfig.gostc.key" type="password" class="col-span-3" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
|
||||
<Input v-model="extConfig.gostc.key" type="password" class="sm:col-span-3" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.gostc.tls') }}</Label>
|
||||
<div class="col-span-3">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.gostc.tls') }}</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<Switch v-model="extConfig.gostc.tls" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2120,17 +2286,17 @@ onMounted(async () => {
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="extConfig.easytier.enabled" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.easytier.networkName') }}</Label>
|
||||
<Input v-model="extConfig.easytier.network_name" class="col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label>
|
||||
<Input v-model="extConfig.easytier.network_name" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
|
||||
<Input v-model="extConfig.easytier.network_secret" type="password" class="col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
|
||||
<Input v-model="extConfig.easytier.network_secret" type="password" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.easytier.peers') }}</Label>
|
||||
<div class="col-span-3 space-y-2">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.peers') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<div v-for="(_, i) in extConfig.easytier.peer_urls" :key="i" class="flex gap-2">
|
||||
<Input v-model="extConfig.easytier.peer_urls[i]" placeholder="tcp://1.2.3.4:11010" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<Button variant="ghost" size="icon" @click="removeEasytierPeer(i)" :disabled="isExtRunning(extensions?.easytier?.status)">
|
||||
@@ -2143,9 +2309,9 @@ onMounted(async () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.easytier.virtualIp') }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.virtualIp') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.easytier.virtual_ip" placeholder="10.0.0.1/24" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.easytier.virtualIpHint') }}</p>
|
||||
</div>
|
||||
@@ -2237,9 +2403,9 @@ onMounted(async () => {
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="rustdeskLocalConfig.enabled" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
v-model="rustdeskLocalConfig.rendezvous_server"
|
||||
:placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')"
|
||||
@@ -2247,9 +2413,9 @@ onMounted(async () => {
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.rendezvousServerHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.rustdesk.relayServer') }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.relayServer') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
v-model="rustdeskLocalConfig.relay_server"
|
||||
:placeholder="t('extensions.rustdesk.relayServerPlaceholder')"
|
||||
@@ -2257,9 +2423,9 @@ onMounted(async () => {
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayServerHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.rustdesk.relayKey') }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.relayKey') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
v-model="rustdeskLocalConfig.relay_key"
|
||||
type="password"
|
||||
@@ -2276,9 +2442,9 @@ onMounted(async () => {
|
||||
<h4 class="text-sm font-medium">{{ t('extensions.rustdesk.deviceInfo') }}</h4>
|
||||
|
||||
<!-- Device ID -->
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.rustdesk.deviceId') }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.deviceId') }}</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-2">
|
||||
<code class="font-mono text-lg bg-muted px-3 py-1 rounded">{{ rustdeskConfig?.device_id || '-' }}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2298,9 +2464,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Device Password (直接显示) -->
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.rustdesk.devicePassword') }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.devicePassword') }}</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-2">
|
||||
<code class="font-mono text-lg bg-muted px-3 py-1 rounded">{{ rustdeskPassword?.device_password || '-' }}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2320,9 +2486,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Keypair Status -->
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.rustdesk.keypairGenerated') }}</Label>
|
||||
<div class="col-span-3">
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.keypairGenerated') }}</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<Badge :variant="rustdeskConfig?.has_keypair ? 'default' : 'secondary'">
|
||||
{{ rustdeskConfig?.has_keypair ? t('common.yes') : t('common.no') }}
|
||||
</Badge>
|
||||
|
||||
@@ -96,6 +96,9 @@ const hidBackend = ref('ch9329')
|
||||
const ch9329Port = ref('')
|
||||
const ch9329Baudrate = ref(9600)
|
||||
const otgUdc = ref('')
|
||||
const hidOtgProfile = ref('full')
|
||||
const otgProfileTouched = ref(false)
|
||||
const showAdvancedOtg = ref(false)
|
||||
|
||||
// Extension settings
|
||||
const ttydEnabled = ref(false)
|
||||
@@ -200,6 +203,26 @@ const availableFps = computed(() => {
|
||||
return resolution?.fps || []
|
||||
})
|
||||
|
||||
const isLowEndpointUdc = computed(() => {
|
||||
if (otgUdc.value) {
|
||||
return /musb/i.test(otgUdc.value)
|
||||
}
|
||||
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
|
||||
})
|
||||
|
||||
function applyOtgProfileDefault() {
|
||||
if (otgProfileTouched.value) return
|
||||
if (hidBackend.value !== 'otg') return
|
||||
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
|
||||
if (hidOtgProfile.value === preferred) return
|
||||
hidOtgProfile.value = preferred
|
||||
}
|
||||
|
||||
function onOtgProfileChange(value: unknown) {
|
||||
hidOtgProfile.value = typeof value === 'string' ? value : 'full'
|
||||
otgProfileTouched.value = true
|
||||
}
|
||||
|
||||
// Common baud rates for CH9329
|
||||
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
||||
|
||||
@@ -315,6 +338,17 @@ watch(hidBackend, (newBackend) => {
|
||||
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
|
||||
otgUdc.value = devices.value.udc[0]?.name || ''
|
||||
}
|
||||
applyOtgProfileDefault()
|
||||
})
|
||||
|
||||
watch(otgUdc, () => {
|
||||
applyOtgProfileDefault()
|
||||
})
|
||||
|
||||
watch(showAdvancedOtg, (open) => {
|
||||
if (open) {
|
||||
applyOtgProfileDefault()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -336,6 +370,7 @@ onMounted(async () => {
|
||||
if (result.udc.length > 0 && result.udc[0]) {
|
||||
otgUdc.value = result.udc[0].name
|
||||
}
|
||||
applyOtgProfileDefault()
|
||||
|
||||
// Auto-select audio device if available (and no video device to trigger watch)
|
||||
if (result.audio.length > 0 && !audioDevice.value) {
|
||||
@@ -487,6 +522,7 @@ async function handleSetup() {
|
||||
}
|
||||
if (hidBackend.value === 'otg' && otgUdc.value) {
|
||||
setupData.hid_otg_udc = otgUdc.value
|
||||
setupData.hid_otg_profile = hidOtgProfile.value
|
||||
}
|
||||
|
||||
// Encoder backend setting
|
||||
@@ -520,7 +556,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div class="min-h-screen flex items-start sm:items-center justify-center bg-background px-4 py-6 sm:py-10">
|
||||
<Card class="w-full max-w-lg relative">
|
||||
<!-- Language Switcher -->
|
||||
<div class="absolute top-4 right-4">
|
||||
@@ -547,28 +583,28 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<CardHeader class="text-center space-y-2 pt-12">
|
||||
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10"
|
||||
>
|
||||
<Monitor class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle class="text-2xl">{{ t('setup.welcome') }}</CardTitle>
|
||||
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
|
||||
<CardDescription>{{ t('setup.description') }}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-6">
|
||||
<CardContent class="space-y-5 sm:space-y-6">
|
||||
<!-- Progress Text -->
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{{ t('setup.progress', { current: step, total: totalSteps }) }}
|
||||
</p>
|
||||
|
||||
<!-- Step Indicator with Labels -->
|
||||
<div class="flex items-center justify-center gap-2 mb-6">
|
||||
<div class="flex items-center justify-center gap-1.5 sm:gap-2 mb-5 sm:mb-6">
|
||||
<template v-for="i in totalSteps" :key="i">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-300"
|
||||
class="flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-full border-2 transition-all duration-300"
|
||||
:class="
|
||||
step > i
|
||||
? 'bg-primary border-primary text-primary-foreground scale-100'
|
||||
@@ -577,11 +613,11 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
: 'border-muted text-muted-foreground scale-100'
|
||||
"
|
||||
>
|
||||
<Check v-if="step > i" class="w-5 h-5" />
|
||||
<component :is="stepIcons[i - 1]" v-else class="w-5 h-5" />
|
||||
<Check v-if="step > i" class="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<component :is="stepIcons[i - 1]" v-else class="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</div>
|
||||
<span
|
||||
class="text-xs transition-colors duration-300 max-w-16 text-center leading-tight"
|
||||
class="text-[10px] sm:text-xs transition-colors duration-300 max-w-14 sm:max-w-16 text-center leading-tight"
|
||||
:class="step >= i ? 'text-foreground font-medium' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ stepLabels[i - 1] }}
|
||||
@@ -589,7 +625,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</div>
|
||||
<div
|
||||
v-if="i < totalSteps"
|
||||
class="w-8 h-0.5 transition-colors duration-300 mb-6"
|
||||
class="w-5 sm:w-8 h-0.5 transition-colors duration-300 mb-5 sm:mb-6"
|
||||
:class="step > i ? 'bg-primary' : 'bg-muted'"
|
||||
/>
|
||||
</template>
|
||||
@@ -924,6 +960,46 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
{{ t('setup.noUdcDevices') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
|
||||
@click="showAdvancedOtg = !showAdvancedOtg"
|
||||
>
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('setup.otgAdvanced') }} ({{ t('common.optional') }})
|
||||
</span>
|
||||
<ChevronRight
|
||||
class="h-4 w-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': showAdvancedOtg }"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="showAdvancedOtg" class="px-3 pb-3 space-y-3">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('setup.otgProfileDesc') }}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<Label for="otgProfile">{{ t('setup.otgProfile') }}</Label>
|
||||
<Select :model-value="hidOtgProfile" @update:modelValue="onOtgProfileChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
|
||||
<SelectItem value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</SelectItem>
|
||||
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
|
||||
<SelectItem value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
|
||||
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
|
||||
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('setup.otgLowEndpointHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user