From 583e2c84f97305f6eb76ecf0d3c88f48e72f0ec3 Mon Sep 17 00:00:00 2001 From: mrcong Date: Fri, 30 Jan 2026 10:44:42 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/BUILD_INSTRUCTIONS.md | 56 +++ .github/workflows/build-release.yml | 148 ++++++++ .github/workflows/pr-check.yml | 61 ++++ One-KVM_Architecture_Design.md | 517 ++++++++++++++++++++++++++++ 4 files changed, 782 insertions(+) create mode 100644 .github/BUILD_INSTRUCTIONS.md create mode 100644 .github/workflows/build-release.yml create mode 100644 .github/workflows/pr-check.yml create mode 100644 One-KVM_Architecture_Design.md 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..49c1e5222 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,148 @@ +name: Build and Release + +on: + push: + branches: [ main, master ] + tags: [ 'v*' ] # 当创建标签时触发构建 + pull_request: + branches: [ main, master ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + 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: | + cross build --target ${{ matrix.target }} --release + # Also build static version for better portability + cross build --target ${{ matrix.target }} --profile release-static + + - 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/* + + # Create version file + echo $(date +%Y%m%d)-$(git rev-parse --short HEAD) > dist/BUILD_INFO + echo "Target: ${{ matrix.target }}" >> dist/BUILD_INFO + echo "Version: $(git describe --tags --always --dirty)" >> dist/BUILD_INFO + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: one-kvm-${{ matrix.target }} + path: dist/ + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + 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: | + mkdir -p release_assets + for artifact in artifacts/*/; do + target_name=$(basename "$artifact") + echo "Processing $target_name" + + # Create archive for each target + cd "$artifact" + tar -czf "../release_assets/one-kvm-$target_name.tar.gz" . + cd ../.. + done + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: Release ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + draft: false + prerelease: false + files: release_assets/* + body: | + ## One-KVM Release ${{ github.ref_name }} + + This release includes pre-built binaries 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) + + ### 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 + + ### 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 \ No newline at end of file diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 000000000..6c8c5358e --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,61 @@ +name: PR Check + +on: + 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/One-KVM_Architecture_Design.md b/One-KVM_Architecture_Design.md new file mode 100644 index 000000000..a6e7a8412 --- /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 终端(ttyd) +- 内网穿透支持(gostc) +- P2P 组网支持(EasyTier) +- RustDesk 协议集成(跨平台远程访问) +- Ventoy 虚拟U盘模式 \ No newline at end of file From 926e0e0b301ad7152ea394c0a5f504134de6ae62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=B1=E5=85=88=E7=94=9F?= Date: Thu, 5 Nov 2026 20:50:16 +0800 Subject: [PATCH 02/16] =?UTF-8?q?fix(build):=20=E4=BF=AE=E5=A4=8D=20ARM=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=20MPP=20=E5=A4=B1=E8=B4=A5=E5=B9=B6=E5=B0=86?= =?UTF-8?q?=20glibc=20=E5=9F=BA=E7=BA=BF=E9=99=8D=E8=87=B3=202.31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-release 工作流为 aarch64/armv7 启用 rkmpp feature,避免静态 FFmpeg 的 MPP 符号未解析 - hwcodec 在 ARM 使用自定义静态 FFmpeg 时始终链接 rockchip_mpp/rga - 三个交叉编译镜像由 Debian 12 (glibc 2.36) 切换到 Debian 11 (glibc 2.31) - 同步国内镜像源替换逻辑、deb 依赖下限及相关注释 --- .github/workflows/build-release.yml | 12 +++++++++--- Cross.toml | 4 +++- build/Dockerfile.runtime | 3 ++- build/Dockerfile.runtime-full | 3 ++- build/build-images.sh | 2 +- build/cross/Dockerfile.arm64 | 25 +++++++++++++++++-------- build/cross/Dockerfile.armv7 | 25 +++++++++++++++++-------- build/cross/Dockerfile.x86_64 | 21 +++++++++++++++------ build/package-deb.sh | 6 +++--- libs/hwcodec/build.rs | 9 ++++++++- 10 files changed, 77 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 49c1e5222..734415ee1 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -59,9 +59,15 @@ jobs: - name: Build with cross run: | - cross build --target ${{ matrix.target }} --release - # Also build static version for better portability - cross build --target ${{ matrix.target }} --profile release-static + # 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: | 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/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..02e4151df 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}" 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..9277722df 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 @@ -20,10 +21,18 @@ ARG RKRGA_BRANCH=jellyfin-rga ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b # Optionally use China mirrors for builds in China. +# Debian 11 uses /etc/apt/sources.list while Debian 12 uses the deb822 file, +# so rewrite whichever one the base image ships. 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; \ + for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ + if [ -f "$f" ]; then \ + sed -i -E \ + -e 's@http://security.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ + -e 's@http://deb.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ + -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ + "$f"; \ + fi; \ + done; \ fi # Install Rust toolchain @@ -84,8 +93,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..611eed4c0 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 @@ -20,10 +21,18 @@ ARG RKRGA_BRANCH=jellyfin-rga ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b # Optionally use China mirrors for builds in China. +# Debian 11 uses /etc/apt/sources.list while Debian 12 uses the deb822 file, +# so rewrite whichever one the base image ships. 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; \ + for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ + if [ -f "$f" ]; then \ + sed -i -E \ + -e 's@http://security.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ + -e 's@http://deb.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ + -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ + "$f"; \ + fi; \ + done; \ fi # Install Rust toolchain @@ -84,8 +93,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..7819994ed 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 @@ -18,10 +19,18 @@ ARG OPUS_VERSION=1.5.2 ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b # Optionally use China mirrors for builds in China. +# Debian 11 uses /etc/apt/sources.list while Debian 12 uses the deb822 file, +# so rewrite whichever one the base image ships. 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; \ + for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ + if [ -f "$f" ]; then \ + sed -i -E \ + -e 's@http://security.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ + -e 's@http://deb.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ + -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ + "$f"; \ + fi; \ + done; \ fi # Install Rust toolchain 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"); From a73c96e154d13a2caa9301225c5f881904d7a11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=B1=E5=85=88=E7=94=9F?= Date: Thu, 5 Nov 2026 21:02:17 +0800 Subject: [PATCH 03/16] =?UTF-8?q?fix(build):=20Debian=2011=20=E4=BA=A4?= =?UTF-8?q?=E5=8F=89=E9=95=9C=E5=83=8F=E6=94=B9=E7=94=A8=20archive.debian.?= =?UTF-8?q?org=20=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bullseye 已 EOL,deb.debian.org 仍提供索引但 security 池中的 .deb 已移除, 镜像构建时 apt-get install 返回 404。改用 archive.debian.org 并关闭 Valid-Until 检查;CHINAMIRRO 模式使用南京大学 debian-archive 镜像。 --- build/build-images.sh | 2 +- build/cross/Dockerfile.arm64 | 29 +++++++++++++++-------------- build/cross/Dockerfile.armv7 | 29 +++++++++++++++-------------- build/cross/Dockerfile.x86_64 | 29 +++++++++++++++-------------- 4 files changed, 46 insertions(+), 43 deletions(-) diff --git a/build/build-images.sh b/build/build-images.sh index 02e4151df..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:11}" + 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 9277722df..dbd30d081 100644 --- a/build/cross/Dockerfile.arm64 +++ b/build/cross/Dockerfile.arm64 @@ -20,20 +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. -# Debian 11 uses /etc/apt/sources.list while Debian 12 uses the deb822 file, -# so rewrite whichever one the base image ships. -RUN if [ "$CHINAMIRRO" = "1" ]; then \ - for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ - if [ -f "$f" ]; then \ - sed -i -E \ - -e 's@http://security.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ - -e 's@http://deb.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ - -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ - "$f"; \ - fi; \ - done; \ - 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/cross/Dockerfile.armv7 b/build/cross/Dockerfile.armv7 index 611eed4c0..8bf87251d 100644 --- a/build/cross/Dockerfile.armv7 +++ b/build/cross/Dockerfile.armv7 @@ -20,20 +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. -# Debian 11 uses /etc/apt/sources.list while Debian 12 uses the deb822 file, -# so rewrite whichever one the base image ships. -RUN if [ "$CHINAMIRRO" = "1" ]; then \ - for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ - if [ -f "$f" ]; then \ - sed -i -E \ - -e 's@http://security.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ - -e 's@http://deb.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ - -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ - "$f"; \ - fi; \ - done; \ - 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/cross/Dockerfile.x86_64 b/build/cross/Dockerfile.x86_64 index 7819994ed..3dea1fceb 100644 --- a/build/cross/Dockerfile.x86_64 +++ b/build/cross/Dockerfile.x86_64 @@ -18,20 +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. -# Debian 11 uses /etc/apt/sources.list while Debian 12 uses the deb822 file, -# so rewrite whichever one the base image ships. -RUN if [ "$CHINAMIRRO" = "1" ]; then \ - for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \ - if [ -f "$f" ]; then \ - sed -i -E \ - -e 's@http://security.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ - -e 's@http://deb.debian.org/debian-security@http://mirrors.tuna.tsinghua.edu.cn/debian-security@g' \ - -e 's@http://deb.debian.org/debian([[:space:]]|$)@http://mirrors.tuna.tsinghua.edu.cn/debian\1@g' \ - "$f"; \ - fi; \ - done; \ - 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 \ From 7838cba7d942db93dd3affad8fa500ca05b5968e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=B1=E5=85=88=E7=94=9F?= Date: Thu, 5 Nov 2026 21:57:36 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=A5=E5=BF=97=E9=9D=A2=E6=9D=BF=E4=B8=8E=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=97=A5=E5=BF=97=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置页新增“运行日志”,通过 /api/ws/logs 实时推送最近 1000 行日志 - 新增 /api/logs/report,将问题描述与日志附件提交到反馈接口 - tracing 增加内存环形缓冲与广播层,供 WebSocket 和报告复用 - 移除 MSD 大文件 DVD 支持提示及 >2.2GB 标签 --- src/lib.rs | 2 + src/logging.rs | 233 +++++++++++++++++++ src/main.rs | 7 + src/web/handlers/logs.rs | 191 +++++++++++++++ src/web/handlers/mod.rs | 2 + src/web/logs_ws.rs | 98 ++++++++ src/web/mod.rs | 2 + src/web/routes.rs | 5 + web/src/components/MsdDialog.vue | 20 -- web/src/components/RuntimeLogsPanel.vue | 297 ++++++++++++++++++++++++ web/src/i18n/en-US.ts | 30 ++- web/src/i18n/zh-CN.ts | 30 ++- web/src/views/SettingsView.vue | 10 + 13 files changed, 903 insertions(+), 24 deletions(-) create mode 100644 src/logging.rs create mode 100644 src/web/handlers/logs.rs create mode 100644 src/web/logs_ws.rs create mode 100644 web/src/components/RuntimeLogsPanel.vue 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/web/handlers/logs.rs b/src/web/handlers/logs.rs new file mode 100644 index 000000000..700805cff --- /dev/null +++ b/src/web/handlers/logs.rs @@ -0,0 +1,191 @@ +//! Runtime log reporting to the project feedback endpoint. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use axum::{http::header::CONTENT_TYPE, Json}; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use crate::error::{AppError, Result}; +use crate::logging; + +/// Feedback endpoint that accepts a problem description plus a log attachment. +pub const REPORT_ENDPOINT: &str = "http://api.mrcong.cn/report.php?app=one-kvm"; +const REPORT_DESCRIPTION_FIELD: &str = "description"; +const REPORT_LOG_FIELD: &str = "log"; +const REPORT_LOG_FILENAME: &str = "one-kvm.log"; +const REPORT_LOG_LINES: usize = 1000; +const MAX_DESCRIPTION_CHARS: usize = 2000; +const MIN_REPORT_INTERVAL: Duration = Duration::from_secs(10); +const REPORT_TIMEOUT: Duration = Duration::from_secs(30); + +static LAST_REPORT: Mutex> = 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/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..38fbb87e1 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,6 +1,7 @@ mod audio_ws; mod error; mod handlers; +mod logs_ws; pub mod openwrt_system; mod routes; pub(crate) mod state; @@ -11,6 +12,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..5e3df55eb 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)) diff --git a/web/src/components/MsdDialog.vue b/web/src/components/MsdDialog.vue index 4aea68b4f..ad29bb0de 100644 --- a/web/src/components/MsdDialog.vue +++ b/web/src/components/MsdDialog.vue @@ -47,7 +47,6 @@ import { ArrowLeft, Globe, X, - AlertCircle, Info, } from 'lucide-vue-next' @@ -160,7 +159,6 @@ const downloadProgress = ref<{ } | null>(null) const downloadFailureNotifiedId = ref(null) -const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024 const tabTriggerClass = 'h-8 rounded-md border-0 bg-transparent text-center text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-0 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm' const segmentedGroupClass = 'grid w-full grid-cols-2 items-center gap-1 rounded-md border border-border bg-muted p-0.5' const segmentedItemClass = 'h-8 w-full justify-center rounded-md border-0 bg-transparent px-3 text-center text-xs text-muted-foreground shadow-none hover:bg-transparent hover:text-foreground data-[state=on]:border-0 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm data-[state=on]:hover:bg-background' @@ -198,10 +196,6 @@ const operationInProgress = computed(() => { const usbReenumerating = computed(() => systemStore.msd?.usbReenumerating || modeChanging.value) -function isLargeFile(image: MsdImage): boolean { - return image.size > TWO_POINT_TWO_GB -} - function isIsoImage(image: MsdImage): boolean { return image.name.toLowerCase().endsWith('.iso') } @@ -885,20 +879,6 @@ onUnmounted(() => {
{{ formatBytes(image.size) }} - - - - - {{ t('msd.largeFileWarning') }} - - - -

{{ t('msd.largeFileTooltip') }}

-
-
diff --git a/web/src/components/RuntimeLogsPanel.vue b/web/src/components/RuntimeLogsPanel.vue new file mode 100644 index 000000000..4058a803f --- /dev/null +++ b/web/src/components/RuntimeLogsPanel.vue @@ -0,0 +1,297 @@ + + +