diff --git a/.github/BUILD_INSTRUCTIONS.md b/.github/BUILD_INSTRUCTIONS.md new file mode 100644 index 000000000..eefede49e --- /dev/null +++ b/.github/BUILD_INSTRUCTIONS.md @@ -0,0 +1,56 @@ +# GitHub Actions 构建说明 + +## 工作流说明 + +此项目使用 GitHub Actions 自动构建和发布安装包。 + +### 工作流文件 + +- `.github/workflows/build-release.yml` - 主要构建和发布工作流 +- `.github/workflows/pr-check.yml` - PR 检查工作流 + +### 构建配置 + +项目支持以下目标平台的交叉编译: + +- `x86_64-unknown-linux-gnu` - 64位 x86 Linux +- `aarch64-unknown-linux-gnu` - 64位 ARM Linux +- `armv7-unknown-linux-gnueabihf` - 32位 ARM Linux + +### 构建流程 + +1. 检出代码(包含子模块) +2. 构建前端(Vue.js) +3. 安装交叉编译工具链 +4. 编译 Rust 代码 +5. 创建发布资产 +6. 上传构建产物 + +### 发布说明 + +当创建带有 `v` 前缀的标签时(如 `v0.1.0`),会触发发布流程,自动生成预构建的二进制文件并创建 GitHub Release。 + +### 静态构建 + +工作流同时构建常规版本和静态链接版本: +- 常规版本:较小,但可能需要系统库 +- 静态版本:包含所有依赖,可在任何兼容系统上运行 + +## 本地构建 + +如果要在本地构建项目,请确保安装了以下依赖: + +- Rust 工具链 +- Node.js (用于前端构建) +- 交叉编译工具链 +- 系统依赖(pkg-config, libssl-dev, libasound2-dev 等) + +然后运行: + +```bash +# 构建前端 +cd web && npm install && npm run build && cd .. + +# 构建后端 +cargo build --release +``` \ No newline at end of file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 000000000..6d0f46ee5 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,229 @@ +name: Build and Release + +on: + # 仅手动触发:构建完成后直接创建 Release 并上传产物。 + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +# 手动发布流程不自动取消,避免中断正在进行的 Release。 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Generate release version + id: version + run: | + version="V$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Release version: ${version}" + + build: + needs: prepare + runs-on: ubuntu-latest + + strategy: + matrix: + target: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + - armv7-unknown-linux-gnueabihf + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Node.js for frontend build + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build frontend + working-directory: ./web + run: | + npm ci + npm run build + + - name: Install cross-compilation tools + run: | + rustup target add ${{ matrix.target }} + cargo install cross --git https://github.com/cross-rs/cross + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libasound2-dev libudev-dev clang cmake protobuf-compiler + + # Install target-specific packages if needed + case "${{ matrix.target }}" in + aarch64-unknown-linux-gnu) + sudo apt-get install -y gcc-aarch64-linux-gnu + ;; + armv7-unknown-linux-gnueabihf) + sudo apt-get install -y gcc-arm-linux-gnueabihf + ;; + esac + + - name: Build with cross + run: | + # ARM build images ship a static FFmpeg built with --enable-rkmpp, so the + # rkmpp feature is required for the link step as well as for the encoders. + features=() + case "${{ matrix.target }}" in + aarch64-*|armv7-*) features=(--features rkmpp) ;; + esac + cross build --target ${{ matrix.target }} --release "${features[@]}" + # Also build size-optimized version for better portability + cross build --target ${{ matrix.target }} --profile release-static "${features[@]}" + + - name: Prepare artifacts + run: | + # Create dist directory + mkdir -p dist + + # Copy binaries + cp target/${{ matrix.target }}/release/one-kvm dist/one-kvm-${{ matrix.target }} + if [ -f target/${{ matrix.target }}/release-static/one-kvm ]; then + cp target/${{ matrix.target }}/release-static/one-kvm dist/one-kvm-${{ matrix.target }}-static + fi + + # Make binaries executable + chmod +x dist/* + + # Write release version and build metadata + echo "Version: ${{ needs.prepare.outputs.version }}" > dist/BUILD_INFO + echo "Target: ${{ matrix.target }}" >> dist/BUILD_INFO + echo "Commit: $(git rev-parse --short HEAD)" >> dist/BUILD_INFO + echo "${{ needs.prepare.outputs.version }}" > dist/VERSION + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: one-kvm-${{ matrix.target }} + path: dist/ + retention-days: 7 + + deb: + needs: build + runs-on: ubuntu-22.04 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Reconstruct target layout + run: | + set -euo pipefail + for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu armv7-unknown-linux-gnueabihf; do + mkdir -p "target/${target}/release" + cp "artifacts/one-kvm-${target}/one-kvm-${target}" "target/${target}/release/one-kvm" + chmod +x "target/${target}/release/one-kvm" + done + + - name: Install packaging tools + run: | + sudo apt-get update + sudo apt-get install -y xz-utils + + - name: Package deb + run: bash build/package-deb.sh + + - name: Upload deb + uses: actions/upload-artifact@v4 + with: + name: one-kvm-deb + path: target/debian/*.deb + if-no-files-found: error + retention-days: 7 + + release: + needs: [prepare, build, deb] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release assets + run: | + set -euo pipefail + assets_dir="$PWD/release_assets" + mkdir -p "$assets_dir" + + for artifact in artifacts/*/; do + target_name=$(basename "$artifact") + echo "Processing $target_name" + + if [ "$target_name" = "one-kvm-deb" ]; then + # Keep deb packages as release assets instead of tarring them. + cp "$artifact"/*.deb "$assets_dir"/ + continue + fi + + # Artifact names already contain the one-kvm- prefix, so do not add it twice. + tar -czf "$assets_dir/${target_name}.tar.gz" -C "$artifact" . + done + + ls -lh "$assets_dir" + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: Release ${{ needs.prepare.outputs.version }} + tag_name: ${{ needs.prepare.outputs.version }} + target_commitish: ${{ github.sha }} + draft: false + prerelease: false + make_latest: 'true' + files: release_assets/* + body: | + ## One-KVM Release ${{ needs.prepare.outputs.version }} + + This release includes pre-built binaries and Debian packages for multiple platforms. + + ### Platforms + - `x86_64-unknown-linux-gnu`: 64-bit x86 Linux (most desktop/server systems) + - `aarch64-unknown-linux-gnu`: 64-bit ARM Linux (Raspberry Pi 4, ARM servers) + - `armv7-unknown-linux-gnueabihf`: 32-bit ARM Linux (Raspberry Pi 3 and earlier) + + ### Packages + - `one-kvm-.tar.gz`: raw Linux binaries + - `one-kvm_*.deb`: Debian/Ubuntu packages (`amd64`, `arm64`, `armhf`) + + ### Installation + 1. Download the appropriate binary for your platform + 2. Extract: `tar -xzf one-kvm-*.tar.gz` + 3. Make executable: `chmod +x one-kvm-*` + 4. Run: `./one-kvm-* --help` to see available options + 5. Debian/Ubuntu: `sudo dpkg -i one-kvm_*.deb` + + ### Changes + Please see the commit history for detailed changes in this release. + + ### Notes + - The static builds (`-static` suffix) have all dependencies included and should run on any compatible system + - Regular builds are smaller but may require system libraries to be installed diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 348a6bbf9..ab5f74de5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,7 @@ name: Build on: + # 功能分支推送不跑前端/deb/Windows 构建,仅由 build-release.yml 编译二进制。 pull_request: workflow_dispatch: inputs: diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 000000000..37ddfd101 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,62 @@ +name: PR Check + +on: + # 功能分支推送只构建二进制(build-release.yml),不跑格式检查/check/测试。 + pull_request: + branches: [ main, master ] + +env: + CARGO_TERM_COLOR: always + +jobs: + test-build: + runs-on: ubuntu-latest + strategy: + matrix: + target: + - x86_64-unknown-linux-gnu + # 只在一个平台上测试以节省CI时间 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Node.js for frontend build + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build frontend + working-directory: ./web + run: | + npm ci + npm run build + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libasound2-dev libudev-dev clang cmake protobuf-compiler + + - name: Install rust target + run: rustup target add ${{ matrix.target }} + + - name: Check formatting + run: | + rustup component add rustfmt + cargo fmt --check + + - name: Check for warnings + run: | + RUSTFLAGS="-D warnings" cargo check --target ${{ matrix.target }} + + - name: Run tests + run: cargo test --target ${{ matrix.target }} + + - name: Build release + run: cargo build --target ${{ matrix.target }} --release + + - name: Verify binary + run: | + file target/${{ matrix.target }}/release/one-kvm + ldd target/${{ matrix.target }}/release/one-kvm || echo "Binary built successfully" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 32fb5b962..0e0ff3bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ CLAUDE.md secrets.toml .env /docs/ + +# Local feedback server (PHP backend must never be committed) +/server/ diff --git a/Cargo.toml b/Cargo.toml index de23edff0..c5e52c61d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,7 +184,7 @@ audiopus = { version = "0.2", optional = true } one-kvm-bluetooth-hid = { path = "libs/bluetooth-hid", optional = true } # Utilities -nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true } +nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll", "term"], optional = true } # Video capture (V4L2) v4l2r = { path = "libs/v4l2r", optional = true } diff --git a/Cross.toml b/Cross.toml index 0c142f777..683ec2eb5 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,5 +1,7 @@ # Cross-compilation configuration for One-KVM -# Uses pre-built custom Debian 12 based images for consistent build/runtime environment +# Uses pre-built custom Debian 11 based images for consistent build/runtime environment +# Building on Debian 11 keeps the glibc baseline at 2.31, so binaries run on +# Debian 11/12, Ubuntu 20.04+, and Raspberry Pi OS bullseye/bookworm. # See: https://github.com/cross-rs/cross [build] diff --git a/One-KVM_Architecture_Design.md b/One-KVM_Architecture_Design.md new file mode 100644 index 000000000..d7c21389d --- /dev/null +++ b/One-KVM_Architecture_Design.md @@ -0,0 +1,517 @@ +# One-KVM 核心架构设计文档 + +## 1. 项目概述 + +One-KVM 是一个用 Rust 编写的轻量级、开源 IP-KVM 解决方案。它提供 BIOS 级别的远程服务器管理能力,支持视频流、键鼠控制、虚拟存储、电源管理和音频等功能。 + +### 1.1 核心特性 + +- **单一二进制部署**:Web UI + 后端一体化,无需额外配置文件 +- **双流模式**:支持 WebRTC(H264/H265/VP8/VP9)和 MJPEG 两种流模式 +- **USB OTG**:虚拟键鼠、虚拟存储、虚拟网卡 +- **ATX 电源控制**:GPIO/USB 继电器 +- **RustDesk 协议集成**:支持跨平台访问 +- **Vue3 SPA 前端**:支持中文/英文 +- **SQLite 配置存储**:无需配置文件 + +### 1.2 目标平台 + +| 平台 | 架构 | 用途 | +|------|------|------| +| aarch64-unknown-linux-gnu | ARM64 | 主要目标(Rockchip RK3328 等) | +| armv7-unknown-linux-gnueabihf | ARMv7 | 备选平台 | +| x86_64-unknown-linux-gnu | x86-64 | 开发/测试环境 | + +## 2. 系统架构 + +### 2.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ One-KVM System Architecture │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Web Frontend (Vue3) │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Axum Web Server │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ AppState (Central State) │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Video │ │ HID │ │ MSD │ │ ATX │ │ │ +│ │ │ Pipeline │ │ Controller │ │ Controller │ │ Controller │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Audio │ │ WebRTC │ │ RustDesk │ │ Events │ │ │ +│ │ │ Controller │ │ Streamer │ │ Service │ │ Bus │ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Hardware Interfaces │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ V4L2 Cap │ │ USB OTG │ │ GPIO │ │ ALSA/Audio │ │ │ +│ │ │ Device │ │ Gadget │ │ Control │ │ Device │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 数据流架构 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Data Flow Overview │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────────┐ + │ Target PC │ + └────────┬────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ HDMI Capture │ │ USB Port │ │ GPIO/Relay │ +│ Card │ │ (OTG Mode) │ │ (ATX) │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ /dev/video0 │ │ /dev/hidg* │ │ /sys/class/ │ +│ (V4L2) │ │ (USB Gadget) │ │ gpio/gpio* │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ One-KVM Application │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Video │ │ HID │ │ ATX │ │ +│ │ Pipeline │ │ Controller │ │ Controller │ │ +│ │ │ │ │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Capture │ │ Backend │ │ Power │ │ +│ │ Stage │ │ (OTG/ │ │ Control │ │ +│ │ │ │ CH9329) │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Encode Stage │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ +│ │ │ H264 │ │ H265 │ │ VP8 │ │ VP9 │ │ │ +│ │ │Encoder │ │Encoder │ │Encoder │ │Encoder │ │ │ +│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │ +│ │ │ (VAAPI/RKMPP/V4L2 M2M/Software) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ + │ + ├──────────────────────────────┬──────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ MJPEG Streamer │ │ WebRTC Streamer │ │ RustDesk Service │ +│ (HTTP Stream) │ │ (RTP Packets) │ │ (P2P Stream) │ +│ │ │ │ │ │ +│ - HTTP/1.1 │ │ - DataChannel │ │ - TCP/UDP Relay │ +│ - WebSocket │ │ - Video Track │ │ - UDP Hole Punch │ +│ - Frame Buffer │ │ - Audio Track │ │ - Encryption │ +└───────────────────┘ └───────────────────┘ └───────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐ +│ Web Client (Browser) │ │ Web Client (Browser) │ │ RustDesk Client App │ +│ │ │ │ │ │ +│ - Vue3 SPA Interface │ │ - WebRTC Peer │ │ - Cross-platform │ +│ - MJPEG Stream Display │ │ - Real-time Video/ │ │ - Direct Connection │ +│ - HID Event Send/Recv │ │ Audio │ │ - Same Protocol │ +│ - Stream Control │ │ - HID via DC │ │ - Hardware Access │ +└─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘ +``` + +## 3. 模块架构详解 + +### 3.1 Video 模块 + +Video 模块负责视频采集、编码和流传输,是 One-KVM 的核心功能模块。 + +#### 3.1.1 文件结构 +``` +src/video/ +├── mod.rs # 模块导出 +├── capture.rs # V4L2 视频采集 +├── device.rs # V4L2 设备枚举和能力查询 +├── streamer.rs # 视频流服务 (MJPEG) +├── stream_manager.rs # 流管理器 (统一管理 MJPEG/WebRTC) +├── video_session.rs # 视频会话管理 (多编码器会话) +├── shared_video_pipeline.rs # 共享视频编码管道 (多编解码器) +├── h264_pipeline.rs # H264 专用编码管道 (WebRTC) +├── format.rs # 像素格式定义 +├── frame.rs # 视频帧结构 (零拷贝) +├── convert.rs # 格式转换 (libyuv SIMD) +├── decoder/ # 解码器 +│ ├── mod.rs +│ └── mjpeg.rs # MJPEG 解码 (TurboJPEG/VAAPI) +└── encoder/ # 编码器 + ├── mod.rs + ├── traits.rs # Encoder trait + BitratePreset + ├── codec.rs # 编码器类型定义 + ├── h264.rs # H264 编码 + ├── h265.rs # H265 编码 + ├── vp8.rs # VP8 编码 + ├── vp9.rs # VP9 编码 + ├── jpeg.rs # JPEG 编码 + └── registry.rs # 编码器注册表 (硬件探测) +``` + +#### 3.1.2 视频处理流水线 +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Video Processing Pipeline │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ +│ │ V4L2 │ │ Pixel │ │ Format │ │ Zero-Copy │ │ +│ │ Capture │ │ Conversion │ │ Convert │ │ Frame Buf │ │ +│ │ │ │ │ │ │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┤ +│ │ Shared Video Pipeline │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ H264 │ │ H265 │ │ VP8 │ │ VP9 │ │ │ +│ │ │ Encoder │ │ Encoder │ │ Encoder │ │ Encoder │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────┤ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┤ +│ │ Stream Management Layer │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ MJPEG │ │ WebRTC │ │ Stream │ │ │ +│ │ │ Streamer │ │ Streamer │ │ Manager │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 HID 模块 + +HID 模块负责键盘和鼠标控制,支持 USB OTG 模式和 CH9329 串口控制器。 + +#### 3.2.1 架构设计 +``` +Web Client --> WebSocket/DataChannel --> HID Events --> Backend --> Target PC + | + [OTG | CH9329] +``` + +#### 3.2.2 文件结构 +``` +src/hid/ +├── mod.rs # HidController(主控制器) +├── backend.rs # HidBackend trait 和 HidBackendType +├── otg.rs # OTG 后端(USB Gadget HID) +├── ch9329.rs # CH9329 串口后端 +├── consumer.rs # Consumer Control usage codes +├── keymap.rs # JS keyCode → USB HID 转换表 +├── types.rs # 事件类型定义 +├── monitor.rs # HidHealthMonitor(错误跟踪与恢复) +├── datachannel.rs # DataChannel 二进制协议解析 +└── websocket.rs # WebSocket 二进制协议适配 +``` + +### 3.3 OTG 模块 + +USB OTG 模块负责 USB Gadget 管理,实现虚拟设备功能。 + +#### 3.3.1 文件结构 +``` +src/otg/ +├── mod.rs +├── service.rs # OtgService +├── manager.rs # GadgetManager +├── hid.rs # HID Function +├── msd.rs # MSD Function +├── configfs.rs # ConfigFS 操作 +├── endpoint.rs # 端点分配 +└── report_desc.rs # HID 报告描述符 +``` + +### 3.4 MSD 模块 + +MSD 模块负责虚拟存储设备管理,支持 ISO/IMG 镜像挂载和 Ventoy 模式。 + +#### 3.4.1 文件结构 +``` +src/msd/ +├── mod.rs +├── controller.rs # MsdController +├── image.rs # 镜像管理 +├── ventoy_drive.rs # Ventoy 驱动 +├── monitor.rs # 健康监视 +└── types.rs # 类型定义 +``` + +### 3.5 ATX 模块 + +ATX 模块负责电源控制,支持 GPIO 和 USB 继电器控制。 + +#### 3.5.1 文件结构 +``` +src/atx/ +├── mod.rs +├── controller.rs +├── executor.rs +├── led.rs +├── types.rs +└── wol.rs +``` + +### 3.6 Audio 模块 + +Audio 模块负责音频采集和编码,支持 Opus 编码。 + +#### 3.6.1 文件结构 +``` +src/audio/ +├── mod.rs +├── capture.rs +├── controller.rs +├── device.rs +├── encoder.rs +├── monitor.rs +├── streamer.rs +└── types.rs +``` + +### 3.7 WebRTC 模块 + +WebRTC 模块负责实时音视频流传输。 + +#### 3.7.1 文件结构 +``` +src/webrtc/ +├── mod.rs +├── config.rs +├── h265_payloader.rs +├── mdns.rs +├── peer.rs +├── rtp.rs +├── session.rs +├── signaling.rs +├── track.rs +├── unified_video_track.rs +├── universal_session.rs +├── video_track.rs +└── webrtc_streamer.rs +``` + +### 3.8 RustDesk 模块 + +RustDesk 模块负责 RustDesk 协议集成,实现跨平台远程访问。 + +#### 3.8.1 文件结构 +``` +src/rustdesk/ +├── mod.rs # 模块导出 +├── config.rs # 配置类型 +├── crypto.rs # 加密模块 +├── bytes_codec.rs # 帧编码 +├── protocol.rs # 消息辅助 +├── rendezvous.rs # Rendezvous 中介 +├── connection.rs # 连接处理 +├── hid_adapter.rs # HID 转换 +└── frame_adapters.rs # 视频/音频适配 +``` + +## 4. AppState 中央状态管理 + +AppState 是应用全局状态容器,协调各个模块之间的交互。 + +```rust +pub struct AppState { + /// 配置存储 + pub config: ConfigStore, + /// 会话存储 + pub sessions: SessionStore, + /// 用户存储 + pub users: UserStore, + /// OTG 服务 - 集中化的 USB gadget 生命周期管理 + pub otg_service: Arc, + /// 视频流管理器 (统一 MJPEG/WebRTC 管理) + pub stream_manager: Arc, + /// HID 控制器 + pub hid: Arc, + /// MSD 控制器 (可选,可能未初始化) + pub msd: Arc>>, + /// ATX 控制器 (可选,可能未初始化) + pub atx: Arc>>, + /// 音频控制器 + pub audio: Arc, + /// RustDesk 远程访问服务 (可选) + pub rustdesk: Arc>>>, + /// 扩展管理器 (ttyd, gostc, easytier) + pub extensions: Arc, + /// 事件总线用于实时通知 + pub events: Arc, + /// 关闭信号发送器 + pub shutdown_tx: broadcast::Sender<()>, + /// 最近撤销的会话 ID (用于客户端踢出检测) + pub revoked_sessions: Arc>>, + /// 数据目录路径 + data_dir: std::path::PathBuf, +} +``` + +## 5. 事件系统 + +One-KVM 使用事件总线系统实现模块间的松耦合通信。 + +### 5.1 事件类型 +- StreamStateChanged +- StreamConfigApplied +- StreamModeReady +- HidStateChanged +- MsdStateChanged +- AtxStateChanged +- AudioStateChanged +- DeviceInfo + +### 5.2 事件广播机制 +- 使用 tokio broadcast channel 实现实时通知 +- 支持设备状态变化的自动推送 +- 客户端可以实时接收状态更新 + +## 6. 硬件编码支持 + +One-KVM 支持多种硬件加速编码: + +- **VAAPI**:Intel/AMD GPU +- **RKMPP**:Rockchip SoC +- **V4L2 M2M**:通用硬件编码器 +- **软件编码**:CPU 编码 + +### 6.1 自动硬件检测 +- 运行时自动检测可用的硬件加速后端 +- 基于质量和性能为不同编码器分配优先级 +- 针对实时流媒体场景进行了专门优化 + +## 7. 网络架构 + +### 7.1 接入方式 +1. **本地访问**: http://one-kvm.local:8080 +2. **HTTPS访问**: https://one-kvm.local:8443 +3. **RustDesk协议**: 通过 RustDesk 客户端连接 + +### 7.2 协议支持 +- HTTP/1.1 for MJPEG streaming +- WebSocket for real-time HID events +- WebRTC for low-latency streaming +- RustDesk protocol for cross-platform access + +## 8. 部署架构 + +### 8.1 单机部署 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Single Binary Deployment │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ARM64 Device (e.g., Rockchip RK3328) │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ one-kvm (single binary, ~15MB) │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Embedded Assets (rust-embed, gzip compressed) │ │ │ +│ │ │ - index.html, app.js, app.css, assets/* │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Runtime Data (data_dir) │ │ │ +│ │ │ - one-kvm.db (SQLite) │ │ │ +│ │ │ - images/ (MSD images) │ │ │ +│ │ │ - certs/ (SSL certificates) │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 8.2 网络拓扑 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Network Topology │ +└─────────────────────────────────────────────────────────────────────────────┘ + + Internet + │ + │ + ┌───────────┴───────────┐ + │ │ + ▼ ▼ + ┌───────────────┐ ┌───────────────┐ + │ RustDesk │ │ Client │ + │ Server │ │ Browser │ + │ (hbbs/hbbr) │ │ │ + └───────────────┘ └───────────────┘ + │ │ + │ │ + └───────────┬───────────┘ + │ + ┌────┴────┐ + │ Router │ + │ NAT │ + └────┬────┘ + │ + Local Network + │ + ┌───────────┴───────────┐ + │ │ + ▼ ▼ + ┌───────────────┐ ┌───────────────┐ + │ One-KVM │───────│ Target PC │ + │ Device │ USB │ │ + │ :8080/:8443 │ HID │ │ + └───────────────┘ └───────────────┘ +``` + +## 9. 安全特性 + +### 9.1 认证授权 +- 基于会话的用户认证 +- HTTPOnly Cookie 存储 +- 随机会话 ID +- 会话超时机制 +- Argon2id 密码哈希 + +### 9.2 传输安全 +- TLS 1.3 优先 +- 自动证书生成 +- 证书轮换支持 + +### 9.3 输入验证 +- 类型安全 (serde) +- 路径验证 +- SQL 参数化查询 + +## 10. 扩展能力 + +One-KVM 提供多种扩展能力: + +- Web UI 配置,多语言支持(中文/英文) +- 内置 Web 终端(服务端 PTY,无需安装 ttyd) +- 内网穿透支持(gostc) +- P2P 组网支持(EasyTier) +- RustDesk 协议集成(跨平台远程访问) +- Ventoy 虚拟U盘模式 \ No newline at end of file diff --git a/build/Dockerfile.runtime b/build/Dockerfile.runtime index e57f2140e..6d788dfcd 100644 --- a/build/Dockerfile.runtime +++ b/build/Dockerfile.runtime @@ -1,7 +1,8 @@ # One-KVM Runtime Image # This Dockerfile only packages pre-compiled binaries (no compilation) # Used after cross-compiling with `cross build` -# Using Debian 12 to match the build images (GLIBC 2.36) +# Binaries are built on Debian 11 (GLIBC 2.31); this Debian 12 runtime is +# backward compatible and also matches the Docker build images. ARG TARGETPLATFORM=linux/amd64 diff --git a/build/Dockerfile.runtime-full b/build/Dockerfile.runtime-full index 2d8358e50..bdd23776c 100644 --- a/build/Dockerfile.runtime-full +++ b/build/Dockerfile.runtime-full @@ -1,7 +1,8 @@ # One-KVM Runtime Image (full) # This Dockerfile only packages pre-compiled binaries (no compilation) # Used after cross-compiling with `cross build` -# Using Debian 12 to match the build images (GLIBC 2.36) +# Binaries are built on Debian 11 (GLIBC 2.31); this Debian 12 runtime is +# backward compatible and also matches the Docker build images. ARG TARGETPLATFORM=linux/amd64 diff --git a/build/build-images.sh b/build/build-images.sh index 933b843fc..aeb66c1f9 100755 --- a/build/build-images.sh +++ b/build/build-images.sh @@ -31,7 +31,7 @@ build_arch() { local rust_root="${RUSTUP_UPDATE_ROOT_MIRROR:-https://mirrors.ustc.edu.cn/rust-static/rustup}" local crates_sparse="${CARGO_SPARSE_MIRROR:-sparse+https://mirrors.ustc.edu.cn/crates.io-index/}" local crates_git="${CARGO_GIT_MIRROR:-https://mirrors.ustc.edu.cn/crates.io-index}" - local cross_build_opts="${CROSS_BUILD_OPTS:+$CROSS_BUILD_OPTS }--progress=plain --build-arg CHINAMIRRO=1 --build-arg GH_PROXY=${GH_PROXY:-https://ghfast.top/} --build-arg DEBIAN_IMAGE=${DEBIAN_IMAGE:-dockerproxy.net/library/debian:12}" + local cross_build_opts="${CROSS_BUILD_OPTS:+$CROSS_BUILD_OPTS }--progress=plain --build-arg CHINAMIRRO=1 --build-arg GH_PROXY=${GH_PROXY:-https://ghfast.top/} --build-arg DEBIAN_IMAGE=${DEBIAN_IMAGE:-dockerproxy.net/library/debian:11} --build-arg APT_ARCHIVE_MIRROR=${APT_ARCHIVE_MIRROR:-https://mirror.nju.edu.cn/debian-archive}" cross_build_opts="$cross_build_opts --build-arg HTTP_PROXY= --build-arg HTTPS_PROXY= --build-arg ALL_PROXY= --build-arg NO_PROXY=" cross_build_opts="$cross_build_opts --build-arg http_proxy= --build-arg https_proxy= --build-arg all_proxy= --build-arg no_proxy=" echo "=== China mirror acceleration: enabled ===" diff --git a/build/cross/Dockerfile.arm64 b/build/cross/Dockerfile.arm64 index 2aa20c2d4..dbd30d081 100644 --- a/build/cross/Dockerfile.arm64 +++ b/build/cross/Dockerfile.arm64 @@ -1,7 +1,8 @@ -# Cross-compilation image for ARM64 based on Debian 12 -# Build on Debian 12 (GLIBC 2.36) to match the runtime images +# Cross-compilation image for ARM64 based on Debian 11 +# Build on Debian 11 (GLIBC 2.31) so the resulting binary runs on +# Debian 11/12, Ubuntu 20.04+, and Raspberry Pi OS bullseye/bookworm. -ARG DEBIAN_IMAGE=debian:12 +ARG DEBIAN_IMAGE=debian:11 FROM ${DEBIAN_IMAGE} ARG CHINAMIRRO=0 @@ -19,12 +20,21 @@ ARG RKMPP_BRANCH=jellyfin-mpp ARG RKRGA_BRANCH=jellyfin-rga ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b -# Optionally use China mirrors for builds in China. -RUN if [ "$CHINAMIRRO" = "1" ]; then \ - sed -i -E \ - -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ - /etc/apt/sources.list.d/debian.sources; \ - fi +# Debian 11 is EOL: deb.debian.org still serves a package index that points at +# pool files which have been removed (404). Build against archive.debian.org, +# which still hosts the complete bullseye, updates and security suites. +# Override APT_ARCHIVE_MIRROR to use a closer archive mirror. +ARG APT_ARCHIVE_MIRROR=http://archive.debian.org +RUN for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ + if [ -f "$f" ]; then \ + sed -i -E \ + -e "s@https?://security\.debian\.org/debian-security@${APT_ARCHIVE_MIRROR%/}/debian-security@g" \ + -e "s@https?://deb\.debian\.org/debian@${APT_ARCHIVE_MIRROR%/}/debian@g" \ + "$f"; \ + fi; \ + done \ + && printf 'Acquire::Check-Valid-Until "false";\nAcquire::Retries "5";\n' \ + > /etc/apt/apt.conf.d/99-one-kvm-archive # Install Rust toolchain RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -84,8 +94,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install ARM64 development libraries (without VAAPI/X11 for ARM) RUN apt-get update && apt-get install -y --no-install-recommends \ - libssl3 \ - libssl3:arm64 \ + libssl1.1 \ + libssl1.1:arm64 \ libasound2-dev:arm64 \ libv4l-dev:arm64 \ libudev-dev:arm64 \ diff --git a/build/cross/Dockerfile.armv7 b/build/cross/Dockerfile.armv7 index 3e1931e46..8bf87251d 100644 --- a/build/cross/Dockerfile.armv7 +++ b/build/cross/Dockerfile.armv7 @@ -1,7 +1,8 @@ -# Cross-compilation image for ARMv7 based on Debian 12 -# Build on Debian 12 (GLIBC 2.36) to match the runtime images +# Cross-compilation image for ARMv7 based on Debian 11 +# Build on Debian 11 (GLIBC 2.31) so the resulting binary runs on +# Debian 11/12, Ubuntu 20.04+, and Raspberry Pi OS bullseye/bookworm. -ARG DEBIAN_IMAGE=debian:12 +ARG DEBIAN_IMAGE=debian:11 FROM ${DEBIAN_IMAGE} ARG CHINAMIRRO=0 @@ -19,12 +20,21 @@ ARG RKMPP_BRANCH=jellyfin-mpp ARG RKRGA_BRANCH=jellyfin-rga ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b -# Optionally use China mirrors for builds in China. -RUN if [ "$CHINAMIRRO" = "1" ]; then \ - sed -i -E \ - -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ - /etc/apt/sources.list.d/debian.sources; \ - fi +# Debian 11 is EOL: deb.debian.org still serves a package index that points at +# pool files which have been removed (404). Build against archive.debian.org, +# which still hosts the complete bullseye, updates and security suites. +# Override APT_ARCHIVE_MIRROR to use a closer archive mirror. +ARG APT_ARCHIVE_MIRROR=http://archive.debian.org +RUN for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ + if [ -f "$f" ]; then \ + sed -i -E \ + -e "s@https?://security\.debian\.org/debian-security@${APT_ARCHIVE_MIRROR%/}/debian-security@g" \ + -e "s@https?://deb\.debian\.org/debian@${APT_ARCHIVE_MIRROR%/}/debian@g" \ + "$f"; \ + fi; \ + done \ + && printf 'Acquire::Check-Valid-Until "false";\nAcquire::Retries "5";\n' \ + > /etc/apt/apt.conf.d/99-one-kvm-archive # Install Rust toolchain RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -84,8 +94,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install ARMv7 development libraries (without VAAPI/X11 for ARM) RUN apt-get update && apt-get install -y --no-install-recommends \ - libssl3 \ - libssl3:armhf \ + libssl1.1 \ + libssl1.1:armhf \ libasound2-dev:armhf \ libv4l-dev:armhf \ libudev-dev:armhf \ diff --git a/build/cross/Dockerfile.x86_64 b/build/cross/Dockerfile.x86_64 index bd44def0d..3dea1fceb 100644 --- a/build/cross/Dockerfile.x86_64 +++ b/build/cross/Dockerfile.x86_64 @@ -1,7 +1,8 @@ -# Cross-compilation image for x86_64 based on Debian 12 -# Build on Debian 12 (GLIBC 2.36) to match the runtime images +# Cross-compilation image for x86_64 based on Debian 11 +# Build on Debian 11 (GLIBC 2.31) so the resulting binary runs on +# Debian 11/12, Ubuntu 20.04+, and similar distributions. -ARG DEBIAN_IMAGE=debian:12 +ARG DEBIAN_IMAGE=debian:11 FROM ${DEBIAN_IMAGE} ARG CHINAMIRRO=0 @@ -17,12 +18,21 @@ ARG X265_VERSION=3.4 ARG OPUS_VERSION=1.5.2 ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b -# Optionally use China mirrors for builds in China. -RUN if [ "$CHINAMIRRO" = "1" ]; then \ - sed -i -E \ - -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ - /etc/apt/sources.list.d/debian.sources; \ - fi +# Debian 11 is EOL: deb.debian.org still serves a package index that points at +# pool files which have been removed (404). Build against archive.debian.org, +# which still hosts the complete bullseye, updates and security suites. +# Override APT_ARCHIVE_MIRROR to use a closer archive mirror. +ARG APT_ARCHIVE_MIRROR=http://archive.debian.org +RUN for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ + if [ -f "$f" ]; then \ + sed -i -E \ + -e "s@https?://security\.debian\.org/debian-security@${APT_ARCHIVE_MIRROR%/}/debian-security@g" \ + -e "s@https?://deb\.debian\.org/debian@${APT_ARCHIVE_MIRROR%/}/debian@g" \ + "$f"; \ + fi; \ + done \ + && printf 'Acquire::Check-Valid-Until "false";\nAcquire::Retries "5";\n' \ + > /etc/apt/apt.conf.d/99-one-kvm-archive # Install Rust toolchain RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/build/package-deb.sh b/build/package-deb.sh index 05d3dd62e..86710e5a5 100755 --- a/build/package-deb.sh +++ b/build/package-deb.sh @@ -1,6 +1,6 @@ #!/bin/bash # Build deb packages from pre-compiled binaries -# Binaries are compiled once on Debian 12 (GLIBC 2.36) via build-images.sh +# Binaries are compiled once on Debian 11 (GLIBC 2.31) via build-images.sh # This script packages them directly on the host using dpkg-deb # Usage: ./build/build-deb.sh [arch] # Example: ./build/build-deb.sh aarch64 @@ -125,7 +125,7 @@ EOF chmod 755 "$PKG_DIR/DEBIAN/prerm" # Create control file - BASE_DEPS="libc6 (>= 2.36), libgcc-s1, libstdc++6, libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4), libudev1" + BASE_DEPS="libc6 (>= 2.31), libgcc-s1, libstdc++6 (>= 10), libasound2t64 (>= 1.1) | libasound2 (>= 1.1), libdrm2 (>= 2.4), libudev1" # libmfx.so.1 is provided by libmfx1; libmfx-gen has a different SONAME. AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14), i965-va-driver-shaders (>= 2.4), intel-media-va-driver-non-free (>= 21.1)" DEPS="$BASE_DEPS" @@ -144,7 +144,7 @@ Recommends: bluez Maintainer: SilentWind Description: A open and lightweight IP-KVM solution Enables BIOS-level remote management of servers and workstations. - Built on Debian 12, compatible with Debian 12+, Ubuntu 24.04+. + Built on Debian 11, compatible with Debian 11+, Ubuntu 20.04+. EOF # Build deb directly on host diff --git a/libs/hwcodec/build.rs b/libs/hwcodec/build.rs index a95fa5dee..a09a313ef 100644 --- a/libs/hwcodec/build.rs +++ b/libs/hwcodec/build.rs @@ -171,7 +171,14 @@ mod ffmpeg { println!("cargo:rustc-link-lib=va-drm"); println!("cargo:rustc-link-lib=va-x11"); // Required for vaGetDisplay println!("cargo:rustc-link-lib=mfx"); - } else if std::env::var_os("CARGO_FEATURE_RKMPP").is_some() { + } else if matches!(target_arch.as_str(), "aarch64" | "arm") + && (std::env::var_os("CARGO_FEATURE_RKMPP").is_some() + || lib_dir.join("librockchip_mpp.a").exists()) + { + // The ARM cross images ship a static FFmpeg built with + // --enable-rkmpp, so libavcodec/libavutil reference MPP + // symbols even when the crate's rkmpp feature is disabled. + // Link the native libraries whenever we link that FFmpeg. for lib in ["rockchip_mpp", "rga"] { if !lib_dir.join(format!("lib{lib}.a")).exists() { panic!("missing static library: lib{lib}.a"); diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index a1448e2cc..5a3253b9b 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -10,6 +10,7 @@ mod hid; mod mcp; mod otg_network; mod stream; +mod terminal; mod uac; mod watchdog; mod web; @@ -20,6 +21,7 @@ pub use hid::*; pub use mcp::*; pub use otg_network::*; pub use stream::*; +pub use terminal::*; pub use uac::*; pub use watchdog::*; pub use web::*; @@ -40,6 +42,7 @@ pub struct AppConfig { pub stream: StreamConfig, pub web: WebConfig, pub mcp: McpConfig, + pub terminal: TerminalConfig, pub extensions: ExtensionsConfig, pub rustdesk: RustDeskConfig, pub vnc: VncConfig, @@ -61,6 +64,9 @@ impl AppConfig { } self.atx.normalize(); self.mcp.requests_per_second = self.mcp.requests_per_second.clamp(1, 100); + if !crate::video::format::is_valid_output_max_height(self.stream.output_max_height) { + self.stream.output_max_height = 0; + } } pub fn apply_platform_defaults(&mut self) { diff --git a/src/config/schema/stream.rs b/src/config/schema/stream.rs index f1acb752f..404644890 100644 --- a/src/config/schema/stream.rs +++ b/src/config/schema/stream.rs @@ -138,6 +138,9 @@ pub struct StreamConfig { pub auto_pause_delay_secs: u64, #[typeshare(skip)] pub client_timeout_secs: u64, + /// Maximum height of the transmitted video. Capture resolution is never + /// changed. `0` means "use the captured resolution". + pub output_max_height: u32, } impl Default for StreamConfig { @@ -153,6 +156,7 @@ impl Default for StreamConfig { auto_pause_enabled: false, auto_pause_delay_secs: 10, client_timeout_secs: 30, + output_max_height: 0, } } } diff --git a/src/config/schema/terminal.rs b/src/config/schema/terminal.rs new file mode 100644 index 000000000..4f7c7a982 --- /dev/null +++ b/src/config/schema/terminal.rs @@ -0,0 +1,117 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +/// Built-in browser terminal settings. +/// +/// The built-in terminal does not depend on an external `ttyd` binary; the +/// server allocates a pseudo-terminal and runs the configured shell directly. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct TerminalConfig { + /// Whether authenticated users may open the built-in web terminal. + pub enabled: bool, + /// Shell executable used for new sessions. Arguments are allowed, for + /// example `/bin/bash -l`. Arguments are passed directly to the process and + /// are never interpreted by a shell. + pub shell: String, +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { + // Opt-in: a web shell runs with the service account's privileges. + enabled: false, + shell: default_shell().to_string(), + } + } +} + +/// Default shell for the current platform. +pub fn default_shell() -> &'static str { + #[cfg(unix)] + { + if std::path::Path::new("/bin/bash").is_file() { + "/bin/bash" + } else { + "/bin/sh" + } + } + #[cfg(windows)] + { + "cmd" + } +} + +/// Validate a configured shell command without executing it. +/// +/// Only the executable (the first whitespace-separated token) is checked. The +/// remaining tokens are passed as literal arguments, so there is no shell +/// injection surface here. +pub fn validate_shell(shell: &str) -> Result<(), String> { + let shell = shell.trim(); + if shell.is_empty() { + return Err("Shell path must not be empty".to_string()); + } + + let program = shell + .split_whitespace() + .next() + .ok_or_else(|| "Shell path must not be empty".to_string())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let path = std::path::Path::new(program); + if !path.is_absolute() { + return Err("Shell path must be absolute".to_string()); + } + + let metadata = + std::fs::metadata(path).map_err(|_| format!("Shell not found: {program}"))?; + if !metadata.is_file() { + return Err(format!("Shell is not a regular file: {program}")); + } + if metadata.permissions().mode() & 0o111 == 0 { + return Err(format!("Shell is not executable: {program}")); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{default_shell, validate_shell, TerminalConfig}; + + #[test] + fn default_config_is_opt_in() { + let config = TerminalConfig::default(); + assert!(!config.enabled); + assert_eq!(config.shell, default_shell()); + } + + #[test] + fn missing_terminal_section_uses_defaults() { + let config: TerminalConfig = serde_json::from_str("{}").unwrap(); + assert!(!config.enabled); + assert_eq!(config.shell, default_shell()); + } + + #[test] + fn rejects_empty_and_relative_shells() { + assert!(validate_shell("").is_err()); + assert!(validate_shell(" ").is_err()); + #[cfg(unix)] + assert!(validate_shell("bash").is_err()); + } + + #[cfg(unix)] + #[test] + fn accepts_existing_executable() { + assert!(validate_shell("/bin/sh").is_ok()); + assert!(validate_shell("/bin/sh -l").is_ok()); + assert!(validate_shell("/bin/sh --definitely-not-a-real-flag").is_ok()); + } +} diff --git a/src/config/store.rs b/src/config/store.rs index e18941143..0706f3ef6 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -56,8 +56,28 @@ impl ConfigStore { removed |= auth.remove("totp_enabled").is_some(); removed |= auth.remove("totp_secret").is_some(); } - let config = + let had_terminal_config = value.get("terminal").is_some(); + let mut config: AppConfig = serde_json::from_value(value).map_err(|e| AppError::Config(e.to_string()))?; + if !had_terminal_config { + // Preserve the previous ttyd opt-in when upgrading. The + // built-in terminal replaces ttyd but must not widen access. + config.terminal.enabled = config.extensions.ttyd.enabled; + #[cfg(unix)] + { + let shell = config.extensions.ttyd.shell.trim(); + config.terminal.shell = if crate::config::validate_shell(shell).is_ok() { + shell.to_string() + } else { + crate::config::default_shell().to_string() + }; + } + #[cfg(windows)] + { + config.terminal.shell = config.extensions.ttyd.shell.clone(); + } + removed = true; + } Ok((config, removed)) } None => { diff --git a/src/events/mod.rs b/src/events/mod.rs index ecd2c84f2..d7ab1fe58 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -6,7 +6,7 @@ use self::types::EXACT_EVENT_TOPICS; pub use types::{ AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo, - MsdDeviceMediaInfo, StreamKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, + MsdDeviceMediaInfo, StreamKind, SystemEvent, TerminalDeviceInfo, VideoDeviceInfo, }; use std::sync::RwLock; diff --git a/src/events/types.rs b/src/events/types.rs index 7a4383540..4da4238e2 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -80,7 +80,7 @@ pub struct AudioDeviceInfo { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TtydDeviceInfo { +pub struct TerminalDeviceInfo { pub available: bool, pub running: bool, } @@ -216,7 +216,7 @@ pub enum SystemEvent { msd: Option, atx: Option, audio: Option, - ttyd: TtydDeviceInfo, + terminal: TerminalDeviceInfo, }, #[serde(rename = "error")] @@ -406,7 +406,7 @@ mod tests { msd: None, atx: None, audio: None, - ttyd: TtydDeviceInfo { + terminal: TerminalDeviceInfo { available: false, running: false, }, diff --git a/src/lib.rs b/src/lib.rs index d4d5d7aa3..6e1b69ec1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,8 @@ pub mod extensions; #[cfg(feature = "desktop")] pub mod hid; #[cfg(feature = "desktop")] +pub mod logging; +#[cfg(feature = "desktop")] pub mod mcp; #[cfg(all(unix, feature = "desktop"))] pub mod msd; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 000000000..01c6db8f3 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,233 @@ +//! In-memory runtime log capture. +//! +//! A second `tracing` formatting layer mirrors every emitted event into a +//! bounded ring buffer and a broadcast channel. The Web UI uses this to show +//! the most recent log lines and to attach them to a problem report. + +use std::collections::VecDeque; +use std::io; +use std::sync::{Mutex, OnceLock}; + +use tokio::sync::broadcast; + +/// Number of formatted log lines kept for snapshots and reports. +pub const LOG_BUFFER_CAPACITY: usize = 1000; +/// Broadly matches the ring buffer; slow WebSocket clients get a `Lagged` notice. +const LOG_BROADCAST_CAPACITY: usize = 1024; +/// Individual log lines are truncated to keep the in-memory buffer bounded. +const MAX_LINE_BYTES: usize = 16 * 1024; +const TRUNCATION_SUFFIX: &str = " … (truncated)"; + +/// Bounded, concurrently readable runtime log buffer. +pub struct LogHub { + lines: Mutex>, + sender: broadcast::Sender, +} + +impl LogHub { + pub fn new() -> Self { + let (sender, _) = broadcast::channel(LOG_BROADCAST_CAPACITY); + Self { + lines: Mutex::new(VecDeque::with_capacity(LOG_BUFFER_CAPACITY)), + sender, + } + } + + /// Append a log line, evicting the oldest entry when the buffer is full. + /// + /// Logging must never panic, so a poisoned lock is recovered from. + pub fn push(&self, line: impl Into) { + let line = truncate_line(line.into()); + if line.is_empty() { + return; + } + + { + let mut lines = self + .lines + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if lines.len() >= LOG_BUFFER_CAPACITY { + lines.pop_front(); + } + lines.push_back(line.clone()); + } + + // An error only means nobody is currently subscribed. + let _ = self.sender.send(line); + } + + /// Return up to `limit` of the most recent lines, oldest first. + pub fn snapshot(&self, limit: usize) -> Vec { + let lines = self + .lines + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let start = lines.len().saturating_sub(limit); + lines.range(start..).cloned().collect() + } + + /// Number of buffered lines. + pub fn len(&self) -> usize { + self.lines + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Subscribe to lines emitted after this call. + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } +} + +impl Default for LogHub { + fn default() -> Self { + Self::new() + } +} + +static LOG_HUB: OnceLock = OnceLock::new(); + +/// Process-wide log hub shared by the tracing layer and the web handlers. +pub fn hub() -> &'static LogHub { + LOG_HUB.get_or_init(LogHub::new) +} + +fn truncate_line(mut line: String) -> String { + let trimmed_len = line.trim_end().len(); + line.truncate(trimmed_len); + if line.len() <= MAX_LINE_BYTES { + return line; + } + + let mut cut = MAX_LINE_BYTES - TRUNCATION_SUFFIX.len(); + while cut > 0 && !line.is_char_boundary(cut) { + cut -= 1; + } + line.truncate(cut); + line.push_str(TRUNCATION_SUFFIX); + line +} + +/// `MakeWriter` that feeds the shared [`LogHub`] instead of stdout. +#[derive(Clone, Copy, Default)] +pub struct LogCapture; + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { + type Writer = LogCaptureWriter; + + fn make_writer(&'a self) -> Self::Writer { + LogCaptureWriter::default() + } +} + +/// Accumulates one formatted event and publishes it when dropped. +/// +/// `tracing-subscriber` may call `write` several times per event, so the +/// writer buffers until it is dropped. +#[derive(Default)] +pub struct LogCaptureWriter { + buffer: Vec, +} + +impl io::Write for LogCaptureWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for LogCaptureWriter { + fn drop(&mut self) { + if self.buffer.is_empty() { + return; + } + + let hub = hub(); + for line in lines_from_bytes(&self.buffer) { + hub.push(line); + } + } +} + +fn lines_from_bytes(bytes: &[u8]) -> Vec { + String::from_utf8_lossy(bytes) + .split('\n') + .map(|line| line.trim_end_matches('\r').to_string()) + .filter(|line| !line.is_empty()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn buffer_keeps_only_the_newest_lines() { + let hub = LogHub::new(); + for index in 0..(LOG_BUFFER_CAPACITY + 5) { + hub.push(format!("line {index}")); + } + + assert_eq!(hub.len(), LOG_BUFFER_CAPACITY); + let snapshot = hub.snapshot(LOG_BUFFER_CAPACITY); + assert_eq!(snapshot.len(), LOG_BUFFER_CAPACITY); + assert_eq!(snapshot.first().unwrap(), "line 5"); + assert_eq!( + snapshot.last().unwrap(), + &format!("line {}", LOG_BUFFER_CAPACITY + 4) + ); + } + + #[test] + fn snapshot_respects_limit_and_order() { + let hub = LogHub::new(); + for index in 0..10 { + hub.push(format!("line {index}")); + } + + assert_eq!( + hub.snapshot(3), + vec!["line 7".to_string(), "line 8".into(), "line 9".into()] + ); + } + + #[test] + fn subscribers_receive_new_lines() { + let hub = LogHub::new(); + let mut receiver = hub.subscribe(); + + hub.push("hello"); + + assert_eq!(receiver.try_recv().unwrap(), "hello"); + } + + #[test] + fn blank_lines_are_ignored_and_long_lines_truncated() { + let hub = LogHub::new(); + hub.push(" "); + assert!(hub.is_empty()); + + let long = "x".repeat(MAX_LINE_BYTES + 100); + hub.push(long); + let snapshot = hub.snapshot(1); + assert_eq!(snapshot.len(), 1); + assert!(snapshot[0].len() <= MAX_LINE_BYTES); + assert!(snapshot[0].ends_with(TRUNCATION_SUFFIX)); + } + + #[test] + fn formatted_writer_splits_events_into_lines() { + let lines = lines_from_bytes(b"one\r\ntwo\n\nthree"); + assert_eq!(lines, vec!["one", "two", "three"]); + } +} diff --git a/src/main.rs b/src/main.rs index 42193d734..ab9fe3a14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -251,6 +251,13 @@ fn init_logging(level: LogLevel) { if let Err(err) = tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer()) + // Mirror formatted events into the in-memory buffer served by the + // runtime log WebSocket, without writing them to stdout again. + .with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(one_kvm::logging::LogCapture), + ) .try_init() { eprintln!("failed to initialize tracing: {}", err); diff --git a/src/otg/configfs.rs b/src/otg/configfs.rs index 5b959a52a..5da983c76 100644 --- a/src/otg/configfs.rs +++ b/src/otg/configfs.rs @@ -64,6 +64,39 @@ pub fn list_udcs() -> Vec { devices } +/// Sorted entry names of a directory, or an empty list when it cannot be read. +pub fn list_dir_names(path: &Path) -> Vec { + let mut names = Vec::new(); + collect_dir_names(path, &mut names); + names.sort(); + names.dedup(); + names +} + +/// Returns the name of another gadget currently bound to `udc`, if any. +/// +/// The gadget called `exclude_gadget` is ignored: callers handle their own +/// gadget (including stale ones) separately. +pub fn udc_owner_in(root: &Path, udc: &str, exclude_gadget: &str) -> Option { + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().trim().to_string(); + if name.is_empty() || name == exclude_gadget { + continue; + } + let bound = fs::read_to_string(entry.path().join("UDC")).unwrap_or_default(); + if bound.trim() == udc { + return Some(name); + } + } + None +} + +/// Returns the name of another gadget bound to `udc` on the live system. +pub fn udc_claimed_by_other_gadget(udc: &str, exclude_gadget: &str) -> Option { + udc_owner_in(configfs_path(), udc, exclude_gadget) +} + fn collect_dir_names(path: &Path, devices: &mut Vec) { if let Ok(entries) = fs::read_dir(path) { for entry in entries.flatten() { @@ -168,3 +201,44 @@ pub fn create_symlink(src: &Path, dest: &Path) -> Result<()> { )) }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn write_udc(root: &Path, gadget: &str, udc: &str) { + let path = root.join(gadget); + fs::create_dir_all(&path).unwrap(); + fs::write(path.join("UDC"), udc).unwrap(); + } + + #[test] + fn udc_owner_in_reports_other_gadget_bindings() { + let temp = tempfile::tempdir().unwrap(); + write_udc(temp.path(), "one-kvm", "fe800000.usb\n"); + write_udc(temp.path(), "rockchip", "fe800000.usb\n"); + write_udc(temp.path(), "idle", ""); + + assert_eq!( + udc_owner_in(temp.path(), "fe800000.usb", "one-kvm").as_deref(), + Some("rockchip") + ); + assert_eq!(udc_owner_in(temp.path(), "other.usb", "one-kvm"), None); + assert_eq!( + udc_owner_in(temp.path(), "fe800000.usb", "rockchip").as_deref(), + Some("one-kvm") + ); + } + + #[test] + fn udc_owner_in_ignores_unreadable_entries() { + let temp = tempfile::tempdir().unwrap(); + write_udc(temp.path(), "rockchip", ""); + + assert_eq!(udc_owner_in(temp.path(), "fe800000.usb", "one-kvm"), None); + assert_eq!( + udc_owner_in(&temp.path().join("missing"), "fe800000.usb", "one-kvm"), + None + ); + } +} diff --git a/src/otg/manager.rs b/src/otg/manager.rs index 8bf4899a2..1de4d78e4 100644 --- a/src/otg/manager.rs +++ b/src/otg/manager.rs @@ -1,11 +1,11 @@ use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tracing::{debug, error, info, warn}; use super::configfs::{ - configfs_path, create_dir, find_udc, is_configfs_available, remove_dir, write_file, - write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, - DEFAULT_USB_VENDOR_ID, USB_BCD_USB, + configfs_path, create_dir, find_udc, is_configfs_available, list_dir_names, remove_dir, + write_file, write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, + DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, USB_BCD_USB, }; use super::function::GadgetFunction; use super::hid::HidFunction; @@ -178,12 +178,16 @@ impl OtgGadgetManager { } if self.gadget_exists() { - if self.is_bound() { - debug!("Gadget already exists and is bound, skipping setup"); - return Ok(()); - } - warn!("Gadget exists but not bound, will reconfigure"); - self.cleanup()?; + // Never adopt a gadget created by another process: its function + // set, descriptors and bound UDC are unknown. Leaving it in place + // makes the subsequent UDC write fail with EBUSY, and using the + // in-memory function list to clean it may leave unknown functions + // behind. + warn!( + "Removing pre-existing OTG gadget before setup: {}", + self.gadget_name + ); + remove_gadget_tree(&self.gadget_path)?; } create_dir(&self.gadget_path)?; @@ -421,6 +425,102 @@ impl Drop for OtgGadgetManager { } } +/// Remove a gadget that this process does not own. +/// +/// Works from the on-disk configfs layout because the in-memory function list +/// of an inherited gadget is unknown. Configfs attribute files cannot be +/// unlinked individually, so only symlinks are unlinked and directories are +/// removed bottom-up. +pub fn remove_gadget_tree(gadget_path: &Path) -> Result<()> { + if !gadget_path.exists() { + return Ok(()); + } + + let mut errors: Vec = Vec::new(); + + // A bound gadget must be unbound before its directory can be removed. + let udc_file = gadget_path.join("UDC"); + let bound = fs::read_to_string(&udc_file) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + if bound { + match write_file(&udc_file, "") { + Ok(()) => std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS)), + Err(error) => errors.push(format!("unbind failed: {error}")), + } + } + + // RNDIS exposes an os_desc link to the configuration; remove it first. + // Use `fs::remove_file` directly: the wrapper's `exists()` check follows + // symlinks and would leave dangling links behind. + let os_desc_link = gadget_path.join("os_desc/c.1"); + if is_symlink(&os_desc_link) { + if let Err(error) = fs::remove_file(&os_desc_link) { + errors.push(error.to_string()); + } + } + + // Function links must be unlinked before their configuration directory + // can be removed. + let configs = gadget_path.join("configs"); + for config in list_dir_names(&configs) { + let config_path = configs.join(&config); + for name in list_dir_names(&config_path) { + let link = config_path.join(&name); + if is_symlink(&link) { + if let Err(error) = fs::remove_file(&link) { + errors.push(error.to_string()); + } + } + } + } + + if let Err(error) = remove_configfs_dir(gadget_path) { + errors.push(format!("remove {} failed: {error}", gadget_path.display())); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(AppError::Config(format!( + "Failed to remove inherited OTG gadget {}: {}", + gadget_path.display(), + errors.join("; ") + ))) + } +} + +fn is_symlink(path: &Path) -> bool { + fs::symlink_metadata(path) + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(false) +} + +fn remove_configfs_dir(path: &Path) -> std::io::Result<()> { + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + for entry in entries.flatten() { + let child = entry.path(); + match fs::symlink_metadata(&child) { + Ok(meta) if meta.is_dir() => remove_configfs_dir(&child)?, + // Configfs attribute files cannot be unlinked; they disappear with + // the directory, so only best-effort cleanup is possible (and + // required when this helper runs against a regular filesystem). + Ok(_) => { + let _ = fs::remove_file(&child); + } + Err(_) => {} + } + } + match fs::remove_dir(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + result => result, + } +} + pub async fn wait_for_hid_devices(device_paths: &[PathBuf], timeout_ms: u64) -> bool { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_millis(timeout_ms); @@ -543,4 +643,41 @@ mod tests { assert!(manager.add_mouse_absolute().is_ok()); assert_eq!(manager.functions.len(), 3); } + + #[test] + fn remove_gadget_tree_removes_an_inherited_bound_gadget() { + let temp = tempfile::tempdir().unwrap(); + let gadget = temp.path().join("one-kvm"); + fs::create_dir_all(gadget.join("configs/c.1/strings/0x409")).unwrap(); + fs::create_dir_all(gadget.join("functions/hid.usb0")).unwrap(); + fs::create_dir_all(gadget.join("functions/mass_storage.usb0/lun.0")).unwrap(); + fs::create_dir_all(gadget.join("strings/0x409")).unwrap(); + fs::create_dir_all(gadget.join("os_desc")).unwrap(); + fs::write(gadget.join("UDC"), "fe800000.usb\n").unwrap(); + fs::write(gadget.join("MaxPower"), "500").unwrap(); + fs::write(gadget.join("configs/c.1/bmAttributes"), "0x80").unwrap(); + std::os::unix::fs::symlink( + gadget.join("functions/hid.usb0"), + gadget.join("configs/c.1/hid.usb0"), + ) + .unwrap(); + // A dangling link must be removed as well, otherwise the configuration + // directory cannot be removed. + std::os::unix::fs::symlink("/nonexistent-uac", gadget.join("configs/c.1/uac1.usb0")) + .unwrap(); + std::os::unix::fs::symlink(gadget.join("configs/c.1"), gadget.join("os_desc/c.1")).unwrap(); + + remove_gadget_tree(&gadget).unwrap(); + + assert!( + !gadget.exists(), + "inherited gadget directory should be gone" + ); + } + + #[test] + fn remove_gadget_tree_is_a_noop_for_missing_paths() { + let temp = tempfile::tempdir().unwrap(); + remove_gadget_tree(&temp.path().join("missing")).unwrap(); + } } diff --git a/src/otg/service.rs b/src/otg/service.rs index 9d451c097..1d07c6d1f 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -5,7 +5,9 @@ use tracing::{debug, info, warn}; use typeshare::typeshare; use super::bridge::NetworkBridgeRuntime; -use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; +use super::manager::{ + remove_gadget_tree, wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager, +}; use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig}; use crate::config::{ HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, UacConfig, @@ -196,6 +198,10 @@ impl Default for OtgServiceState { pub struct OtgService { manager: Mutex>, + /// Serializes complete gadget rebuilds. The manager handle alone is not + /// enough: setup and bind happen outside of the manager lock, so two + /// concurrent reconciles could both create and bind the same gadget. + operation: Mutex<()>, state: RwLock, msd_function: RwLock>, network_bridge: Mutex>, @@ -207,6 +213,7 @@ impl OtgService { pub fn new() -> Self { Self { manager: Mutex::new(None), + operation: Mutex::new(()), state: RwLock::new(OtgServiceState::default()), msd_function: RwLock::new(None), network_bridge: Mutex::new(None), @@ -291,6 +298,9 @@ impl OtgService { network: &OtgNetworkConfig, uac: &UacConfig, ) -> Result<()> { + // Serialize the whole snapshot/apply/rollback sequence so concurrent + // applies cannot overwrite `desired` between the two reconciles. + let _operation = self.operation.lock().await; if !self.recovery_checked.load(Ordering::SeqCst) { if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() { let message = format!("Failed to recover stale OTG network transaction: {error}"); @@ -308,10 +318,10 @@ impl OtgService { state.health = OtgRuntimeHealth::Applying; state.error = None; } - if let Err(error) = self.apply_desired_state(desired).await { + if let Err(error) = self.apply_desired_state_locked(desired).await { warn!("OTG apply failed, restoring previous desired state: {error}"); self.mark_degraded(error.to_string()).await; - return match self.apply_desired_state(previous).await { + return match self.apply_desired_state_locked(previous).await { Ok(()) => { self.mark_healthy().await; Err(error) @@ -369,15 +379,22 @@ impl OtgService { } pub(crate) async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> { + let _operation = self.operation.lock().await; + self.apply_desired_state_locked(desired).await + } + + /// Applies a desired state while the caller holds `self.operation`. + async fn apply_desired_state_locked(&self, desired: OtgDesiredState) -> Result<()> { { let mut current = self.desired.write().await; *current = desired; } - self.reconcile_gadget().await + self.reconcile_gadget_locked().await } - async fn reconcile_gadget(&self) -> Result<()> { + /// Reconciles the kernel gadget. The caller must hold `self.operation`. + async fn reconcile_gadget_locked(&self) -> Result<()> { let desired = self.desired.read().await.clone(); debug!( @@ -409,6 +426,21 @@ impl OtgService { } } + // Check for a foreign UDC owner before tearing down a working gadget: + // configfs returns a bare EBUSY on bind otherwise. + if let Some(udc) = desired.udc.as_deref() { + if let Some(owner) = super::configfs::udc_claimed_by_other_gadget( + udc, + super::configfs::DEFAULT_GADGET_NAME, + ) { + let error = format!( + "UDC {udc} is already bound to another USB gadget ({owner}); stop it or select a free UDC" + ); + self.state.write().await.error = Some(error.clone()); + return Err(AppError::Config(error)); + } + } + let network_runtime = { self.network_bridge.lock().await.as_ref().cloned() }; if let Some(runtime) = network_runtime { debug!("Restoring network before OTG gadget reconcile"); @@ -420,11 +452,32 @@ impl OtgService { { let mut manager = self.manager.lock().await; - if let Some(m) = manager.as_mut() { - debug!("Cleaning up existing gadget before OTG reconcile"); - m.cleanup().map_err(|e| { - AppError::Internal(format!("Failed to clean up existing OTG gadget: {e}")) - })?; + match manager.as_mut() { + Some(m) => { + debug!("Cleaning up existing gadget before OTG reconcile"); + m.cleanup().map_err(|e| { + AppError::Internal(format!("Failed to clean up existing OTG gadget: {e}")) + })?; + } + None => { + // A previous process (or a cancelled earlier reconcile) can + // leave a bound gadget in configfs. There is no in-memory + // manager for it, so remove it from the on-disk layout; + // otherwise the later UDC write fails with EBUSY. + let inherited = + super::configfs::configfs_path().join(super::configfs::DEFAULT_GADGET_NAME); + if inherited.exists() { + info!( + "Removing inherited OTG gadget before reconcile: {}", + inherited.display() + ); + remove_gadget_tree(&inherited).map_err(|e| { + AppError::Internal(format!( + "Failed to remove inherited OTG gadget: {e}" + )) + })?; + } + } } manager.take(); } @@ -656,6 +709,9 @@ impl OtgService { pub async fn shutdown(&self) -> Result<()> { info!("Shutting down OTG service"); + // Wait for an in-flight rebuild so setup/bind cannot race the teardown. + let _operation = self.operation.lock().await; + { let mut desired = self.desired.write().await; *desired = OtgDesiredState::default(); diff --git a/src/runtime/builder.rs b/src/runtime/builder.rs index fba2dcd5e..8b5124140 100644 --- a/src/runtime/builder.rs +++ b/src/runtime/builder.rs @@ -137,6 +137,9 @@ impl RuntimeBuilder { ); stream_manager.set_event_bus(events.clone()).await; stream_manager.set_config_store(config_store.clone()).await; + stream_manager + .init_output_max_height(config.stream.output_max_height) + .await; connect_audio_recovery(&audio, &stream_manager).await; let initial_mode = config.stream.mode.clone(); @@ -335,6 +338,7 @@ fn build_webrtc( config.stream.encoder.clone(), ), webrtc: build_ice_config(config), + output_max_height: config.stream.output_max_height, ..Default::default() }); tracing::info!("WebRTC streamer created"); diff --git a/src/runtime/config_apply.rs b/src/runtime/config_apply.rs index e110bf3f1..a29107f4e 100644 --- a/src/runtime/config_apply.rs +++ b/src/runtime/config_apply.rs @@ -94,6 +94,18 @@ pub(crate) async fn apply_stream_config( .await?; } + if options.force || old_config.output_max_height != new_config.output_max_height { + tracing::info!( + "Updating transmission height limit: {} -> {}", + old_config.output_max_height, + new_config.output_max_height + ); + state + .stream_manager + .set_output_max_height_default(new_config.output_max_height) + .await?; + } + let ice_changed = old_config.stun_server != new_config.stun_server || old_config.turn_server != new_config.turn_server || old_config.turn_username != new_config.turn_username diff --git a/src/runtime/heartbeat.rs b/src/runtime/heartbeat.rs new file mode 100644 index 000000000..62d917484 --- /dev/null +++ b/src/runtime/heartbeat.rs @@ -0,0 +1,191 @@ +//! Silent device heartbeat. +//! +//! Sends a small identity payload to the project server every 15 minutes so the +//! online status of a device can be inspected from the report center. Failures +//! are intentionally ignored and only recorded at debug level: the heartbeat +//! must never disturb the user or the web UI. + +use std::path::Path; +use std::time::Duration; + +use serde::Serialize; + +use crate::platform::PlatformMode; +use crate::utils::hostname_uname; + +/// Heartbeat receiver. Kept separate from the report endpoint so both can move +/// independently. +pub(super) const HEARTBEAT_ENDPOINT: &str = + "http://api.mrcong.cn:300/apps/one-kvm/heartbeat.php?app=one-kvm"; + +/// Default heartbeat period: 15 minutes. +pub(super) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15 * 60); + +/// Wait a little after startup so boot-time work is not delayed. +const INITIAL_DELAY: Duration = Duration::from_secs(15); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Serialize)] +struct HeartbeatPayload<'a> { + id: &'a str, + name: &'a str, + version: &'a str, + platform: &'a str, +} + +/// Spawn the background heartbeat task. The task is aborted by the runtime +/// supervisor during shutdown. +pub(super) fn spawn(data_dir: &Path) -> tokio::task::JoinHandle<()> { + let data_dir = data_dir.to_path_buf(); + + let task = tokio::spawn(async move { + let device_id = load_or_create_device_id(&data_dir).await; + let client = match reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .user_agent(concat!("one-kvm/", env!("CARGO_PKG_VERSION"))) + .build() + { + Ok(client) => client, + Err(error) => { + tracing::debug!("Heartbeat client unavailable: {}", error); + return; + } + }; + + let name = hostname_uname(); + let version = env!("CARGO_PKG_VERSION"); + let platform = PlatformMode::current().label(); + let payload = HeartbeatPayload { + id: &device_id, + name: &name, + version, + platform, + }; + + tokio::time::sleep(INITIAL_DELAY).await; + loop { + send_once(&client, &payload).await; + tokio::time::sleep(HEARTBEAT_INTERVAL).await; + } + }); + + tracing::debug!("Heartbeat task started"); + task +} + +async fn send_once(client: &reqwest::Client, payload: &HeartbeatPayload<'_>) { + match client.post(HEARTBEAT_ENDPOINT).json(payload).send().await { + Ok(response) if response.status().is_success() => { + tracing::debug!(status = response.status().as_u16(), "Heartbeat sent"); + } + Ok(response) => { + tracing::debug!(status = response.status().as_u16(), "Heartbeat was not accepted"); + } + Err(error) => { + tracing::debug!("Heartbeat failed: {}", error); + } + } +} + +/// Read the persisted device id, or create one on first run. +/// +/// The id is a random UUID stored under the data directory, so it survives +/// restarts but carries no hardware or user information. +async fn load_or_create_device_id(data_dir: &Path) -> String { + let path = data_dir.join("device-id"); + + if let Ok(existing) = tokio::fs::read_to_string(&path).await { + let existing = existing.trim(); + if is_valid_device_id(existing) { + return existing.to_string(); + } + } + + let id = uuid::Uuid::new_v4().simple().to_string(); + if let Err(error) = persist_device_id(data_dir, &path, &id).await { + tracing::debug!("Could not persist device id: {}", error); + } + + id +} + +async fn persist_device_id(data_dir: &Path, path: &Path, id: &str) -> std::io::Result<()> { + tokio::fs::create_dir_all(data_dir).await?; + + // Write to a temporary file first so a crash cannot leave a partial id. + let temp = data_dir.join(".device-id.tmp"); + tokio::fs::write(&temp, format!("{id}\n")).await?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o600); + let _ = tokio::fs::set_permissions(&temp, permissions).await; + } + + // Windows cannot rename over an existing file. The existing file is only + // removed when it was missing or invalid, so this is safe on every platform. + let _ = tokio::fs::remove_file(path).await; + if let Err(error) = tokio::fs::rename(&temp, path).await { + let _ = tokio::fs::remove_file(&temp).await; + return Err(error); + } + + Ok(()) +} + +fn is_valid_device_id(value: &str) -> bool { + value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heartbeat_interval_is_fifteen_minutes() { + assert_eq!(HEARTBEAT_INTERVAL, Duration::from_secs(900)); + } + + #[test] + fn endpoint_uses_app_scoped_port_300_url() { + assert!(HEARTBEAT_ENDPOINT.contains("api.mrcong.cn:300")); + assert!(HEARTBEAT_ENDPOINT.contains("/apps/one-kvm/heartbeat.php")); + } + + #[test] + fn device_id_validation_accepts_generated_id() { + let id = uuid::Uuid::new_v4().simple().to_string(); + assert!(is_valid_device_id(&id)); + assert!(!is_valid_device_id("short")); + assert!(!is_valid_device_id(&"z".repeat(32))); + } + + #[test] + fn payload_serializes_identity_fields() { + let payload = HeartbeatPayload { + id: "0123456789abcdef0123456789abcdef", + name: "one-kvm", + version: "0.2.9", + platform: "Linux", + }; + let value = serde_json::to_value(payload).unwrap(); + + assert_eq!(value["id"], "0123456789abcdef0123456789abcdef"); + assert_eq!(value["name"], "one-kvm"); + assert_eq!(value["version"], "0.2.9"); + assert_eq!(value["platform"], "Linux"); + } + + #[tokio::test] + async fn device_id_is_stable_between_calls() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create_device_id(dir.path()).await; + let second = load_or_create_device_id(dir.path()).await; + + assert_eq!(first, second); + assert!(is_valid_device_id(&first)); + assert!(dir.path().join("device-id").is_file()); + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 8535794a4..1efc22f88 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1,5 +1,6 @@ mod builder; mod config_apply; +mod heartbeat; mod remote_access; mod supervisor; mod usb; diff --git a/src/runtime/supervisor.rs b/src/runtime/supervisor.rs index 2b00b8b41..23b72a77d 100644 --- a/src/runtime/supervisor.rs +++ b/src/runtime/supervisor.rs @@ -20,8 +20,10 @@ impl RuntimeSupervisor { extensions: Arc, config: ConfigStore, ) -> Self { + let data_dir = state.data_dir().clone(); let mut tasks = spawn_device_info_broadcaster(state, events); tasks.push(spawn_extension_health_check(extensions, config)); + tasks.push(super::heartbeat::spawn(&data_dir)); Self { tasks } } diff --git a/src/state.rs b/src/state.rs index 571119e12..7c3edae34 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,9 +10,11 @@ use crate::db::DatabasePool; use crate::events::MsdDeviceMediaInfo; use crate::events::{ AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, SystemEvent, - TtydDeviceInfo, VideoDeviceInfo, + TerminalDeviceInfo, VideoDeviceInfo, }; -use crate::extensions::{ExtensionId, ExtensionManager}; +#[cfg(windows)] +use crate::extensions::ExtensionId; +use crate::extensions::ExtensionManager; use crate::hid::HidController; use crate::mcp::{McpHardware, McpService}; #[cfg(unix)] @@ -213,13 +215,13 @@ impl AppState { } pub async fn get_device_info(&self) -> SystemEvent { - let (video, hid, msd, atx, audio, ttyd) = tokio::join!( + let (video, hid, msd, atx, audio, terminal) = tokio::join!( self.collect_video_info(), self.collect_hid_info(), self.collect_msd_info(), self.collect_atx_info(), self.collect_audio_info(), - self.collect_ttyd_info(), + self.collect_terminal_info(), ); SystemEvent::DeviceInfo { @@ -228,7 +230,7 @@ impl AppState { msd, atx, audio, - ttyd, + terminal, } } @@ -351,12 +353,25 @@ impl AppState { }) } - async fn collect_ttyd_info(&self) -> TtydDeviceInfo { - let status = self.extensions.status(ExtensionId::Ttyd).await; + async fn collect_terminal_info(&self) -> TerminalDeviceInfo { + #[cfg(unix)] + { + // The built-in PTY shell needs no daemon and is ready whenever the + // administrator has enabled it. + let enabled = self.config.get().terminal.enabled; + TerminalDeviceInfo { + available: enabled, + running: enabled, + } + } - TtydDeviceInfo { - available: self.extensions.check_available(ExtensionId::Ttyd), - running: status.is_running(), + #[cfg(windows)] + { + let status = self.extensions.status(ExtensionId::Ttyd).await; + TerminalDeviceInfo { + available: self.extensions.check_available(ExtensionId::Ttyd), + running: status.is_running(), + } } } } diff --git a/src/stream/mjpeg.rs b/src/stream/mjpeg.rs index 8cf80e905..9758a9d79 100644 --- a/src/stream/mjpeg.rs +++ b/src/stream/mjpeg.rs @@ -3,7 +3,7 @@ use arc_swap::ArcSwap; use parking_lot::Mutex as ParkingMutex; use parking_lot::RwLock as ParkingRwLock; use std::collections::{HashMap, VecDeque}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::broadcast; @@ -28,6 +28,8 @@ pub type ClientId = String; #[derive(Clone, Copy, PartialEq, Eq)] struct RawFrameFingerprint { resolution: crate::video::format::Resolution, + /// Encoded/transmitted resolution after optional downscaling. + output_resolution: crate::video::format::Resolution, format: PixelFormat, stride: u32, online: bool, @@ -136,6 +138,8 @@ pub struct MjpegStreamHandler { #[cfg(feature = "desktop")] jpeg_encoder: ParkingMutex>, jpeg_quality: AtomicU64, + /// Maximum transmitted height; `0` keeps the capture resolution. + output_max_height: AtomicU32, } impl MjpegStreamHandler { @@ -159,6 +163,7 @@ impl MjpegStreamHandler { dropped_same_frames: AtomicU64::new(0), max_drop_same_frames: AtomicU64::new(max_drop), jpeg_quality: AtomicU64::new(80), + output_max_height: AtomicU32::new(0), } } @@ -167,6 +172,23 @@ impl MjpegStreamHandler { self.jpeg_quality.store(clamped, Ordering::Relaxed); } + /// Set the maximum transmitted height (`0` = capture resolution). + pub fn set_output_max_height(&self, max_height: u32) { + self.output_max_height + .store(max_height, Ordering::Relaxed); + } + + pub fn output_max_height(&self) -> u32 { + self.output_max_height.load(Ordering::Relaxed) + } + + fn output_resolution_for( + &self, + input: crate::video::format::Resolution, + ) -> crate::video::format::Resolution { + crate::video::format::scale_to_max_height(input, self.output_max_height()) + } + pub fn update_frame(&self, frame: VideoFrame) { let has_clients = !self.clients.read().is_empty(); if !has_clients { @@ -175,6 +197,25 @@ impl MjpegStreamHandler { self.online.store(frame.online, Ordering::SeqCst); *self.last_frame_ts.write() = Some(Instant::now()); + // Cache the frame a new client will receive first. If a + // transmission downscale is configured, encode it now instead of + // letting the client start with a full-resolution frame. + #[cfg(feature = "desktop")] + let frame = if frame.format.is_compressed() + && self.output_resolution_for(frame.resolution) != frame.resolution + { + match self.encode_to_jpeg(&frame) { + Ok(jpeg_frame) => jpeg_frame, + Err(e) => { + warn!("Failed to encode cached frame to JPEG: {}", e); + self.current_frame.store(Arc::new(None)); + return; + } + } + } else { + frame + }; + if frame.format.is_compressed() { self.current_frame.store(Arc::new(Some(frame))); } else { @@ -183,7 +224,8 @@ impl MjpegStreamHandler { return; } - let frame = if !frame.format.is_compressed() { + let needs_scale = self.output_resolution_for(frame.resolution) != frame.resolution; + let frame = if !frame.format.is_compressed() || needs_scale { #[cfg(feature = "desktop")] match self.encode_to_jpeg(&frame) { Ok(jpeg_frame) => jpeg_frame, @@ -236,12 +278,14 @@ impl MjpegStreamHandler { #[cfg(feature = "desktop")] fn encode_to_jpeg(&self, frame: &VideoFrame) -> Result { let resolution = frame.resolution; + let output_resolution = self.output_resolution_for(resolution); let sequence = self.sequence.load(Ordering::Relaxed); let desired_quality = self.jpeg_quality.load(Ordering::Relaxed) as u32; // Keep only a fingerprint and the encoded output, not a second raw // frame. Unchanged input can reuse JPEG bytes even for forced refreshes. let fingerprint = RawFrameFingerprint { resolution, + output_resolution, format: frame.format, stride: frame.stride, online: frame.online, @@ -293,7 +337,14 @@ impl MjpegStreamHandler { } } + encoder.set_output_resolution( + (output_resolution != resolution).then_some(output_resolution), + ); + let encoded = match frame.format { + PixelFormat::Mjpeg => encoder + .encode_mjpeg(frame.data(), sequence) + .map_err(|e| format!("MJPEG encode failed: {}", e))?, PixelFormat::Yuyv => encoder .encode_yuyv(frame.data(), sequence) .map_err(|e| format!("YUYV encode failed: {}", e))?, @@ -323,7 +374,13 @@ impl MjpegStreamHandler { } }; - let mut jpeg = VideoFrame::new(encoded.data, resolution, PixelFormat::Mjpeg, 0, sequence); + let mut jpeg = VideoFrame::new( + encoded.data, + output_resolution, + PixelFormat::Mjpeg, + 0, + sequence, + ); jpeg.capture_ts = frame.capture_ts; jpeg.online = frame.online; state.last_input = Some(fingerprint); diff --git a/src/video/codec/convert.rs b/src/video/codec/convert.rs index 2107c4fd4..ff01d74ca 100644 --- a/src/video/codec/convert.rs +++ b/src/video/codec/convert.rs @@ -527,6 +527,140 @@ pub fn yuyv_buffer_size(resolution: Resolution) -> usize { (resolution.width * resolution.height * 2) as usize } +fn planar_buffer_size(resolution: Resolution) -> usize { + libyuv::i420_size(resolution.width as usize, resolution.height as usize) +} + +/// Downscale a planar YUV frame. +/// +/// The source and destination formats must be `Yuv420` or `Nv12`. Buffers are +/// allocated once and reused for every frame, which makes this cheap enough to +/// run inline in the encoder worker. +pub struct FrameScaler { + src: Resolution, + dst: Resolution, + src_format: PixelFormat, + dst_format: PixelFormat, + /// Destination buffer, in `dst_format`. + output: Vec, + /// Intermediate buffer used when the source and destination formats differ. + intermediate: Vec, +} + +impl FrameScaler { + pub fn new( + src: Resolution, + dst: Resolution, + src_format: PixelFormat, + dst_format: PixelFormat, + ) -> Self { debug_assert!(matches!( + src_format, + PixelFormat::Yuv420 | PixelFormat::Nv12 + )); + debug_assert!(matches!( + dst_format, + PixelFormat::Yuv420 | PixelFormat::Nv12 + )); + + let dst_len = planar_buffer_size(dst); + let intermediate_len = dst_len.max(planar_buffer_size(src)); + + Self { + src, + dst, + src_format, + dst_format, + output: vec![0; dst_len], + intermediate: vec![0; intermediate_len], + } + } + + /// Scale `input` and return the destination buffer. + pub fn scale(&mut self, input: &[u8]) -> Result<&[u8]> { + if self.src == self.dst && self.src_format == self.dst_format { + // Copy into our own buffer so the returned reference is tied to + // `&mut self` (matching the non-identity paths below). + let len = planar_buffer_size(self.src); + if input.len() < len { + return Err(AppError::VideoError(format!( + "Frame buffer too small for scaling: {} < {}", + input.len(), + len + ))); + } + if self.output.len() < len { + self.output.resize(len, 0); + } + self.output[..len].copy_from_slice(&input[..len]); + return Ok(&self.output); + } + + let src_w = self.src.width as i32; + let src_h = self.src.height as i32; + let dst_w = self.dst.width as i32; + let dst_h = self.dst.height as i32; + + match (self.src_format, self.dst_format) { + (PixelFormat::Yuv420, PixelFormat::Yuv420) => libyuv::i420_scale( + input, + src_w, + src_h, + &mut self.output, + dst_w, + dst_h, + libyuv::FilterMode::kFilterBox, + ) + .map_err(|e| AppError::VideoError(format!("Frame scaling failed: {}", e)))?, + (PixelFormat::Nv12, PixelFormat::Nv12) => libyuv::nv12_scale( + input, + src_w, + src_h, + &mut self.output, + dst_w, + dst_h, + libyuv::FilterMode::kFilterBox, + ) + .map_err(|e| AppError::VideoError(format!("Frame scaling failed: {}", e)))?, + (PixelFormat::Nv12, PixelFormat::Yuv420) => { + libyuv::nv12_scale( + input, + src_w, + src_h, + &mut self.intermediate, + dst_w, + dst_h, + libyuv::FilterMode::kFilterBox, + ) + .map_err(|e| AppError::VideoError(format!("Frame scaling failed: {}", e)))?; + libyuv::nv12_to_i420(&self.intermediate, &mut self.output, dst_w, dst_h) + .map_err(|e| AppError::VideoError(format!("Frame scaling failed: {}", e)))?; + } + (PixelFormat::Yuv420, PixelFormat::Nv12) => { + libyuv::i420_scale( + input, + src_w, + src_h, + &mut self.intermediate, + dst_w, + dst_h, + libyuv::FilterMode::kFilterBox, + ) + .map_err(|e| AppError::VideoError(format!("Frame scaling failed: {}", e)))?; + libyuv::i420_to_nv12(&self.intermediate, &mut self.output, dst_w, dst_h) + .map_err(|e| AppError::VideoError(format!("Frame scaling failed: {}", e)))?; + } + _ => { + return Err(AppError::VideoError(format!( + "Unsupported scaling conversion: {} → {}", + self.src_format, self.dst_format + ))) + } + } + + Ok(&self.output) + } +} + // ============================================================================ // NV12 Converter for VAAPI encoder (using libyuv) // ============================================================================ @@ -918,3 +1052,54 @@ mod tests { assert_eq!(decoder.decode(&jpeg).unwrap().as_ptr(), internal); } } + +#[cfg(test)] +mod scaling_tests { + use super::*; + + #[test] + fn scales_i420_to_i420() { + let src = Resolution::new(4, 4); + let dst = Resolution::new(2, 2); + let mut scaler = FrameScaler::new(src, dst, PixelFormat::Yuv420, PixelFormat::Yuv420); + let input = vec![128u8; planar_buffer_size(src)]; + + let output = scaler.scale(&input).unwrap(); + + assert_eq!(output.len(), planar_buffer_size(dst)); + } + + #[test] + fn scales_nv12_to_nv12() { + let src = Resolution::new(4, 4); + let dst = Resolution::new(2, 2); + let mut scaler = FrameScaler::new(src, dst, PixelFormat::Nv12, PixelFormat::Nv12); + let input = vec![64u8; planar_buffer_size(src)]; + + let output = scaler.scale(&input).unwrap(); + + assert_eq!(output.len(), planar_buffer_size(dst)); + } + + #[test] + fn scales_nv12_to_i420() { + let src = Resolution::new(4, 4); + let dst = Resolution::new(2, 2); + let mut scaler = FrameScaler::new(src, dst, PixelFormat::Nv12, PixelFormat::Yuv420); + let input = vec![32u8; planar_buffer_size(src)]; + + let output = scaler.scale(&input).unwrap(); + + assert_eq!(output.len(), planar_buffer_size(dst)); + } + + #[test] + fn same_geometry_copies_input() { + let resolution = Resolution::new(4, 4); + let mut scaler = + FrameScaler::new(resolution, resolution, PixelFormat::Yuv420, PixelFormat::Yuv420); + let input = vec![7u8; planar_buffer_size(resolution)]; + + assert_eq!(scaler.scale(&input).unwrap(), input.as_slice()); + } +} diff --git a/src/video/codec/jpeg.rs b/src/video/codec/jpeg.rs index 03f3182f8..25a7d8988 100644 --- a/src/video/codec/jpeg.rs +++ b/src/video/codec/jpeg.rs @@ -8,6 +8,7 @@ use bytes::Bytes; use super::traits::{EncodedFormat, EncodedFrame, EncoderConfig}; use crate::error::{AppError, Result}; +use crate::video::codec::convert::MjpegToNv12Decoder; use crate::video::format::{PixelFormat, Resolution}; /// JPEG encoder using libyuv + turbojpeg @@ -17,6 +18,10 @@ use crate::video::format::{PixelFormat, Resolution}; /// YUYV/NV12/NV16/NV24/BGR24/RGB24 ──libyuv──> I420 ──turbojpeg──> JPEG /// ``` /// +/// The capture format/resolution stays untouched. When +/// [`Self::set_output_resolution`] selects a smaller size, the I420 plane is +/// downscaled with libyuv immediately before JPEG compression. +/// /// Note: This encoder is NOT thread-safe due to turbojpeg limitations. /// Use it from a single thread or wrap in a Mutex. pub struct JpegEncoder { @@ -28,6 +33,18 @@ pub struct JpegEncoder { uv_split_buffer: Vec, /// BGRA buffer used when a source format needs explicit YUV matrix expansion before JPEG. bgra_buffer: Vec, + /// Resolution actually encoded (may be smaller than `config.resolution`). + output_resolution: Resolution, + /// I420 buffer at `output_resolution`, used only when downscaling. + scaled_i420_buffer: Vec, + /// Decoder used when a capture device delivers MJPEG and downscaling is required. + mjpeg_decoder: Option, + /// NV12 scratch buffer for MJPEG decode. + nv12_scratch: Vec, +} + +fn i420_buffer_size(resolution: Resolution) -> usize { + libyuv::i420_size(resolution.width as usize, resolution.height as usize) } impl JpegEncoder { @@ -42,17 +59,20 @@ impl JpegEncoder { .map_err(|e| AppError::VideoError(format!("Failed to set JPEG quality: {}", e)))?; Ok(Self { + output_resolution: config.resolution, config, compressor, i420_buffer: Vec::new(), uv_split_buffer: Vec::new(), bgra_buffer: Vec::new(), + scaled_i420_buffer: Vec::new(), + mjpeg_decoder: None, + nv12_scratch: Vec::new(), }) } fn ensure_i420_buffer(&mut self) { - let size = - self.config.resolution.width as usize * self.config.resolution.height as usize * 3 / 2; + let size = i420_buffer_size(self.config.resolution); self.i420_buffer.resize(size, 0); } @@ -71,15 +91,51 @@ impl JpegEncoder { Ok(()) } + /// Select the encoded size. `None` keeps the input resolution. + /// + /// The input buffer format and resolution are unchanged; downscaling is + /// applied to the intermediate I420 plane right before compression. + pub fn set_output_resolution(&mut self, resolution: Option) { + self.output_resolution = resolution.unwrap_or(self.config.resolution); + } + /// Encode I420 buffer to JPEG using turbojpeg's YUV encoder #[inline] fn encode_i420_to_jpeg(&mut self, sequence: u64) -> Result { - let width = self.config.resolution.width as usize; - let height = self.config.resolution.height as usize; + let src_width = self.config.resolution.width as usize; + let src_height = self.config.resolution.height as usize; + let output = self.output_resolution; + + let (pixels, width, height): (&[u8], usize, usize) = + if output != self.config.resolution { + let dst_size = i420_buffer_size(output); + if self.scaled_i420_buffer.len() < dst_size { + self.scaled_i420_buffer.resize(dst_size, 0); + } + + libyuv::i420_scale( + &self.i420_buffer, + src_width as i32, + src_height as i32, + &mut self.scaled_i420_buffer, + output.width as i32, + output.height as i32, + libyuv::FilterMode::kFilterBox, + ) + .map_err(|e| AppError::VideoError(format!("JPEG downscale failed: {}", e)))?; + + ( + &self.scaled_i420_buffer, + output.width as usize, + output.height as usize, + ) + } else { + (&self.i420_buffer, src_width, src_height) + }; // Create YuvImage for turbojpeg (I420 = YUV420 = Sub2x2) let yuv_image = turbojpeg::YuvImage { - pixels: self.i420_buffer.as_slice(), + pixels, width, height, align: 1, // No padding between rows @@ -92,11 +148,37 @@ impl JpegEncoder { .compress_yuv_to_vec(yuv_image) .map_err(|e| AppError::VideoError(format!("JPEG compression failed: {}", e)))?; - Ok(EncodedFrame::jpeg( - Bytes::from(jpeg_data), - self.config.resolution, - sequence, - )) + Ok(EncodedFrame::jpeg(Bytes::from(jpeg_data), output, sequence)) + } + + /// Encode an MJPEG frame, decoding it first when downscaling is required. + pub fn encode_mjpeg(&mut self, data: &[u8], sequence: u64) -> Result { + let resolution = self.config.resolution; + let width = resolution.width as usize; + let height = resolution.height as usize; + let nv12_size = width * height * 3 / 2; + + if self.nv12_scratch.len() < nv12_size { + self.nv12_scratch.resize(nv12_size, 0); + } + + { + let decoder = self + .mjpeg_decoder + .get_or_insert_with(|| MjpegToNv12Decoder::new(resolution)); + decoder.decode_into(data, &mut self.nv12_scratch)?; + } + + self.ensure_i420_buffer(); + libyuv::nv12_to_i420( + &self.nv12_scratch[..nv12_size], + &mut self.i420_buffer, + width as i32, + height as i32, + ) + .map_err(|e| AppError::VideoError(format!("MJPEG→I420 failed: {}", e)))?; + + self.encode_i420_to_jpeg(sequence) } /// Encode BGRA buffer to JPEG using turbojpeg's RGB path. @@ -105,6 +187,22 @@ impl JpegEncoder { let width = self.config.resolution.width as usize; let height = self.config.resolution.height as usize; + if self.output_resolution != self.config.resolution { + let expected = width * height * 4; + if self.bgra_buffer.len() < expected { + self.bgra_buffer.resize(expected, 0); + } + self.ensure_i420_buffer(); + libyuv::bgra_to_i420( + &self.bgra_buffer[..expected], + &mut self.i420_buffer, + width as i32, + height as i32, + ) + .map_err(|e| AppError::VideoError(format!("BGRA→I420 failed: {}", e)))?; + return self.encode_i420_to_jpeg(sequence); + } + self.compressor .set_subsamp(turbojpeg::Subsamp::Sub2x2) .map_err(|e| AppError::VideoError(format!("Failed to set JPEG subsampling: {}", e)))?; @@ -429,4 +527,32 @@ mod tests { nv24.encode_nv24(&vec![128; pixels * 3], 0).unwrap(); assert!(nv24.i420_buffer.is_empty()); } + + #[test] + fn output_resolution_downscales_the_encoded_frame() { + let source = Resolution::new(16, 16); + let target = Resolution::new(8, 8); + let mut encoder = JpegEncoder::new(EncoderConfig::jpeg(source, 80)).unwrap(); + encoder.set_output_resolution(Some(target)); + + let frame = encoder + .encode_nv12(&vec![128; 16 * 16 * 3 / 2], 0) + .unwrap(); + + assert_eq!(frame.resolution, target); + assert_eq!(encoder.scaled_i420_buffer.len(), 8 * 8 * 3 / 2); + } + + #[test] + fn output_resolution_defaults_to_the_input_size() { + let resolution = Resolution::new(16, 16); + let mut encoder = JpegEncoder::new(EncoderConfig::jpeg(resolution, 80)).unwrap(); + encoder.set_output_resolution(None); + + let frame = encoder + .encode_nv12(&vec![128; 16 * 16 * 3 / 2], 0) + .unwrap(); + + assert_eq!(frame.resolution, resolution); + } } diff --git a/src/video/format.rs b/src/video/format.rs index b35142d7b..8340cf6d9 100644 --- a/src/video/format.rs +++ b/src/video/format.rs @@ -358,3 +358,101 @@ impl From<(u32, u32)> for Resolution { Self { width, height } } } + +/// Smallest supported transmission height (0 means "use the source size"). +pub const MIN_OUTPUT_MAX_HEIGHT: u32 = 144; +/// Largest supported transmission height. +pub const MAX_OUTPUT_MAX_HEIGHT: u32 = 4320; + +/// Whether `height` is a valid transmission height limit. +/// +/// `0` is the sentinel for "no downscale, use the captured resolution". +pub fn is_valid_output_max_height(height: u32) -> bool { + height == 0 || (MIN_OUTPUT_MAX_HEIGHT..=MAX_OUTPUT_MAX_HEIGHT).contains(&height) +} + +fn even_dimension(value: u32) -> u32 { + value.max(2) & !1 +} + +/// Downscale `source` so that its height does not exceed `max_height`. +/// +/// The capture resolution is never changed. The aspect ratio is preserved, +/// both dimensions are even (required by YUV 4:2:0 encoders), and the result +/// is never larger than the source (no upscaling). +pub fn scale_to_max_height(source: Resolution, max_height: u32) -> Resolution { + if max_height == 0 || source.height <= max_height { + return source; + } + + let height = even_dimension(max_height.min(source.height)); + let ideal_width = source.width as f64 * height as f64 / source.height as f64; + // Round to the nearest even pixel so the aspect ratio stays as close as + // possible to the source (e.g. 1920×1080 → 480 gives 854×480, not 852×480). + let width = (((ideal_width / 2.0).round() as u32) * 2) + .max(2) + .min(source.width); + + Resolution::new(width, height) +} + +#[cfg(test)] +mod output_scaling_tests { + use super::*; + + #[test] + fn zero_keeps_source_resolution() { + assert_eq!( + scale_to_max_height(Resolution::HD1080, 0), + Resolution::HD1080 + ); + } + + #[test] + fn never_upscales() { + assert_eq!( + scale_to_max_height(Resolution::HD720, 1080), + Resolution::HD720 + ); + } + + #[test] + fn scales_1080p_to_720p() { + assert_eq!( + scale_to_max_height(Resolution::HD1080, 720), + Resolution::HD720 + ); + } + + #[test] + fn preserves_aspect_ratio_for_16x10() { + assert_eq!( + scale_to_max_height(Resolution::WUXGA, 720), + Resolution::new(1152, 720) + ); + } + + #[test] + fn rounds_to_even_dimensions() { + assert_eq!( + scale_to_max_height(Resolution::new(1366, 768), 720), + Resolution::new(1280, 720) + ); + assert_eq!( + scale_to_max_height(Resolution::new(854, 480), 360), + Resolution::new(640, 360) + ); + assert_eq!( + scale_to_max_height(Resolution::HD1080, 480), + Resolution::new(854, 480) + ); + } + + #[test] + fn validates_supported_heights() { + assert!(is_valid_output_max_height(0)); + assert!(is_valid_output_max_height(720)); + assert!(!is_valid_output_max_height(1)); + assert!(!is_valid_output_max_height(MAX_OUTPUT_MAX_HEIGHT + 1)); + } +} diff --git a/src/video/pipeline/dmabuf.rs b/src/video/pipeline/dmabuf.rs index 359dfe91b..21fd68f60 100644 --- a/src/video/pipeline/dmabuf.rs +++ b/src/video/pipeline/dmabuf.rs @@ -6,6 +6,11 @@ use crate::video::codec::registry::EncoderRegistry; use hwcodec::rkmpp_dmabuf::{DmaEncoder, DmaEncoderConfig, DmaFormat}; pub(super) fn eligible(config: &SharedVideoPipelineConfig) -> bool { + // The DMA path feeds captured frames straight into the encoder, so it + // cannot apply the transmission downscale. Fall back to the copy pipeline. + if config.is_scaling() { + return false; + } if std::env::var("ONE_KVM_RKMPP_DMABUF").as_deref() == Ok("0") { return false; } @@ -168,15 +173,15 @@ fn run( idle_since = None; while let Ok(command) = commands.try_recv() { - let PipelineCmd::SetBitrate { preset } = command; + let PipelineCmd::SetBitrate { preset, .. } = command; // Preserve Custom values and preset-specific GOPs across fallback/reopen. config.bitrate_preset = preset; + let bitrate_kbps = config.bitrate_kbps(); + let gop_size = config.gop_size().max(1); if let Some(capture) = active.as_mut() { match capture.encoder.as_mut().expect("active encoder") { CaptureEncoder::Dma(encoder) => { - if let Err(error) = - encoder.reconfigure(config.bitrate_kbps(), config.gop_size().max(1)) - { + if let Err(error) = encoder.reconfigure(bitrate_kbps, gop_size) { warn!( "RKMPP DMA reconfigure failed, using copy encoder: {}", error @@ -186,9 +191,14 @@ fn run( pipeline.keyframe_requested.store(true, Ordering::Release); } } - CaptureEncoder::Copy(encoder) => { - pipeline.apply_cmd(encoder, PipelineCmd::SetBitrate { preset })? - } + CaptureEncoder::Copy(encoder) => pipeline.apply_cmd( + encoder, + PipelineCmd::SetBitrate { + preset, + bitrate_kbps, + gop_size, + }, + )?, } } } diff --git a/src/video/pipeline/encoder_state.rs b/src/video/pipeline/encoder_state.rs index 5f0ad6993..2d2039fb1 100644 --- a/src/video/pipeline/encoder_state.rs +++ b/src/video/pipeline/encoder_state.rs @@ -1,5 +1,5 @@ use crate::error::{AppError, Result}; -use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter}; +use crate::video::codec::convert::{FrameScaler, MjpegToNv12Decoder, Nv12Converter, PixelConverter}; use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat}; use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat}; use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType}; @@ -21,6 +21,8 @@ pub(super) struct EncoderThreadState { pub(super) mjpeg_decoder: Option, pub(super) nv12_converter: Option, pub(super) yuv420p_converter: Option, + /// Optional downscaler applied after conversion and before encoding. + pub(super) scaler: Option, pub(super) encoder_needs_yuv420p: bool, #[cfg(feature = "rkmpp")] pub(super) ffmpeg_hw_pipeline: Option, @@ -106,7 +108,7 @@ fn create_h264_encoder( ) -> Result> { let encoder = H264Encoder::with_codec( H264Config { - base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()), + base: EncoderConfig::h264(config.output_resolution(), config.bitrate_kbps()), bitrate_kbps: config.bitrate_kbps(), gop_size: config.gop_size(), fps: config.fps, @@ -263,16 +265,21 @@ pub(super) fn build_encoder_state( }; let needs_mjpeg_decode = config.input_format.is_compressed(); + // Downscaling requires a format conversion pass, so the zero-copy direct + // input paths and the FFmpeg hardware transcode pipeline cannot be used. + let scaling = config.is_scaling(); let is_rkmpp_available = registry .encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp) .is_some(); let rkmpp_is_allowed = config.encoder_backend.is_none() || config.encoder_backend == Some(EncoderBackend::Rkmpp); - let use_yuyv_direct = is_rkmpp_available + let use_yuyv_direct = !scaling + && is_rkmpp_available && rkmpp_is_allowed && !needs_mjpeg_decode && config.input_format == PixelFormat::Yuyv; - let use_rkmpp_direct = is_rkmpp_available + let use_rkmpp_direct = !scaling + && is_rkmpp_available && rkmpp_is_allowed && !needs_mjpeg_decode && matches!( @@ -352,6 +359,7 @@ pub(super) fn build_encoder_state( #[cfg(feature = "rkmpp")] if needs_mjpeg_decode && is_rkmpp_encoder + && !scaling && matches!( config.output_codec, VideoEncoderType::H264 | VideoEncoderType::H265 @@ -388,6 +396,7 @@ pub(super) fn build_encoder_state( mjpeg_decoder: None, nv12_converter: None, yuv420p_converter: None, + scaler: None, encoder_needs_yuv420p: false, #[cfg(feature = "rkmpp")] ffmpeg_hw_pipeline: Some(pipeline), @@ -406,17 +415,46 @@ pub(super) fn build_encoder_state( (None, config.input_format) }; + // Encoder input format is determined by the *converter/scaler output*. + // When no downscale is configured, direct capture formats can be used; + // when downscaling, everything runs through the planar converter paths. + let mut needs_yuv420p = if selected_codec_name.contains("libx264") { + !matches!( + pipeline_input_format, + PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420 + ) + } else { + selected_codec_name.contains("libvpx") || selected_codec_name.contains("libx265") + }; + + if scaling + && pipeline_input_format == PixelFormat::Yuv420 + && selected_codec_name.contains("libx264") + { + // libx264 already accepts YUV420P directly; scale in that format + // instead of paying for a full-frame YUV420→NV12 conversion. + needs_yuv420p = true; + } + let encoder: Box = match config.output_codec { VideoEncoderType::H264 => { let codec_name = selected_codec_name.clone(); let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format); - let input_format = direct_input_format.unwrap_or_else(|| { - if codec_name.contains("libx264") { + let input_format = if scaling { + if needs_yuv420p { H264InputFormat::Yuv420p } else { H264InputFormat::Nv12 } - }); + } else { + direct_input_format.unwrap_or_else(|| { + if codec_name.contains("libx264") { + H264InputFormat::Yuv420p + } else { + H264InputFormat::Nv12 + } + }) + }; if use_rkmpp_direct { info!( @@ -435,13 +473,21 @@ pub(super) fn build_encoder_state( VideoEncoderType::H265 => { let codec_name = selected_codec_name.clone(); let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format); - let input_format = direct_input_format.unwrap_or_else(|| { - if codec_name.contains("libx265") { + let input_format = if scaling { + if needs_yuv420p { H265InputFormat::Yuv420p } else { H265InputFormat::Nv12 } - }); + } else { + direct_input_format.unwrap_or_else(|| { + if codec_name.contains("libx265") { + H265InputFormat::Yuv420p + } else { + H265InputFormat::Nv12 + } + }) + }; if use_rkmpp_direct { info!( @@ -458,7 +504,7 @@ pub(super) fn build_encoder_state( let encoder = H265Encoder::with_codec( H265Config { base: EncoderConfig { - resolution: config.resolution, + resolution: config.output_resolution(), input_format: config.input_format, quality: config.bitrate_kbps(), fps: config.fps, @@ -483,7 +529,7 @@ pub(super) fn build_encoder_state( ); } let encoder = VP8Encoder::with_codec( - VP8Config::low_latency(config.resolution, config.bitrate_kbps()), + VP8Config::low_latency(config.output_resolution(), config.bitrate_kbps()), &codec_name, )?; info!("Created VP8 encoder: {}", encoder.codec_name()); @@ -498,7 +544,7 @@ pub(super) fn build_encoder_state( ); } let encoder = VP9Encoder::with_codec( - VP9Config::low_latency(config.resolution, config.bitrate_kbps()), + VP9Config::low_latency(config.output_resolution(), config.bitrate_kbps()), &codec_name, )?; info!("Created VP9 encoder: {}", encoder.codec_name()); @@ -507,34 +553,27 @@ pub(super) fn build_encoder_state( }; let codec_name = encoder.codec_name(); - let use_direct_input = if codec_name.contains("rkmpp") { - matches!( - pipeline_input_format, - PixelFormat::Yuyv - | PixelFormat::Yuv420 - | PixelFormat::Rgb24 - | PixelFormat::Bgr24 - | PixelFormat::Nv12 - | PixelFormat::Nv16 - | PixelFormat::Nv21 - | PixelFormat::Nv24 - ) - } else if codec_name.contains("libx264") { - matches!( - pipeline_input_format, - PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420 - ) - } else { - false - }; - let needs_yuv420p = if codec_name.contains("libx264") { - !matches!( - pipeline_input_format, - PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420 - ) - } else { - codec_name.contains("libvpx") || codec_name.contains("libx265") - }; + let use_direct_input = !scaling + && (if codec_name.contains("rkmpp") { + matches!( + pipeline_input_format, + PixelFormat::Yuyv + | PixelFormat::Yuv420 + | PixelFormat::Rgb24 + | PixelFormat::Bgr24 + | PixelFormat::Nv12 + | PixelFormat::Nv16 + | PixelFormat::Nv21 + | PixelFormat::Nv24 + ) + } else if codec_name.contains("libx264") { + matches!( + pipeline_input_format, + PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420 + ) + } else { + false + }); info!( "Encoder {} needs {} format", @@ -567,11 +606,36 @@ pub(super) fn build_encoder_state( needs_yuv420p, )?; + let scaler = if scaling { + // `converters_for_pipeline` always leaves data in YUV420P when the + // encoder needs it, otherwise NV12. Scale in that same planar format + // so the encoder receives exactly what it expects. + let plane_format = if needs_yuv420p { + PixelFormat::Yuv420 + } else { + PixelFormat::Nv12 + }; + info!( + "Downscaling transmission video {} → {} (capture unchanged)", + config.resolution, + config.output_resolution() + ); + Some(FrameScaler::new( + config.resolution, + config.output_resolution(), + plane_format, + plane_format, + )) + } else { + None + }; + Ok(EncoderThreadState { encoder: Some(encoder), mjpeg_decoder, nv12_converter, yuv420p_converter, + scaler, encoder_needs_yuv420p: needs_yuv420p, #[cfg(feature = "rkmpp")] ffmpeg_hw_pipeline: None, diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index bbc7aaef4..929e2006d 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -292,6 +292,9 @@ pub struct EncodedVideoFrame { enum PipelineCmd { SetBitrate { preset: crate::video::codec::BitratePreset, + /// Preset bitrate after applying the transmission scale ratio. + bitrate_kbps: u32, + gop_size: u32, }, } @@ -358,6 +361,9 @@ pub struct SharedVideoPipelineConfig { pub fps: u32, /// Encoder backend (None = auto select best available) pub encoder_backend: Option, + /// Maximum height of the encoded (transmitted) video. `0` keeps the + /// captured resolution. Capture geometry is never changed by this limit. + pub output_max_height: u32, } impl Default for SharedVideoPipelineConfig { @@ -370,6 +376,7 @@ impl Default for SharedVideoPipelineConfig { bitrate_preset: crate::video::codec::BitratePreset::Balanced, fps: 30, encoder_backend: None, + output_max_height: 0, } } } @@ -384,9 +391,43 @@ impl SharedVideoPipelineConfig { } } - /// Get effective bitrate in kbps + /// Get effective bitrate in kbps. + /// + /// When the transmission is downscaled, scale the preset by the pixel + /// ratio so bandwidth follows the output size instead of spending the full + /// capture-resolution bitrate on a smaller frame. Custom presets are used + /// verbatim. pub fn bitrate_kbps(&self) -> u32 { - self.bitrate_preset.bitrate_kbps() + let base = self.bitrate_preset.bitrate_kbps(); + if !self.is_scaling() + || matches!( + self.bitrate_preset, + crate::video::codec::BitratePreset::Custom(_) + ) + { + return base; + } + + let source_pixels = self.resolution.pixels(); + let output_pixels = self.output_resolution().pixels(); + if source_pixels == 0 || output_pixels >= source_pixels { + return base; + } + + ((base as u64 * output_pixels) / source_pixels) + .max(200) + .min(base as u64) as u32 + } + + /// Resolution handed to the encoder. Derived from the negotiated input + /// resolution so a source change automatically recomputes the scale. + pub fn output_resolution(&self) -> Resolution { + crate::video::format::scale_to_max_height(self.resolution, self.output_max_height) + } + + /// Whether encoding happens at a lower resolution than capture. + pub fn is_scaling(&self) -> bool { + self.output_resolution() != self.resolution } /// Get effective GOP size @@ -723,13 +764,20 @@ impl SharedVideoPipeline { fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> { match cmd { - PipelineCmd::SetBitrate { preset } => { - let bitrate_kbps = preset.bitrate_kbps(); + PipelineCmd::SetBitrate { + preset, + bitrate_kbps, + gop_size, + } => { + debug!( + "Applying bitrate {:?}: {} kbps (gop {})", + preset, bitrate_kbps, gop_size + ); #[cfg(feature = "rkmpp")] if state.ffmpeg_hw_enabled { if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline { pipeline - .reconfigure(bitrate_kbps as i32, preset.gop_size(state.fps) as i32) + .reconfigure(bitrate_kbps as i32, gop_size as i32) .map_err(|e| { let detail = if e.is_empty() { ffmpeg_hw_last_error() @@ -1894,6 +1942,29 @@ impl SharedVideoPipeline { .unwrap_or(raw_frame); let needs_yuv420p = state.encoder_needs_yuv420p; + let scaling = state.scaler.is_some(); + + // First reach the planar format the encoder expects at capture + // resolution, then optionally downscale it for transmission. + let planar_frame: &[u8] = if needs_yuv420p { + match state.yuv420p_converter.as_mut() { + Some(conv) => conv.convert(raw_frame).map_err(|e| { + AppError::VideoError(format!("YUV420P conversion failed: {}", e)) + })?, + None => raw_frame, + } + } else if let Some(conv) = state.nv12_converter.as_mut() { + conv.convert(raw_frame) + .map_err(|e| AppError::VideoError(format!("NV12 conversion failed: {}", e)))? + } else { + raw_frame + }; + + let encode_frame: &[u8] = match state.scaler.as_mut() { + Some(scaler) => scaler.scale(planar_frame)?, + None => planar_frame, + }; + let encoder = state .encoder .as_mut() @@ -1905,33 +1976,17 @@ impl SharedVideoPipeline { debug!("[Pipeline] Keyframe will be generated for this frame"); } - let encode_result = if needs_yuv420p { - // Software encoder with direct input conversion to YUV420P - if let Some(conv) = state.yuv420p_converter.as_mut() { - let yuv420p_data = conv.convert(raw_frame).map_err(|e| { - AppError::VideoError(format!("YUV420P conversion failed: {}", e)) - })?; - encoder.encode_raw(yuv420p_data, pts_ms) - } else { - encoder.encode_raw(raw_frame, pts_ms) - } - } else if let Some(conv) = state.nv12_converter.as_mut() { - // Hardware encoder with input conversion to NV12 - let nv12_data = conv - .convert(raw_frame) - .map_err(|e| AppError::VideoError(format!("NV12 conversion failed: {}", e)))?; - encoder.encode_raw(nv12_data, pts_ms) + let encode_result = if !scaling + && decoded_buf.is_none() + && compacted_buf.is_none() + && frame.format == PixelFormat::Nv12 + && encoder.supports_owned_nv12_input() + { + // No conversion or scaling: hand the owned buffer straight to the + // encoder and avoid a full-frame copy. + encoder.encode_owned_nv12(frame.owned_data_bytes(), pts_ms) } else { - // Direct input (already in correct format) - if decoded_buf.is_none() - && compacted_buf.is_none() - && frame.format == PixelFormat::Nv12 - && encoder.supports_owned_nv12_input() - { - encoder.encode_owned_nv12(frame.owned_data_bytes(), pts_ms) - } else { - encoder.encode_raw(raw_frame, pts_ms) - } + encoder.encode_raw(encode_frame, pts_ms) }; match encode_result { @@ -2067,11 +2122,16 @@ impl SharedVideoPipeline { &self, preset: crate::video::codec::BitratePreset, ) -> Result<()> { - { + let (bitrate_kbps, gop_size) = { let mut config = self.config.write().await; config.bitrate_preset = preset; - } - self.send_cmd(PipelineCmd::SetBitrate { preset }); + (config.bitrate_kbps(), config.gop_size()) + }; + self.send_cmd(PipelineCmd::SetBitrate { + preset, + bitrate_kbps, + gop_size, + }); Ok(()) } @@ -2281,7 +2341,9 @@ mod tests { BitratePreset::Quality, ] { pipeline.set_bitrate_preset(preset).await.unwrap(); - let PipelineCmd::SetBitrate { preset: received } = rx.try_recv().unwrap(); + let PipelineCmd::SetBitrate { + preset: received, .. + } = rx.try_recv().unwrap(); assert_eq!(received, preset); assert_eq!(pipeline.config().await.bitrate_preset, preset); // Rebuilt encoders must retain the preset's policy at the new FPS. @@ -2295,6 +2357,31 @@ mod tests { } } + #[test] + fn downscale_scales_preset_bitrate_by_pixel_ratio() { + let config = SharedVideoPipelineConfig { + resolution: Resolution::HD1080, + output_max_height: 720, + bitrate_preset: BitratePreset::Balanced, + ..Default::default() + }; + + assert_eq!(config.output_resolution(), Resolution::HD720); + assert_eq!(config.bitrate_kbps(), 1777); + } + + #[test] + fn downscale_keeps_custom_bitrate_verbatim() { + let config = SharedVideoPipelineConfig { + resolution: Resolution::HD1080, + output_max_height: 720, + bitrate_preset: BitratePreset::Custom(2500), + ..Default::default() + }; + + assert_eq!(config.bitrate_kbps(), 2500); + } + #[test] fn source_reopen_updates_fps_and_gop_without_changing_geometry_or_bitrate() { let mut config = SharedVideoPipelineConfig { @@ -2537,6 +2624,7 @@ mod tests { mjpeg_decoder: None, nv12_converter: None, yuv420p_converter: None, + scaler: None, encoder_needs_yuv420p: false, #[cfg(feature = "rkmpp")] ffmpeg_hw_pipeline: None, diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index 2e41ce070..f00e908ef 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -106,6 +106,11 @@ pub struct VideoStreamManager { switching: AtomicBool, /// Current mode switch transaction ID (set while switching=true) transition_id: RwLock>, + /// Persistent ("default") transmission height limit from the config store. + output_max_height_default: RwLock, + /// Temporary override set from the console; never persisted and cleared + /// when the process restarts. + output_max_height_override: RwLock>, } impl VideoStreamManager { @@ -123,6 +128,8 @@ impl VideoStreamManager { runtime_codec_constraints: RwLock::new(None), switching: AtomicBool::new(false), transition_id: RwLock::new(None), + output_max_height_default: RwLock::new(0), + output_max_height_override: RwLock::new(None), }) } @@ -151,6 +158,61 @@ impl VideoStreamManager { *self.runtime_codec_constraints.write().await = Some(constraints); } + /// Initialize the persistent transmission height limit during startup. + /// + /// The streamers are already constructed with this value, so this only + /// records it and forwards it to the MJPEG handler without restarting a + /// pipeline. + pub async fn init_output_max_height(&self, max_height: u32) { + *self.output_max_height_default.write().await = max_height; + self.streamer + .mjpeg_handler() + .set_output_max_height(max_height); + } + + /// Effective transmission height limit (temporary override wins). + pub async fn effective_output_max_height(&self) -> u32 { + let override_value = *self.output_max_height_override.read().await; + match override_value { + Some(value) => value, + None => *self.output_max_height_default.read().await, + } + } + + /// Persistent default and the current temporary override, if any. + pub async fn output_max_height_state(&self) -> (u32, Option) { + let default = *self.output_max_height_default.read().await; + let override_value = *self.output_max_height_override.read().await; + (default, override_value) + } + + /// Apply a persisted default. A Settings change takes effect immediately + /// and clears any temporary override set from the console. + pub async fn set_output_max_height_default(&self, max_height: u32) -> Result<()> { + *self.output_max_height_default.write().await = max_height; + *self.output_max_height_override.write().await = None; + self.apply_output_max_height().await + } + + /// Set (or clear with `None`) the temporary console override. + pub async fn set_output_max_height_override( + &self, + max_height: Option, + ) -> Result<()> { + *self.output_max_height_override.write().await = max_height; + self.apply_output_max_height().await + } + + async fn apply_output_max_height(&self) -> Result<()> { + let max_height = self.effective_output_max_height().await; + self.streamer + .mjpeg_handler() + .set_output_max_height(max_height); + self.webrtc_streamer + .set_output_max_height(max_height) + .await + } + /// Get current stream codec constraints derived from running services. pub async fn codec_constraints(&self) -> StreamCodecConstraints { if let Some(constraints) = self.runtime_codec_constraints.read().await.as_ref() { @@ -646,6 +708,20 @@ impl VideoStreamManager { } s.clients = self.webrtc_streamer.session_count().await as u64; } + + let (default_max_height, override_max_height) = self.output_max_height_state().await; + let effective_max_height = override_max_height.unwrap_or(default_max_height); + s.output_max_height = effective_max_height; + s.output_max_height_default = default_max_height; + s.output_max_height_is_override = override_max_height.is_some(); + s.output_resolution = s.resolution.map(|(width, height)| { + let output = crate::video::format::scale_to_max_height( + Resolution::new(width, height), + effective_max_height, + ); + (output.width, output.height) + }); + s } diff --git a/src/video/streamer.rs b/src/video/streamer.rs index b80bf4dbf..8b71ee2b2 100644 --- a/src/video/streamer.rs +++ b/src/video/streamer.rs @@ -1317,6 +1317,10 @@ impl Streamer { clients: self.mjpeg_handler.client_count(), target_fps: config.fps, fps, + output_resolution: None, + output_max_height: 0, + output_max_height_default: 0, + output_max_height_is_override: false, } } @@ -1563,6 +1567,14 @@ pub struct StreamerStats { pub target_fps: u32, /// Current actual FPS pub fps: f32, + /// Resolution actually sent to clients (after optional downscaling). + pub output_resolution: Option<(u32, u32)>, + /// Effective transmission height limit (`0` = capture resolution). + pub output_max_height: u32, + /// Persisted default transmission height limit. + pub output_max_height_default: u32, + /// Whether a temporary console override is currently active. + pub output_max_height_is_override: bool, } fn probe_subdev_signal( diff --git a/src/video/traits.rs b/src/video/traits.rs index 3ab62e9c6..bbe8229d4 100644 --- a/src/video/traits.rs +++ b/src/video/traits.rs @@ -40,6 +40,8 @@ pub trait VideoOutput: Send + Sync { async fn get_pipeline_config(&self) -> Option; async fn set_video_codec(&self, codec: VideoCodecType) -> Result<()>; async fn set_bitrate_preset(&self, preset: BitratePreset) -> Result<()>; + /// Set the maximum encoded video height (`0` = capture resolution). + async fn set_output_max_height(&self, max_height: u32) -> Result<()>; async fn request_keyframe(&self) -> Result<()>; async fn current_video_geometry(&self) -> (Resolution, PixelFormat, u32); async fn pipeline_stats(&self) -> Option; diff --git a/src/vnc/mod.rs b/src/vnc/mod.rs index c73954823..0b2ef0213 100644 --- a/src/vnc/mod.rs +++ b/src/vnc/mod.rs @@ -355,12 +355,22 @@ async fn initial_frame_size( match config.encoding { VncEncoding::TightJpeg => { let (_, resolution, _, _, _) = video_manager.streamer().current_capture_config().await; - (resolution.width as u16, resolution.height as u16) + // The JPEG frames come from the shared MJPEG handler, which may be + // downscaled for transmission. Advertise the size the client will + // actually receive so the first frame does not force a resize. + let output = crate::video::format::scale_to_max_height( + resolution, + video_manager.mjpeg_handler().output_max_height(), + ); + (output.width as u16, output.height as u16) } VncEncoding::H264 => video_manager .get_encoding_config() .await - .map(|cfg| (cfg.resolution.width as u16, cfg.resolution.height as u16)) + .map(|cfg| { + let output = cfg.output_resolution(); + (output.width as u16, output.height as u16) + }) .unwrap_or((1280, 720)), } } @@ -428,7 +438,7 @@ async fn subscribe_frames( let geometry = video_manager .get_encoding_config() .await - .map(|cfg| cfg.resolution) + .map(|cfg| cfg.output_resolution()) .unwrap_or(crate::video::format::Resolution::HD720); let width = geometry.width as u16; let height = geometry.height as u16; @@ -445,7 +455,7 @@ async fn subscribe_frames( let geometry = geometry_manager .get_encoding_config() .await - .map(|cfg| cfg.resolution) + .map(|cfg| cfg.output_resolution()) .unwrap_or(crate::video::format::Resolution::HD720); tx.send_replace(Some(RfbFrame::H264 { data: Bytes::copy_from_slice(&frame.data), diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 247d9487f..bfca04f4f 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -14,6 +14,7 @@ mod redfish; mod rtsp; mod rustdesk; mod stream; +mod terminal; #[cfg(unix)] mod uac; mod usb_update; @@ -42,6 +43,7 @@ pub use rustdesk::{ update_rustdesk_config, }; pub use stream::{get_stream_config, update_stream_config}; +pub use terminal::{get_terminal_config, update_terminal_config}; #[cfg(unix)] pub use uac::{get_uac_config, update_uac_config}; pub use video::{get_video_config, update_video_config}; diff --git a/src/web/handlers/config/terminal.rs b/src/web/handlers/config/terminal.rs new file mode 100644 index 000000000..35be3fda0 --- /dev/null +++ b/src/web/handlers/config/terminal.rs @@ -0,0 +1,70 @@ +use axum::{extract::State, Json}; +use serde::Deserialize; +use std::sync::Arc; +use typeshare::typeshare; + +use crate::config::validate_shell; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +#[typeshare] +#[derive(Debug, Clone, serde::Serialize)] +pub struct TerminalConfigResponse { + pub enabled: bool, + pub shell: String, + /// Whether this platform has a built-in PTY terminal implementation. + pub supported: bool, +} + +#[typeshare] +#[derive(Debug, Deserialize)] +pub struct TerminalConfigUpdate { + pub enabled: Option, + pub shell: Option, +} + +fn response(state: &AppState) -> TerminalConfigResponse { + let config = state.config.get(); + TerminalConfigResponse { + enabled: config.terminal.enabled, + shell: config.terminal.shell.clone(), + supported: cfg!(unix), + } +} + +pub async fn get_terminal_config( + State(state): State>, +) -> Json { + Json(response(&state)) +} + +pub async fn update_terminal_config( + State(state): State>, + Json(req): Json, +) -> Result> { + let new_shell = match req.shell.as_deref() { + Some(raw) => { + let shell = raw.trim(); + validate_shell(shell).map_err(AppError::BadRequest)?; + Some(shell.to_string()) + } + None => None, + }; + + state + .config + .update(|config| { + if let Some(enabled) = req.enabled { + config.terminal.enabled = enabled; + } + if let Some(shell) = &new_shell { + config.terminal.shell = shell.clone(); + } + }) + .await?; + + // Let connected consoles update their web-terminal availability. + state.events.mark_device_info_dirty(); + + Ok(Json(response(&state))) +} diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 5fe785146..2ee856723 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -159,6 +159,8 @@ pub struct StreamConfigResponse { pub turn_server: Option, pub turn_username: Option, pub turn_password: Option, + /// Maximum transmitted height (`0` = capture resolution). + pub output_max_height: u32, } impl From<&StreamConfig> for StreamConfigResponse { @@ -174,6 +176,7 @@ impl From<&StreamConfig> for StreamConfigResponse { turn_server: config.turn_server.clone(), turn_username: config.turn_username.clone(), turn_password: config.turn_password.clone(), + output_max_height: config.output_max_height, } } } @@ -194,6 +197,8 @@ pub struct StreamConfigUpdate { pub turn_username: Option, /// TURN password pub turn_password: Option, + /// Maximum transmitted height (`0` = capture resolution) + pub output_max_height: Option, } impl StreamConfigUpdate { @@ -216,6 +221,15 @@ impl StreamConfigUpdate { )); } } + if let Some(height) = self.output_max_height { + if !crate::video::format::is_valid_output_max_height(height) { + return Err(AppError::BadRequest(format!( + "output_max_height must be 0 (capture resolution) or between {} and {}", + crate::video::format::MIN_OUTPUT_MAX_HEIGHT, + crate::video::format::MAX_OUTPUT_MAX_HEIGHT, + ))); + } + } Ok(()) } @@ -229,6 +243,9 @@ impl StreamConfigUpdate { if let Some(preset) = self.bitrate_preset { config.bitrate_preset = preset; } + if let Some(height) = self.output_max_height { + config.output_max_height = height; + } // STUN/TURN settings - empty string means clear (use public servers), Some("value") means set custom if let Some(ref stun) = self.stun_server { config.stun_server = if stun.is_empty() { diff --git a/src/web/handlers/inventory.rs b/src/web/handlers/inventory.rs index 25082a198..07ba330b8 100644 --- a/src/web/handlers/inventory.rs +++ b/src/web/handlers/inventory.rs @@ -11,7 +11,7 @@ pub struct DeviceList { #[derive(Serialize)] pub struct ExtensionsAvailability { - pub ttyd_available: bool, + pub terminal_available: bool, pub rustdesk_available: bool, } @@ -173,8 +173,12 @@ pub async fn list_devices(State(state): State>) -> Json vec![], }; - // Check extension availability - let ttyd_available = state + // The built-in terminal is always available on Unix; Windows keeps the + // legacy ttyd fallback. + #[cfg(unix)] + let terminal_available = true; + #[cfg(windows)] + let terminal_available = state .extensions .check_available(crate::extensions::ExtensionId::Ttyd); @@ -184,7 +188,7 @@ pub async fn list_devices(State(state): State>) -> Json> = Mutex::new(None); + +#[derive(Debug, Deserialize)] +pub struct LogReportRequest { + pub description: String, +} + +#[derive(Debug, Serialize)] +pub struct LogReportResponse { + pub success: bool, + pub status: u16, +} + +/// Submit the recent runtime log together with a user-supplied description. +pub async fn report_runtime_logs( + Json(request): Json, +) -> Result> { + let description = normalize_description(&request.description)?; + + if !allow_report(Instant::now()) { + return Err(AppError::RateLimited( + "a log report was submitted recently, please try again later".to_string(), + )); + } + + let lines = logging::hub().snapshot(REPORT_LOG_LINES); + if lines.is_empty() { + return Err(AppError::ServiceUnavailable( + "no runtime logs are available yet".to_string(), + )); + } + + let log_content = format!("{}\n", lines.join("\n")); + let boundary = format!("----OneKvmBoundary{}", uuid::Uuid::new_v4().simple()); + let body = build_multipart_body(&boundary, &description, &log_content); + + let client = reqwest::Client::builder() + .timeout(REPORT_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .user_agent(concat!("one-kvm/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| { + AppError::Internal(format!("failed to create the report client: {error}")) + })?; + + let response = client + .post(REPORT_ENDPOINT) + .header( + CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(body) + .send() + .await + .map_err(|error| { + AppError::ServiceUnavailable(format!("failed to submit the log report: {error}")) + })?; + + let status = response.status(); + // The endpoint redirects after processing, so treat redirects as accepted. + if !(status.is_success() || status.is_redirection()) { + return Err(AppError::ServiceUnavailable(format!( + "the report endpoint returned HTTP {}", + status.as_u16() + ))); + } + + info!( + lines = lines.len(), + status = status.as_u16(), + "Runtime log report submitted" + ); + + Ok(Json(LogReportResponse { + success: true, + status: status.as_u16(), + })) +} + +fn normalize_description(raw: &str) -> Result { + let description = raw.trim(); + if description.is_empty() { + return Err(AppError::BadRequest( + "a problem description is required".to_string(), + )); + } + if description.chars().count() > MAX_DESCRIPTION_CHARS { + return Err(AppError::BadRequest(format!( + "the problem description must not exceed {MAX_DESCRIPTION_CHARS} characters" + ))); + } + Ok(description.to_string()) +} + +fn allow_report(now: Instant) -> bool { + let mut last = LAST_REPORT + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + allow_report_at(&mut last, now) +} + +fn allow_report_at(last: &mut Option, now: Instant) -> bool { + match *last { + Some(previous) if now.duration_since(previous) < MIN_REPORT_INTERVAL => false, + _ => { + *last = Some(now); + true + } + } +} + +fn build_multipart_body(boundary: &str, description: &str, log: &str) -> Vec { + let mut body = String::with_capacity(description.len() + log.len() + 512); + body.push_str(&format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"{REPORT_DESCRIPTION_FIELD}\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\r\n\ + {description}\r\n" + )); + body.push_str(&format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"{REPORT_LOG_FIELD}\"; filename=\"{REPORT_LOG_FILENAME}\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\r\n\ + {log}\r\n" + )); + body.push_str(&format!("--{boundary}--\r\n")); + body.into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn description_is_required_and_trimmed() { + assert!(normalize_description(" ").is_err()); + assert_eq!(normalize_description(" broken video ").unwrap(), "broken video"); + } + + #[test] + fn description_length_is_bounded() { + let too_long = "x".repeat(MAX_DESCRIPTION_CHARS + 1); + assert!(normalize_description(&too_long).is_err()); + let at_limit = "x".repeat(MAX_DESCRIPTION_CHARS); + assert!(normalize_description(&at_limit).is_ok()); + } + + #[test] + fn multipart_body_contains_description_and_log_attachment() { + let body = build_multipart_body("BOUNDARY", "no signal", "line one\nline two\n"); + let text = String::from_utf8(body).unwrap(); + + assert!(text.starts_with("--BOUNDARY\r\n")); + assert!(text.contains("name=\"description\"")); + assert!(text.contains("no signal")); + assert!(text.contains("name=\"log\"; filename=\"one-kvm.log\"")); + assert!(text.contains("line one\nline two")); + assert!(text.ends_with("--BOUNDARY--\r\n")); + } + + #[test] + fn report_cooldown_rejects_immediate_repeats() { + let start = Instant::now(); + let mut last = None; + assert!(allow_report_at(&mut last, start)); + assert!(!allow_report_at(&mut last, start + Duration::from_secs(1))); + assert!(allow_report_at(&mut last, start + MIN_REPORT_INTERVAL)); + } +} diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 4ade59e06..e7e20735f 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -10,6 +10,7 @@ mod auth; mod hid_api; mod host_power; mod inventory; +mod logs; mod mcp; #[cfg(unix)] mod msd_api; @@ -26,6 +27,7 @@ pub use auth::*; pub use hid_api::*; pub use host_power::*; pub use inventory::*; +pub use logs::*; pub use mcp::*; #[cfg(unix)] pub use msd_api::*; diff --git a/src/web/handlers/setup.rs b/src/web/handlers/setup.rs index 43f909152..32a4e3441 100644 --- a/src/web/handlers/setup.rs +++ b/src/web/handlers/setup.rs @@ -38,6 +38,8 @@ pub struct SetupRequest { pub hid_otg_profile: Option, pub hid_otg_keyboard_leds: Option, pub msd_enabled: Option, + // Web terminal settings + pub terminal_enabled: Option, // Extension settings pub ttyd_enabled: Option, pub rustdesk_enabled: Option, @@ -177,9 +179,20 @@ pub async fn setup_init( } config.enforce_invariants(); - // Extension settings + // Web terminal settings. `ttyd_enabled` is the legacy field sent by + // older clients; map it to the built-in terminal as well. + if let Some(enabled) = req.terminal_enabled { + config.terminal.enabled = enabled; + // Windows has no built-in PTY backend yet; keep ttyd as the + // fallback and mirror the choice into its legacy config. + #[cfg(windows)] + { + config.extensions.ttyd.enabled = enabled; + } + } if let Some(enabled) = req.ttyd_enabled { config.extensions.ttyd.enabled = enabled; + config.terminal.enabled = enabled; } if let Some(enabled) = req.rustdesk_enabled { config.rustdesk.enabled = enabled; @@ -194,12 +207,14 @@ pub async fn setup_init( } tracing::info!( - "Extension config after save: ttyd.enabled={}, rustdesk.enabled={}", - new_config.extensions.ttyd.enabled, + "Terminal config after save: terminal.enabled={}, rustdesk.enabled={}", + new_config.terminal.enabled, new_config.rustdesk.enabled ); - // Start extensions if enabled + // The built-in PTY terminal needs no daemon on Unix. On Windows keep the + // legacy ttyd fallback working when it was explicitly enabled. + #[cfg(windows)] if new_config.extensions.ttyd.enabled { if let Err(e) = state .extensions diff --git a/src/web/handlers/stream.rs b/src/web/handlers/stream.rs index d70b818b8..4252c45e4 100644 --- a/src/web/handlers/stream.rs +++ b/src/web/handlers/stream.rs @@ -286,6 +286,38 @@ pub async fn stream_constraints_get( }) } +/// Set the temporary transmission resolution for this runtime. +/// +/// `max_height: null` clears the override and falls back to the value saved in +/// Settings. Overrides live only in memory and disappear when the process +/// restarts. +#[derive(Deserialize)] +pub struct SetOutputResolutionRequest { + pub max_height: Option, +} + +pub async fn stream_set_output_resolution( + State(state): State>, + Json(req): Json, +) -> Result> { + if let Some(height) = req.max_height { + if !crate::video::format::is_valid_output_max_height(height) { + return Err(AppError::BadRequest(format!( + "max_height must be 0 (capture resolution) or between {} and {}", + crate::video::format::MIN_OUTPUT_MAX_HEIGHT, + crate::video::format::MAX_OUTPUT_MAX_HEIGHT, + ))); + } + } + + state + .stream_manager + .set_output_max_height_override(req.max_height) + .await?; + + Ok(Json(state.stream_manager.stats().await)) +} + /// Set bitrate request #[derive(Deserialize)] pub struct SetBitrateRequest { diff --git a/src/web/logs_ws.rs b/src/web/logs_ws.rs new file mode 100644 index 000000000..299b276b7 --- /dev/null +++ b/src/web/logs_ws.rs @@ -0,0 +1,98 @@ +//! WebSocket endpoint streaming runtime log lines to the settings page. + +use std::time::Duration; + +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + response::Response, +}; +use futures::{stream::SplitSink, SinkExt, StreamExt}; +use serde::Serialize; +use tokio::{ + sync::broadcast::error::RecvError, + time::{interval, MissedTickBehavior}, +}; +use tracing::debug; + +use crate::logging::{self, LOG_BUFFER_CAPACITY}; + +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum LogSocketMessage<'a> { + /// Initial backlog sent right after the client connects. + Snapshot { lines: &'a [String] }, + /// A single line emitted after the snapshot. + Line { line: &'a str }, + /// The client could not keep up; `count` lines were dropped. + Lagged { count: u64 }, +} + +pub async fn logs_ws_handler(ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(handle_socket) +} + +async fn handle_socket(socket: WebSocket) { + let (mut sender, mut receiver) = socket.split(); + + // Subscribe before taking the snapshot so lines emitted in between are + // not lost. The overlap can produce a duplicate, which is harmless. + let hub = logging::hub(); + let mut lines_rx = hub.subscribe(); + let snapshot = hub.snapshot(LOG_BUFFER_CAPACITY); + + if !send_message(&mut sender, &LogSocketMessage::Snapshot { lines: &snapshot }).await { + return; + } + + debug!("Runtime log WebSocket client connected"); + + let mut heartbeat = interval(HEARTBEAT_INTERVAL); + heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + tokio::select! { + received = lines_rx.recv() => match received { + Ok(line) => { + if !send_message(&mut sender, &LogSocketMessage::Line { line: &line }).await { + break; + } + } + Err(RecvError::Lagged(count)) => { + if !send_message(&mut sender, &LogSocketMessage::Lagged { count }).await { + break; + } + } + Err(RecvError::Closed) => break, + }, + incoming = receiver.next() => match incoming { + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(payload))) => { + if sender.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Some(Err(_)) => break, + _ => {} + }, + _ = heartbeat.tick() => { + if sender.send(Message::Ping(Vec::::new().into())).await.is_err() { + break; + } + } + } + } + + debug!("Runtime log WebSocket client disconnected"); +} + +async fn send_message( + sender: &mut SplitSink, + message: &LogSocketMessage<'_>, +) -> bool { + match serde_json::to_string(message) { + Ok(text) => sender.send(Message::Text(text.into())).await.is_ok(), + Err(_) => false, + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index ed53024bf..1bb5affab 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,8 +1,11 @@ mod audio_ws; mod error; mod handlers; +mod logs_ws; pub mod openwrt_system; mod routes; +#[cfg(unix)] +mod shell_ws; pub(crate) mod state; mod static_files; #[cfg(unix)] @@ -11,6 +14,7 @@ mod ws; pub use audio_ws::audio_ws_handler; pub use error::ErrorResponse; +pub use logs_ws::logs_ws_handler; pub use routes::create_router; #[cfg(not(debug_assertions))] pub use static_files::StaticAssets; diff --git a/src/web/routes.rs b/src/web/routes.rs index b502b3146..8e9ff3361 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -18,6 +18,7 @@ use tower_http::{ use super::audio_ws::audio_ws_handler; use super::handlers; +use super::logs_ws::logs_ws_handler; #[cfg(unix)] use super::uac_ws::uac_audio_ws_handler; use super::ws::ws_handler; @@ -70,6 +71,10 @@ pub fn create_router(state: Arc) -> Router { .route("/video/input-status", get(handlers::video_input_status)) // WebSocket endpoint for real-time events .route("/ws", any(ws_handler)) + // WebSocket endpoint streaming runtime log lines + .route("/ws/logs", any(logs_ws_handler)) + // Runtime log report (description + recent log attachment) + .route("/logs/report", post(handlers::report_runtime_logs)) // Stream control endpoints .route("/stream/status", get(handlers::stream_state)) .route("/stream/start", post(handlers::stream_start)) @@ -77,6 +82,10 @@ pub fn create_router(state: Arc) -> Router { .route("/stream/mode", get(handlers::stream_mode_get)) .route("/stream/mode", post(handlers::stream_mode_set)) .route("/stream/bitrate", post(handlers::stream_set_bitrate)) + .route( + "/stream/output-resolution", + post(handlers::stream_set_output_resolution), + ) .route("/stream/codecs", get(handlers::stream_codecs_list)) .route("/video/codecs", get(handlers::stream_codecs_list)) .route("/stream/constraints", get(handlers::stream_constraints_get)) @@ -206,6 +215,15 @@ pub fn create_router(state: Arc) -> Router { "/config/watchdog", patch(handlers::config::update_watchdog_config), ) + // Built-in web terminal (PTY shell) configuration + .route( + "/config/terminal", + get(handlers::config::get_terminal_config), + ) + .route( + "/config/terminal", + patch(handlers::config::update_terminal_config), + ) .route( "/config/mcp", get(handlers::mcp_config).patch(handlers::mcp_update_config), @@ -283,6 +301,8 @@ pub fn create_router(state: Arc) -> Router { let user_routes = { user_routes .route("/ws/uac-audio", any(uac_audio_ws_handler)) + // Built-in PTY-backed shell terminal (no external ttyd required) + .route("/terminal/shell", get(super::shell_ws::shell_ws_handler)) .route("/hid/otg/self-check", get(handlers::hid_otg_self_check)) .route("/config/msd", get(handlers::config::get_msd_config)) .route("/config/msd", patch(handlers::config::update_msd_config)) diff --git a/src/web/shell_ws.rs b/src/web/shell_ws.rs new file mode 100644 index 000000000..52bf9cd58 --- /dev/null +++ b/src/web/shell_ws.rs @@ -0,0 +1,432 @@ +//! Built-in shell over WebSocket, backed by a pseudo-terminal. +//! +//! This replaces the external `ttyd` dependency for the web terminal. The +//! server owns the PTY and the shell process; the browser only renders bytes. +//! +//! Wire protocol +//! ------------- +//! Client -> server: +//! - text frames are JSON commands: `{"type":"input","data":"..."}` and +//! `{"type":"resize","cols":120,"rows":32}` +//! - binary frames are treated as raw input bytes +//! +//! Server -> client: +//! - binary frames contain raw terminal output +//! - text frames contain JSON control messages: `ready`, `exit`, `error` + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Query, State, + }, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use futures::{SinkExt, StreamExt}; +use serde::Deserialize; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::config::validate_shell; +use crate::state::AppState; + +/// Maximum number of concurrent terminal sessions across the whole server. +const MAX_SESSIONS: usize = 8; +/// Reject oversized client frames before they reach the PTY. +const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +const DEFAULT_COLS: u16 = 100; +const DEFAULT_ROWS: u16 = 30; + +static ACTIVE_SESSIONS: AtomicUsize = AtomicUsize::new(0); + +struct SessionGuard; + +impl Drop for SessionGuard { + fn drop(&mut self) { + ACTIVE_SESSIONS.fetch_sub(1, Ordering::SeqCst); + } +} + +fn acquire_session() -> Option { + let mut current = ACTIVE_SESSIONS.load(Ordering::SeqCst); + loop { + if current >= MAX_SESSIONS { + return None; + } + match ACTIVE_SESSIONS.compare_exchange_weak( + current, + current + 1, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => return Some(SessionGuard), + Err(actual) => current = actual, + } + } +} + +#[derive(Debug, Default, Deserialize)] +pub struct TerminalQuery { + pub cols: Option, + pub rows: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientCommand { + Input { data: String }, + Resize { cols: u16, rows: u16 }, + Ping, +} + +pub async fn shell_ws_handler( + State(state): State>, + Query(query): Query, + ws: WebSocketUpgrade, +) -> Response { + let config = state.config.get(); + + if !config.terminal.enabled { + return (StatusCode::FORBIDDEN, "Web terminal is disabled").into_response(); + } + + let shell = config.terminal.shell.clone(); + drop(config); + + if let Err(error) = validate_shell(&shell) { + tracing::warn!(%error, "Rejected built-in terminal connection"); + return (StatusCode::SERVICE_UNAVAILABLE, error).into_response(); + } + + let Some(guard) = acquire_session() else { + return (StatusCode::TOO_MANY_REQUESTS, "Too many terminal sessions").into_response(); + }; + + let cols = query.cols.unwrap_or(DEFAULT_COLS).clamp(1, 1000); + let rows = query.rows.unwrap_or(DEFAULT_ROWS).clamp(1, 1000); + + ws.max_message_size(MAX_MESSAGE_BYTES) + .max_frame_size(MAX_MESSAGE_BYTES) + .on_upgrade(move |socket| async move { + let _guard = guard; + run_shell_session(socket, shell, cols, rows).await; + }) +} + +async fn run_shell_session(socket: WebSocket, shell: String, cols: u16, rows: u16) { + let session = match PtySession::spawn(&shell, cols, rows) { + Ok(session) => session, + Err(error) => { + tracing::warn!(%error, "Failed to start built-in shell"); + send_shell_error(socket, format!("Failed to start shell: {error}")).await; + return; + } + }; + + tracing::info!(shell = %shell, cols, rows, pid = session.child.id().unwrap_or_default(), "Built-in shell session started"); + + let PtySession { + master_fd, + reader, + writer, + mut child, + } = session; + + let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::>(64); + tokio::spawn(read_pty(reader, output_tx)); + + let (mut sender, mut receiver) = socket.split(); + let mut writer = writer; + + let _ = send_control(&mut sender, json!({ "type": "ready" })).await; + + loop { + tokio::select! { + message = receiver.next() => { + match message { + Some(Ok(Message::Text(text))) => { + if !handle_client_command(&text, master_fd, &mut writer).await { + break; + } + } + Some(Ok(Message::Binary(data))) => { + if writer.write_all(&data).await.is_err() { + break; + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(_)) => break, + } + } + chunk = output_rx.recv() => { + match chunk { + Some(data) => { + if sender.send(Message::Binary(data.into())).await.is_err() { + break; + } + } + None => break, + } + } + status = child.wait() => { + let code = status.ok().and_then(|status| status.code()); + let _ = send_control(&mut sender, json!({ "type": "exit", "code": code })).await; + break; + } + } + } + + // The client may have disconnected while the shell was still running. Make + // sure the process does not outlive the WebSocket. + let pid = child.id().unwrap_or_default(); + if matches!(child.try_wait(), Ok(None)) { + let _ = child.kill().await; + } + let _ = child.wait().await; + + tracing::info!(pid, "Built-in shell session ended"); +} + +async fn read_pty(mut reader: tokio::fs::File, output_tx: tokio::sync::mpsc::Sender>) { + let mut buffer = vec![0u8; 8192]; + + loop { + match reader.read(&mut buffer).await { + Ok(0) => break, + Ok(read) => { + if output_tx.send(buffer[..read].to_vec()).await.is_err() { + break; + } + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + // EIO is the normal way a Linux PTY master reports that the slave + // side (the shell) has gone away. + Err(error) if error.raw_os_error() == Some(libc::EIO) => break, + Err(_) => break, + } + } +} + +async fn handle_client_command( + text: &str, + master_fd: std::os::fd::RawFd, + writer: &mut tokio::fs::File, +) -> bool { + let command: ClientCommand = match serde_json::from_str(text) { + Ok(command) => command, + // Ignore malformed frames instead of tearing down an interactive + // session; the browser side only sends JSON control messages. + Err(_) => return true, + }; + + match command { + ClientCommand::Input { data } => writer.write_all(data.as_bytes()).await.is_ok(), + ClientCommand::Resize { cols, rows } => { + resize_pty(master_fd, cols, rows); + true + } + ClientCommand::Ping => true, + } +} + +async fn send_control( + sender: &mut futures::stream::SplitSink, + value: serde_json::Value, +) -> Result<(), axum::Error> { + sender.send(Message::Text(value.to_string().into())).await +} + +async fn send_shell_error(socket: WebSocket, message: String) { + let (mut sender, _receiver) = socket.split(); + let _ = send_control(&mut sender, json!({ "type": "error", "message": message })).await; + let _ = sender.send(Message::Close(None)).await; +} + +fn resize_pty(master_fd: std::os::fd::RawFd, cols: u16, rows: u16) { + let size = libc::winsize { + ws_row: rows.max(1), + ws_col: cols.max(1), + ws_xpixel: 0, + ws_ypixel: 0, + }; + + // SAFETY: `master_fd` is owned by the live `PtySession` and is not closed + // while this function runs. The kernel only reads the passed winsize. + unsafe { + libc::ioctl(master_fd, libc::TIOCSWINSZ, &size); + } +} + +struct PtySession { + master_fd: std::os::fd::RawFd, + reader: tokio::fs::File, + writer: tokio::fs::File, + child: tokio::process::Child, +} + +impl PtySession { + fn spawn(shell: &str, cols: u16, rows: u16) -> std::io::Result { + use std::os::fd::AsRawFd; + + let winsize = nix::pty::Winsize { + ws_row: rows.max(1), + ws_col: cols.max(1), + ws_xpixel: 0, + ws_ypixel: 0, + }; + + let openpty = nix::pty::openpty(Some(&winsize), None).map_err(nix_error)?; + let master = openpty.master; + let slave = openpty.slave; + + set_close_on_exec(&master)?; + set_close_on_exec(&slave)?; + + let slave_fd = slave.as_raw_fd(); + let stdin = std::process::Stdio::from(slave.try_clone()?); + let stdout = std::process::Stdio::from(slave.try_clone()?); + let stderr = std::process::Stdio::from(slave.try_clone()?); + + let mut parts = shell.split_whitespace(); + let program = parts.next().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "Shell path is empty") + })?; + + let mut command = tokio::process::Command::new(program); + command + .args(parts) + .stdin(stdin) + .stdout(stdout) + .stderr(stderr) + .env("TERM", "xterm-256color") + .env("COLORTERM", "truecolor") + .kill_on_drop(true); + + if let Some(home) = home_dir() { + command.current_dir(home); + } + + // SAFETY: `pre_exec` runs in the forked child before `exec`. `setsid` + // and `ioctl(TIOCSCTTY)` are async-signal-safe and only operate on the + // slave PTY passed below. Returning an error aborts the exec safely. + unsafe { + command.pre_exec(move || { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let child = command.spawn()?; + drop(slave); + + let master_fd = master.as_raw_fd(); + let std_master = std::fs::File::from(master); + let std_reader = std_master.try_clone()?; + + Ok(Self { + master_fd, + reader: tokio::fs::File::from_std(std_reader), + writer: tokio::fs::File::from_std(std_master), + child, + }) + } +} + +fn set_close_on_exec(fd: &std::os::fd::OwnedFd) -> std::io::Result<()> { + nix::fcntl::fcntl( + fd, + nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::FD_CLOEXEC), + ) + .map(|_| ()) + .map_err(nix_error) +} + +fn nix_error(error: nix::errno::Errno) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::Other, error.to_string()) +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .filter(|path| path.is_dir()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_client_commands() { + let input: ClientCommand = + serde_json::from_str(r#"{"type":"input","data":"ls\n"}"#).unwrap(); + assert!(matches!(input, ClientCommand::Input { data } if data == "ls\n")); + + let resize: ClientCommand = + serde_json::from_str(r#"{"type":"resize","cols":120,"rows":40}"#).unwrap(); + assert!(matches!( + resize, + ClientCommand::Resize { + cols: 120, + rows: 40 + } + )); + + assert!(serde_json::from_str::(r#"{"type":"ping"}"#).is_ok()); + assert!(serde_json::from_str::("not json").is_err()); + } + + #[tokio::test] + async fn pty_runs_a_shell_and_returns_output() { + let session = PtySession::spawn("/bin/sh", 80, 24).expect("spawn shell"); + let PtySession { + reader, + writer, + mut child, + .. + } = session; + let mut reader = reader; + let mut writer = writer; + + writer + .write_all(b"echo one-kvm-pty\n") + .await + .expect("write"); + + let mut collected = Vec::new(); + let mut buffer = [0u8; 4096]; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + + while tokio::time::Instant::now() < deadline { + let read = tokio::time::timeout( + std::time::Duration::from_millis(500), + reader.read(&mut buffer), + ) + .await; + + if let Ok(Ok(read)) = read { + collected.extend_from_slice(&buffer[..read]); + if String::from_utf8_lossy(&collected).contains("one-kvm-pty") { + break; + } + } + } + + assert!( + String::from_utf8_lossy(&collected).contains("one-kvm-pty"), + "shell output was: {:?}", + String::from_utf8_lossy(&collected) + ); + + let _ = child.kill().await; + let _ = child.wait().await; + } +} diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index bccbb3fa0..1eaee915c 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -52,6 +52,8 @@ pub struct WebRtcStreamerConfig { pub fps: u32, pub audio_enabled: bool, pub encoder_backend: Option, + /// Maximum height of the encoded/transmitted video. `0` = source size. + pub output_max_height: u32, } impl Default for WebRtcStreamerConfig { @@ -65,6 +67,7 @@ impl Default for WebRtcStreamerConfig { fps: 30, audio_enabled: false, encoder_backend: None, + output_max_height: 0, } } } @@ -511,6 +514,7 @@ impl WebRtcStreamer { bitrate_preset: config.bitrate_preset, fps: config.fps, encoder_backend: config.encoder_backend, + output_max_height: config.output_max_height, } }; @@ -1042,15 +1046,18 @@ impl WebRtcStreamer { // Ensure video pipeline is running let pipeline = self.ensure_video_pipeline().await?; - // Create session config + // Create session config from the running pipeline: it may have aligned + // itself with DV-negotiated capture geometry, and its encoder runs at + // the (possibly downscaled) transmission resolution. + let pipeline_config = pipeline.config().await; let config = self.config.read().await; let session_config = UniversalSessionConfig { webrtc: config.webrtc.clone(), codec: Self::codec_type_to_encoder_type(codec), - resolution: config.resolution, - input_format: config.input_format, - bitrate_preset: config.bitrate_preset, - fps: config.fps, + resolution: pipeline_config.output_resolution(), + input_format: pipeline_config.input_format, + bitrate_preset: pipeline_config.bitrate_preset, + fps: pipeline_config.fps, audio_enabled: *self.audio_enabled.read().await, video_sdp_fmtp_line: None, }; @@ -1322,6 +1329,85 @@ impl WebRtcStreamer { Ok(()) } + + /// Set the maximum height of the transmitted video. + /// + /// Capture geometry is untouched. If the effective transmission resolution + /// changes while a pipeline is running, the pipeline is restarted and the + /// sessions are reattached so the encoder immediately emits the new size. + pub async fn set_output_max_height(&self, max_height: u32) -> Result<()> { + let resolution_changed = { + let mut config = self.config.write().await; + if config.output_max_height == max_height { + return Ok(()); + } + + let previous = crate::video::format::scale_to_max_height( + config.resolution, + config.output_max_height, + ); + config.output_max_height = max_height; + let next = crate::video::format::scale_to_max_height(config.resolution, max_height); + previous != next + }; + + if !resolution_changed { + debug!( + "Output max height set to {} (effective resolution unchanged)", + max_height + ); + return Ok(()); + } + + let (pipeline_running, external_subscribers) = { + if let Some(ref pipeline) = *self.video_pipeline.read().await { + (pipeline.is_running(), pipeline.subscriber_count()) + } else { + (false, 0) + } + }; + + if pipeline_running { + info!( + "Restarting video pipeline to apply output max height {}", + max_height + ); + + self.stop_video_pipeline_and_release().await?; + + let has_source = self.capture_device.read().await.is_some(); + if !has_source { + return Ok(()); + } + + let reconnected = self + .reconnect_sessions_to_current_pipeline("output resolution change") + .await?; + if reconnected > 0 { + info!( + "Video pipeline restarted with output max height {}, reconnected {} sessions", + max_height, reconnected + ); + } else if external_subscribers > 0 { + // RTSP/RustDesk/VNC can keep the shared pipeline alive without + // a WebRTC session; restart it so they keep receiving frames. + if let Err(error) = self.ensure_video_pipeline().await { + warn!( + "Failed to restart video pipeline for external consumers: {}", + error + ); + } + } + } else { + debug!( + "Pipeline not running, output max height {} will apply on next start", + max_height + ); + } + + self.notify_device_info_dirty().await; + Ok(()) + } } #[async_trait::async_trait] @@ -1396,6 +1482,10 @@ impl crate::video::traits::VideoOutput for WebRtcStreamer { self.set_bitrate_preset(preset).await } + async fn set_output_max_height(&self, max_height: u32) -> Result<()> { + self.set_output_max_height(max_height).await + } + async fn request_keyframe(&self) -> Result<()> { self.request_keyframe().await } diff --git a/web/package-lock.json b/web/package-lock.json index 3a172b050..7c01047aa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,8 @@ "version": "0.2.9", "dependencies": { "@vueuse/core": "^14.3.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.556.0", @@ -1709,6 +1711,21 @@ "simple-yenc": "^1.0.4" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/alien-signals": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", diff --git a/web/package.json b/web/package.json index 8bf957428..c41b63bdd 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@vueuse/core": "^14.3.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.556.0", diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 9f496c7af..ad99ef4f0 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -38,6 +38,8 @@ import type { WebConfigUpdate, WatchdogConfigResponse, WatchdogConfigUpdate, + TerminalConfigResponse, + TerminalConfigUpdate, } from '@/types/generated' import { request } from './request' @@ -56,6 +58,16 @@ export const watchdogConfigApi = { }, { toastOnError: false }), } +export const terminalConfigApi = { + get: () => request('/config/terminal', {}, { toastOnError: false }), + + update: (config: TerminalConfigUpdate) => + request('/config/terminal', { + method: 'PATCH', + body: JSON.stringify(config), + }), +} + export const authConfigApi = { get: () => request('/config/auth'), @@ -385,6 +397,7 @@ export const vncConfigApi = { export type WebConfig = WebConfigResponse export type { WebConfigUpdate } +export type { TerminalConfigResponse, TerminalConfigUpdate } export const webConfigApi = { get: () => request('/config/web'), diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 87858ee25..bdd996a4b 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -187,7 +187,7 @@ export const systemApi = { msd_enabled?: boolean encoder_backend?: string audio_device?: string - ttyd_enabled?: boolean + terminal_enabled?: boolean rustdesk_enabled?: boolean }) => request<{ success: boolean; message?: string }>('/setup/init', { @@ -312,18 +312,28 @@ export interface VideoEncoderSelfCheckResponse { rows: VideoEncoderSelfCheckRow[] } +export interface StreamStatusResponse { + state: StreamState + reason?: StreamSignalReason | null + device: string | null + format: string | null + resolution: [number, number] | null + /** Resolution actually transmitted (after optional downscaling). */ + output_resolution: [number, number] | null + clients: number + target_fps: number + fps: number + /** Effective transmission height limit (`0` = capture resolution). */ + output_max_height: number + /** Persisted default configured in Settings. */ + output_max_height_default: number + /** Whether the console set a temporary override for this runtime. */ + output_max_height_is_override: boolean +} + export const streamApi = { status: () => - request<{ - state: StreamState - reason?: StreamSignalReason | null - device: string | null - format: string | null - resolution: [number, number] | null - clients: number - target_fps: number - fps: number - }>('/stream/status'), + request('/stream/status'), start: () => request<{ success: boolean }>('/stream/start', { method: 'POST' }), @@ -361,6 +371,20 @@ export const streamApi = { method: 'POST', body: JSON.stringify({ bitrate_preset }), }), + + /** + * Set a temporary transmission height for this runtime. + * `null` clears the override and falls back to the Settings default. + */ + setOutputMaxHeight: (max_height: number | null) => + request( + '/stream/output-resolution', + { + method: 'POST', + body: JSON.stringify({ max_height }), + }, + { toastOnError: false }, + ), } export interface IceCandidate { @@ -760,7 +784,7 @@ export interface DeviceList { }> udc: Array<{ name: string }> extensions: { - ttyd_available: boolean + terminal_available: boolean rustdesk_available: boolean } } @@ -831,6 +855,7 @@ export { vncConfigApi, webConfigApi, watchdogConfigApi, + terminalConfigApi, type RustDeskConfigResponse, type RustDeskStatusResponse, type RustDeskConfigUpdate, @@ -846,6 +871,8 @@ export { type VncStatusResponse, type WebConfig, type WebConfigUpdate, + type TerminalConfigResponse, + type TerminalConfigUpdate, } from './config' export type { diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index ed3977876..10427171c 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -63,7 +63,7 @@ const props = defineProps<{ mouseMode?: 'absolute' | 'relative' videoMode?: VideoMode videoRotation?: VideoRotation - ttydRunning?: boolean + terminalRunning?: boolean showPower?: boolean atxEnabled?: boolean showTerminal?: boolean @@ -566,7 +566,7 @@ const hasRightOverflow = computed(() => { variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" - :disabled="!props.ttydRunning" + :disabled="!props.terminalRunning" :aria-label="t('actionbar.webTerminal')" :aria-pressed="!!props.terminalOpen" @click="emit('openTerminal')" @@ -631,7 +631,7 @@ const hasRightOverflow = computed(() => { diff --git a/web/src/components/ConsoleStatusSummary.vue b/web/src/components/ConsoleStatusSummary.vue index d3f57a8ba..81248655d 100644 --- a/web/src/components/ConsoleStatusSummary.vue +++ b/web/src/components/ConsoleStatusSummary.vue @@ -32,7 +32,7 @@ const hasError = computed(() => issues.value.some(item => item.status === 'error function dotClass(status: ConsoleStatusItem['status']) { return { - connected: 'bg-status-active', + connected: 'bg-status-active console-dot-live', connecting: 'bg-warning animate-pulse', disconnected: 'bg-muted-foreground', error: 'bg-destructive', @@ -55,7 +55,7 @@ function dotClass(status: ConsoleStatusItem['status']) { > - + {{ summary }} diff --git a/web/src/components/EmbeddedTerminal.vue b/web/src/components/EmbeddedTerminal.vue new file mode 100644 index 000000000..703e51f06 --- /dev/null +++ b/web/src/components/EmbeddedTerminal.vue @@ -0,0 +1,268 @@ + + + diff --git a/web/src/components/InfoBar.vue b/web/src/components/InfoBar.vue index 0ff560314..31dc9042f 100644 --- a/web/src/components/InfoBar.vue +++ b/web/src/components/InfoBar.vue @@ -1,5 +1,6 @@ + +