Compare commits

...

12 Commits

Author SHA1 Message Date
mofeng-git
8eac31f69f chore: bump version to v0.1.9
Made-with: Cursor
2026-04-12 20:43:44 +08:00
mofeng-git
9653e16a68 feat: CLI 改密、自定义 TLS、移动端适配与扩展校验
- 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话
- /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert
- 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化
- GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致
- RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节
- StatusCard、InfoBar:合并精简冗余状态信息
2026-04-12 19:28:17 +08:00
mofeng-git
d0c0852fbb fix: 修复设置页 IPv6 URL 拼接问题 #241
统一处理 Settings 页面中的主机地址格式,避免 IPv6 场景下 RTSP 地址展示和重启后跳转 URL 因缺少方括号而失效。

Made-with: Cursor
2026-04-12 09:49:42 +08:00
mofeng-git
c0a0c90cbd fix: 修复 ATX 串口继电器共享设备失效 #233
当 power/reset 配置为同一串口设备时,改为复用同一个串口句柄,避免重复 open 导致 reset 不生效。

Made-with: Cursor
2026-04-11 22:31:17 +08:00
mofeng-git
9e3483b836 style: 统一代码格式
应用 fmt 对已暂存 Rust 文件进行格式化,保持代码风格一致并减少无意义差异。

Made-with: Cursor
2026-04-11 22:25:46 +08:00
mofeng-git
132f445c29 完善音频采集 2026-04-11 22:22:05 +08:00
mofeng-git
4952cbaf19 fix: 修复 RTSP 功能对 VLC 视频播放器的兼容性 #237 2026-04-11 21:55:34 +08:00
mofeng-git
099f0b1ca2 fix: 优化视频切换流畅性;修复 OTG HID 功能无法一次保存成功和页面未即刻生效问题 2026-04-11 21:20:54 +08:00
mofeng-git
eecbc0fc13 CSI 采集适配优化 2026-04-11 20:26:33 +08:00
mofeng-git
2d81a071e5 fix: 修复 MJPEG模式可用但状态显示离线的问题 2026-04-11 15:11:47 +08:00
mofeng-git
3e35181583 fix: 修复镜像列表滚动条问题 #238 2026-04-11 13:13:24 +08:00
mofeng-git
c3a3f41a2c 修改 .gitignore 2026-04-11 13:12:11 +08:00
58 changed files with 3462 additions and 1271 deletions

2
.gitignore vendored
View File

@@ -30,6 +30,7 @@ Thumbs.db
# Build artifacts
/dist/
/build-staging
# Frontend (built files)
/web/node_modules/
@@ -41,3 +42,4 @@ CLAUDE.md
secrets.toml
.env
/docs/
web/package-lock.json

View File

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

235
README.en.md Normal file
View File

@@ -0,0 +1,235 @@
<div align="center">
<h1>One-KVM</h1>
<p><strong>An open, lightweight IP-KVM stack in Rust — remote management down to BIOS level</strong></p>
<p><a href="README.md">简体中文</a> · <a href="README.en.md">English</a></p>
[![GitHub Release](https://img.shields.io/github/v/release/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/releases)
[![GitHub stars](https://img.shields.io/github/stars/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/network/members)
[![GitHub issues](https://img.shields.io/github/issues/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/issues)
</div>
---
## Overview
**One-KVM (Rust)** is a lightweight IP-KVM solution written in Rust. It lets you manage servers and workstations over the network, including at BIOS level.
Goals: an open, lightweight, easy-to-use IP-KVM stack.
- **Open**: not tied to one hardware recipe; runs across many setups.
- **Lightweight**: shipped as a binary with minimal moving parts for deployment.
- **Easy to use**: no hand-edited config files required; settings are done in the web UI.
> **One-KVM (Python)** is no longer maintained. If you still need it, see <https://github.com/mofeng-git/One-KVM/tree/python>.
<div align="center">
![One-KVM web console](https://one-kvm.cn/hero-app-effect.png)
</div>
## Features
### Core
| Area | Capabilities |
|------|----------------|
| Video capture | HDMI USB / MIPI CSI / RK3588 HDMI IN; MJPEG and WebRTC (H.264 / H.265 / VP8 / VP9) |
| Video encoding | VAAPI / QSV / RKMPP / V4L2 M2M hardware paths, with software fallback |
| Keyboard & mouse | USB OTG HID or CH340 + CH9329 HID; absolute / relative mouse |
| Virtual media | USB mass storage; ISO/IMG mount and Ventoy-style virtual USB |
| ATX power | GPIO or USB relay; power and reset control |
| Audio | ALSA capture + Opus (HTTP / WebRTC) |
The web UI supports visual configuration and Chinese/English locales. Built-ins include a web terminal (ttyd), intranet tunnel (gostc), P2P (EasyTier), RustDesk protocol (optional cross-platform remote access), and RTSP streaming.
## Installation
Release artifacts are on [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases). Below are short paths for common setups. For **system requirements, hardware, Docker env vars, USB OTG**, and full troubleshooting, see the [One-KVM documentation](https://docs.one-kvm.cn/) (Chinese; use a translator if needed).
### Debian / Ubuntu (.deb)
Download a `one-kvm_*.deb` matching your CPU architecture from Releases, then from the directory containing the package:
```bash
sudo apt update
sudo apt install ./one-kvm_0.x.x_<arch>.deb
```
Replace the version and architecture in the filename with your actual file name.
### Docker
Images:
- **one-kvm** — main app + ttyd
- **one-kvm-full** — same plus optional extras (e.g. gostc, easytier-core)
Example:
```bash
docker run --name one-kvm -itd \
--privileged=true --restart unless-stopped \
-v /dev:/dev -v /sys:/sys \
--net=host \
silentwind0/one-kvm-full
```
If pulls are slow, use the Aliyun mirror, e.g. `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm-full` (and `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm` for the slim image).
### fnOS NAS (Feiniu / 飞牛)
One-KVM is listed in the fnOS **app store**; search and install on your NAS.
### Web UI and first run
Open `http://<device-ip>:8080` in a browser (**8420** after fnOS install). The first visit runs initial setup.
## Reporting issues
If something breaks:
1. Open [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) or report in the project QQ group.
2. Include **useful** error messages and steps to reproduce.
3. Mention software version, hardware, and OS details.
## Sponsorship
One-KVM builds on many great open-source projects; a lot of time goes into testing and maintenance. If you find it useful, you can support development on **[Afdian (为爱发电)](https://afdian.com/a/silentwind)**.
### Thanks
<details>
<summary><strong>Supporter list</strong></summary>
- 浩龙的电子嵌入式之路
- Tsuki
- H_xiaoming
- 0蓝蓝0
- fairybl
- Will
- 自.知
- 观棋不语٩ ི۶
- 爱发电用户_a57a4
- 爱发电用户_2c769
- 霜序
- 远方(闲鱼用户名:小远技术店铺)
- 爱发电用户_399fc
- 斐斐の
- 爱发电用户_09451
- 超高校级的錆鱼
- 爱发电用户_08cff
- guoke
- mgt
- 姜沢掵
- ui_beam
- 爱发电用户_c0dd7
- 爱发电用户_dnjK
- 忍者胖猪
- 永遠の願い
- 爱发电用户_GBrF
- 爱发电用户_fd65c
- 爱发电用户_vhNa
- 爱发电用户_Xu6S
- moss
- woshididi
- 爱发电用户_a0fd1
- 爱发电用户_f6bH
- 码农
- 爱发电用户_6639f
- jeron
- 爱发电用户_CN7y
- 爱发电用户_Up6w
- 爱发电用户_e3202
- 一语念白
- 云边
- 爱发电用户_5a711
- 爱发电用户_9a706
- T0m9ir1SUKI
- 爱发电用户_56d52
- 爱发电用户_3N6F
- DUSK
- 飘零
- .
- 饭太稀
-
- MaxZ
- 爱发电用户_c5f33
- 爱发电用户_09386
- 爱发电用户_JT6c
- 爱发电用户_d3d9c
- ......
</details>
### Sponsors
**File hosting**
- **[Huang1111 public-interest program](https://pan.huang1111.cn/s/mxkx3T1)** — login-free downloads
**Cloud**
- **[林枫云](https://www.dkdun.cn)** — project server sponsorship
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.

View File

@@ -2,8 +2,9 @@
<h1>One-KVM</h1>
<p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p>
<p><a href="README.md">简体中文</a></p>
<p><a href="README.md">简体中文</a> · <a href="README.en.md">English</a></p>
[![GitHub Release](https://img.shields.io/github/v/release/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/releases)
[![GitHub stars](https://img.shields.io/github/stars/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/network/members)
[![GitHub issues](https://img.shields.io/github/issues/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/issues)
@@ -15,57 +16,78 @@
**One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。
项目目标:
项目目标:提供一个开放、轻量、易用的 IPKVM 解决方案。
- **开放**:不绑定特定硬件配置,尽量适配常见 Linux 设备
- **轻量**二进制分发,部署过程简单
- **易用**网页界面完成设备与参数配置,无需手动配置文件
- **开放**:不绑定特定硬件配置,可在各类硬件环境中稳定运行。
- **轻量**二进制文件形式分发,无繁杂的依赖项,部署过程简单
- **易用**:无需手动编辑配置文件,参数设置均可通过网页界面完成。
> **注意:** One-KVM Rust 目前仍处于开发早期阶段,功能与细节会快速迭代,欢迎体验与反馈
> **One-KVM Python** 已停止开发,如有需要可访问 <https://github.com/mofeng-git/One-KVM/tree/python>
## 🔁 迁移说明
<div align="center">
开发重心正在从 **One-KVM Python** 逐步转向 **One-KVM Rust**
![One-KVM Web 控制台界面](https://one-kvm.cn/hero-app-effect.png)
- 如果你在使用 **One-KVM Python基于 PiKVM**,请查看 [One-KVM Python 文档](https://docs.one-kvm.cn/python/)
- One-KVM Rust 相较于 One-KVM Python**尚未完全适配 CSI HDMI 采集卡**、**不支持 VNC 访问**,仍处于开发早期阶段
</div>
## 📊 功能介绍
### 核心功能
| 功能 | 说明 |
| 功能 | 能力说明 |
|------|------|
| 视频采集 | HDMI USB 采集支持,提供 MJPEG / WebRTCH.264/H.265/VP8/VP9 |
| 视频采集 | HDMI USB /MIPI CSI/RK3588 HDMI IN 采集支持,提供 MJPEG / WebRTCH.264/H.265/VP8/VP9 视频流|
| 视频编码 | VAAPI/QSV/RKMPP/V4L2M2M 硬件编码支持,以及软件编码兜底 |
| 键鼠控制 | USB OTG HID 或 CH340 + CH9329 HID支持绝对/相对鼠标模式 |
| 虚拟媒体 | USB Mass Storage支持 ISO/IMG 镜像挂载和 Ventoy 虚拟U盘模式 |
| ATX 电源控制 | GPIO 控制电源/重启按钮 |
| ATX 电源控制 | GPIO /USB 继电器,支持控制电源重启按钮 |
| 音频传输 | ALSA 采集 + Opus 编码HTTP/WebRTC |
### 硬件编码
支持自动检测和选择硬件加速:
- **VAAPI**Intel/AMD GPU
- **RKMPP**Rockchip SoC
- **V4L2 M2M**:通用硬件编码器
- **软件编码**CPU 编码
### 扩展能力
- Web UI 配置,多语言支持(中文/英文)
- 内置 Web 终端ttyd、内网穿透支持gostc、P2P 组网支持EasyTier、RustDesk 协议集成(用于跨平台远程访问能力扩展)和 RTSP 视频流(用于视频推流)
此外提供基于 Web UI 的可视化配置与中英文界面;并集成 Web 终端ttyd、内网穿透gostc、P2P 组网EasyTier、RustDesk 协议(扩展跨平台远程访问)以及 RTSP 推流等能力。
## ⚡ 安装使用
可以访问 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/) 获取详细信息
构建产物见 [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases)。以下为常见安装方式的简要步骤;**系统要求、硬件准备、Docker 环境变量与 USB OTG 等完整说明**请查阅 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/)。
### 使用 deb 安装Debian / Ubuntu
从 Releases 下载与本机架构匹配的 `one-kvm_*.deb`,在包所在目录执行:
```bash
sudo apt update
sudo apt install ./one-kvm_0.x.x_<arch>.deb
```
将文件名中的版本号与架构替换为实际下载的包名。
### 使用 Docker
镜像分为 **one-kvm**One-KVM 主程序 + ttyd**one-kvm-full**(另含 gostc、easytier-core 等可选扩展),按需选用。
```bash
docker run --name one-kvm -itd \
--privileged=true --restart unless-stopped \
-v /dev:/dev -v /sys:/sys \
--net=host \
silentwind0/one-kvm-full
```
拉取较慢时,可将镜像名替换为阿里云加速,例如 `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm-full``one-kvm` 镜像同理,将 `silentwind0/one-kvm` 换为 `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm`)。
### 飞牛 NAS
One-KVM 已上架飞牛 **应用市场**,在 NAS 上直接搜索安装即可。
### 访问 Web 与首次配置
浏览器访问 `http://<设备 IP>:8080`(飞牛 NAS 安装后为 8420 端口)。首次访问将引导完成初始配置。
## 报告问题
如果您发现了问题,请:
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告,或加入 QQ 群聊反馈。
2. 提供详细的错误信息和复现步骤
3. 包含您硬件配置和系统信息
2. 提供有帮助的错误信息和复现步骤
3. 包含您使用的软件版本、硬件配置和系统信息
## 赞助支持
@@ -88,8 +110,6 @@
- Will
- 浩龙的电子嵌入式之路
- 自.知
- 观棋不语٩ ི۶
@@ -188,8 +208,6 @@
- 爱发电用户_JT6c
- MaxZ
- 爱发电用户_d3d9c
- ......
@@ -205,7 +223,7 @@
**云服务商**
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目宁波大带宽服务器
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目服务器
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)

View File

@@ -243,8 +243,7 @@ fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
}
codecs.retain(|codec| {
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P as i32
&& codec.name.contains("qsv"))
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P as i32 && codec.name.contains("qsv"))
});
codecs
}

View File

@@ -3,9 +3,7 @@
#![allow(non_snake_case)]
use crate::common::DataFormat::{self, *};
use crate::ffmpeg::{
AVHWDeviceType::{self, *},
};
use crate::ffmpeg::AVHWDeviceType::{self, *};
use serde_derive::{Deserialize, Serialize};
use std::ffi::c_int;

View File

@@ -364,8 +364,7 @@ pub fn split_uv_plane(
let dst_u_required = (dst_stride_u as usize).saturating_mul(height);
let dst_v_required = (dst_stride_v as usize).saturating_mul(height);
if src_uv.len() < src_required || dst_u.len() < dst_u_required || dst_v.len() < dst_v_required
{
if src_uv.len() < src_required || dst_u.len() < dst_u_required || dst_v.len() < dst_v_required {
return Err(YuvError::BufferTooSmall);
}

View File

@@ -43,32 +43,93 @@ pub struct AtxController {
}
impl AtxController {
async fn init_components(inner: &mut AtxInner) {
// Initialize power executor
if inner.config.power.is_configured() {
let mut executor = AtxKeyExecutor::new(inner.config.power.clone());
if let Err(e) = executor.init().await {
warn!("Failed to initialize power executor: {}", e);
} else {
info!(
"Power executor initialized: {:?} on {} pin {}",
inner.config.power.driver, inner.config.power.device, inner.config.power.pin
);
inner.power_executor = Some(executor);
}
}
fn should_share_serial_device(power: &AtxKeyConfig, reset: &AtxKeyConfig) -> bool {
power.is_configured()
&& reset.is_configured()
&& power.driver == super::types::AtxDriverType::Serial
&& reset.driver == super::types::AtxDriverType::Serial
&& !power.device.is_empty()
&& power.device == reset.device
&& power.baud_rate == reset.baud_rate
}
// Initialize reset executor
if inner.config.reset.is_configured() {
let mut executor = AtxKeyExecutor::new(inner.config.reset.clone());
if let Err(e) = executor.init().await {
warn!("Failed to initialize reset executor: {}", e);
} else {
info!(
"Reset executor initialized: {:?} on {} pin {}",
inner.config.reset.driver, inner.config.reset.device, inner.config.reset.pin
);
inner.reset_executor = Some(executor);
async fn init_components(inner: &mut AtxInner) {
if Self::should_share_serial_device(&inner.config.power, &inner.config.reset) {
match AtxKeyExecutor::open_shared_serial(
&inner.config.power.device,
inner.config.power.baud_rate,
) {
Ok(shared_serial) => {
let mut power_executor = AtxKeyExecutor::new_with_shared_serial(
inner.config.power.clone(),
shared_serial.clone(),
);
if let Err(e) = power_executor.init().await {
warn!("Failed to initialize power executor: {}", e);
} else {
info!(
"Power executor initialized: {:?} on {} pin {}",
inner.config.power.driver,
inner.config.power.device,
inner.config.power.pin
);
inner.power_executor = Some(power_executor);
}
let mut reset_executor = AtxKeyExecutor::new_with_shared_serial(
inner.config.reset.clone(),
shared_serial,
);
if let Err(e) = reset_executor.init().await {
warn!("Failed to initialize reset executor: {}", e);
} else {
info!(
"Reset executor initialized: {:?} on {} pin {}",
inner.config.reset.driver,
inner.config.reset.device,
inner.config.reset.pin
);
inner.reset_executor = Some(reset_executor);
}
}
Err(e) => {
warn!(
"Failed to open shared serial device {} for ATX power/reset: {}",
inner.config.power.device, e
);
}
}
} else {
// Initialize power executor
if inner.config.power.is_configured() {
let mut executor = AtxKeyExecutor::new(inner.config.power.clone());
if let Err(e) = executor.init().await {
warn!("Failed to initialize power executor: {}", e);
} else {
info!(
"Power executor initialized: {:?} on {} pin {}",
inner.config.power.driver,
inner.config.power.device,
inner.config.power.pin
);
inner.power_executor = Some(executor);
}
}
// Initialize reset executor
if inner.config.reset.is_configured() {
let mut executor = AtxKeyExecutor::new(inner.config.reset.clone());
if let Err(e) = executor.init().await {
warn!("Failed to initialize reset executor: {}", e);
} else {
info!(
"Reset executor initialized: {:?} on {} pin {}",
inner.config.reset.driver,
inner.config.reset.device,
inner.config.reset.pin
);
inner.reset_executor = Some(executor);
}
}
}
@@ -262,3 +323,49 @@ impl AtxController {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atx::AtxDriverType;
#[test]
fn test_should_share_serial_device_true() {
let power = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 9600,
};
let reset = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 2,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 9600,
};
assert!(AtxController::should_share_serial_device(&power, &reset));
}
#[test]
fn test_should_share_serial_device_false_on_different_baud() {
let power = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 9600,
};
let reset = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 2,
active_level: super::super::types::ActiveLevel::High,
baud_rate: 115200,
};
assert!(!AtxController::should_share_serial_device(&power, &reset));
}
}

View File

@@ -8,7 +8,7 @@ use serialport::SerialPort;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, info};
@@ -16,6 +16,8 @@ use tracing::{debug, info};
use super::types::{ActiveLevel, AtxDriverType, AtxKeyConfig};
use crate::error::{AppError, Result};
pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>;
/// Timing constants for ATX operations
pub mod timing {
use std::time::Duration;
@@ -39,8 +41,8 @@ pub struct AtxKeyExecutor {
gpio_handle: Mutex<Option<LineHandle>>,
/// Cached USB relay file handle to avoid repeated open/close syscalls
usb_relay_handle: Mutex<Option<File>>,
/// Cached Serial port handle
serial_handle: Mutex<Option<Box<dyn SerialPort>>>,
/// Cached Serial port handle (can be shared across power/reset executors)
serial_handle: Mutex<Option<SharedSerialHandle>>,
initialized: AtomicBool,
}
@@ -56,6 +58,26 @@ impl AtxKeyExecutor {
}
}
/// Create a new executor with a pre-opened shared serial handle.
pub fn new_with_shared_serial(config: AtxKeyConfig, serial_handle: SharedSerialHandle) -> Self {
Self {
config,
gpio_handle: Mutex::new(None),
usb_relay_handle: Mutex::new(None),
serial_handle: Mutex::new(Some(serial_handle)),
initialized: AtomicBool::new(false),
}
}
/// Open a serial relay device and wrap it for shared use.
pub fn open_shared_serial(device: &str, baud_rate: u32) -> Result<SharedSerialHandle> {
let port = serialport::new(device, baud_rate)
.timeout(Duration::from_millis(100))
.open()
.map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?;
Ok(Arc::new(Mutex::new(port)))
}
/// Check if this executor is configured
pub fn is_configured(&self) -> bool {
self.config.is_configured()
@@ -181,14 +203,11 @@ impl AtxKeyExecutor {
self.config.device, self.config.pin
);
let baud_rate = self.config.baud_rate;
let port = serialport::new(&self.config.device, baud_rate)
.timeout(Duration::from_millis(100))
.open()
.map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?;
*self.serial_handle.lock().unwrap() = Some(port);
let existing_handle = self.serial_handle.lock().unwrap().as_ref().cloned();
if existing_handle.is_none() {
let shared = Self::open_shared_serial(&self.config.device, self.config.baud_rate)?;
*self.serial_handle.lock().unwrap() = Some(shared);
}
// Ensure relay is off initially
self.send_serial_relay_command(false)?;
@@ -337,10 +356,14 @@ impl AtxKeyExecutor {
// OFF: A0 01 00 A1
let cmd = [0xA0, channel, state, checksum];
let mut guard = self.serial_handle.lock().unwrap();
let port = guard
.as_mut()
let serial_handle = self
.serial_handle
.lock()
.unwrap()
.as_ref()
.cloned()
.ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?;
let mut port = serial_handle.lock().unwrap();
port.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?;

View File

@@ -97,12 +97,14 @@ pub struct AudioFrame {
}
impl AudioFrame {
pub fn new(data: Bytes, config: &AudioConfig, sequence: u64) -> Self {
/// One capture block: `sample_rate` must be the **hardware** rate (e.g. ALSA `actual_rate`).
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
let bps = 2 * channels;
Self {
samples: data.len() as u32 / config.bytes_per_sample(),
samples: data.len() as u32 / bps,
data,
sample_rate: config.sample_rate,
channels: config.channels,
sample_rate,
channels,
sequence,
timestamp: Instant::now(),
}
@@ -285,10 +287,17 @@ fn run_capture(
.map(|h| h.get_rate().unwrap_or(config.sample_rate))
.unwrap_or(config.sample_rate);
info!(
"Audio capture configured: {}Hz {}ch (requested {}Hz)",
actual_rate, config.channels, config.sample_rate
);
if actual_rate != config.sample_rate {
info!(
"ALSA sample rate differs from requested ({}Hz vs {}Hz); streamer will resample to 48000Hz for Opus",
actual_rate, config.sample_rate
);
} else {
info!(
"Audio capture configured: {}Hz {}ch (requested {}Hz)",
actual_rate, config.channels, config.sample_rate
);
}
// Prepare for capture
pcm.prepare()
@@ -296,9 +305,17 @@ fn run_capture(
let _ = state.send(CaptureState::Running);
// Allocate buffer - use u8 directly for zero-copy
let frame_bytes = config.bytes_per_frame();
let mut buffer = vec![0u8; frame_bytes];
// Sized from actual period — `readi` may return up to ~one period of frames per call.
let period_frames = pcm
.hw_params_current()
.ok()
.and_then(|h| h.get_period_size().ok())
.map(|f| f as usize)
.unwrap_or(1024)
.max(256);
let buf_frames = period_frames.saturating_mul(4).max(2048);
let bytes_per_frame = (config.channels as usize) * 2;
let mut buffer = vec![0u8; buf_frames * bytes_per_frame];
// Capture loop
while !stop_flag.load(Ordering::Relaxed) {
@@ -337,8 +354,12 @@ fn run_capture(
// Directly use the buffer slice (already in correct byte format)
let seq = sequence.fetch_add(1, Ordering::Relaxed);
let frame =
AudioFrame::new(Bytes::copy_from_slice(&buffer[..byte_count]), config, seq);
let frame = AudioFrame::new_interleaved(
Bytes::copy_from_slice(&buffer[..byte_count]),
config.channels,
actual_rate,
seq,
);
// Send to subscribers
if frame_tx.receiver_count() > 0 {

View File

@@ -381,7 +381,7 @@ impl AudioController {
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
let was_streaming = self.is_streaming().await;
// Stop streaming if running
// Stop streaming if running (device/quality/enabled may all change)
if was_streaming {
self.stop_streaming().await?;
}
@@ -389,8 +389,10 @@ impl AudioController {
// Update config
*self.config.write().await = new_config.clone();
// Restart streaming if it was running and still enabled
if was_streaming && new_config.enabled {
// Start whenever audio is enabled — not only when we were already streaming.
// Otherwise PATCH /config/audio alone leaves enabled=true with no capture until
// POST /audio/start, which races WebRTC reconnect and matches "apply twice" reports.
if new_config.enabled {
self.start_streaming().await?;
}

View File

@@ -13,6 +13,7 @@ pub mod controller;
pub mod device;
pub mod encoder;
pub mod monitor;
pub mod resample;
pub mod streamer;
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};

202
src/audio/resample.rs Normal file
View File

@@ -0,0 +1,202 @@
//! Resample capture PCM to 48 kHz stereo for Opus (fixed 20 ms / 960×2 samples).
const OUT_RATE: f64 = 48000.0;
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
enum PipelineState {
/// Native 48 kHz interleaved stereo: only buffer and slice into 20 ms blocks (no float work).
Stereo48kPassthrough,
/// Other rates / mono: linear interpolation to 48 kHz stereo.
Resample {
in_rate: u32,
in_channels: u32,
next_out_frame: u64,
buffer_start_frame: u64,
},
}
/// Converts incoming interleaved PCM to 48 kHz stereo, then exposes fixed 960×2-sample chunks.
pub struct Opus48kPcmBuffer {
state: PipelineState,
pending: Vec<i16>,
}
impl Opus48kPcmBuffer {
pub fn new(in_rate: u32, in_channels: u32) -> Self {
let ch = in_channels.max(1);
let rate = in_rate.max(1);
let state = if rate == 48000 && ch == 2 {
PipelineState::Stereo48kPassthrough
} else {
PipelineState::Resample {
in_rate: rate,
in_channels: ch,
next_out_frame: 0,
buffer_start_frame: 0,
}
};
Self {
state,
pending: Vec::new(),
}
}
/// True when input is already 48 kHz stereo (no interpolation loop).
#[cfg(test)]
pub fn is_passthrough(&self) -> bool {
matches!(self.state, PipelineState::Stereo48kPassthrough)
}
/// Append one capture block (`sample_rate` must match the rate this buffer was built for).
pub fn push_interleaved(&mut self, data: &[i16]) {
self.pending.extend_from_slice(data);
}
/// Drain as many 960×2 stereo S16LE samples (20 ms @ 48 kHz) as possible.
pub fn pop_opus_frames(&mut self, out: &mut Vec<i16>) {
match &mut self.state {
PipelineState::Stereo48kPassthrough => {
while self.pending.len() >= OPUS_STEREO_SAMPLES {
out.extend_from_slice(&self.pending[..OPUS_STEREO_SAMPLES]);
self.pending.drain(..OPUS_STEREO_SAMPLES);
}
}
PipelineState::Resample {
in_rate,
in_channels,
next_out_frame,
buffer_start_frame,
} => {
let ch = *in_channels as usize;
if ch == 0 {
return;
}
loop {
let batch_start = *next_out_frame;
let mut block = Vec::with_capacity(OPUS_STEREO_SAMPLES);
let mut complete = true;
for i in 0u64..960 {
let k = batch_start + i;
let p_abs = (k as f64) * (*in_rate as f64) / OUT_RATE;
let f_abs = p_abs.floor() as u64;
let frac = p_abs - f_abs as f64;
let f_rel = f_abs.saturating_sub(*buffer_start_frame) as usize;
if f_rel + 1 >= self.pending.len() / ch {
complete = false;
break;
}
let base0 = f_rel * ch;
let base1 = (f_rel + 1) * ch;
let (l, r) = if *in_channels >= 2 {
let l0 = self.pending[base0] as f64;
let l1 = self.pending[base1] as f64;
let r0 = self.pending[base0 + 1] as f64;
let r1 = self.pending[base1 + 1] as f64;
(l0 + frac * (l1 - l0), r0 + frac * (r1 - r0))
} else {
let m0 = self.pending[base0] as f64;
let m1 = self.pending[base1] as f64;
let v = m0 + frac * (m1 - m0);
(v, v)
};
block.push(clamp_f64_to_i16(l));
block.push(clamp_f64_to_i16(r));
}
if !complete || block.len() != OPUS_STEREO_SAMPLES {
break;
}
out.extend_from_slice(&block);
*next_out_frame = batch_start + 960;
trim_resample_prefix(
&mut self.pending,
*in_rate,
*next_out_frame,
buffer_start_frame,
ch,
);
}
}
}
}
}
fn trim_resample_prefix(
pending: &mut Vec<i16>,
in_rate: u32,
next_out_frame: u64,
buffer_start_frame: &mut u64,
ch: usize,
) {
if pending.is_empty() {
return;
}
let p_next = (next_out_frame as f64) * (in_rate as f64) / OUT_RATE;
let need_abs = p_next.floor() as u64;
let keep_from_abs = need_abs.saturating_sub(1);
if keep_from_abs <= *buffer_start_frame {
return;
}
let drop_frames = (keep_from_abs - *buffer_start_frame) as usize;
let drop_samples = drop_frames.saturating_mul(ch).min(pending.len());
if drop_samples > 0 {
pending.drain(0..drop_samples);
*buffer_start_frame += drop_frames as u64;
}
}
#[inline]
fn clamp_f64_to_i16(v: f64) -> i16 {
v.round().clamp(i16::MIN as f64, i16::MAX as f64) as i16
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passthrough_48k_identity_tone_length() {
let mut buf = Opus48kPcmBuffer::new(48000, 2);
assert!(buf.is_passthrough());
let mut chunk = vec![0i16; 960 * 2];
for i in 0..960 {
let s = (i as f32 * 0.1).sin() * 3000.0;
chunk[2 * i] = s as i16;
chunk[2 * i + 1] = s as i16;
}
buf.push_interleaved(&chunk);
let mut out = Vec::new();
buf.pop_opus_frames(&mut out);
assert_eq!(out.len(), 960 * 2);
}
#[test]
fn upsample_44k_to_48k_chunk() {
let mut buf = Opus48kPcmBuffer::new(44100, 2);
assert!(!buf.is_passthrough());
let mut chunk = vec![0i16; 882 * 2];
for i in 0..882 {
chunk[2 * i] = (i as i16).wrapping_mul(10);
chunk[2 * i + 1] = (i as i16).wrapping_mul(-7);
}
buf.push_interleaved(&chunk);
let mut out = Vec::new();
buf.pop_opus_frames(&mut out);
assert_eq!(out.len(), 960 * 2, "expected one 20ms Opus block");
}
#[test]
fn mono_48k_not_passthrough() {
let buf = Opus48kPcmBuffer::new(48000, 1);
assert!(!buf.is_passthrough());
}
}

View File

@@ -9,9 +9,12 @@ use std::time::Instant;
use tokio::sync::{broadcast, watch, Mutex, RwLock};
use tracing::{error, info, warn};
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
use super::resample::Opus48kPcmBuffer;
use crate::error::{AppError, Result};
use bytemuck;
use bytes::Bytes;
/// Audio stream state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -254,6 +257,9 @@ impl AudioStreamer {
info!("Audio stream task started");
let mut to_48k: Option<Opus48kPcmBuffer> = None;
let mut queued_48k: Vec<i16> = Vec::new();
loop {
// Check stop flag (atomic, no async lock needed)
if stop_flag.load(Ordering::Relaxed) {
@@ -273,27 +279,56 @@ impl AudioStreamer {
match recv_result {
Ok(Ok(audio_frame)) => {
// Encode to Opus
let opus_result = {
let mut enc_guard = encoder.lock().await;
(*enc_guard)
.as_mut()
.map(|enc| enc.encode_frame(&audio_frame))
if to_48k.is_none() {
to_48k = Some(Opus48kPcmBuffer::new(
audio_frame.sample_rate,
audio_frame.channels,
));
}
let pipeline = match to_48k.as_mut() {
Some(p) => p,
None => continue,
};
match opus_result {
Some(Ok(opus_frame)) => {
// Publish latest frame to subscribers
if opus_tx.receiver_count() > 0 {
let _ = opus_tx.send(Some(Arc::new(opus_frame)));
let samples: &[i16] = match bytemuck::try_cast_slice(&audio_frame.data) {
Ok(s) => s,
Err(_) => {
warn!("Audio frame size not multiple of 2; skipping");
continue;
}
};
if !samples.is_empty() {
pipeline.push_interleaved(samples);
}
pipeline.pop_opus_frames(&mut queued_48k);
while queued_48k.len() >= 960 * 2 {
let pcm_20ms =
Bytes::copy_from_slice(bytemuck::cast_slice(&queued_48k[..960 * 2]));
queued_48k.drain(..960 * 2);
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48000, 0);
let opus_result = {
let mut enc_guard = encoder.lock().await;
(*enc_guard)
.as_mut()
.map(|enc| enc.encode_frame(&frame_48k))
};
match opus_result {
Some(Ok(opus_frame)) => {
if opus_tx.receiver_count() > 0 {
let _ = opus_tx.send(Some(Arc::new(opus_frame)));
}
}
Some(Err(e)) => {
error!("Opus encode error: {}", e);
}
None => {
warn!("Encoder not available");
break;
}
}
Some(Err(e)) => {
error!("Opus encode error: {}", e);
}
None => {
warn!("Encoder not available");
break;
}
}
}

View File

@@ -126,6 +126,15 @@ impl SessionStore {
Ok(result.rows_affected())
}
/// Delete all sessions for a specific user
pub async fn delete_by_user_id(&self, user_id: &str) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?1")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
/// List all session IDs
pub async fn list_ids(&self) -> Result<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")

View File

@@ -703,7 +703,9 @@ impl StreamConfig {
}
}
/// Web server configuration
/// Web server configuration persisted in the database (includes on-disk TLS paths).
///
/// The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]

View File

@@ -4,6 +4,7 @@ use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio::sync::Mutex;
use super::AppConfig;
use crate::error::{AppError, Result};
@@ -18,6 +19,8 @@ pub struct ConfigStore {
/// Lock-free cache using ArcSwap for zero-cost reads
cache: Arc<ArcSwap<AppConfig>>,
change_tx: broadcast::Sender<ConfigChange>,
/// Serializes `set` / `update` so concurrent PATCH handlers cannot clobber each other
write_lock: Arc<Mutex<()>>,
}
/// Configuration change event
@@ -59,6 +62,7 @@ impl ConfigStore {
pool,
cache,
change_tx,
write_lock: Arc::new(Mutex::new(())),
})
}
@@ -191,6 +195,7 @@ impl ConfigStore {
/// Set entire configuration
pub async fn set(&self, config: AppConfig) -> Result<()> {
let _guard = self.write_lock.lock().await;
Self::save_config_to_db(&self.pool, &config).await?;
self.cache.store(Arc::new(config));
@@ -204,13 +209,13 @@ impl ConfigStore {
/// Update configuration with a closure
///
/// Note: This uses a read-modify-write pattern. For concurrent updates,
/// the last write wins. This is acceptable for configuration changes
/// which are infrequent and typically user-initiated.
/// Uses read-modify-write under a mutex so concurrent `update` / `set` calls are serialized
/// and merged correctly (each closure sees the latest stored config).
pub async fn update<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut AppConfig),
{
let _guard = self.write_lock.lock().await;
// Load current config, clone it for modification
let current = self.cache.load();
let mut config = (**current).clone();

View File

@@ -280,6 +280,9 @@ impl ExtensionManager {
ExtensionId::Gostc => {
let c = &config.gostc;
if c.addr.trim().is_empty() {
return Err("GOSTC server address is required".into());
}
if c.key.is_empty() {
return Err("GOSTC client key is required".into());
}
@@ -291,10 +294,8 @@ impl ExtensionManager {
args.push("--tls=true".to_string());
}
// Add server address
if !c.addr.is_empty() {
args.extend(["-addr".to_string(), c.addr.clone()]);
}
// Server address (validated non-empty above)
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
// Add client key
args.extend(["-key".to_string(), c.key.clone()]);
@@ -375,7 +376,11 @@ impl ExtensionManager {
.filter_map(|id| {
let should_run = match id {
ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => config.gostc.enabled && !config.gostc.key.is_empty(),
ExtensionId::Gostc => {
config.gostc.enabled
&& !config.gostc.key.is_empty()
&& !config.gostc.addr.trim().is_empty()
}
ExtensionId::Easytier => {
config.easytier.enabled && !config.easytier.network_name.is_empty()
}
@@ -435,6 +440,7 @@ impl ExtensionManager {
if config.gostc.enabled
&& !config.gostc.key.is_empty()
&& !config.gostc.addr.trim().is_empty()
&& self.check_available(ExtensionId::Gostc)
{
start_futures.push(Box::pin(async {

View File

@@ -121,7 +121,7 @@ impl Default for TtydConfig {
pub struct GostcConfig {
/// Enable auto-start
pub enabled: bool,
/// Server address (e.g., gostc.mofeng.run)
/// Server address (hostname or IP)
pub addr: String,
/// Client key from GOSTC management panel
#[serde(skip_serializing_if = "String::is_empty")]
@@ -134,7 +134,7 @@ impl Default for GostcConfig {
fn default() -> Self {
Self {
enabled: false,
addr: "gostc.mofeng.run".to_string(),
addr: String::new(),
key: String::new(),
tls: true,
}

View File

@@ -1,10 +1,11 @@
use std::collections::HashSet;
use std::io::Write;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use axum_server::tls_rustls::RustlsConfig;
use clap::{Parser, ValueEnum};
use clap::{Args, Parser, Subcommand, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::{broadcast, mpsc};
@@ -49,6 +50,10 @@ enum LogLevel {
#[command(name = "one-kvm")]
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
struct CliArgs {
/// User management commands
#[command(subcommand)]
command: Option<CliCommand>,
/// Listen address (overrides database config)
#[arg(short = 'a', long, value_name = "ADDRESS")]
address: Option<String>,
@@ -86,6 +91,24 @@ struct CliArgs {
verbose: u8,
}
#[derive(Subcommand, Debug)]
enum CliCommand {
/// Manage local users
User(UserCommand),
}
#[derive(Args, Debug)]
struct UserCommand {
#[command(subcommand)]
action: UserAction,
}
#[derive(Subcommand, Debug)]
enum UserAction {
/// Set password for the single local user (interactive terminal prompt)
SetPassword,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Parse command line arguments
@@ -101,9 +124,15 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
// Determine data directory (CLI arg takes precedence)
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
let data_dir = args.data_dir.clone().unwrap_or_else(get_data_dir);
tracing::info!("Data directory: {}", data_dir.display());
// Run one-off CLI command and exit.
if let Some(command) = args.command {
run_cli_command(command, data_dir).await?;
return Ok(());
}
// Ensure data directory exists
tokio::fs::create_dir_all(&data_dir).await?;
@@ -765,6 +794,81 @@ fn get_data_dir() -> PathBuf {
PathBuf::from("/etc/one-kvm")
}
async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> {
tokio::fs::create_dir_all(&data_dir).await?;
let db_path = data_dir.join("one-kvm.db");
let config_store = ConfigStore::new(&db_path).await?;
let users = UserStore::new(config_store.pool().clone());
let sessions = SessionStore::new(config_store.pool().clone(), 0);
match command {
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
}
}
async fn run_user_action(
action: UserAction,
users: &UserStore,
sessions: &SessionStore,
) -> anyhow::Result<()> {
match action {
UserAction::SetPassword => set_user_password(users, sessions).await,
}
}
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
let all = users.list().await?;
let user = match all.len() {
0 => anyhow::bail!("No local user exists yet; complete setup in the web UI first."),
1 => &all[0],
_ => anyhow::bail!(
"Expected exactly one local user (single-user design), found {}. Remove extra users from the database or contact support.",
all.len()
),
};
let new_password = read_new_password_interactive()?;
if new_password.len() < 4 {
anyhow::bail!("Password must be at least 4 characters");
}
users.update_password(&user.id, &new_password).await?;
let revoked = sessions.delete_by_user_id(&user.id).await?;
tracing::info!(
"Password updated for user '{}' and {} sessions revoked",
user.username,
revoked
);
println!(
"Password updated for user '{}' (revoked {} sessions).",
user.username, revoked
);
Ok(())
}
fn read_new_password_interactive() -> anyhow::Result<String> {
let once = |label: &str| -> anyhow::Result<String> {
print!("{}", label);
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let s = line.trim_end_matches(['\r', '\n']).to_string();
if s.is_empty() {
anyhow::bail!("Password cannot be empty");
}
Ok(s)
};
let a = once("New password: ")?;
let b = once("Confirm password: ")?;
if a != b {
anyhow::bail!("Passwords do not match");
}
Ok(a)
}
/// Resolve bind IPs from config, preferring bind_addresses when set.
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
let raw_addrs = if !web.bind_addresses.is_empty() {

View File

@@ -498,6 +498,9 @@ async fn stream_video_interleaved(
let mut h265_payloader = H265Payloader::new();
let mut ctrl_read_buf = [0u8; RTSP_BUF_SIZE];
let mut ctrl_buffer = Vec::with_capacity(RTSP_BUF_SIZE);
// RTP timestamps must increase; pts_ms is often 0 for many frames (capture→encode jitter),
// which yields a flat RTP timestamp and breaks VLC/ffplay.
let mut last_rtp_timestamp: u32 = 0;
loop {
tokio::select! {
@@ -529,7 +532,11 @@ async fn stream_video_interleaved(
update_parameter_sets(&mut params, &frame);
}
let rtp_timestamp = pts_to_rtp_timestamp(frame.pts_ms);
let rtp_timestamp = monotonic_rtp_timestamp(
frame.pts_ms,
&mut last_rtp_timestamp,
frame.duration,
);
let payloads: Vec<Bytes> = match rtsp_codec {
RtspCodec::H264 => h264_payloader
@@ -1128,6 +1135,25 @@ fn pts_to_rtp_timestamp(pts_ms: i64) -> u32 {
((pts_ms as u64 * RTP_CLOCK_RATE as u64) / 1000) as u32
}
/// 90 kHz ticks per frame from nominal duration (at least 1).
fn rtp_timestamp_increment(frame_duration: Duration) -> u32 {
let inc = (frame_duration.as_secs_f64() * f64::from(RTP_CLOCK_RATE)).round() as u32;
inc.max(1)
}
/// Prefer PTS-based RTP time when it advances; otherwise step by `frame_duration` in 90 kHz units.
fn monotonic_rtp_timestamp(pts_ms: i64, last: &mut u32, frame_duration: Duration) -> u32 {
let from_pts = pts_to_rtp_timestamp(pts_ms);
let inc = rtp_timestamp_increment(frame_duration);
let ts = if from_pts > *last {
from_pts
} else {
last.wrapping_add(inc)
};
*last = ts;
ts
}
fn generate_session_id() -> String {
let mut rng = rand::rng();
let value: u64 = rng.random();
@@ -1199,6 +1225,28 @@ mod tests {
assert_eq!(response.status(), rtsp::StatusCode::MethodNotAllowed);
}
#[test]
fn monotonic_rtp_timestamp_steps_when_pts_stays_zero() {
let d = Duration::from_millis(33);
let mut last = 0u32;
let a = monotonic_rtp_timestamp(0, &mut last, d);
let b = monotonic_rtp_timestamp(0, &mut last, d);
let c = monotonic_rtp_timestamp(0, &mut last, d);
assert!(a > 0);
assert!(b > a);
assert!(c > b);
}
#[test]
fn monotonic_rtp_timestamp_uses_pts_when_it_advances() {
let d = Duration::from_millis(33);
let mut last = 0u32;
let a = monotonic_rtp_timestamp(1000, &mut last, d);
assert_eq!(a, 90_000);
let b = monotonic_rtp_timestamp(2000, &mut last, d);
assert_eq!(b, 180_000);
}
#[test]
fn build_sdp_h264_is_parseable_with_expected_video_attributes() {
let config = RtspConfig::default();

View File

@@ -3,20 +3,64 @@
//! Manages video frame distribution and per-client statistics.
use arc_swap::ArcSwap;
use bytes::Bytes;
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::Arc;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use crate::video::encoder::traits::{Encoder, EncoderConfig};
use crate::video::encoder::JpegEncoder;
use crate::video::format::PixelFormat;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::VideoFrame;
/// Cached "no signal" placeholder JPEG (640×360 dark-gray image).
/// Generated once on first use and reused for all NoSignal frames.
static NO_SIGNAL_JPEG: OnceLock<Bytes> = OnceLock::new();
/// Generate a minimal "no signal" JPEG (640×360, dark gray background).
/// Uses turbojpeg directly to produce a valid JPEG without additional deps.
fn generate_no_signal_jpeg() -> Bytes {
const W: usize = 640;
const H: usize = 360;
let y_size = W * H;
let uv_size = y_size / 4;
let mut i420 = vec![0u8; y_size + uv_size * 2];
// Y = 32 (dark gray, above the 16 black floor so it is clearly visible)
i420[..y_size].fill(32);
// U and V = 128 (neutral chroma → no colour tint)
i420[y_size..].fill(128);
match turbojpeg::Compressor::new() {
Ok(mut compressor) => {
let _ = compressor.set_quality(70);
let yuv = turbojpeg::YuvImage {
pixels: i420.as_slice(),
width: W,
height: H,
align: 1,
subsamp: turbojpeg::Subsamp::Sub2x2,
};
match compressor.compress_yuv_to_vec(yuv) {
Ok(jpeg) => Bytes::from(jpeg),
Err(_) => Bytes::new(),
}
}
Err(_) => Bytes::new(),
}
}
/// Return a reference to the cached no-signal JPEG bytes.
fn no_signal_jpeg() -> &'static Bytes {
NO_SIGNAL_JPEG.get_or_init(generate_no_signal_jpeg)
}
/// Client ID type (UUID string)
pub type ClientId = String;
@@ -354,6 +398,34 @@ impl MjpegStreamHandler {
let _ = self.frame_notify.send(());
}
/// Push a "no signal" placeholder JPEG to all connected MJPEG clients.
///
/// Unlike `set_offline()`, this keeps the stream marked as **online** so
/// that HTTP clients remain connected and see the placeholder image instead
/// of a black/empty screen. Call this whenever the capture thread enters
/// the `NoSignal` state.
pub fn push_no_signal_placeholder(&self) {
let jpeg = no_signal_jpeg();
if jpeg.is_empty() {
return;
}
let frame = VideoFrame::new(
jpeg.clone(),
Resolution::new(640, 360),
PixelFormat::Mjpeg,
0,
self.sequence.fetch_add(1, Ordering::Relaxed),
);
// Store as current frame so late-joining clients get it immediately.
self.current_frame.store(Arc::new(Some(frame)));
// Ensure stream is marked online so the HTTP handler keeps iterating.
self.online.store(true, Ordering::SeqCst);
// Wake up waiting HTTP clients.
let _ = self.frame_notify.send(());
}
/// Set stream online (called when streaming starts)
pub fn set_online(&self) {
self.online.store(true, Ordering::SeqCst);

View File

@@ -563,6 +563,16 @@ impl VideoDevice {
Some((bt.width, bt.height, dv_timings_fps(&bt)))
}
/// Query current DV timings resolution for runtime change detection.
///
/// Returns the active resolution reported by DV timings (used by CSI/HDMI bridges
/// such as TC358743, rk_hdmirx, etc.). Returns `None` when the device does not
/// support DV timings or no signal is detected.
pub fn query_dv_timings_resolution(&self) -> Option<Resolution> {
let (w, h, _fps) = self.current_dv_timings_mode()?;
Some(Resolution::new(w, h))
}
fn current_format_resolution(&self) -> Option<(u32, u32)> {
let format = self.get_format().ok()?;
if format.width == 0 || format.height == 0 {

View File

@@ -318,18 +318,26 @@ impl H265Encoder {
)
} else {
match config.input_format {
H265InputFormat::Nv12 => {
("nv12", AVPixelFormat::AV_PIX_FMT_NV12, H265InputFormat::Nv12)
}
H265InputFormat::Nv21 => {
("nv21", AVPixelFormat::AV_PIX_FMT_NV21, H265InputFormat::Nv21)
}
H265InputFormat::Nv16 => {
("nv16", AVPixelFormat::AV_PIX_FMT_NV16, H265InputFormat::Nv16)
}
H265InputFormat::Nv24 => {
("nv24", AVPixelFormat::AV_PIX_FMT_NV24, H265InputFormat::Nv24)
}
H265InputFormat::Nv12 => (
"nv12",
AVPixelFormat::AV_PIX_FMT_NV12,
H265InputFormat::Nv12,
),
H265InputFormat::Nv21 => (
"nv21",
AVPixelFormat::AV_PIX_FMT_NV21,
H265InputFormat::Nv21,
),
H265InputFormat::Nv16 => (
"nv16",
AVPixelFormat::AV_PIX_FMT_NV16,
H265InputFormat::Nv16,
),
H265InputFormat::Nv24 => (
"nv24",
AVPixelFormat::AV_PIX_FMT_NV24,
H265InputFormat::Nv24,
),
H265InputFormat::Yuv420p => (
"yuv420p",
AVPixelFormat::AV_PIX_FMT_YUV420P,
@@ -340,12 +348,16 @@ impl H265Encoder {
AVPixelFormat::AV_PIX_FMT_YUYV422,
H265InputFormat::Yuyv422,
),
H265InputFormat::Rgb24 => {
("rgb24", AVPixelFormat::AV_PIX_FMT_RGB24, H265InputFormat::Rgb24)
}
H265InputFormat::Bgr24 => {
("bgr24", AVPixelFormat::AV_PIX_FMT_BGR24, H265InputFormat::Bgr24)
}
H265InputFormat::Rgb24 => (
"rgb24",
AVPixelFormat::AV_PIX_FMT_RGB24,
H265InputFormat::Rgb24,
),
H265InputFormat::Bgr24 => (
"bgr24",
AVPixelFormat::AV_PIX_FMT_BGR24,
H265InputFormat::Bgr24,
),
}
};
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);

View File

@@ -31,8 +31,12 @@ use self::encoder_state::{build_encoder_state, EncoderThreadState};
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
/// Restart capture stream after this many consecutive timeouts.
/// After this many consecutive timeouts, log a prominent warning.
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
/// After this many consecutive timeouts, actually stop the pipeline.
/// Setting this high (60 × 2 s poll = ~120 s) keeps WebRTC sessions alive
/// while the source is temporarily unavailable (e.g. resolution change/reboot).
const CAPTURE_TIMEOUT_STOP_THRESHOLD: u32 = 60;
/// Minimum valid frame size for capture
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
/// Validate every JPEG frame during startup to avoid poisoning HW decoders
@@ -576,9 +580,16 @@ impl SharedVideoPipeline {
consecutive_timeouts = consecutive_timeouts.saturating_add(1);
warn!("Capture timeout - no signal?");
if consecutive_timeouts >= CAPTURE_TIMEOUT_RESTART_THRESHOLD {
if consecutive_timeouts == CAPTURE_TIMEOUT_RESTART_THRESHOLD {
warn!(
"Capture timed out {} consecutive times, restarting video pipeline",
"Capture timed out {} consecutive times no signal?",
consecutive_timeouts
);
}
if consecutive_timeouts >= CAPTURE_TIMEOUT_STOP_THRESHOLD {
warn!(
"Capture timed out {} consecutive times, stopping video pipeline",
consecutive_timeouts
);
let _ = pipeline.running.send(false);
@@ -833,7 +844,7 @@ impl SharedVideoPipeline {
}
}
/// Stop the pipeline
/// Stop the pipeline (non-blocking, does not wait for capture thread to exit)
pub fn stop(&self) {
if *self.running_rx.borrow() {
let _ = self.running.send(false);
@@ -843,6 +854,39 @@ impl SharedVideoPipeline {
}
}
/// Stop the pipeline and wait for the capture thread to fully exit.
///
/// This ensures the V4L2 device is released before returning, which is
/// necessary when another consumer (e.g. MJPEG streamer) needs to open
/// the same device immediately after.
pub async fn stop_and_wait(&self, timeout: std::time::Duration) {
self.stop();
let mut rx = self.running_watch();
if !*rx.borrow() {
// Capture thread may still be running from a previous `stop()` call.
// Wait for the "Video pipeline stopped" log (thread sets running=false
// at exit), unless it already happened.
}
let deadline = tokio::time::Instant::now() + timeout;
loop {
if !self.running_flag.load(Ordering::Acquire) {
// Flag is cleared, but the capture thread may still be unwinding
// (dropping the V4L2 stream). Give it a brief moment.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
break;
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
warn!(
"Timed out waiting for video pipeline to stop after {:?}",
timeout
);
break;
}
let _ = tokio::time::timeout(remaining, rx.changed()).await;
}
}
/// Set bitrate using preset
pub async fn set_bitrate_preset(
&self,

View File

@@ -404,8 +404,11 @@ impl VideoStreamManager {
}
}
StreamMode::WebRTC => {
info!("Closing all WebRTC sessions");
let closed = self.webrtc_streamer.close_all_sessions().await;
info!("Closing all WebRTC sessions and releasing capture device");
let closed = self
.webrtc_streamer
.close_all_sessions_and_release_device()
.await;
if closed > 0 {
info!("Closed {} WebRTC sessions", closed);
}
@@ -781,6 +784,61 @@ impl VideoStreamManager {
self.webrtc_streamer.request_keyframe().await
}
/// Notify frontend about a codec-only switch (WebRTC mode unchanged, codec changed).
///
/// `set_video_codec` already rebuilt the pipeline synchronously, so we just
/// emit the events the frontend waits on: `StreamModeChanged`, `WebRTCReady`,
/// and `StreamModeReady`.
///
/// Events are spawned asynchronously so the HTTP response (carrying the
/// `transition_id`) reaches the client before the WebSocket events, giving
/// the frontend time to call `registerTransition()` first.
pub async fn notify_codec_switch(
self: &Arc<Self>,
transition_id: &str,
new_codec_str: &str,
previous_codec_str: &str,
) {
let manager = Arc::clone(self);
let transition_id = transition_id.to_string();
let new_codec = new_codec_str.to_string();
let prev_codec = previous_codec_str.to_string();
tokio::spawn(async move {
// Small yield to ensure the HTTP response is flushed first.
tokio::task::yield_now().await;
manager
.publish_event(SystemEvent::StreamModeChanged {
transition_id: Some(transition_id.clone()),
mode: new_codec.clone(),
previous_mode: prev_codec.clone(),
})
.await;
let is_hardware = manager.webrtc_streamer.is_hardware_encoding().await;
manager
.publish_event(SystemEvent::WebRTCReady {
transition_id: Some(transition_id.clone()),
codec: new_codec.clone(),
hardware: is_hardware,
})
.await;
manager
.publish_event(SystemEvent::StreamModeReady {
transition_id: transition_id.clone(),
mode: new_codec.clone(),
})
.await;
info!(
"Codec switch notified: {} -> {} (transition: {})",
prev_codec, new_codec, transition_id
);
});
}
/// Publish event to event bus
async fn publish_event(&self, event: SystemEvent) {
if let Some(ref events) = *self.events.read().await {

View File

@@ -11,7 +11,7 @@ use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, error, info, trace, warn};
use super::device::{enumerate_devices, find_best_device, VideoDeviceInfo};
use super::device::{enumerate_devices, find_best_device, VideoDevice, VideoDeviceInfo};
use super::format::{PixelFormat, Resolution};
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use super::is_rk_hdmirx_device;
@@ -270,8 +270,7 @@ impl Streamer {
.find(|d| d.path.to_string_lossy() == device_path)
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?;
let (format, resolution) =
self.resolve_capture_config(&device, format, resolution)?;
let (format, resolution) = self.resolve_capture_config(&device, format, resolution)?;
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
// This prevents race conditions where clients try to reconnect and reopen the device
@@ -620,12 +619,22 @@ impl Streamer {
Ok(())
}
/// Direct capture loop for MJPEG mode (single loop, no broadcast)
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, config: StreamerConfig) {
/// Direct capture loop for MJPEG mode.
///
/// The outer `'session` loop allows "soft restarts": when no signal has been
/// detected for `NOSIGNAL_SOFT_RESTART_SECS` the capture stream is closed and
/// re-opened (re-probing format/resolution) without going through the full
/// DeviceLost recovery path. This handles the common CSI/HDMI-bridge case where
/// the source switches resolution and the driver requires a new `s_fmt` call.
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, _initial_config: StreamerConfig) {
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
const IDLE_STOP_DELAY_SECS: u64 = 5;
const BUFFER_COUNT: u32 = 2;
/// After this many seconds without signal, close+re-open the device.
const NOSIGNAL_SOFT_RESTART_SECS: u64 = 8;
/// Placeholder frame re-send interval while in NoSignal state (iterations of 100 ms).
const NOSIGNAL_PLACEHOLDER_INTERVAL: u32 = 10; // every ~1 s
let handle = tokio::runtime::Handle::current();
let mut last_state = StreamerState::Streaming;
@@ -640,222 +649,369 @@ impl Streamer {
}
};
let mut stream_opt: Option<V4l2rCaptureStream> = None;
let mut last_error: Option<String> = None;
// How many soft-restart cycles have been attempted (for exponential back-off).
let mut no_signal_restart_count: u32 = 0;
for attempt in 0..MAX_RETRIES {
'session: loop {
if self.direct_stop.load(Ordering::Relaxed) {
self.direct_active.store(false, Ordering::SeqCst);
return;
break 'session;
}
match V4l2rCaptureStream::open(
&device_path,
config.resolution,
config.format,
config.fps,
BUFFER_COUNT,
Duration::from_secs(2),
) {
Ok(stream) => {
stream_opt = Some(stream);
break;
// Re-read config at the start of each session so that a re_init_device()
// call (from a previous soft-restart or recovery) is reflected here.
let config = handle.block_on(async { self.config.read().await.clone() });
// ── Open the capture stream ─────────────────────────────────────────
let mut stream_opt: Option<V4l2rCaptureStream> = None;
let mut last_error: Option<String> = None;
for attempt in 0..MAX_RETRIES {
if self.direct_stop.load(Ordering::Relaxed) {
self.direct_active.store(false, Ordering::SeqCst);
return;
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("busy") || err_str.contains("resource") {
warn!(
"Device busy on attempt {}/{}, retrying in {}ms...",
attempt + 1,
MAX_RETRIES,
RETRY_DELAY_MS
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
match V4l2rCaptureStream::open(
&device_path,
config.resolution,
config.format,
config.fps,
BUFFER_COUNT,
Duration::from_secs(2),
) {
Ok(stream) => {
stream_opt = Some(stream);
break;
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("busy") || err_str.contains("resource") {
warn!(
"Device busy on attempt {}/{}, retrying in {}ms...",
attempt + 1,
MAX_RETRIES,
RETRY_DELAY_MS
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
last_error = Some(err_str);
continue;
}
last_error = Some(err_str);
continue;
}
last_error = Some(err_str);
break;
}
}
}
let mut stream = match stream_opt {
Some(stream) => stream,
None => {
error!(
"Failed to open device {:?}: {}",
device_path,
last_error.unwrap_or_else(|| "unknown error".to_string())
);
self.mjpeg_handler.set_offline();
set_state(StreamerState::Error);
self.direct_active.store(false, Ordering::SeqCst);
self.current_fps.store(0, Ordering::Relaxed);
return;
}
};
let resolution = stream.resolution();
let pixel_format = stream.format();
let stride = stream.stride();
info!(
"Capture format: {}x{} {:?} stride={}",
resolution.width, resolution.height, pixel_format, stride
);
let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize));
let mut signal_present = true;
let mut validate_counter: u64 = 0;
let mut idle_since: Option<std::time::Instant> = None;
let mut fps_frame_count: u64 = 0;
let mut last_fps_time = std::time::Instant::now();
let capture_error_throttler = LogThrottler::with_secs(5);
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
let classify_capture_error = |err: &std::io::Error| -> String {
let message = err.to_string();
if message.contains("dqbuf failed") && message.contains("EINVAL") {
"capture_dqbuf_einval".to_string()
} else if message.contains("dqbuf failed") {
"capture_dqbuf".to_string()
} else {
format!("capture_{:?}", err.kind())
}
};
while !self.direct_stop.load(Ordering::Relaxed) {
let mjpeg_clients = self.mjpeg_handler.client_count();
if mjpeg_clients == 0 {
if idle_since.is_none() {
idle_since = Some(std::time::Instant::now());
trace!("No active video consumers, starting idle timer");
} else if let Some(since) = idle_since {
if since.elapsed().as_secs() >= IDLE_STOP_DELAY_SECS {
info!(
"No active video consumers for {}s, stopping capture",
IDLE_STOP_DELAY_SECS
);
self.mjpeg_handler.set_offline();
set_state(StreamerState::Ready);
break;
}
}
} else if idle_since.is_some() {
trace!("Video consumers active, resetting idle timer");
idle_since = None;
}
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
let meta = match stream.next_into(&mut owned) {
Ok(meta) => meta,
Err(e) => {
if e.kind() == std::io::ErrorKind::TimedOut {
if signal_present {
signal_present = false;
self.mjpeg_handler.set_offline();
set_state(StreamerState::NoSignal);
self.current_fps.store(0, Ordering::Relaxed);
fps_frame_count = 0;
last_fps_time = std::time::Instant::now();
}
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
let is_device_lost = match e.raw_os_error() {
Some(6) => true, // ENXIO
Some(19) => true, // ENODEV
Some(5) => true, // EIO
Some(32) => true, // EPIPE
Some(108) => true, // ESHUTDOWN
_ => false,
};
if is_device_lost {
error!("Video device lost: {} - {}", device_path.display(), e);
self.mjpeg_handler.set_offline();
handle.block_on(async {
*self.last_lost_device.write().await =
Some(device_path.display().to_string());
*self.last_lost_reason.write().await = Some(e.to_string());
});
set_state(StreamerState::DeviceLost);
handle.block_on(async {
let streamer = Arc::clone(&self);
tokio::spawn(async move {
streamer.start_device_recovery_internal().await;
});
});
break;
}
let key = classify_capture_error(&e);
if capture_error_throttler.should_log(&key) {
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
if suppressed > 0 {
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
} else {
error!("Capture error: {}", e);
}
} else {
let counter = suppressed_capture_errors.entry(key).or_insert(0);
*counter = counter.saturating_add(1);
}
continue;
let mut stream = match stream_opt {
Some(stream) => stream,
None => {
error!(
"Failed to open device {:?}: {}",
device_path,
last_error.unwrap_or_else(|| "unknown error".to_string())
);
self.mjpeg_handler.set_offline();
set_state(StreamerState::Error);
break 'session;
}
};
let frame_size = meta.bytes_used;
if frame_size < MIN_CAPTURE_FRAME_SIZE {
continue;
}
let resolution = stream.resolution();
let pixel_format = stream.format();
let stride = stream.stride();
validate_counter = validate_counter.wrapping_add(1);
if pixel_format.is_compressed()
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
{
continue;
}
owned.truncate(frame_size);
let frame = VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
resolution,
pixel_format,
stride,
meta.sequence,
info!(
"Capture format: {}x{} {:?} stride={}",
resolution.width, resolution.height, pixel_format, stride
);
if !signal_present {
signal_present = true;
self.mjpeg_handler.set_online();
set_state(StreamerState::Streaming);
let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize));
let mut signal_present = true;
let mut validate_counter: u64 = 0;
let mut idle_since: Option<std::time::Instant> = None;
let mut fps_frame_count: u64 = 0;
let mut last_fps_time = std::time::Instant::now();
let capture_error_throttler = LogThrottler::with_secs(5);
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
let classify_capture_error = |err: &std::io::Error| -> String {
let message = err.to_string();
if message.contains("dqbuf failed") && message.contains("EINVAL") {
"capture_dqbuf_einval".to_string()
} else if message.contains("dqbuf failed") {
"capture_dqbuf".to_string()
} else {
format!("capture_{:?}", err.kind())
}
};
// None = signal is present; Some(Instant) = when signal was first lost.
let mut no_signal_since: Option<std::time::Instant> = None;
// Counter for periodic placeholder pushes during NoSignal.
let mut nosignal_placeholder_counter: u32 = 0;
// Whether the inner 'capture loop should trigger a soft restart.
let mut need_soft_restart = false;
// ── Inner capture loop ──────────────────────────────────────────────
'capture: while !self.direct_stop.load(Ordering::Relaxed) {
let mjpeg_clients = self.mjpeg_handler.client_count();
if mjpeg_clients == 0 {
if idle_since.is_none() {
idle_since = Some(std::time::Instant::now());
trace!("No active video consumers, starting idle timer");
} else if let Some(since) = idle_since {
if since.elapsed().as_secs() >= IDLE_STOP_DELAY_SECS {
info!(
"No active video consumers for {}s, stopping capture",
IDLE_STOP_DELAY_SECS
);
self.mjpeg_handler.set_offline();
set_state(StreamerState::Ready);
break 'capture;
}
}
} else if idle_since.is_some() {
trace!("Video consumers active, resetting idle timer");
idle_since = None;
}
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
let meta = match stream.next_into(&mut owned) {
Ok(meta) => meta,
Err(e) => {
if e.kind() == std::io::ErrorKind::TimedOut {
if signal_present {
signal_present = false;
// Don't call set_offline() instead keep the MJPEG stream
// alive by pushing a placeholder frame so clients stay
// connected and see the "no signal" image.
self.mjpeg_handler.push_no_signal_placeholder();
set_state(StreamerState::NoSignal);
no_signal_since = Some(std::time::Instant::now());
self.current_fps.store(0, Ordering::Relaxed);
fps_frame_count = 0;
last_fps_time = std::time::Instant::now();
nosignal_placeholder_counter = 0;
} else {
// Already in NoSignal re-send placeholder periodically so
// the HTTP keepalive timer does not expire.
nosignal_placeholder_counter =
nosignal_placeholder_counter.wrapping_add(1);
if nosignal_placeholder_counter >= NOSIGNAL_PLACEHOLDER_INTERVAL {
nosignal_placeholder_counter = 0;
self.mjpeg_handler.push_no_signal_placeholder();
}
// Soft-restart after exponential back-off.
if let Some(since) = no_signal_since {
let backoff_secs = NOSIGNAL_SOFT_RESTART_SECS
.saturating_mul(2u64.pow(no_signal_restart_count.min(2)))
.min(30);
if since.elapsed().as_secs() >= backoff_secs {
info!(
"NoSignal for {}s, attempting soft restart (attempt {})",
backoff_secs,
no_signal_restart_count + 1
);
need_soft_restart = true;
break 'capture;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
continue 'capture;
}
let is_device_lost = match e.raw_os_error() {
Some(6) => true, // ENXIO
Some(19) => true, // ENODEV
Some(5) => true, // EIO
Some(32) => true, // EPIPE
Some(108) => true, // ESHUTDOWN
_ => false,
};
if is_device_lost {
error!("Video device lost: {} - {}", device_path.display(), e);
self.mjpeg_handler.set_offline();
handle.block_on(async {
*self.last_lost_device.write().await =
Some(device_path.display().to_string());
*self.last_lost_reason.write().await = Some(e.to_string());
});
set_state(StreamerState::DeviceLost);
handle.block_on(async {
let streamer = Arc::clone(&self);
tokio::spawn(async move {
streamer.start_device_recovery_internal().await;
});
});
break 'capture;
}
let key = classify_capture_error(&e);
if capture_error_throttler.should_log(&key) {
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
if suppressed > 0 {
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
} else {
error!("Capture error: {}", e);
}
} else {
let counter = suppressed_capture_errors.entry(key).or_insert(0);
*counter = counter.saturating_add(1);
}
continue 'capture;
}
};
let frame_size = meta.bytes_used;
if frame_size < MIN_CAPTURE_FRAME_SIZE {
continue 'capture;
}
validate_counter = validate_counter.wrapping_add(1);
if pixel_format.is_compressed()
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
{
continue 'capture;
}
owned.truncate(frame_size);
let frame = VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
resolution,
pixel_format,
stride,
meta.sequence,
);
if !signal_present {
signal_present = true;
no_signal_since = None;
no_signal_restart_count = 0;
// Stream was kept online (placeholder pushes), just update state.
set_state(StreamerState::Streaming);
}
self.mjpeg_handler.update_frame(frame);
fps_frame_count += 1;
let fps_elapsed = last_fps_time.elapsed();
if fps_elapsed >= std::time::Duration::from_secs(1) {
let current_fps = fps_frame_count as f32 / fps_elapsed.as_secs_f32();
fps_frame_count = 0;
last_fps_time = std::time::Instant::now();
self.current_fps
.store((current_fps * 100.0) as u32, Ordering::Relaxed);
}
} // 'capture
// ── After inner loop ────────────────────────────────────────────────
// The stream is dropped here, releasing the device FD.
drop(stream);
if self.direct_stop.load(Ordering::Relaxed) {
break 'session;
}
self.mjpeg_handler.update_frame(frame);
fps_frame_count += 1;
let fps_elapsed = last_fps_time.elapsed();
if fps_elapsed >= std::time::Duration::from_secs(1) {
let current_fps = fps_frame_count as f32 / fps_elapsed.as_secs_f32();
fps_frame_count = 0;
last_fps_time = std::time::Instant::now();
self.current_fps
.store((current_fps * 100.0) as u32, Ordering::Relaxed);
if !need_soft_restart {
// Normal exit (idle / device-lost / stop).
break 'session;
}
}
// ── Soft restart path ───────────────────────────────────────────────
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
// Re-probe the device to pick up a changed resolution/format.
match VideoDevice::open_readonly(&device_path).and_then(|d| d.info()) {
Ok(device_info) => {
handle.block_on(async {
let fmt;
let res;
{
let cfg = self.config.read().await;
fmt = self
.select_format(&device_info, cfg.format)
.unwrap_or(cfg.format);
res = self
.select_resolution(&device_info, &fmt, cfg.resolution)
.unwrap_or(cfg.resolution);
}
{
let mut cfg = self.config.write().await;
cfg.format = fmt;
cfg.resolution = res;
}
*self.current_device.write().await = Some(device_info);
info!(
"Soft restart: re-probed device → {}x{} {:?}",
res.width, res.height, fmt
);
});
}
Err(e) => {
warn!("Soft restart: failed to re-probe device: {}", e);
// Brief wait before retrying to avoid spinning.
let wait = 2u64.pow(no_signal_restart_count.min(3));
std::thread::sleep(Duration::from_secs(wait));
}
}
// Reset no_signal_since so the back-off timer is fresh for the new session.
// no_signal_since will be re-set if the new session immediately times out.
// Continue 'session → re-open V4l2rCaptureStream with updated config.
} // 'session
self.direct_active.store(false, Ordering::SeqCst);
self.current_fps.store(0, Ordering::Relaxed);
}
/// Check if streaming
/// Check if streaming (or in NoSignal state — capture thread is still running)
pub async fn is_streaming(&self) -> bool {
self.state().await == StreamerState::Streaming
matches!(
self.state().await,
StreamerState::Streaming | StreamerState::NoSignal
)
}
/// Re-probe a device and update the stored config/device info.
///
/// Called during recovery or after a NoSignal soft restart so that a
/// resolution / format change on the source side is picked up before
/// the capture stream is re-opened.
pub async fn re_init_device(self: &Arc<Self>, device_path: &str) -> Result<()> {
let device = VideoDevice::open_readonly(device_path)
.map_err(|e| AppError::VideoError(format!("Cannot open device for re-init: {}", e)))?;
let device_info = device.info()?;
let (format, resolution) = {
let config = self.config.read().await;
let fmt = self
.select_format(&device_info, config.format)
.unwrap_or(config.format);
let res = self
.select_resolution(&device_info, &fmt, config.resolution)
.unwrap_or(config.resolution);
(fmt, res)
};
{
let mut cfg = self.config.write().await;
cfg.format = format;
cfg.resolution = resolution;
}
*self.current_device.write().await = Some(device_info);
info!(
"Device re-initialized: {}x{} {:?}",
resolution.width, resolution.height, format
);
Ok(())
}
/// Get stream statistics
@@ -997,6 +1153,15 @@ impl Streamer {
continue;
}
// Re-probe device to pick up resolution/format changes
if let Err(e) = streamer.re_init_device(&device_path).await {
debug!(
"Failed to re-probe device format (attempt {}): {}",
attempt, e
);
// Don't skip device exists, try restart anyway
}
// Try to restart capture
match streamer.restart_capture().await {
Ok(_) => {

View File

@@ -3,7 +3,8 @@ use crate::error::AppError;
use crate::rtsp::RtspServiceStatus;
use crate::rustdesk::config::RustDeskConfig;
use crate::video::encoder::BitratePreset;
use serde::Deserialize;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use std::path::Path;
use typeshare::typeshare;
@@ -660,6 +661,26 @@ impl AudioConfigUpdate {
}
// ===== RustDesk Config =====
/// hbbs/hbbr `-k` relay key: standard Base64 encoding of exactly 32 bytes (typically 44 chars with padding).
fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
let decoded = STANDARD.decode(key.as_bytes()).map_err(|_| {
AppError::BadRequest(
"Relay key must be standard Base64 (32 raw bytes, e.g. hbbs/hbbr -k output)".into(),
)
})?;
if decoded.len() != 32 {
return Err(AppError::BadRequest(
format!(
"Relay key must decode to exactly 32 bytes (got {} bytes after Base64 decode)",
decoded.len()
)
.into(),
));
}
Ok(())
}
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct RustDeskConfigUpdate {
@@ -698,6 +719,12 @@ impl RustDeskConfigUpdate {
));
}
}
if let Some(ref key) = self.relay_key {
let trimmed = key.trim();
if !trimmed.is_empty() {
validate_rustdesk_relay_key(trimmed)?;
}
}
Ok(())
}
@@ -716,10 +743,11 @@ impl RustDeskConfigUpdate {
};
}
if let Some(ref key) = self.relay_key {
config.relay_key = if key.is_empty() {
let trimmed = key.trim();
config.relay_key = if trimmed.is_empty() {
None
} else {
Some(key.clone())
Some(trimmed.to_string())
};
}
if let Some(ref password) = self.device_password {
@@ -849,6 +877,45 @@ impl RtspConfigUpdate {
}
// ===== Web Config =====
/// Web server settings returned by `GET` / `PATCH /api/config/web`.
///
/// Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebConfigResponse {
pub http_port: u16,
pub https_port: u16,
pub bind_addresses: Vec<String>,
pub bind_address: String,
pub https_enabled: bool,
/// Whether a custom TLS certificate is active (non-empty cert + key paths in stored config).
pub has_custom_cert: bool,
}
impl WebConfigResponse {
pub fn from_stored(web: &WebConfig) -> Self {
let has_custom_cert = web
.ssl_cert_path
.as_deref()
.map(|p| !p.is_empty())
.unwrap_or(false)
&& web
.ssl_key_path
.as_deref()
.map(|p| !p.is_empty())
.unwrap_or(false);
Self {
http_port: web.http_port,
https_port: web.https_port,
bind_addresses: web.bind_addresses.clone(),
bind_address: web.bind_address.clone(),
https_enabled: web.https_enabled,
has_custom_cert,
}
}
}
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct WebConfigUpdate {
@@ -857,6 +924,12 @@ pub struct WebConfigUpdate {
pub bind_addresses: Option<Vec<String>>,
pub bind_address: Option<String>,
pub https_enabled: Option<bool>,
/// PEM-encoded certificate content (must be provided together with ssl_key_pem)
pub ssl_cert_pem: Option<String>,
/// PEM-encoded private key content (must be provided together with ssl_cert_pem)
pub ssl_key_pem: Option<String>,
/// Set to true to remove the custom certificate and revert to self-signed
pub clear_custom_cert: Option<bool>,
}
impl WebConfigUpdate {
@@ -883,6 +956,22 @@ impl WebConfigUpdate {
return Err(AppError::BadRequest("Invalid bind address".into()));
}
}
// Cert and key must be provided together (cryptographic validity is checked in the
// handler via `RustlsConfig::from_pem`, same stack as the running HTTPS server).
match (&self.ssl_cert_pem, &self.ssl_key_pem) {
(Some(_cert), Some(_key)) => {}
(Some(_), None) => {
return Err(AppError::BadRequest(
"ssl_key_pem is required when ssl_cert_pem is provided".into(),
));
}
(None, Some(_)) => {
return Err(AppError::BadRequest(
"ssl_cert_pem is required when ssl_key_pem is provided".into(),
));
}
(None, None) => {}
}
Ok(())
}
@@ -907,6 +996,8 @@ impl WebConfigUpdate {
if let Some(enabled) = self.https_enabled {
config.https_enabled = enabled;
}
// ssl_cert_pem, ssl_key_pem, clear_custom_cert are handled at the handler level
// (they require async file I/O before updating config paths)
}
}
@@ -988,4 +1079,30 @@ mod tests {
assert!(update.validate_with_current(&current).is_err());
}
#[test]
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
let update = RustDeskConfigUpdate {
enabled: None,
rendezvous_server: None,
relay_server: None,
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
device_password: None,
};
assert!(update.validate().is_ok());
}
#[test]
fn rustdesk_relay_key_rejects_non_32_byte_payload() {
// Standard Base64 for 16 zero bytes (not 32).
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
let update = RustDeskConfigUpdate {
enabled: None,
rendezvous_server: None,
relay_server: None,
relay_key: Some(not_32),
device_password: None,
};
assert!(update.validate().is_err());
}
}

View File

@@ -1,32 +1,103 @@
//! Web 服务器配置 Handler
use axum::{extract::State, Json};
use axum_server::tls_rustls::RustlsConfig;
use std::sync::Arc;
use crate::config::WebConfig;
use crate::error::Result;
use crate::error::{AppError, Result};
use crate::state::AppState;
use super::types::WebConfigUpdate;
use super::types::{WebConfigResponse, WebConfigUpdate};
/// 获取 Web 配置
pub async fn get_web_config(State(state): State<Arc<AppState>>) -> Json<WebConfig> {
Json(state.config.get().web.clone())
pub async fn get_web_config(
State(state): State<Arc<AppState>>,
) -> Json<WebConfigResponse> {
Json(WebConfigResponse::from_stored(&state.config.get().web))
}
/// 更新 Web 配置
/// 更新 Web 配置(支持 PEM 证书上传)
pub async fn update_web_config(
State(state): State<Arc<AppState>>,
Json(req): Json<WebConfigUpdate>,
) -> Result<Json<WebConfig>> {
) -> Result<Json<WebConfigResponse>> {
req.validate()?;
// Determine certificate path changes (requires async file I/O before config update)
// Some(Some((cert, key))) = write new cert
// Some(None) = clear custom cert
// None = no cert change
let cert_path_update: Option<Option<(String, String)>> =
if let (Some(cert_pem), Some(key_pem)) = (&req.ssl_cert_pem, &req.ssl_key_pem) {
RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec())
.await
.map_err(|e| {
AppError::BadRequest(
format!(
"Invalid TLS certificate or private key (PEM must match what the HTTPS server can load): {e}"
)
.into(),
)
})?;
let cert_dir = state.data_dir().join("certs");
tokio::fs::create_dir_all(&cert_dir)
.await
.map_err(|e| AppError::Internal(format!("Failed to create cert dir: {e}")))?;
let cert_path = cert_dir.join("custom.crt");
let key_path = cert_dir.join("custom.key");
tokio::fs::write(&cert_path, cert_pem.as_bytes())
.await
.map_err(|e| AppError::Internal(format!("Failed to write certificate: {e}")))?;
tokio::fs::write(&key_path, key_pem.as_bytes())
.await
.map_err(|e| AppError::Internal(format!("Failed to write private key: {e}")))?;
Some(Some((
cert_path.to_string_lossy().into_owned(),
key_path.to_string_lossy().into_owned(),
)))
} else if req.clear_custom_cert.unwrap_or(false) {
let cert_dir = state.data_dir().join("certs");
let _ = tokio::fs::remove_file(cert_dir.join("custom.crt")).await;
let _ = tokio::fs::remove_file(cert_dir.join("custom.key")).await;
Some(None)
} else {
None
};
state
.config
.update(|config| {
.update(move |config| {
req.apply_to(&mut config.web);
match cert_path_update {
Some(Some((cert_path, key_path))) => {
config.web.ssl_cert_path = Some(cert_path);
config.web.ssl_key_path = Some(key_path);
}
Some(None) => {
config.web.ssl_cert_path = None;
config.web.ssl_key_path = None;
}
None => {}
}
})
.await?;
Ok(Json(state.config.get().web.clone()))
Ok(Json(WebConfigResponse::from_stored(&state.config.get().web)))
}
#[cfg(test)]
mod tests {
use super::*;
use rustls::crypto::{ring, CryptoProvider};
#[tokio::test]
async fn rustls_accepts_rcgen_self_signed_pem() {
let _ = CryptoProvider::install_default(ring::default_provider());
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let cert_pem = cert.cert.pem();
let key_pem = cert.signing_key.serialize_pem();
RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes())
.await
.unwrap();
}
}

View File

@@ -256,12 +256,14 @@ pub async fn update_gostc_config(
let new_config = state.config.get();
let is_enabled = new_config.extensions.gostc.enabled;
let has_key = !new_config.extensions.gostc.key.is_empty();
let has_addr = !new_config.extensions.gostc.addr.trim().is_empty();
if was_enabled && !is_enabled {
state.extensions.stop(ExtensionId::Gostc).await.ok();
} else if !was_enabled
&& is_enabled
&& has_key
&& has_addr
&& state.extensions.check_available(ExtensionId::Gostc)
{
state

View File

@@ -1282,7 +1282,29 @@ pub async fn stream_mode_set(
}
}
// Set video codec if switching to WebRTC mode with specific codec
let requested_mode_str = match (&new_mode, &video_codec) {
(StreamMode::Mjpeg, _) => "mjpeg",
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
(StreamMode::WebRTC, Some(VideoCodecType::VP8)) => "vp8",
(StreamMode::WebRTC, Some(VideoCodecType::VP9)) => "vp9",
(StreamMode::WebRTC, None) => "webrtc",
};
// Detect codec-only switch: already in WebRTC mode, just changing codec.
// switch_mode_transaction treats this as "no switch needed" since StreamMode
// is still WebRTC, so we handle codec change + event emission here.
let current_mode = state.stream_manager.current_mode().await;
let prev_codec = state
.stream_manager
.webrtc_streamer()
.current_video_codec()
.await;
let codec_changed = video_codec.is_some_and(|c| c != prev_codec);
let is_codec_only_switch =
current_mode == StreamMode::WebRTC && new_mode == StreamMode::WebRTC && codec_changed;
if let Some(codec) = video_codec {
info!("Setting WebRTC video codec to {:?}", codec);
if let Err(e) = state
@@ -1295,22 +1317,30 @@ pub async fn stream_mode_set(
}
}
// For codec-only switch, emit events directly instead of going through
// switch_mode_transaction (which short-circuits when mode is unchanged).
if is_codec_only_switch {
let transition_id = uuid::Uuid::new_v4().to_string();
state
.stream_manager
.notify_codec_switch(&transition_id, requested_mode_str, &codec_to_id(prev_codec))
.await;
return Ok(Json(StreamModeResponse {
success: true,
mode: requested_mode_str.to_string(),
transition_id: Some(transition_id),
switching: false,
message: Some(format!("Codec switched to {}", requested_mode_str)),
}));
}
let tx = state
.stream_manager
.switch_mode_transaction(new_mode.clone())
.await?;
// Return the requested codec identifier (for UI display). The actual active mode
// may differ if the request was rejected due to an in-progress switch.
let requested_mode_str = match (&new_mode, &video_codec) {
(StreamMode::Mjpeg, _) => "mjpeg",
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
(StreamMode::WebRTC, Some(VideoCodecType::VP8)) => "vp8",
(StreamMode::WebRTC, Some(VideoCodecType::VP9)) => "vp9",
(StreamMode::WebRTC, None) => "webrtc",
};
let active_mode_str = match state.stream_manager.current_mode().await {
StreamMode::Mjpeg => "mjpeg".to_string(),
StreamMode::WebRTC => {

View File

@@ -201,7 +201,7 @@ pub fn placeholder_html() -> &'static str {
<h1>One-KVM</h1>
<p>Frontend not built yet.</p>
<p>Please build the frontend or access the API directly.</p>
<div class="version">v0.1.8</div>
<div class="version">v0.1.9</div>
</div>
</body>
</html>"#

View File

@@ -787,6 +787,22 @@ impl WebRtcStreamer {
count
}
/// Close all sessions and wait for the video pipeline to fully release the
/// capture device. Use this when the caller needs the V4L2 device immediately
/// afterwards (e.g. switching to MJPEG mode).
pub async fn close_all_sessions_and_release_device(&self) -> usize {
let count = self.close_all_sessions().await;
if let Some(ref pipeline) = *self.video_pipeline.read().await {
pipeline
.stop_and_wait(std::time::Duration::from_secs(3))
.await;
}
*self.video_pipeline.write().await = None;
count
}
/// Get session count
pub async fn session_count(&self) -> usize {
self.sessions.read().await.len()

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -30,6 +30,8 @@ import type {
GostcConfigUpdate,
EasytierConfig,
EasytierConfigUpdate,
WebConfigResponse,
WebConfigUpdate,
} from '@/types/generated'
import { request } from './request'
@@ -384,36 +386,24 @@ export const rtspConfigApi = {
}
// ===== Web 服务器配置 API =====
// `/config/web` 使用 `WebConfigResponse` / `WebConfigUpdate`(由 typeshare 自 Rust 生成)。
/** Web 服务器配置 */
export interface WebConfig {
http_port: number
https_port: number
bind_addresses: string[]
bind_address: string
https_enabled: boolean
}
/** REST `/config/web` 响应(`WebConfigResponse` 别名,兼容旧命名) */
export type WebConfig = WebConfigResponse
/** Web 服务器配置更新 */
export interface WebConfigUpdate {
http_port?: number
https_port?: number
bind_addresses?: string[]
bind_address?: string
https_enabled?: boolean
}
export type { WebConfigUpdate }
export const webConfigApi = {
/**
* 获取 Web 服务器配置
*/
get: () => request<WebConfig>('/config/web'),
get: () => request<WebConfigResponse>('/config/web'),
/**
* 更新 Web 服务器配置
* 更新 Web 服务器配置(含可选的证书上传)
*/
update: (config: WebConfigUpdate) =>
request<WebConfig>('/config/web', {
request<WebConfigResponse>('/config/web', {
method: 'PATCH',
body: JSON.stringify(config),
}),

View File

@@ -8,10 +8,14 @@ const API_BASE = '/api'
// Auth API
export const authApi = {
login: (username: string, password: string) =>
request<{ success: boolean; message?: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
request<{ success: boolean; message?: string }>(
'/auth/login',
{
method: 'POST',
body: JSON.stringify({ username, password }),
},
{ toastOnError: false },
),
logout: () =>
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
@@ -688,6 +692,7 @@ export {
type RtspConfigUpdate,
type RtspStatusResponse,
type WebConfig,
type WebConfigUpdate,
} from './config'
// 导出生成的类型

View File

@@ -22,6 +22,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
ClipboardPaste,
HardDrive,
@@ -74,6 +80,7 @@ const emit = defineEmits<{
(e: 'openTerminal'): void
}>()
// Desktop toolbar popover/dialog state
const pasteOpen = ref(false)
const atxOpen = ref(false)
const videoPopoverOpen = ref(false)
@@ -81,13 +88,52 @@ const hidPopoverOpen = ref(false)
const audioPopoverOpen = ref(false)
const msdDialogOpen = ref(false)
const extensionOpen = ref(false)
// Mobile Sheet state — opened from the overflow menu.
// We use Sheet (bottom drawer) instead of Popover because Popover relies on an
// anchor element that is hidden / clipped on small screens, causing it to
// immediately close after opening.
const mobileAtxOpen = ref(false)
const mobilePasteOpen = ref(false)
// Timestamps used to suppress spurious "interact-outside" events that arrive
// within ~300 ms of the Sheet opening (e.g. delayed synthetic pointer events
// from the same touch gesture that opened the overflow menu).
const mobileAtxOpenTime = ref(0)
const mobilePasteOpenTime = ref(0)
const OPEN_GUARD_MS = 350
const guardOutside = (openTime: number, e: Event) => {
if (Date.now() - openTime < OPEN_GUARD_MS) {
e.preventDefault()
}
}
// On mobile, clicking a DropdownMenuItem generates pointer events that can
// immediately dismiss any overlay opened in the same tick. Close the dropdown
// first, then open the target after a short delay.
const openFromOverflow = (setter: () => void) => {
overflowMenuOpen.value = false
setTimeout(setter, 50)
}
const openMobileAtx = () => openFromOverflow(() => {
mobileAtxOpen.value = true
mobileAtxOpenTime.value = Date.now()
})
const openMobilePaste = () => openFromOverflow(() => {
mobilePasteOpen.value = true
mobilePasteOpenTime.value = Date.now()
})
</script>
<template>
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
<!-- Left side buttons -->
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
<div class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
<!-- Left side buttons overflow hidden so it never pushes into right side -->
<div class="flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
<!-- Video Config - Always visible -->
<VideoConfigPopover
v-model:open="videoPopoverOpen"
@@ -95,7 +141,7 @@ const extensionOpen = ref(false)
@update:video-mode="emit('update:videoMode', $event)"
/>
<!-- Audio Config - Always visible -->
<!-- Audio Config - Always visible (xs shows icon only) -->
<AudioConfigPopover v-model:open="audioPopoverOpen" />
<!-- HID Config - Always visible -->
@@ -105,112 +151,123 @@ const extensionOpen = ref(false)
@update:mouse-mode="emit('toggleMouseMode')"
/>
<!-- Virtual Media (MSD) - Hidden on small screens, shown in overflow -->
<!-- Also hidden when HID backend is CH9329 (no USB gadget support) -->
<TooltipProvider v-if="showMsd" class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
<HardDrive class="h-4 w-4" />
<span class="hidden md:inline">{{ t('actionbar.virtualMedia') }}</span>
<!-- Virtual Media (MSD) - Hidden below md, shown in overflow -->
<div v-if="showMsd" class="hidden md:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
<HardDrive class="h-4 w-4" />
<span class="hidden lg:inline">{{ t('actionbar.virtualMedia') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.virtualMediaTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- ATX Power Control - Hidden below md; shown as Sheet on mobile -->
<div class="hidden md:block">
<Popover v-model:open="atxOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" />
<span class="hidden lg:inline">{{ t('actionbar.power') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.virtualMediaTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</PopoverTrigger>
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
<AtxPopover
@close="atxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</PopoverContent>
</Popover>
</div>
<!-- ATX Power Control - Hidden on small screens -->
<Popover v-model:open="atxOpen" class="hidden sm:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" />
<span class="hidden md:inline">{{ t('actionbar.power') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[280px] p-0" align="start">
<AtxPopover
@close="atxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</PopoverContent>
</Popover>
<!-- Paste Text - Hidden on small screens -->
<Popover v-model:open="pasteOpen" class="hidden md:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<ClipboardPaste class="h-4 w-4" />
<span class="hidden lg:inline">{{ t('actionbar.paste') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[400px] p-0" align="start">
<PasteModal @close="pasteOpen = false" />
</PopoverContent>
</Popover>
<!-- Paste Text - Hidden below lg; shown as Sheet on mobile -->
<div class="hidden lg:block">
<Popover v-model:open="pasteOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<ClipboardPaste class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.paste') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
<PasteModal @close="pasteOpen = false" />
</PopoverContent>
</Popover>
</div>
</div>
<!-- Right side buttons -->
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
<!-- Extension Menu - Hidden on small screens -->
<Popover v-model:open="extensionOpen" class="hidden lg:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.extension') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-1" align="start">
<div class="space-y-0.5">
<Button
variant="ghost"
size="sm"
class="w-full justify-start gap-2 h-8"
:disabled="!props.ttydRunning"
@click="extensionOpen = false; emit('openTerminal')"
>
<Terminal class="h-4 w-4" />
{{ t('extensions.ttyd.title') }}
<!-- Right side buttons always shrink-0, never compressed -->
<div class="flex items-center gap-0.5 sm:gap-1.5 shrink-0 ml-1 sm:ml-2">
<!-- Extension Menu - Hidden below xl -->
<div class="hidden xl:block">
<Popover v-model:open="extensionOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" />
{{ t('actionbar.extension') }}
</Button>
</div>
</PopoverContent>
</Popover>
</PopoverTrigger>
<PopoverContent class="w-48 p-1" align="start">
<div class="space-y-0.5">
<Button
variant="ghost"
size="sm"
class="w-full justify-start gap-2 h-8"
:disabled="!props.ttydRunning"
@click="extensionOpen = false; emit('openTerminal')"
>
<Terminal class="h-4 w-4" />
{{ t('extensions.ttyd.title') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Settings - Hidden on small screens -->
<TooltipProvider class="hidden lg:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
<Settings class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.settings') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.settingsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Settings - Hidden below xl -->
<div class="hidden xl:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
<Settings class="h-4 w-4" />
{{ t('actionbar.settings') }}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.settingsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Connection Stats - Hidden on very small screens -->
<TooltipProvider class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
<BarChart3 class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.statsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Connection Stats - Hidden below md -->
<div class="hidden md:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
<BarChart3 class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.statsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block" />
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden md:block" />
<!-- Virtual Keyboard - Always visible (important for mobile) -->
<TooltipProvider>
@@ -219,10 +276,10 @@ const extensionOpen = ref(false)
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
@click="emit('toggleVirtualKeyboard')"
>
<Keyboard class="h-4 w-4" />
<Keyboard class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
</Button>
</TooltipTrigger>
@@ -239,10 +296,10 @@ const extensionOpen = ref(false)
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
@click="emit('toggleFullscreen')"
>
<Maximize class="h-4 w-4" />
<Maximize class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
</Button>
</TooltipTrigger>
@@ -252,52 +309,52 @@ const extensionOpen = ref(false)
</Tooltip>
</TooltipProvider>
<!-- Overflow Menu - Shows hidden items on small screens -->
<!-- Overflow Menu - Shows hidden items on smaller screens -->
<DropdownMenu v-model:open="overflowMenuOpen">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 lg:hidden">
<MoreHorizontal class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-8 p-0 xl:hidden">
<MoreHorizontal class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<!-- MSD - Mobile only, hidden when CH9329 backend -->
<DropdownMenuItem v-if="showMsd" class="sm:hidden" @click="msdDialogOpen = true; overflowMenuOpen = false">
<!-- MSD - Below md, hidden when CH9329 backend -->
<DropdownMenuItem v-if="showMsd" class="md:hidden" @click="openFromOverflow(() => msdDialogOpen = true)">
<HardDrive class="h-4 w-4 mr-2" />
{{ t('actionbar.virtualMedia') }}
</DropdownMenuItem>
<!-- ATX - Mobile only -->
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<!-- ATX - Opens a Sheet on mobile (below md) -->
<DropdownMenuItem class="md:hidden" @click="openMobileAtx">
<Power class="h-4 w-4 mr-2" />
{{ t('actionbar.power') }}
</DropdownMenuItem>
<!-- Paste - Tablet and below -->
<DropdownMenuItem class="md:hidden" @click="pasteOpen = true; overflowMenuOpen = false">
<!-- Paste - Opens a Sheet on mobile (below lg) -->
<DropdownMenuItem class="lg:hidden" @click="openMobilePaste">
<ClipboardPaste class="h-4 w-4 mr-2" />
{{ t('actionbar.paste') }}
</DropdownMenuItem>
<DropdownMenuSeparator class="lg:hidden" />
<DropdownMenuSeparator />
<!-- Stats - Mobile only -->
<DropdownMenuItem class="sm:hidden" @click="emit('toggleStats'); overflowMenuOpen = false">
<!-- Stats - Below md -->
<DropdownMenuItem class="md:hidden" @click="openFromOverflow(() => emit('toggleStats'))">
<BarChart3 class="h-4 w-4 mr-2" />
{{ t('actionbar.stats') }}
</DropdownMenuItem>
<!-- Extension - Tablet and below -->
<!-- Extension - Below xl -->
<DropdownMenuItem
class="lg:hidden"
class="xl:hidden"
:disabled="!props.ttydRunning"
@click="emit('openTerminal'); overflowMenuOpen = false"
@click="openFromOverflow(() => emit('openTerminal'))"
>
<Terminal class="h-4 w-4 mr-2" />
{{ t('extensions.ttyd.title') }}
</DropdownMenuItem>
<!-- Settings - Tablet and below -->
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<!-- Settings - Below xl -->
<DropdownMenuItem class="xl:hidden" @click="openFromOverflow(() => router.push('/settings'))">
<Settings class="h-4 w-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>
@@ -309,4 +366,41 @@ const extensionOpen = ref(false)
<!-- MSD Dialog -->
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
<!-- Mobile ATX Sheet used when ATX is opened from the overflow menu.
A Sheet avoids the Popover anchor-positioning issues on mobile. -->
<Sheet v-model:open="mobileAtxOpen">
<SheetContent
side="bottom"
class="max-h-[90dvh] overflow-y-auto"
@pointer-down-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
@interact-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
>
<SheetHeader class="mb-2">
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
</SheetHeader>
<AtxPopover
@close="mobileAtxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</SheetContent>
</Sheet>
<!-- Mobile Paste Sheet — used when Paste is opened from the overflow menu. -->
<Sheet v-model:open="mobilePasteOpen">
<SheetContent
side="bottom"
class="max-h-[90dvh] overflow-y-auto"
@pointer-down-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
@interact-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
>
<SheetHeader class="mb-2">
<SheetTitle>{{ t('actionbar.paste') }}</SheetTitle>
</SheetHeader>
<PasteModal @close="mobilePasteOpen = false" />
</SheetContent>
</Sheet>
</template>

View File

@@ -1,21 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Monitor,
Settings,
LogOut,
Sun,
Moon,
@@ -23,16 +20,10 @@ import {
} from 'lucide-vue-next'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const systemStore = useSystemStore()
const navItems = computed(() => [
{ path: '/', name: 'Console', icon: Monitor, label: t('nav.console') },
{ path: '/settings', name: 'Settings', icon: Settings, label: t('nav.settings') },
])
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark')
document.documentElement.classList.toggle('dark', !isDark)
@@ -49,38 +40,22 @@ async function handleLogout() {
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
<!-- Header -->
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex h-14 items-center px-4 max-w-full">
<div class="flex h-11 sm:h-14 items-center px-3 sm:px-4 max-w-full">
<!-- Logo -->
<RouterLink to="/" class="flex items-center gap-2 font-semibold">
<Monitor class="h-5 w-5" />
<RouterLink to="/" class="flex items-center gap-1.5 sm:gap-2 font-semibold">
<BrandMark size="sm" />
<span class="hidden sm:inline">One-KVM</span>
</RouterLink>
<!-- Navigation -->
<nav class="hidden md:flex items-center gap-1 ml-6">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors"
:class="route.path === item.path
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'"
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.label }}
</RouterLink>
</nav>
<!-- Right Side -->
<div class="flex items-center gap-2 ml-auto">
<div class="flex items-center gap-1 sm:gap-2 ml-auto">
<!-- Version Badge -->
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
v{{ systemStore.version }}
</span>
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
@@ -92,16 +67,11 @@ async function handleLogout() {
<!-- Mobile Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child class="md:hidden">
<Button variant="ghost" size="icon" :aria-label="t('common.menu')">
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.menu')">
<Menu class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-for="item in navItems" :key="item.path" @click="router.push(item.path)">
<component :is="item.icon" class="h-4 w-4 mr-2" />
{{ item.label }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleLogout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('nav.logout') }}
@@ -110,7 +80,7 @@ async function handleLogout() {
</DropdownMenu>
<!-- Logout Button (Desktop) -->
<Button variant="ghost" size="icon" class="hidden md:flex" :aria-label="t('nav.logout')" @click="handleLogout">
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8" :aria-label="t('nav.logout')" @click="handleLogout">
<LogOut class="h-4 w-4" />
<span class="sr-only">{{ t('nav.logout') }}</span>
</Button>

View File

@@ -169,12 +169,12 @@ watch(() => props.open, (isOpen) => {
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Volume2 class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<Volume2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
class?: string
}>(),
{ size: 'md' },
)
const dim = computed(() => {
switch (props.size) {
case 'xs':
return 'h-4 w-4'
case 'sm':
return 'h-5 w-5'
case 'md':
return 'h-6 w-6'
case 'lg':
return 'h-10 w-10'
case 'xl':
return 'h-14 w-14'
default:
return 'h-6 w-6'
}
})
</script>
<template>
<img
src="/favicon.png"
alt=""
:class="cn('shrink-0 object-contain select-none', dim, props.class)"
decoding="async"
draggable="false"
/>
</template>

View File

@@ -247,13 +247,13 @@ watch(() => props.open, (isOpen) => {
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<MousePointer v-if="mouseMode === 'absolute'" class="h-4 w-4" />
<Move v-else class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<MousePointer v-if="mouseMode === 'absolute'" class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<Move v-else class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>

View File

@@ -42,81 +42,70 @@ const keysDisplay = computed(() => {
<template>
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<!-- Compact mode for small screens -->
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
<!-- LED indicator only in compact mode -->
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
<span
v-if="capsLock"
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
>C</span>
<span v-else class="text-muted-foreground/40 text-[10px]">C</span>
<span
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>N</span>
<span
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>S</span>
</div>
<div v-else class="text-[10px] text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
<!-- Keys in compact mode -->
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
{{ keysDisplay }}
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
<div :class="compact ? '' : 'sm:hidden'">
<div class="flex items-center justify-between text-xs px-2 py-0.5">
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
<span
:class="capsLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>C</span>
<span
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>N</span>
<span
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>S</span>
</div>
<div v-else class="text-[10px] text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[200px]">
{{ keysDisplay }}
</div>
</div>
</div>
<!-- Normal mode -->
<div v-else class="flex flex-wrap items-center justify-between text-xs">
<!-- Left side: Debug info and pressed keys -->
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
<!-- Pressed Keys -->
<div class="flex items-center gap-1.5 min-w-0">
<span class="font-medium text-muted-foreground shrink-0 hidden sm:inline">{{ t('infobar.keys') }}:</span>
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
<!-- Normal mode (hidden on small screens unless compact is explicitly set) -->
<div :class="compact ? 'hidden' : 'hidden sm:block'">
<div class="flex flex-wrap items-center justify-between text-xs">
<!-- Left side: Debug info and pressed keys -->
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
<div class="flex items-center gap-1.5 min-w-0">
<span class="font-medium text-muted-foreground shrink-0">{{ t('infobar.keys') }}:</span>
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
</div>
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
</div>
</div>
<!-- Debug: Mouse Position -->
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
</div>
</div>
<!-- Right side: Keyboard LED states -->
<div class="flex items-center shrink-0">
<template v-if="keyboardLedEnabled">
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
<span class="sm:hidden">C</span>
<!-- Right side: Keyboard LED states -->
<div class="flex items-center shrink-0">
<template v-if="keyboardLedEnabled">
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>{{ t('infobar.caps') }}</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>{{ t('infobar.num') }}</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>{{ t('infobar.scroll') }}</div>
</template>
<div v-else class="px-3 py-1 text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
<span class="sm:hidden">N</span>
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
<span class="sm:hidden">S</span>
</div>
</template>
<div v-else class="px-3 py-1 text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
</div>
</div>

View File

@@ -28,7 +28,6 @@ import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
HardDrive,
Upload,
@@ -512,7 +511,7 @@ onUnmounted(() => {
<TooltipProvider>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col p-0">
<DialogHeader class="px-6 pt-6">
<DialogHeader class="px-6 pt-6 shrink-0">
<DialogTitle class="flex items-center gap-2">
<HardDrive class="h-5 w-5" />
{{ t('msd.title') }}
@@ -551,10 +550,11 @@ onUnmounted(() => {
</DialogDescription>
</DialogHeader>
<Separator />
<Separator class="shrink-0" />
<Tabs v-model="activeTab" class="flex-1 flex flex-col overflow-hidden px-6 pb-6 pt-4">
<TabsList class="w-full grid grid-cols-2">
<div class="flex-1 min-h-0 flex flex-col px-6 pb-6 pt-4">
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0">
<TabsList class="w-full grid grid-cols-2 shrink-0">
<TabsTrigger value="images">
<Disc class="h-4 w-4 mr-1.5" />
{{ t('msd.images') }}
@@ -566,15 +566,13 @@ onUnmounted(() => {
</TabsList>
<!-- Tab Description -->
<p class="text-xs text-muted-foreground mt-2 mb-1">
<p class="text-xs text-muted-foreground mt-2 mb-1 shrink-0">
{{ activeTab === 'images' ? t('msd.imagesDesc') : t('msd.driveDesc') }}
</p>
<ScrollArea class="flex-1 mt-2">
<!-- Images Tab -->
<TabsContent value="images" class="m-0 space-y-3 pr-4">
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-3">
<!-- Compact Upload Toolbar -->
<div class="flex items-center gap-2 min-w-0">
<div class="shrink-0 flex items-center gap-2 min-w-0">
<label class="flex-1">
<input
type="file"
@@ -598,10 +596,10 @@ onUnmounted(() => {
{{ t('msd.downloadFromUrl') }}
</Button>
</div>
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1" />
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
<!-- Options - Vertical compact layout -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 p-2 rounded-lg bg-muted/50 text-xs min-w-0">
<div class="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2 p-2 rounded-lg bg-muted/50 text-xs min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground whitespace-nowrap">{{ t('msd.storageMode') }}:</span>
<HelpTooltip :content="mountMode === 'flash' ? t('help.flashMode') : t('help.cdromMode')" icon-size="sm" />
@@ -629,99 +627,101 @@ onUnmounted(() => {
</div>
<!-- Image List -->
<div class="space-y-2 min-w-0">
<div class="flex items-center justify-between">
<div class="flex-1 min-h-0 flex flex-col space-y-2 min-w-0">
<div class="shrink-0 flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
</Button>
</div>
<div v-if="images.length === 0" class="text-center py-6 text-muted-foreground text-sm">
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
{{ t('msd.noImages') }}
</div>
<div v-else class="space-y-2">
<div
v-for="image in images"
:key="image.id"
class="p-3 rounded-lg border transition-colors"
:class="[
msdConnected && systemStore.msd?.imageId === image.id
? 'border-primary bg-primary/5'
: 'hover:bg-accent/50'
]"
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-start gap-2 w-0 flex-1">
<Disc class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div class="w-0 flex-1">
<Tooltip>
<TooltipTrigger as-child>
<p class="text-sm font-medium cursor-help overflow-hidden text-ellipsis whitespace-nowrap">{{ image.name }}</p>
</TooltipTrigger>
<TooltipContent>
<p class="max-w-sm break-all">{{ image.name }}</p>
</TooltipContent>
</Tooltip>
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
<span class="text-xs text-muted-foreground">{{ formatBytes(image.size) }}</span>
<Tooltip v-if="isLargeFile(image)">
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
<div class="space-y-2">
<div
v-for="image in images"
:key="image.id"
class="p-3 rounded-lg border transition-colors"
:class="[
msdConnected && systemStore.msd?.imageId === image.id
? 'border-primary bg-primary/5'
: 'hover:bg-accent/50'
]"
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-start gap-2 w-0 flex-1">
<Disc class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div class="w-0 flex-1">
<Tooltip>
<TooltipTrigger as-child>
<Badge
variant="outline"
class="text-[10px] h-4 px-1.5 border-amber-500/50 text-amber-600 dark:text-amber-400 cursor-help"
>
<AlertCircle class="h-2.5 w-2.5 mr-0.5" />
{{ t('msd.largeFileWarning') }}
</Badge>
<p class="text-sm font-medium cursor-help overflow-hidden text-ellipsis whitespace-nowrap">{{ image.name }}</p>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('msd.largeFileTooltip') }}</p>
<p class="max-w-sm break-all">{{ image.name }}</p>
</TooltipContent>
</Tooltip>
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
<span class="text-xs text-muted-foreground">{{ formatBytes(image.size) }}</span>
<Tooltip v-if="isLargeFile(image)">
<TooltipTrigger as-child>
<Badge
variant="outline"
class="text-[10px] h-4 px-1.5 border-amber-500/50 text-amber-600 dark:text-amber-400 cursor-help"
>
<AlertCircle class="h-2.5 w-2.5 mr-0.5" />
{{ t('msd.largeFileWarning') }}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('msd.largeFileTooltip') }}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<template v-if="msdConnected && systemStore.msd?.imageId === image.id">
<Badge variant="default" class="text-xs h-7 px-2">
<span class="relative flex h-1.5 w-1.5 mr-1.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-white"></span>
</span>
{{ t('common.connected') }}
</Badge>
</template>
<template v-else>
<div class="flex items-center gap-1.5 shrink-0">
<template v-if="msdConnected && systemStore.msd?.imageId === image.id">
<Badge variant="default" class="text-xs h-7 px-2">
<span class="relative flex h-1.5 w-1.5 mr-1.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-white"></span>
</span>
{{ t('common.connected') }}
</Badge>
</template>
<template v-else>
<Button
variant="default"
size="sm"
class="h-7 text-xs"
:disabled="operationInProgress"
@click="connectImage(image)"
>
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
<span v-if="connecting">{{ t('common.connecting') }}...</span>
<span v-else>{{ t('msd.connect') }}</span>
</Button>
</template>
<Button
variant="default"
size="sm"
class="h-7 text-xs"
:disabled="operationInProgress"
@click="connectImage(image)"
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive"
:disabled="operationInProgress || (msdConnected && systemStore.msd?.imageId === image.id)"
@click="confirmDelete('image', image.id, image.name)"
>
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
<span v-if="connecting">{{ t('common.connecting') }}...</span>
<span v-else>{{ t('msd.connect') }}</span>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive"
:disabled="operationInProgress || (msdConnected && systemStore.msd?.imageId === image.id)"
@click="confirmDelete('image', image.id, image.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- System Storage Footer -->
<div v-if="systemStore.diskSpace" class="pt-2 border-t mt-2">
<div v-if="systemStore.diskSpace" class="shrink-0 pt-2 border-t mt-2">
<p class="text-[11px] text-muted-foreground text-center">
{{ t('msd.systemAvailable') }}: {{ formatBytes(systemStore.diskSpace.available) }}
</p>
@@ -729,10 +729,9 @@ onUnmounted(() => {
</div>
</TabsContent>
<!-- Drive Tab -->
<TabsContent value="drive" class="m-0 space-y-4 pr-4">
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
<template v-if="!driveInitialized">
<div class="text-center py-8 space-y-4">
<div class="shrink-0 text-center py-8 space-y-4">
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
<Button size="sm" @click="initializeDrive">
@@ -743,7 +742,7 @@ onUnmounted(() => {
<template v-else>
<!-- Drive Info Card -->
<div class="p-3 rounded-lg border space-y-3" :class="msdConnected && msdMode === 'drive' ? 'border-primary bg-primary/5' : 'bg-muted/50'">
<div class="shrink-0 p-3 rounded-lg border space-y-3" :class="msdConnected && msdMode === 'drive' ? 'border-primary bg-primary/5' : 'bg-muted/50'">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<HardDrive class="h-4 w-4 text-muted-foreground" />
@@ -801,9 +800,9 @@ onUnmounted(() => {
</div>
<!-- File Browser -->
<div class="space-y-2">
<div class="flex-1 min-h-0 flex flex-col space-y-2">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2">
<div class="shrink-0 flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Button
v-if="currentPath !== '/'"
@@ -827,7 +826,7 @@ onUnmounted(() => {
</template>
</nav>
</div>
<div class="flex items-center gap-1 shrink-0">
<div class="shrink-0 flex items-center gap-1 shrink-0">
<label>
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
@@ -843,67 +842,69 @@ onUnmounted(() => {
</div>
</div>
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1" />
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
<!-- File List -->
<div v-if="driveFiles.length === 0" class="text-center py-6 text-muted-foreground text-sm">
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
{{ t('msd.emptyFolder') }}
</div>
<div v-else class="space-y-1">
<div
v-for="file in driveFiles"
:key="file.path"
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
<div class="space-y-1">
<div
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
@click="file.is_dir && navigateTo(file.path)"
v-for="file in driveFiles"
:key="file.path"
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<Folder v-if="file.is_dir" class="h-4 w-4 text-blue-500 shrink-0" />
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<Tooltip>
<TooltipTrigger as-child>
<p class="text-sm font-medium truncate cursor-help">{{ file.name }}</p>
</TooltipTrigger>
<TooltipContent>
<p class="max-w-sm break-all">{{ file.name }}</p>
</TooltipContent>
</Tooltip>
<p v-if="!file.is_dir" class="text-xs text-muted-foreground">
{{ formatBytes(file.size) }}
</p>
<div
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
@click="file.is_dir && navigateTo(file.path)"
>
<Folder v-if="file.is_dir" class="h-4 w-4 text-blue-500 shrink-0" />
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<Tooltip>
<TooltipTrigger as-child>
<p class="text-sm font-medium truncate cursor-help">{{ file.name }}</p>
</TooltipTrigger>
<TooltipContent>
<p class="max-w-sm break-all">{{ file.name }}</p>
</TooltipContent>
</Tooltip>
<p v-if="!file.is_dir" class="text-xs text-muted-foreground">
{{ formatBytes(file.size) }}
</p>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button
v-if="!file.is_dir"
variant="ghost"
size="icon"
class="h-7 w-7"
as="a"
:href="msdApi.downloadDriveFile(file.path)"
download
>
<Download class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('file', file.path, file.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button
v-if="!file.is_dir"
variant="ghost"
size="icon"
class="h-7 w-7"
as="a"
:href="msdApi.downloadDriveFile(file.path)"
download
>
<Download class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('file', file.path, file.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
</template>
</TabsContent>
</ScrollArea>
</Tabs>
</Tabs>
</div>
</DialogContent>
</Dialog>
</TooltipProvider>
@@ -1102,3 +1103,28 @@ onUnmounted(() => {
</DialogContent>
</Dialog>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* For Firefox */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
</style>

View File

@@ -18,7 +18,6 @@ import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
@@ -294,8 +293,8 @@ onMounted(async () => {
<template>
<Sheet :open="open" @update:open="emit('update:open', $event)">
<SheetContent side="right" class="w-full sm:max-w-lg overflow-hidden flex flex-col">
<SheetHeader>
<SheetContent side="right" class="w-full sm:max-w-lg overflow-hidden flex flex-col h-[dvh]">
<SheetHeader class="shrink-0">
<div class="flex items-center justify-between pr-8">
<div>
<SheetTitle class="flex items-center gap-2">
@@ -314,10 +313,10 @@ onMounted(async () => {
</div>
</SheetHeader>
<Separator class="my-4" />
<Separator class="my-4 shrink-0" />
<Tabs v-model="activeTab" class="flex-1 flex flex-col overflow-hidden">
<TabsList class="w-full grid grid-cols-2">
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0 overflow-hidden">
<TabsList class="w-full grid grid-cols-2 shrink-0">
<TabsTrigger value="images">
<Disc class="h-4 w-4 mr-1.5" />
{{ t('msd.images') }}
@@ -328,11 +327,11 @@ onMounted(async () => {
</TabsTrigger>
</TabsList>
<ScrollArea class="flex-1 mt-4">
<div class="flex-1 min-h-0 mt-4 flex flex-col">
<!-- Images Tab -->
<TabsContent value="images" class="m-0 space-y-4">
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
<!-- Upload Area -->
<div class="space-y-3">
<div class="shrink-0 space-y-3">
<label class="block">
<input
type="file"
@@ -352,7 +351,7 @@ onMounted(async () => {
</div>
<!-- Options -->
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="shrink-0 flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="flex items-center gap-2">
<Switch id="cdrom" v-model:checked="cdromMode" />
<Label for="cdrom" class="text-xs">{{ t('msd.cdromMode') }}</Label>
@@ -364,63 +363,65 @@ onMounted(async () => {
</div>
<!-- Image List -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex-1 min-h-0 flex flex-col space-y-2">
<div class="shrink-0 flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
</Button>
</div>
<div v-if="images.length === 0" class="text-center py-6 text-muted-foreground text-sm">
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
{{ t('msd.noImages') }}
</div>
<div v-else class="space-y-1.5">
<div
v-for="image in images"
:key="image.id"
class="flex items-center justify-between p-2.5 rounded-lg border hover:bg-accent/50 transition-colors"
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<Disc class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ image.name }}</p>
<p class="text-xs text-muted-foreground">
{{ formatBytes(image.size) }}
</p>
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
<div class="space-y-1.5">
<div
v-for="image in images"
:key="image.id"
class="flex items-center justify-between p-2.5 rounded-lg border hover:bg-accent/50 transition-colors"
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<Disc class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ image.name }}</p>
<p class="text-xs text-muted-foreground">
{{ formatBytes(image.size) }}
</p>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="!msdConnected || systemStore.msd?.imageId !== image.id"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="connectImage(image)"
>
<Link class="h-3.5 w-3.5 mr-1" />
{{ t('msd.connect') }}
</Button>
<Badge v-else variant="default" class="text-xs">{{ t('common.connected') }}</Badge>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('image', image.id, image.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="!msdConnected || systemStore.msd?.imageId !== image.id"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="connectImage(image)"
>
<Link class="h-3.5 w-3.5 mr-1" />
{{ t('msd.connect') }}
</Button>
<Badge v-else variant="default" class="text-xs">{{ t('common.connected') }}</Badge>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('image', image.id, image.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</ScrollArea>
</div>
</TabsContent>
<!-- Drive Tab -->
<TabsContent value="drive" class="m-0 space-y-4">
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
<template v-if="!driveInitialized">
<div class="text-center py-8 space-y-4">
<div class="shrink-0 text-center py-8 space-y-4">
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
<Button size="sm" @click="initializeDrive">
@@ -431,7 +432,7 @@ onMounted(async () => {
<template v-else>
<!-- Drive Info -->
<div class="p-3 rounded-lg bg-muted/50 space-y-2">
<div class="shrink-0 p-3 rounded-lg bg-muted/50 space-y-2">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<p class="text-xs text-muted-foreground">{{ t('msd.driveSize') }}: {{ (driveInfo?.size || 0) / 1024 / 1024 }}MB</p>
@@ -459,9 +460,9 @@ onMounted(async () => {
</div>
<!-- File Browser -->
<div class="space-y-2">
<div class="flex-1 min-h-0 flex flex-col space-y-2">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2">
<div class="shrink-0 flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Button
v-if="currentPath !== '/'"
@@ -485,7 +486,7 @@ onMounted(async () => {
</template>
</nav>
</div>
<div class="flex items-center gap-1 shrink-0">
<div class="shrink-0 flex items-center gap-1 shrink-0">
<label>
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
@@ -501,59 +502,61 @@ onMounted(async () => {
</div>
</div>
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1" />
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
<!-- File List -->
<div v-if="driveFiles.length === 0" class="text-center py-6 text-muted-foreground text-sm">
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
{{ t('msd.emptyFolder') }}
</div>
<div v-else class="space-y-1">
<div
v-for="file in driveFiles"
:key="file.path"
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
<div class="space-y-1">
<div
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
@click="file.is_dir && navigateTo(file.path)"
v-for="file in driveFiles"
:key="file.path"
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<Folder v-if="file.is_dir" class="h-4 w-4 text-blue-500 shrink-0" />
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ file.name }}</p>
<p v-if="!file.is_dir" class="text-xs text-muted-foreground">
{{ formatBytes(file.size) }}
</p>
<div
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
@click="file.is_dir && navigateTo(file.path)"
>
<Folder v-if="file.is_dir" class="h-4 w-4 text-blue-500 shrink-0" />
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ file.name }}</p>
<p v-if="!file.is_dir" class="text-xs text-muted-foreground">
{{ formatBytes(file.size) }}
</p>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button
v-if="!file.is_dir"
variant="ghost"
size="icon"
class="h-7 w-7"
as="a"
:href="msdApi.downloadDriveFile(file.path)"
download
>
<Download class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('file', file.path, file.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button
v-if="!file.is_dir"
variant="ghost"
size="icon"
class="h-7 w-7"
as="a"
:href="msdApi.downloadDriveFile(file.path)"
download
>
<Download class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('file', file.path, file.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</ScrollArea>
</div>
</template>
</TabsContent>
</ScrollArea>
</div>
</Tabs>
</SheetContent>
</Sheet>
@@ -588,3 +591,28 @@ onMounted(async () => {
</DialogContent>
</Dialog>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* For Firefox */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
</style>

View File

@@ -134,21 +134,30 @@ const statusBadgeText = computed(() => {
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
<template v-if="compact">
<!-- Compact: single row with dot + abbreviated title -->
<div class="flex items-center gap-1">
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
</div>
</template>
<template v-else>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</template>
</button>
</HoverCardTrigger>
@@ -188,17 +197,6 @@ const statusBadgeText = computed(() => {
</div>
</div>
<!-- Error Message -->
<div
v-if="status === 'error' && errorMessage"
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<p class="text-xs text-red-600 dark:text-red-400">
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
{{ errorMessage }}
</p>
</div>
<!-- Details -->
<div v-if="details && details.length > 0" class="space-y-2">
<Separator />
@@ -206,12 +204,12 @@ const statusBadgeText = computed(() => {
<div
v-for="(detail, index) in details"
:key="index"
class="flex items-center justify-between text-xs"
class="flex items-start justify-between gap-3 text-xs"
>
<span class="text-muted-foreground">{{ detail.label }}</span>
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
<span
:class="cn(
'font-medium',
'font-medium text-right break-words min-w-0',
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
@@ -235,25 +233,34 @@ const statusBadgeText = computed(() => {
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
<template v-if="compact">
<!-- Compact: single row with dot + abbreviated title -->
<div class="flex items-center gap-1">
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
</div>
</template>
<template v-else>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</template>
</button>
</PopoverTrigger>
<PopoverContent class="w-80" :align="hoverAlign">
<PopoverContent class="w-[min(320px,90vw)]" :align="hoverAlign">
<div class="space-y-3">
<!-- Header -->
<div class="flex items-center gap-3">
@@ -289,17 +296,6 @@ const statusBadgeText = computed(() => {
</div>
</div>
<!-- Error Message -->
<div
v-if="status === 'error' && errorMessage"
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<p class="text-xs text-red-600 dark:text-red-400">
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
{{ errorMessage }}
</p>
</div>
<!-- Details -->
<div v-if="details && details.length > 0" class="space-y-2">
<Separator />
@@ -307,12 +303,12 @@ const statusBadgeText = computed(() => {
<div
v-for="(detail, index) in details"
:key="index"
class="flex items-center justify-between text-xs"
class="flex items-start justify-between gap-3 text-xs"
>
<span class="text-muted-foreground">{{ detail.label }}</span>
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
<span
:class="cn(
'font-medium',
'font-medium text-right break-words min-w-0',
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :

View File

@@ -632,12 +632,12 @@ watch(
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Monitor class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<Monitor class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>

View File

@@ -1202,12 +1202,17 @@ html.dark .hg-theme-default .hg-button.down-key,
}
@media (max-width: 640px) {
.vkb-body {
padding: 4px;
gap: 4px;
}
.vkb .simple-keyboard .hg-button {
height: 30px;
height: 28px;
font-size: 10px;
padding: 0 4px;
margin: 0 1px 3px 0;
min-width: 26px;
padding: 0 3px;
margin: 0 1px 2px 0;
min-width: 24px;
}
.vkb .simple-keyboard .hg-button.combination-key {
@@ -1275,6 +1280,85 @@ html.dark .hg-theme-default .hg-button.down-key,
}
}
@media (max-width: 400px) {
.vkb .simple-keyboard .hg-button {
height: 26px;
font-size: 9px;
padding: 0 2px;
margin: 0 1px 2px 0;
min-width: 20px;
border-radius: 4px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 100px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 44px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 50px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 34px;
}
.vkb .simple-keyboard .hg-button.combination-key {
font-size: 8px;
height: 22px;
padding: 0 4px;
}
.kb-control-container .hg-button {
min-width: 34px !important;
}
.kb-arrows-container .hg-button {
min-width: 30px !important;
width: 30px !important;
}
.vkb-media-btn {
padding: 3px 6px;
font-size: 12px;
min-width: 28px;
}
.vkb-header {
padding: 2px 6px;
min-height: 24px;
}
.vkb-btn {
padding: 1px 6px;
font-size: 10px;
}
.vkb-os-btn {
padding: 1px 6px;
font-size: 9px;
}
.vkb-title {
font-size: 10px;
}
}
/* Floating mode - slightly smaller keys but still readable */
.vkb--floating .vkb-body {
padding: 8px;

View File

@@ -334,15 +334,14 @@ async function addRemoteIceCandidate(candidate: IceCandidate) {
async function flushPendingRemoteIce() {
if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return
const remaining: WebRTCIceCandidateEvent[] = []
for (const event of pendingRemoteCandidates) {
const queued = pendingRemoteCandidates
pendingRemoteCandidates = []
for (const event of queued) {
if (event.session_id === sessionId) {
await addRemoteIceCandidate(event.candidate)
} else {
// Drop candidates for old sessions
}
}
pendingRemoteCandidates = remaining
if (pendingRemoteIceComplete.has(sessionId)) {
pendingRemoteIceComplete.delete(sessionId)
@@ -546,10 +545,8 @@ async function connect(): Promise<boolean> {
}
}
// 等待连接真正建立(最多等待 15 秒)
// 直接检查 peerConnection.connectionState 而不是 reactive state
// 因为 TypeScript 不知道 state 会被 onconnectionstatechange 回调异步修改
const connectionTimeout = 15000
// Wait for connection to establish (5s for LAN, sufficient for most scenarios)
const connectionTimeout = 5000
const pollInterval = 100
let waited = 0
connectStage.value = 'waiting_connection'
@@ -568,7 +565,6 @@ async function connect(): Promise<boolean> {
waited += pollInterval
}
// 超时
throw new Error('Connection timeout waiting for ICE negotiation')
} catch (err) {
state.value = 'failed'

View File

@@ -41,6 +41,7 @@ export default {
expand: 'Expand',
toggleTheme: 'Toggle theme',
toggleLanguage: 'Toggle language',
retry: 'Retry',
},
api: {
operationFailed: 'Operation Failed',
@@ -64,6 +65,7 @@ export default {
enterPassword: 'Enter password',
loginFailed: 'Login failed',
invalidPassword: 'Invalid username or password',
systemNotInitialized: 'System not initialized. Complete setup first.',
changePassword: 'Change Password',
currentPassword: 'Current Password',
currentPasswordPlaceholder: 'Enter current password',
@@ -78,6 +80,9 @@ export default {
userNotFound: 'User not found',
sessionExpired: 'Session expired',
loggedInElsewhere: 'Logged in elsewhere',
forgotPassword: 'Forgot password',
forgotPasswordHint:
'Forgot your password? On the host running One-KVM, open a terminal and run one-kvm user set-password, then enter and confirm the new password when prompted.',
},
status: {
connected: 'Connected',
@@ -105,7 +110,7 @@ export default {
fullscreen: 'Fullscreen',
fullscreenTip: 'Toggle fullscreen mode',
// Video Config
videoConfig: 'Video Config',
videoConfig: 'Video',
streamSettings: 'Stream Settings',
deviceSettings: 'Device Settings',
videoMode: 'Mode',
@@ -137,7 +142,7 @@ export default {
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
// HID Config
hidConfig: 'Mouse & HID',
hidConfig: 'HID',
mouseSettings: 'Mouse Settings',
hidDeviceSettings: 'HID Device Settings',
positioningMode: 'Positioning Mode',
@@ -497,7 +502,6 @@ export default {
buildInfo: 'Build Info',
detectDevices: 'Detect Devices',
detecting: 'Detecting...',
builtWith: "Copyright {'@'}2025 SilentWind",
networkSettings: 'Network Settings',
msdSettings: 'MSD Settings',
atxSettings: 'ATX Settings',
@@ -524,10 +528,43 @@ export default {
addBindAddress: 'Add address',
bindAddressListEmpty: 'Add at least one IP address.',
httpsEnabled: 'Enable HTTPS',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)',
// Port config
portConfig: 'Port & Protocol',
portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle',
httpPortReserved: 'HTTP port (reserved)',
httpsPortReserved: 'HTTPS port (reserved)',
previewUrl: 'Access URL preview',
// Listen address
listenAddress: 'Listen Address',
listenAddressDesc: 'Configure which network interfaces the web server listens on',
bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces',
bindModeLocalDesc: '127.0.0.1 — Allow local access only',
bindModeCustomDesc: 'Specify a list of IP addresses',
effectiveAddresses: 'Listen address preview',
// SSL certificate
sslCertificate: 'SSL Certificate',
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required',
sslCertCustom: 'Custom Certificate',
sslCertSelfSigned: 'Self-Signed',
sslCertActive: 'Custom certificate is active',
sslCertClear: 'Revert to Self-Signed',
sslCertSave: 'Save Certificate',
sslCertPem: 'Certificate (.crt / .pem)',
sslKeyPem: 'Private Key (.key)',
sslCertPemPlaceholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
sslKeyPemPlaceholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
sslCertSaved: 'Certificate saved, restart to apply',
sslCertCleared: 'Reverted to self-signed certificate, restart to apply',
restartRequired: 'Restart Required',
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
restarting: 'Restarting...',
autoRestarting: 'Restarting automatically',
autoRestartingDesc: 'Configuration saved. Will redirect to the new address once the service is back...',
autoRestartingHttpsDesc: 'Service is restarting. A redirect link will appear in {sec}s...',
autoRestartFailed: 'Auto-restart timed out. Please refresh the page or check the service status.',
httpsManualRedirectTitle: 'Click the link below to open the new address',
httpsManualRedirectDesc: 'HTTPS with a self-signed certificate requires browser approval. Click the link and choose "Proceed" on the security warning.',
onlineUpgrade: 'Online Upgrade',
onlineUpgradeDesc: 'Check and upgrade One-KVM',
updateChannel: 'Update Channel',
@@ -557,7 +594,6 @@ export default {
authSettingsDesc: 'Single-user access and session behavior',
allowMultipleSessions: 'Allow multiple web sessions',
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
singleUserSessionNote: 'Single-user mode is enforced; only session concurrency is configurable.',
// User management
userManagement: 'User Management',
userManagementDesc: 'Manage user accounts and permissions',
@@ -817,27 +853,19 @@ export default {
version: 'Version',
uptime: 'Uptime',
running: 'Running',
mode: 'Mode',
format: 'Format',
resolution: 'Resolution',
targetFps: 'Target FPS',
fps: 'Actual FPS',
fps: 'FPS',
clients: 'Clients',
backend: 'Backend',
initialized: 'Initialized',
yes: 'Yes',
no: 'No',
mouse: 'Mouse',
mouseSupport: 'Mouse Support',
currentMode: 'Current Mode',
absolute: 'Absolute',
relative: 'Relative',
connection: 'Connection',
channel: 'Channel',
networkError: 'Network Error',
disconnected: 'Disconnected',
availability: 'Availability',
errorCode: 'Error Code',
hidUnavailable: 'HID Unavailable',
sampleRate: 'Sample Rate',
channels: 'Channels',
@@ -889,6 +917,7 @@ export default {
title: 'GOSTC NAT Traversal',
desc: 'NAT traversal via GOSTC',
addr: 'Server Address',
addrPlaceholder: 'Hostname or IP (required)',
key: 'Client Key',
tls: 'Enable TLS',
},
@@ -915,9 +944,9 @@ export default {
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
relayKey: 'Relay Key',
relayKeyPlaceholder: 'Enter relay server key',
relayKeySet: '••••••••',
relayKeyHint: 'Authentication key for relay server (if server uses -k option)',
relayKeyPlaceholder: 'e.g. pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=',
relayKeySet: 'Saved (32-byte Base64, usually 44 chars; leave empty and save to keep)',
relayKeyHint: 'Same as hbbs/hbbr -k: standard Base64 decoding to exactly 32 bytes (typically 44 characters including trailing =)',
deviceInfo: 'Device Info',
deviceId: 'Device ID',
deviceIdHint: 'Use this ID in RustDesk client to connect',

View File

@@ -41,6 +41,7 @@ export default {
expand: '展开',
toggleTheme: '切换主题',
toggleLanguage: '切换语言',
retry: '重试',
},
api: {
operationFailed: '操作失败',
@@ -64,6 +65,7 @@ export default {
enterPassword: '请输入密码',
loginFailed: '登录失败',
invalidPassword: '用户名或密码错误',
systemNotInitialized: '系统尚未初始化,请先完成向导设置。',
changePassword: '修改密码',
currentPassword: '当前密码',
currentPasswordPlaceholder: '请输入当前密码',
@@ -78,6 +80,9 @@ export default {
userNotFound: '用户不存在',
sessionExpired: '会话已过期',
loggedInElsewhere: '已在别处登录',
forgotPassword: '忘记密码',
forgotPasswordHint:
'忘记密码?在运行本服务的设备上打开终端,执行 one-kvm user set-password按提示输入并确认新密码即可重置。',
},
status: {
connected: '已连接',
@@ -497,7 +502,6 @@ export default {
buildInfo: '构建信息',
detectDevices: '探测设备',
detecting: '探测中...',
builtWith: "版权信息 {'@'}2025 SilentWind",
networkSettings: '网络设置',
msdSettings: 'MSD 设置',
atxSettings: 'ATX 设置',
@@ -524,10 +528,43 @@ export default {
addBindAddress: '添加地址',
bindAddressListEmpty: '请至少填写一个 IP 地址。',
httpsEnabled: '启用 HTTPS',
httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签证书)',
httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书)',
// Port config
portConfig: '端口与协议',
portConfigDesc: '服务一次只运行在一个端口上,由 HTTPS 开关决定使用哪个端口',
httpPortReserved: 'HTTP 端口(备用)',
httpsPortReserved: 'HTTPS 端口(备用)',
previewUrl: '访问地址预览',
// Listen address
listenAddress: '监听地址',
listenAddressDesc: '配置 Web 服务监听哪些网络接口',
bindModeAllDesc: '0.0.0.0 — 监听所有网络接口',
bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问',
bindModeCustomDesc: '指定一组 IP 地址',
effectiveAddresses: '监听地址预览',
// SSL certificate
sslCertificate: 'SSL 证书',
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需要重启生效',
sslCertCustom: '自定义证书',
sslCertSelfSigned: '自签名证书',
sslCertActive: '自定义证书已启用',
sslCertClear: '恢复自签名',
sslCertSave: '保存证书',
sslCertPem: '证书内容 (.crt / .pem)',
sslKeyPem: '私钥内容 (.key)',
sslCertPemPlaceholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
sslKeyPemPlaceholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
sslCertSaved: '证书已保存,重启后生效',
sslCertCleared: '已恢复自签名证书,重启后生效',
restartRequired: '需要重启',
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
restarting: '正在重启...',
autoRestarting: '正在自动重启',
autoRestartingDesc: '配置已保存,服务恢复后将自动跳转到新地址...',
autoRestartingHttpsDesc: '服务即将重启,{sec} 秒后将显示跳转链接...',
autoRestartFailed: '自动重启超时,请手动刷新页面或检查服务状态。',
httpsManualRedirectTitle: '请点击下方链接前往新地址',
httpsManualRedirectDesc: 'HTTPS 自签名证书需要在浏览器中手动接受,点击链接后在安全警告页选择"继续访问"即可。',
onlineUpgrade: '在线升级',
onlineUpgradeDesc: '检查并升级 One-KVM',
updateChannel: '升级通道',
@@ -557,7 +594,6 @@ export default {
authSettingsDesc: '单用户访问与会话策略',
allowMultipleSessions: '允许多个 Web 会话',
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
singleUserSessionNote: '系统固定为单用户模式,仅可配置会话并发方式。',
// User management
userManagement: '用户管理',
userManagementDesc: '管理用户账号和权限',
@@ -817,27 +853,19 @@ export default {
version: '版本',
uptime: '运行时间',
running: '运行中',
mode: '模式',
format: '格式',
resolution: '分辨率',
targetFps: '目标帧率',
fps: '实际帧率',
fps: '帧率',
clients: '客户端',
backend: '后端',
initialized: '已初始化',
yes: '是',
no: '否',
mouse: '鼠标',
mouseSupport: '鼠标支持',
currentMode: '当前模式',
absolute: '绝对定位',
relative: '相对定位',
connection: '连接',
channel: '通道',
networkError: '网络错误',
disconnected: '已断开',
availability: '可用性',
errorCode: '错误码',
hidUnavailable: 'HID不可用',
sampleRate: '采样率',
channels: '声道',
@@ -889,6 +917,7 @@ export default {
title: 'GOSTC 内网穿透',
desc: '通过 GOSTC 实现内网穿透',
addr: '服务器地址',
addrPlaceholder: '主机名或 IP必填',
key: '客户端密钥',
tls: '启用 TLS',
},
@@ -915,9 +944,9 @@ export default {
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: '中继服务器地址(端口可省略,默认 21117留空则自动从 ID 服务器推导',
relayKey: '中继密钥',
relayKeyPlaceholder: '输入中继服务器密钥',
relayKeySet: '••••••••',
relayKeyHint: '中继服务器认证密钥(如果服务器使用 -k 选项',
relayKeyPlaceholder: '例如 pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=',
relayKeySet: '已保存32 字节 Base64通常 44 字符;留空保存则保留)',
relayKeyHint: '与 hbbs/hbbr 的 -k 一致:标准 Base64解码后固定 32 字节(一般为 44 个字符,含末尾 =',
deviceInfo: '设备信息',
deviceId: '设备 ID',
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',

View File

@@ -27,8 +27,6 @@ import type {
StreamConfigUpdate,
VideoConfig,
VideoConfigUpdate,
WebConfig,
WebConfigUpdate,
} from '@/types/generated'
import type {
RtspConfigResponse as ApiRtspConfigResponse,
@@ -38,6 +36,8 @@ import type {
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
RustDeskStatusResponse as ApiRustDeskStatusResponse,
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
WebConfig,
WebConfigUpdate,
} from '@/api'
function normalizeErrorMessage(error: unknown): string {

View File

@@ -259,6 +259,30 @@ export const useSystemStore = defineStore('system', () => {
}
}
async function fetchStreamState() {
try {
const [status, modeResp] = await Promise.all([
streamApi.status(),
streamApi.getMode().catch(() => ({ mode: 'mjpeg' }))
])
stream.value = {
online: status.state === 'streaming',
active: status.state !== 'uninitialized',
device: status.device,
format: status.format,
resolution: status.resolution,
targetFps: status.target_fps,
clients: status.clients,
streamMode: modeResp.mode || 'mjpeg',
error: status.state === 'error' ? 'Stream error' : null,
}
return status
} catch (e) {
console.error('Failed to fetch stream state:', e)
throw e
}
}
async function fetchAllStates() {
loading.value = true
error.value = null
@@ -266,7 +290,8 @@ export const useSystemStore = defineStore('system', () => {
try {
await Promise.all([
fetchSystemInfo(),
// HID state is updated via WebSocket device_info event
fetchStreamState().catch(() => null),
fetchHidState().catch(() => null),
fetchAtxState().catch(() => null),
fetchMsdState().catch(() => null),
])

View File

@@ -289,7 +289,11 @@ export interface StreamConfig {
turn_password?: string;
}
/** Web server configuration */
/**
* Web server configuration persisted in the database (includes on-disk TLS paths).
*
* The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
*/
export interface WebConfig {
/** HTTP port */
http_port: number;
@@ -321,7 +325,7 @@ export interface TtydConfig {
export interface GostcConfig {
/** Enable auto-start */
enabled: boolean;
/** Server address (e.g., gostc.mofeng.run) */
/** Server address (hostname or IP) */
addr: string;
/** Client key from GOSTC management panel */
key: string;
@@ -686,12 +690,33 @@ export interface VideoConfigUpdate {
quality?: number;
}
/**
* Web server settings returned by `GET` / `PATCH /api/config/web`.
*
* Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
*/
export interface WebConfigResponse {
http_port: number;
https_port: number;
bind_addresses: string[];
bind_address: string;
https_enabled: boolean;
/** Whether a custom TLS certificate is active (non-empty cert + key paths in stored config). */
has_custom_cert: boolean;
}
export interface WebConfigUpdate {
http_port?: number;
https_port?: number;
bind_addresses?: string[];
bind_address?: string;
https_enabled?: boolean;
/** PEM-encoded certificate content (must be provided together with ssl_key_pem) */
ssl_cert_pem?: string;
/** PEM-encoded private key content (must be provided together with ssl_cert_pem) */
ssl_key_pem?: string;
/** Set to true to remove the custom certificate and revert to self-signed */
clear_custom_cert?: boolean;
}
/**

View File

@@ -27,6 +27,7 @@ import InfoBar from '@/components/InfoBar.vue'
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
import StatsSheet from '@/components/StatsSheet.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import {
@@ -46,7 +47,6 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Monitor,
MonitorOff,
RefreshCw,
LogOut,
@@ -96,6 +96,7 @@ const videoLoading = ref(true)
const videoError = ref(false)
const videoErrorMessage = ref('')
const videoRestarting = ref(false) // Track if video is restarting due to config change
const mjpegFrameReceived = ref(false) // Whether MJPEG stream has received at least one frame
// Video aspect ratio (dynamically updated from actual video dimensions)
// Using string format "width/height" to let browser handle the ratio calculation
@@ -188,6 +189,11 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
if (webrtc.isConnecting.value) return 'connecting'
if (webrtc.isConnected.value) return 'connected'
}
// MJPEG: check if frames have actually arrived (frontend-side detection)
// This is more reliable than relying on stream.online from backend,
// which can be stale due to the debounce delay in device_info broadcaster.
// Also handles browsers that don't fire img.onload for multipart MJPEG streams.
if (videoMode.value === 'mjpeg' && mjpegFrameReceived.value) return 'connected'
if (systemStore.stream?.online) return 'connected'
return 'disconnected'
})
@@ -218,25 +224,23 @@ const videoQuickInfo = computed(() => {
const videoDetails = computed<StatusDetail[]>(() => {
const stream = systemStore.stream
if (!stream) return []
// Use backend-provided FPS from WebSocket
const receivedFps = backendFps.value
// Display mode: use local videoMode which is synced with server
const modeDisplay = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
const details: StatusDetail[] = [
// Input (capture) format → output (delivery) mode
const inputFmt = stream.format || 'MJPEG'
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt}${outputFmt}`
// Target / actual FPS combined
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
return [
{ label: t('statusCard.device'), value: stream.device || '-' },
{ label: t('statusCard.mode'), value: modeDisplay, status: 'ok' },
{ label: t('statusCard.format'), value: stream.format || 'MJPEG' },
{ label: t('statusCard.format'), value: formatDisplay },
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
{ label: t('statusCard.targetFps'), value: formatFpsValue(stream.targetFps ?? 0) },
{ label: t('statusCard.fps'), value: formatFpsValue(receivedFps), status: receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined },
{ label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus },
]
// Show network error if WebSocket has network issue
if (wsNetworkError.value) {
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
}
return details
})
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
@@ -352,54 +356,59 @@ const hidDetails = computed<StatusDetail[]>(() => {
const hidErrorStatus: StatusDetail['status'] =
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
const details: StatusDetail[] = [
{ label: t('statusCard.device'), value: hid.device || '-' },
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error && hid.errorCode !== 'udc_not_configured' ? 'error' : hid.initialized ? 'ok' : 'warning' },
{ label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' },
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
{
label: t('settings.otgKeyboardLeds'),
value: hid.keyboardLedsEnabled
? `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`
: t('infobar.keyboardLedUnavailable'),
status: hid.keyboardLedsEnabled ? 'ok' : undefined,
},
]
const details: StatusDetail[] = []
if (hid.errorCode) {
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: hidErrorStatus })
}
// Backend + device combined
const backendStr = hid.backend || t('common.unknown')
const deviceStr = hid.device ? ` @ ${hid.device}` : ''
details.push({ label: t('statusCard.backend'), value: `${backendStr}${deviceStr}` })
// Error message (with error code as suffix when present) OR normal-state info
if (errorMessage) {
details.push({ label: t('common.error'), value: errorMessage, status: hidErrorStatus })
const codeSuffix = hid.errorCode ? ` (${hid.errorCode})` : ''
details.push({ label: t('common.error'), value: `${errorMessage}${codeSuffix}`, status: hidErrorStatus })
} else if (hid.online) {
details.push({ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' })
if (hid.keyboardLedsEnabled) {
details.push({
label: t('settings.otgKeyboardLeds'),
value: `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`,
status: 'ok',
})
}
}
// Add HID channel info based on video mode
// Channel (merged with availability / connection state)
let channelValue: string
let channelStatus: StatusDetail['status']
if (videoMode.value !== 'mjpeg') {
// WebRTC mode - show DataChannel status
if (webrtc.dataChannelReady.value) {
details.push({ label: t('statusCard.channel'), value: 'DataChannel (WebRTC)', status: 'ok' })
channelValue = 'DataChannel (WebRTC)'
channelStatus = 'ok'
} else if (webrtc.isConnecting.value || webrtc.isConnected.value) {
details.push({ label: t('statusCard.channel'), value: 'DataChannel', status: 'warning' })
channelValue = 'DataChannel'
channelStatus = 'warning'
} else {
// Fallback to WebSocket
details.push({ label: t('statusCard.channel'), value: 'WebSocket (fallback)', status: hidWs.connected.value ? 'ok' : 'warning' })
channelValue = 'WebSocket (fallback)'
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
}
} else {
// MJPEG mode - WebSocket HID
details.push({ label: t('statusCard.channel'), value: 'WebSocket', status: hidWs.connected.value ? 'ok' : 'warning' })
channelValue = 'WebSocket'
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
}
// Add connection status for WebSocket (only relevant for MJPEG or fallback)
if (videoMode.value === 'mjpeg' || !webrtc.dataChannelReady.value) {
if (hidWs.networkError.value) {
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
channelValue += ` (${t('statusCard.networkError')})`
channelStatus = 'warning'
} else if (!hidWs.connected.value) {
details.push({ label: t('statusCard.connection'), value: t('statusCard.disconnected'), status: 'warning' })
channelValue += ` (${t('statusCard.disconnected')})`
channelStatus = 'warning'
} else if (hidWs.hidUnavailable.value) {
details.push({ label: t('statusCard.availability'), value: t('statusCard.hidUnavailable'), status: 'warning' })
channelValue += ` (${t('statusCard.hidUnavailable')})`
channelStatus = 'warning'
}
}
details.push({ label: t('statusCard.channel'), value: channelValue, status: channelStatus })
return details
})
@@ -561,6 +570,12 @@ const MAX_CONSECUTIVE_ERRORS = 2 // If 2+ errors in grace period, it's a real pr
let pendingWebRTCReadyGate = false
let webrtcConnectTask: Promise<boolean> | null = null
// WebRTC auto-reconnect on device-lost/recovery
let webrtcRecoveryTimerId: number | null = null
let webrtcRecoveryAttempts = 0
const MAX_WEBRTC_RECOVERY_ATTEMPTS = 8
const WEBRTC_RECOVERY_BASE_DELAY = 2000
// Last-frame overlay (prevents black flash during mode switches)
const frameOverlayUrl = ref<string | null>(null)
@@ -680,6 +695,7 @@ function handleVideoLoad() {
// MJPEG video frame loaded successfully - update stream online status
// This fixes the timing issue where device_info event may arrive before stream is fully active
if (videoMode.value === 'mjpeg') {
mjpegFrameReceived.value = true
systemStore.setStreamOnline(true)
// Update aspect ratio from MJPEG image dimensions
const img = videoRef.value
@@ -758,6 +774,7 @@ function handleVideoError() {
// Show loading state immediately
videoLoading.value = true
mjpegFrameReceived.value = false
// Auto-retry with exponential backoff (infinite retry, capped delay)
retryCount++
@@ -773,9 +790,78 @@ function handleVideoError() {
function handleStreamDeviceLost(data: { device: string; reason: string }) {
videoError.value = true
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
// In WebRTC mode, the pipeline will attempt to restart itself.
// Start an exponential-backoff reconnect loop so the session is
// re-established automatically once the backend is ready again.
if (videoMode.value !== 'mjpeg') {
scheduleWebRTCRecovery()
}
}
function scheduleWebRTCRecovery() {
// Clear any previous timer
if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null
}
if (webrtcRecoveryAttempts >= MAX_WEBRTC_RECOVERY_ATTEMPTS) {
console.warn('[Recovery] Max WebRTC recovery attempts reached, giving up')
webrtcRecoveryAttempts = 0
return
}
const delay = Math.min(
WEBRTC_RECOVERY_BASE_DELAY * Math.pow(2, webrtcRecoveryAttempts),
30000,
)
console.log(
`[Recovery] Scheduling WebRTC reconnect attempt ${webrtcRecoveryAttempts + 1}/${MAX_WEBRTC_RECOVERY_ATTEMPTS} in ${delay}ms`,
)
webrtcRecoveryTimerId = window.setTimeout(async () => {
webrtcRecoveryTimerId = null
webrtcRecoveryAttempts++
// Only reconnect if we are still in a WebRTC mode and error state
if (videoMode.value === 'mjpeg' || !videoError.value) {
webrtcRecoveryAttempts = 0
return
}
console.log(`[Recovery] Attempting WebRTC reconnect (attempt ${webrtcRecoveryAttempts})`)
try {
await webrtc.disconnect()
const ok = await connectWebRTCSerial('device-recovery')
if (ok) {
console.log('[Recovery] WebRTC reconnected successfully')
videoError.value = false
videoErrorMessage.value = ''
webrtcRecoveryAttempts = 0
} else {
// Retry
scheduleWebRTCRecovery()
}
} catch {
scheduleWebRTCRecovery()
}
}, delay)
}
function cancelWebRTCRecovery() {
if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null
}
webrtcRecoveryAttempts = 0
}
function handleStreamRecovered(_data: { device: string }) {
// Cancel any pending recovery timer backend is back
cancelWebRTCRecovery()
// Reset video error state
videoError.value = false
videoErrorMessage.value = ''
@@ -910,6 +996,16 @@ function handleStreamStateChanged(data: any) {
if (data.state === 'error') {
videoError.value = true
videoErrorMessage.value = t('console.streamError')
} else if (data.state === 'recovering' && videoMode.value !== 'mjpeg') {
// Backend is in the DeviceLost recovery loop; start WebRTC reconnect if not already scheduled.
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
scheduleWebRTCRecovery()
}
} else if (data.state === 'streaming' || data.state === 'no_signal') {
// Backend stream is alive; cancel any pending recovery timers.
if (data.state === 'streaming') {
cancelWebRTCRecovery()
}
}
}
@@ -1062,6 +1158,7 @@ function refreshVideo() {
backendFps.value = 0
videoError.value = false
videoErrorMessage.value = ''
mjpegFrameReceived.value = false
// Update timestamp to force MJPEG reconnection via reactive URL
isRefreshingVideo = true
@@ -1218,18 +1315,17 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
}
}
// Step 3: Connect WebRTC with retry
let retries = 3
// Step 3: Connect WebRTC with retry (backoff between retries)
const MAX_ATTEMPTS = 3
const RETRY_DELAYS = [200, 800]
let success = false
while (retries > 0 && !success) {
success = await connectWebRTCSerial('switchToWebRTC')
if (!success) {
retries--
if (retries > 0) {
console.log(`[WebRTC] Connection failed, retrying (${retries} attempts left)`)
await new Promise(resolve => setTimeout(resolve, 500))
}
for (let attempt = 0; attempt < MAX_ATTEMPTS && !success; attempt++) {
if (attempt > 0) {
const delay = RETRY_DELAYS[attempt - 1] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1]
console.log(`[WebRTC] Connection failed, retrying in ${delay}ms (${MAX_ATTEMPTS - attempt} attempts left)`)
await new Promise(resolve => setTimeout(resolve, delay))
}
success = await connectWebRTCSerial('switchToWebRTC')
}
if (success) {
toast.success(t('console.webrtcConnected'), {
@@ -1432,10 +1528,22 @@ watch(() => webrtc.state.value, (newState, oldState) => {
}, 1000)
}
// Handle direct 'failed' state (ICE or DTLS failure)
// Allow one automatic retry before marking as failed, consistent with
// the disconnected->reconnect path that allows 2 failures.
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 1) {
if (webrtcReconnectFailures >= 2) {
markWebRTCFailure(t('console.webrtcFailed'))
} else {
webrtcReconnectTimeout = setTimeout(async () => {
if (videoMode.value !== 'mjpeg' && webrtc.state.value !== 'connected') {
const success = await connectWebRTCSerial('auto reconnect after failed')
if (!success) {
markWebRTCFailure(t('console.webrtcFailed'))
}
}
}, 1000)
}
}
})
@@ -2061,6 +2169,15 @@ async function activateConsoleView() {
isConsoleActive.value = true
registerInteractionListeners()
// REST snapshot: returning from Settings (or other routes) may have missed WS device_info
void systemStore.fetchAllStates()
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
// Ensure HID WebSocket is connected when console becomes active
if (!hidWs.connected.value) {
hidWs.connect().catch(() => {})
}
if (videoMode.value !== 'mjpeg' && webrtc.videoTrack.value) {
await nextTick()
await rebindWebRTCVideo()
@@ -2210,6 +2327,7 @@ onUnmounted(() => {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
}
cancelWebRTCRecovery()
videoSession.clearWaiters()
// Reset counters
@@ -2232,18 +2350,42 @@ onUnmounted(() => {
<div class="h-screen h-dvh flex flex-col bg-background">
<!-- Header -->
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="px-4">
<div class="h-14 flex items-center justify-between">
<div class="px-2 sm:px-4">
<div class="h-10 sm:h-14 flex items-center justify-between">
<!-- Left: Logo -->
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Monitor class="h-6 w-6 text-primary" />
<span class="font-bold text-lg">One-KVM</span>
<div class="flex items-center gap-2 sm:gap-6">
<div class="flex items-center gap-1.5 sm:gap-2">
<BrandMark size="md" class="hidden sm:block" />
<BrandMark size="sm" class="sm:hidden" />
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
</div>
<!-- Mobile Status Indicators (inline, minimal) -->
<div class="flex md:hidden items-center gap-1">
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
compact
/>
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
:hover-align="hidHoverAlign"
compact
/>
</div>
</div>
<!-- Right: Status Cards + User Menu -->
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 sm:gap-2">
<div class="hidden md:flex items-center gap-2">
<!-- Video Status -->
<StatusCard
@@ -2305,9 +2447,9 @@ onUnmounted(() => {
<!-- User Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
<ChevronDown class="h-3.5 w-3.5" />
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 h-7 sm:h-9 px-2 sm:px-3">
<span class="text-xs max-w-[60px] sm:max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
<ChevronDown class="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -2338,60 +2480,6 @@ onUnmounted(() => {
</DropdownMenu>
</div>
</div>
<!-- Mobile Status Row -->
<div class="md:hidden pb-2">
<div class="flex items-center gap-2 overflow-x-auto">
<div class="shrink-0">
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
compact
/>
</div>
<div v-if="systemStore.audio?.available" class="shrink-0">
<StatusCard
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
compact
/>
</div>
<div class="shrink-0">
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
:hover-align="hidHoverAlign"
compact
/>
</div>
<div v-if="showMsdStatusCard" class="shrink-0">
<StatusCard
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
compact
/>
</div>
</div>
</div>
</div>
</header>
@@ -2424,7 +2512,7 @@ onUnmounted(() => {
/>
<!-- Video Container -->
<div class="relative h-full w-full flex items-center justify-center p-2 sm:p-4">
<div class="relative h-full w-full flex items-center justify-center p-1 sm:p-4">
<div
ref="videoContainerRef"
class="relative bg-black overflow-hidden flex items-center justify-center"
@@ -2432,8 +2520,7 @@ onUnmounted(() => {
aspectRatio: videoAspectRatio ?? '16/9',
maxWidth: '100%',
maxHeight: '100%',
minWidth: '320px',
minHeight: '180px',
minHeight: '120px',
}"
:class="{
'opacity-60': videoLoading || videoError,
@@ -2487,11 +2574,11 @@ onUnmounted(() => {
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
</div>
<Spinner class="h-16 w-16 text-white mb-4" />
<p class="text-white/90 text-lg font-medium">
<Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" />
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4">
{{ webrtcLoadingMessage }}
</p>
<p class="text-white/50 text-sm mt-2">
<p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2">
{{ t('console.pleaseWait') }}
</p>
</div>
@@ -2503,10 +2590,10 @@ onUnmounted(() => {
v-if="videoError && !videoLoading"
class="absolute inset-0 flex flex-col items-center justify-center bg-black/85 text-white gap-4 transition-opacity duration-300 p-4"
>
<MonitorOff class="h-16 w-16 text-slate-400" />
<div class="text-center max-w-md">
<p class="font-medium text-lg mb-2">{{ t('console.connectionFailed') }}</p>
<p class="text-sm text-slate-300 mb-3">{{ t('console.connectionFailedDesc') }}</p>
<MonitorOff class="h-10 w-10 sm:h-16 sm:w-16 text-slate-400" />
<div class="text-center max-w-md px-2">
<p class="font-medium text-sm sm:text-lg mb-1 sm:mb-2">{{ t('console.connectionFailed') }}</p>
<p class="text-xs sm:text-sm text-slate-300 mb-2 sm:mb-3">{{ t('console.connectionFailedDesc') }}</p>
<!-- Expandable error details -->
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
@@ -2564,22 +2651,22 @@ onUnmounted(() => {
<!-- Terminal Dialog -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-4 py-3 border-b shrink-0">
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
<DialogTitle class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<Terminal class="h-5 w-5" />
{{ t('extensions.ttyd.title') }}
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 mr-8"
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
@click="openTerminalInNewTab"
:aria-label="t('extensions.ttyd.openInNewTab')"
:title="t('extensions.ttyd.openInNewTab')"
>
<ExternalLink class="h-4 w-4" />
<ExternalLink class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
</DialogTitle>
</DialogHeader>
@@ -2597,11 +2684,11 @@ onUnmounted(() => {
<!-- Change Password Dialog -->
<Dialog v-model:open="changePasswordDialogOpen">
<DialogContent class="sm:max-w-md">
<DialogContent class="w-[95vw] max-w-md">
<DialogHeader>
<DialogTitle>{{ t('auth.changePassword') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-3 sm:space-y-4 py-2 sm:py-4">
<div class="space-y-2">
<Label for="currentPassword">{{ t('auth.currentPassword') }}</Label>
<Input

View File

@@ -7,14 +7,24 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
import BrandMark from '@/components/BrandMark.vue'
import { Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
/** Map backend English messages to locale strings (API returns fixed English copy). */
function localizedLoginError(raw: string | null): string {
if (!raw) return t('auth.loginFailed')
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
if (raw.includes('System not initialized')) return t('auth.systemNotInitialized')
return raw
}
const username = ref('')
const password = ref('')
const showPassword = ref(false)
@@ -40,7 +50,7 @@ async function handleLogin() {
const redirect = route.query.redirect as string
router.push(redirect || '/')
} else {
error.value = authStore.error || t('auth.loginFailed')
error.value = localizedLoginError(authStore.error)
}
loading.value = false
@@ -55,8 +65,8 @@ async function handleLogin() {
</div>
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
<Monitor class="w-8 h-8 text-primary" />
<div class="mx-auto flex justify-center">
<BrandMark size="xl" />
</div>
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
<CardDescription>{{ t('auth.login') }}</CardDescription>
@@ -107,6 +117,25 @@ async function handleLogin() {
<span v-if="loading">{{ t('common.loading') }}</span>
<span v-else>{{ t('auth.login') }}</span>
</Button>
<div class="text-right">
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{{ t('auth.forgotPassword') }}
<CircleHelp class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-80 p-3" align="end">
<p class="text-xs text-muted-foreground">
{{ t('auth.forgotPasswordHint') }}
</p>
</PopoverContent>
</Popover>
</div>
</form>
</CardContent>
</Card>

View File

@@ -50,6 +50,8 @@ import { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
@@ -84,6 +86,7 @@ import {
Copy,
ScreenShare,
Radio,
Globe,
} from 'lucide-vue-next'
const { t, te } = useI18n()
@@ -100,7 +103,7 @@ const saved = ref(false)
const SETTINGS_SECTION_IDS = new Set([
'appearance',
'account',
'access',
'network',
'video',
'hid',
'msd',
@@ -120,7 +123,7 @@ const navGroups = computed(() => [
items: [
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
{ id: 'account', label: t('settings.account'), icon: User },
{ id: 'access', label: t('settings.access'), icon: Lock },
{ id: 'network', label: t('settings.network'), icon: Globe },
]
},
{
@@ -156,7 +159,9 @@ function selectSection(id: string) {
}
function normalizeSettingsSection(value: unknown): string | null {
return typeof value === 'string' && SETTINGS_SECTION_IDS.has(value) ? value : null
if (typeof value !== 'string') return null
if (value === 'access-control') return 'account'
return SETTINGS_SECTION_IDS.has(value) ? value : null
}
// Theme
@@ -205,7 +210,7 @@ const showTerminalDialog = ref(false)
// Extension config (local edit state)
const extConfig = ref({
ttyd: { enabled: false, shell: '/bin/bash' },
gostc: { enabled: false, addr: 'gostc.mofeng.run', key: '', tls: true },
gostc: { enabled: false, addr: '', key: '', tls: true },
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
})
@@ -236,8 +241,16 @@ const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
username: '',
password: '',
})
function formatHostForUrl(hostname: string): string {
if (!hostname) return '127.0.0.1'
return hostname.includes(':') && !hostname.startsWith('[')
? `[${hostname}]`
: hostname
}
const rtspStreamUrl = computed(() => {
const host = window.location.hostname || '127.0.0.1'
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
const path = (rtspLocalConfig.value.path || 'live').trim().replace(/^\/+|\/+$/g, '') || 'live'
const port = Number(rtspLocalConfig.value.port) || 8554
return `rtsp://${host}:${port}/${path}`
@@ -250,10 +263,22 @@ const webServerConfig = ref<WebConfig>({
bind_address: '0.0.0.0',
bind_addresses: ['0.0.0.0'],
https_enabled: false,
has_custom_cert: false,
})
const webServerLoading = ref(false)
// SSL certificate state
const sslCertPem = ref('')
const sslKeyPem = ref('')
const certSaving = ref(false)
const certClearing = ref(false)
const showRestartDialog = ref(false)
const restarting = ref(false)
// Auto-restart flow (no dialog needed for web-config saves)
const autoRestarting = ref(false)
const autoRestartFailed = ref(false)
// For HTTPS targets: can't poll (self-signed cert), show manual link instead
const autoRestartManualUrl = ref<string | null>(null)
const autoRestartCountdown = ref(0)
const updateChannel = ref<UpdateChannel>('stable')
const updateOverview = ref<UpdateOverviewResponse | null>(null)
const updateStatus = ref<UpdateStatusResponse | null>(null)
@@ -291,6 +316,18 @@ const effectiveBindAddresses = computed(() => {
return normalizeBindAddresses(bindAddressList.value)
})
/** 预览当前配置生效后的访问 URL取第一个非通配地址显示 */
const previewAccessUrl = computed(() => {
const https = webServerConfig.value.https_enabled
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
const scheme = https ? 'https' : 'http'
// 对通配地址,用当前浏览器 hostname 替代
const addrs = effectiveBindAddresses.value
const firstAddr = addrs.find(a => a !== '0.0.0.0' && a !== '::') ?? window.location.hostname
const host = firstAddr.includes(':') ? `[${firstAddr}]` : firstAddr
return `${scheme}://${host}:${port}`
})
// Config
interface DeviceConfig {
video: Array<{
@@ -974,33 +1011,29 @@ async function saveConfig() {
saved.value = false
try {
// Save only config related to the active section
const savePromises: Promise<unknown>[] = []
// Save only config related to the active section.
// Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH
// requests could overwrite each other's section (last writer wins on full JSON).
// Video config (including encoder and WebRTC/STUN/TURN settings)
if (activeSection.value === 'video') {
savePromises.push(
configStore.updateVideo({
device: config.value.video_device || undefined,
format: config.value.video_format || undefined,
width: config.value.video_width,
height: config.value.video_height,
fps: toConfigFps(config.value.video_fps),
})
)
// Save Stream/Encoder and STUN/TURN config together
savePromises.push(
configStore.updateStream({
encoder: config.value.encoder_backend as any,
stun_server: config.value.stun_server || undefined,
turn_server: config.value.turn_server || undefined,
turn_username: config.value.turn_username || undefined,
turn_password: config.value.turn_password || undefined,
})
)
await configStore.updateVideo({
device: config.value.video_device || undefined,
format: config.value.video_format || undefined,
width: config.value.video_width,
height: config.value.video_height,
fps: toConfigFps(config.value.video_fps),
})
await configStore.updateStream({
encoder: config.value.encoder_backend as any,
stun_server: config.value.stun_server || undefined,
turn_server: config.value.turn_server || undefined,
turn_username: config.value.turn_username || undefined,
turn_password: config.value.turn_password || undefined,
})
}
// HID config
// HID config (includes MSD enable — same gadget; must not race with updateHid)
if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
return
@@ -1024,24 +1057,20 @@ async function saveConfig() {
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
}
savePromises.push(configStore.updateHid(hidUpdate))
savePromises.push(
configStore.updateMsd({
enabled: config.value.msd_enabled,
})
)
await configStore.updateHid(hidUpdate)
await configStore.updateMsd({
enabled: config.value.msd_enabled,
})
}
// MSD config
if (activeSection.value === 'msd') {
savePromises.push(
configStore.updateMsd({
msd_dir: config.value.msd_dir || undefined,
})
)
await configStore.updateMsd({
msd_dir: config.value.msd_dir || undefined,
})
}
await Promise.all(savePromises)
await loadConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
} catch (e) {
@@ -1435,6 +1464,12 @@ function normalizeRustdeskServer(value: string, defaultPort: number): string | u
return `${trimmed}:${defaultPort}`
}
/** Strip line breaks from pasted keys; empty means “do not change” on PATCH. */
function normalizeRustdeskRelayKey(value: string): string | undefined {
const cleaned = value.replace(/\r?\n/g, '').trim()
return cleaned || undefined
}
function normalizeRtspPath(path: string): string {
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
}
@@ -1496,16 +1531,15 @@ async function saveWebServerConfig() {
if (bindAddressError.value) return
webServerLoading.value = true
try {
const update = {
const updated = await configStore.updateWeb({
http_port: webServerConfig.value.http_port,
https_port: webServerConfig.value.https_port,
https_enabled: webServerConfig.value.https_enabled,
bind_addresses: effectiveBindAddresses.value,
}
const updated = await configStore.updateWeb(update)
})
webServerConfig.value = updated
applyBindStateFromConfig(updated)
showRestartDialog.value = true
await triggerAutoRestart()
} catch (e) {
console.error('Failed to save web server config:', e)
} finally {
@@ -1513,18 +1547,50 @@ async function saveWebServerConfig() {
}
}
async function saveCertificate() {
if (!sslCertPem.value.trim() || !sslKeyPem.value.trim()) return
certSaving.value = true
try {
const updated = await configStore.updateWeb({
ssl_cert_pem: sslCertPem.value,
ssl_key_pem: sslKeyPem.value,
})
webServerConfig.value = updated
sslCertPem.value = ''
sslKeyPem.value = ''
await triggerAutoRestart()
} catch (e) {
console.error('Failed to save certificate:', e)
} finally {
certSaving.value = false
}
}
async function clearCertificate() {
certClearing.value = true
try {
const updated = await configStore.updateWeb({ clear_custom_cert: true })
webServerConfig.value = updated
await triggerAutoRestart()
} catch (e) {
console.error('Failed to clear certificate:', e)
} finally {
certClearing.value = false
}
}
/** 手动点重启按钮(仅用于弹窗场景,保留兼容) */
async function restartServer() {
restarting.value = true
try {
await systemApi.restart()
// Wait for server to restart, then reload page
setTimeout(() => {
const protocol = webServerConfig.value.https_enabled ? 'https' : 'http'
const port = webServerConfig.value.https_enabled
? webServerConfig.value.https_port
: webServerConfig.value.http_port
const newUrl = `${protocol}://${window.location.hostname}:${port}`
window.location.href = newUrl
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
window.location.href = `${protocol}://${host}:${port}`
}, 3000)
} catch (e) {
console.error('Failed to restart server:', e)
@@ -1532,6 +1598,75 @@ async function restartServer() {
}
}
/** 轮询目标地址 /api/health最多等待 maxMs 毫秒 */
async function pollUntilReady(targetOrigin: string, maxMs = 30000): Promise<boolean> {
const deadline = Date.now() + maxMs
const healthUrl = targetOrigin.replace(/\/$/, '') + '/api/health'
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 800))
try {
const ctrl = new AbortController()
const tid = setTimeout(() => ctrl.abort(), 1500)
const res = await fetch(healthUrl, { signal: ctrl.signal })
clearTimeout(tid)
if (res.ok) return true
} catch {
// server still restarting — keep polling
}
}
return false
}
/**
* 保存网络配置后自动重启并跳转。
*
* - HTTP 目标:轮询 /api/health服务恢复后自动跳转。
* - HTTPS 目标:自签名证书导致 fetch 被浏览器拦截ERR_CERT_AUTHORITY_INVALID
* 无法自动轮询。改为倒计时结束后展示跳转链接,由用户点击并在浏览器中手动接受证书。
*/
async function triggerAutoRestart() {
const https = webServerConfig.value.https_enabled
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
const protocol = https ? 'https' : 'http'
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
const targetOrigin = `${protocol}://${host}:${port}`
autoRestarting.value = true
autoRestartFailed.value = false
autoRestartManualUrl.value = null
try {
await systemApi.restart()
if (https) {
// HTTPS浏览器拒绝自签名证书无法轮询。
// 等待固定时间后展示手动跳转链接。
const WAIT_SEC = 6
autoRestartCountdown.value = WAIT_SEC
for (let i = WAIT_SEC - 1; i >= 0; i--) {
await new Promise(r => setTimeout(r, 1000))
autoRestartCountdown.value = i
}
autoRestartManualUrl.value = targetOrigin
autoRestarting.value = false
} else {
// HTTP可以安全轮询服务恢复后自动跳转。
await new Promise(r => setTimeout(r, 1200))
const ready = await pollUntilReady(targetOrigin)
if (ready) {
window.location.href = targetOrigin
} else {
autoRestartFailed.value = true
autoRestarting.value = false
}
}
} catch (e) {
console.error('Auto restart failed:', e)
autoRestartFailed.value = true
autoRestarting.value = false
}
}
async function loadUpdateOverview() {
updateLoading.value = true
try {
@@ -1641,7 +1776,7 @@ async function saveRustdeskConfig() {
enabled: rustdeskLocalConfig.value.enabled,
rendezvous_server: rendezvousServer,
relay_server: relayServer,
relay_key: rustdeskLocalConfig.value.relay_key || undefined,
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
})
await loadRustdeskConfig()
// Clear relay_key input after save (it's a password field)
@@ -1918,16 +2053,16 @@ watch(() => route.query.tab, (tab) => {
<AppLayout>
<div class="flex h-full overflow-hidden">
<!-- Mobile Header -->
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center px-4 py-3 border-b bg-background">
<div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background">
<Sheet v-model:open="mobileMenuOpen">
<SheetTrigger as-child>
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
<Button variant="ghost" size="icon" class="mr-1.5 sm:mr-2 h-8 w-8 sm:h-9 sm:w-9">
<Menu class="h-4 w-4" />
<span class="sr-only">{{ t('common.menu') }}</span>
</Button>
</SheetTrigger>
<SheetContent side="left" class="w-72 p-0">
<div class="p-6">
<div class="p-4 sm:p-6">
<h2 class="text-lg font-semibold mb-4">{{ t('settings.title') }}</h2>
<nav class="space-y-6">
<div v-for="group in navGroups" :key="group.title" class="space-y-1">
@@ -1953,7 +2088,7 @@ watch(() => route.query.tab, (tab) => {
</div>
</SheetContent>
</Sheet>
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
<h1 class="text-base sm:text-lg font-semibold">{{ t('settings.title') }}</h1>
</div>
<!-- Desktop Sidebar -->
@@ -1986,7 +2121,7 @@ watch(() => route.query.tab, (tab) => {
<!-- Main Content -->
<main class="flex-1 overflow-y-auto">
<div class="max-w-2xl mx-auto p-6 lg:p-8 pt-20 lg:pt-8 space-y-6">
<div class="max-w-2xl mx-auto p-3 sm:p-6 lg:p-8 pt-16 sm:pt-20 lg:pt-8 space-y-4 sm:space-y-6">
<!-- Appearance Section -->
<div v-show="activeSection === 'appearance'" class="space-y-6">
@@ -1996,15 +2131,15 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
<Sun class="h-4 w-4 mr-2" />{{ t('settings.lightMode') }}
<Sun class="h-4 w-4 mr-1.5" />{{ t('settings.lightMode') }}
</Button>
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')">
<Moon class="h-4 w-4 mr-2" />{{ t('settings.darkMode') }}
<Moon class="h-4 w-4 mr-1.5" />{{ t('settings.darkMode') }}
</Button>
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')">
<Monitor class="h-4 w-4 mr-2" />{{ t('settings.systemMode') }}
<Monitor class="h-4 w-4 mr-1.5" />{{ t('settings.systemMode') }}
</Button>
</div>
</CardContent>
@@ -2078,6 +2213,31 @@ watch(() => route.query.tab, (tab) => {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
</div>
<Switch
v-model="authConfig.single_user_allow_multiple_sessions"
:disabled="authConfigLoading"
/>
</div>
<div class="flex justify-end pt-2">
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
<Save class="h-4 w-4 mr-2" />
{{ t('common.save') }}
</Button>
</div>
</CardContent>
</Card>
</div>
<!-- Video Section -->
@@ -2584,14 +2744,70 @@ watch(() => route.query.tab, (tab) => {
</Card>
</div>
<!-- Access Section -->
<div v-show="activeSection === 'access'" class="space-y-6">
<!-- Network Section -->
<div v-show="activeSection === 'network'" class="space-y-6">
<!-- Auto-restart: restarting progress -->
<div
v-if="autoRestarting"
class="flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm shadow-sm"
>
<RefreshCw class="h-4 w-4 animate-spin text-primary shrink-0" />
<div class="flex-1 min-w-0">
<p class="font-medium">{{ t('settings.autoRestarting') }}</p>
<p class="text-xs text-muted-foreground">
{{ webServerConfig.https_enabled
? t('settings.autoRestartingHttpsDesc', { sec: autoRestartCountdown })
: t('settings.autoRestartingDesc') }}
</p>
</div>
<span v-if="webServerConfig.https_enabled && autoRestartCountdown > 0"
class="tabular-nums text-lg font-bold text-primary shrink-0">
{{ autoRestartCountdown }}
</span>
</div>
<!-- Auto-restart: HTTPS manual redirect (cert must be accepted by user) -->
<div
v-if="autoRestartManualUrl"
class="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-700 px-4 py-4 space-y-3"
>
<div class="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-300">
<Lock class="h-4 w-4 shrink-0 mt-0.5" />
<div>
<p class="font-medium">{{ t('settings.httpsManualRedirectTitle') }}</p>
<p class="text-xs mt-0.5 opacity-80">{{ t('settings.httpsManualRedirectDesc') }}</p>
</div>
</div>
<a
:href="autoRestartManualUrl"
class="flex items-center justify-center gap-2 w-full rounded-md bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 transition-colors"
>
<ExternalLink class="h-4 w-4" />
{{ autoRestartManualUrl }}
</a>
</div>
<!-- Auto-restart: failure / timeout -->
<div
v-if="autoRestartFailed"
class="flex items-center justify-between rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
>
<p class="text-destructive">{{ t('settings.autoRestartFailed') }}</p>
<Button variant="outline" size="sm" @click="triggerAutoRestart">
<RefreshCw class="h-3 w-3 mr-1" />
{{ t('common.retry') }}
</Button>
</div>
<!-- Port Configuration Card -->
<Card>
<CardHeader>
<CardTitle>{{ t('settings.webServer') }}</CardTitle>
<CardDescription>{{ t('settings.webServerDesc') }}</CardDescription>
<CardTitle>{{ t('settings.portConfig') }}</CardTitle>
<CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- HTTPS toggle -->
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.httpsEnabled') }}</Label>
@@ -2602,91 +2818,212 @@ watch(() => route.query.tab, (tab) => {
<Separator />
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label>{{ t('settings.httpPort') }}</Label>
<Input v-model.number="webServerConfig.http_port" type="number" min="1" max="65535" />
<!-- Single active-port input, label follows the HTTPS toggle -->
<div class="flex items-end gap-3">
<div class="space-y-2 flex-1 max-w-[180px]">
<Label>
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
</Label>
<Input
v-if="webServerConfig.https_enabled"
v-model.number="webServerConfig.https_port"
type="number" min="1" max="65535"
/>
<Input
v-else
v-model.number="webServerConfig.http_port"
type="number" min="1" max="65535"
/>
</div>
<div class="space-y-2">
<Label>{{ t('settings.httpsPort') }}</Label>
<Input v-model.number="webServerConfig.https_port" type="number" min="1" max="65535" />
<!-- Inactive-port reference (read-only hint) -->
<div class="space-y-2 flex-1 max-w-[180px]">
<Label class="text-muted-foreground text-xs">
{{ webServerConfig.https_enabled ? t('settings.httpPortReserved') : t('settings.httpsPortReserved') }}
</Label>
<Input
v-if="webServerConfig.https_enabled"
v-model.number="webServerConfig.http_port"
type="number" min="1" max="65535"
class="opacity-50"
/>
<Input
v-else
v-model.number="webServerConfig.https_port"
type="number" min="1" max="65535"
class="opacity-50"
/>
</div>
</div>
<div class="space-y-2">
<Label>{{ t('settings.bindMode') }}</Label>
<select v-model="bindMode" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="all">{{ t('settings.bindModeAll') }}</option>
<option value="loopback">{{ t('settings.bindModeLocal') }}</option>
<option value="custom">{{ t('settings.bindModeCustom') }}</option>
</select>
<p class="text-sm text-muted-foreground">{{ t('settings.bindModeDesc') }}</p>
<!-- Preview URL -->
<div class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
<span class="text-muted-foreground shrink-0">{{ t('settings.previewUrl') }}:</span>
<span class="font-mono text-xs break-all">{{ previewAccessUrl }}</span>
</div>
<div v-if="bindMode === 'all'" class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.bindIpv6') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.bindAllDesc') }}</p>
</div>
<Switch v-model="bindAllIpv6" />
</div>
<div v-if="bindMode === 'loopback'" class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.bindIpv6') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.bindLocalDesc') }}</p>
</div>
<Switch v-model="bindLocalIpv6" />
</div>
<div v-if="bindMode === 'custom'" class="space-y-2">
<Label>{{ t('settings.bindAddressList') }}</Label>
<div class="space-y-2">
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" @click="addBindAddress">
<Plus class="h-4 w-4 mr-1" />
{{ t('settings.addBindAddress') }}
</Button>
</div>
<p class="text-xs text-muted-foreground">{{ t('settings.bindAddressListDesc') }}</p>
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
</div>
<div class="flex justify-end pt-4">
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError">
<Save class="h-4 w-4 mr-2" />
{{ t('common.save') }}
<!-- Save row -->
<div class="flex items-center justify-between pt-2">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p>
<Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button>
</div>
</CardContent>
</Card>
<!-- Listen Address Card -->
<Card>
<CardHeader>
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
<CardTitle>{{ t('settings.listenAddress') }}</CardTitle>
<CardDescription>{{ t('settings.listenAddressDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
<RadioGroup v-model="bindMode" class="space-y-3">
<!-- All addresses -->
<div class="space-y-2">
<div class="flex items-start gap-3">
<RadioGroupItem value="all" id="bind-all" class="mt-0.5" />
<div class="flex-1">
<Label for="bind-all" class="cursor-pointer">{{ t('settings.bindModeAll') }}</Label>
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeAllDesc') }}</p>
</div>
</div>
<div v-if="bindMode === 'all'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
<Switch v-model="bindAllIpv6" />
</div>
</div>
<Switch
v-model="authConfig.single_user_allow_multiple_sessions"
:disabled="authConfigLoading"
<Separator />
<!-- Loopback only -->
<div class="space-y-2">
<div class="flex items-start gap-3">
<RadioGroupItem value="loopback" id="bind-loopback" class="mt-0.5" />
<div class="flex-1">
<Label for="bind-loopback" class="cursor-pointer">{{ t('settings.bindModeLocal') }}</Label>
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeLocalDesc') }}</p>
</div>
</div>
<div v-if="bindMode === 'loopback'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
<Switch v-model="bindLocalIpv6" />
</div>
</div>
<Separator />
<!-- Custom addresses -->
<div class="space-y-2">
<div class="flex items-start gap-3">
<RadioGroupItem value="custom" id="bind-custom" class="mt-0.5" />
<div class="flex-1">
<Label for="bind-custom" class="cursor-pointer">{{ t('settings.bindModeCustom') }}</Label>
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeCustomDesc') }}</p>
</div>
</div>
<div v-if="bindMode === 'custom'" class="ml-7 space-y-2">
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" @click="addBindAddress">
<Plus class="h-4 w-4 mr-1" />
{{ t('settings.addBindAddress') }}
</Button>
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
</div>
</div>
</RadioGroup>
<!-- Effective addresses preview -->
<div v-if="effectiveBindAddresses.length > 0" class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
<span class="text-muted-foreground shrink-0">{{ t('settings.effectiveAddresses') }}:</span>
<span class="font-mono text-xs break-all">{{ effectiveBindAddresses.join(', ') }}</span>
</div>
<div class="flex items-center justify-between pt-2">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p>
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button>
</div>
</CardContent>
</Card>
<!-- SSL Certificate Card -->
<Card>
<CardHeader class="flex flex-row items-start justify-between space-y-0 pb-3">
<div class="space-y-1.5">
<CardTitle>{{ t('settings.sslCertificate') }}</CardTitle>
<CardDescription>{{ t('settings.sslCertificateDesc') }}</CardDescription>
</div>
<Badge :variant="webServerConfig.has_custom_cert ? 'default' : 'secondary'" class="mt-1 shrink-0">
{{ webServerConfig.has_custom_cert ? t('settings.sslCertCustom') : t('settings.sslCertSelfSigned') }}
</Badge>
</CardHeader>
<CardContent class="space-y-4">
<!-- Active custom cert notice -->
<div
v-if="webServerConfig.has_custom_cert"
class="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/20 dark:border-emerald-800 px-3 py-2"
>
<div class="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-400">
<Check class="h-4 w-4 shrink-0" />
{{ t('settings.sslCertActive') }}
</div>
<Button
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive h-7 text-xs"
:disabled="certClearing || autoRestarting"
@click="clearCertificate"
>
<RefreshCw v-if="certClearing || autoRestarting" class="h-3 w-3 mr-1 animate-spin" />
<Trash2 v-else class="h-3 w-3 mr-1" />
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertClear') }}
</Button>
</div>
<!-- Certificate textarea -->
<div class="space-y-2">
<Label>{{ t('settings.sslCertPem') }}</Label>
<Textarea
v-model="sslCertPem"
:placeholder="t('settings.sslCertPemPlaceholder')"
class="font-mono text-xs min-h-[110px] resize-y"
spellcheck="false"
autocomplete="off"
/>
</div>
<Separator />
<p class="text-xs text-muted-foreground">{{ t('settings.singleUserSessionNote') }}</p>
<div class="flex justify-end pt-2">
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
<Save class="h-4 w-4 mr-2" />
{{ t('common.save') }}
<!-- Key textarea -->
<div class="space-y-2">
<Label>{{ t('settings.sslKeyPem') }}</Label>
<Textarea
v-model="sslKeyPem"
:placeholder="t('settings.sslKeyPemPlaceholder')"
class="font-mono text-xs min-h-[110px] resize-y"
spellcheck="false"
autocomplete="off"
/>
</div>
<div class="flex items-center justify-between pt-1">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p>
<Button
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
@click="saveCertificate"
>
<RefreshCw v-if="certSaving || autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
</Button>
</div>
</CardContent>
@@ -3088,7 +3425,7 @@ watch(() => route.query.tab, (tab) => {
v-if="!isExtRunning(extensions?.gostc?.status)"
size="sm"
@click="startExtension('gostc')"
:disabled="extensionsLoading || !extConfig.gostc.key"
:disabled="extensionsLoading || !extConfig.gostc.key || !extConfig.gostc.addr?.trim()"
>
<Play class="h-4 w-4 mr-1" />
{{ t('extensions.start') }}
@@ -3114,7 +3451,7 @@ watch(() => route.query.tab, (tab) => {
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
@@ -3460,7 +3797,11 @@ watch(() => route.query.tab, (tab) => {
<div class="sm:col-span-3 space-y-1">
<Input
v-model="rustdeskLocalConfig.relay_key"
type="password"
type="text"
maxlength="44"
autocomplete="off"
spellcheck="false"
class="font-mono"
:placeholder="rustdeskStatus?.config?.has_relay_key ? t('extensions.rustdesk.relayKeySet') : t('extensions.rustdesk.relayKeyPlaceholder')"
/>
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayKeyHint') }}</p>
@@ -3635,12 +3976,12 @@ watch(() => route.query.tab, (tab) => {
</CardHeader>
<CardContent>
<div class="space-y-3">
<div class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-muted-foreground">{{ t('settings.hostname') }}</span>
<span class="text-sm font-medium">{{ systemStore.deviceInfo.hostname }}</span>
<div class="flex justify-between items-center py-2 border-b gap-2">
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.hostname') }}</span>
<span class="text-sm font-medium truncate">{{ systemStore.deviceInfo.hostname }}</span>
</div>
<div class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-muted-foreground">{{ t('settings.cpuModel') }}</span>
<div class="flex justify-between items-center py-2 border-b gap-2">
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.cpuModel') }}</span>
<span class="text-sm font-medium truncate max-w-[60%] text-right">{{ systemStore.deviceInfo.cpu_model }}</span>
</div>
<div class="flex justify-between items-center py-2 border-b">
@@ -3667,20 +4008,18 @@ watch(() => route.query.tab, (tab) => {
</CardContent>
</Card>
<p class="text-xs text-muted-foreground text-center">{{ t('settings.builtWith') }}</p>
<p class="text-xs text-muted-foreground text-center">@2025-2026 SilentWind</p>
</div>
<!-- Save Button (sticky) -->
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-4 pb-2 bg-background border-t -mx-6 px-6 lg:-mx-8 lg:px-8">
<div class="flex justify-end">
<div class="flex items-center gap-3">
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgFunctionMinWarning') }}
</p>
<Button :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-2 bg-background border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div class="flex items-center justify-between sm:justify-end gap-2 sm:gap-3">
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0">
{{ t('settings.otgFunctionMinWarning') }}
</p>
<Button class="shrink-0" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
</Button>
</div>
</Button>
</div>
</div>
@@ -3690,17 +4029,17 @@ watch(() => route.query.tab, (tab) => {
<!-- Terminal Dialog -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-4 py-3 border-b shrink-0">
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
<DialogTitle class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<Terminal class="h-5 w-5" />
{{ t('extensions.ttyd.title') }}
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 mr-8"
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
@click="openTerminalInNewTab"
:aria-label="t('extensions.ttyd.openInNewTab')"
:title="t('extensions.ttyd.openInNewTab')"

View File

@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -24,7 +25,6 @@ import {
} from '@/components/ui/hover-card'
import { Switch } from '@/components/ui/switch'
import {
Monitor,
Eye,
EyeOff,
ChevronRight,
@@ -572,10 +572,8 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</div>
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
<div
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10"
>
<Monitor class="w-8 h-8 text-primary" />
<div class="mx-auto flex justify-center">
<BrandMark size="xl" />
</div>
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
<CardDescription>{{ t('setup.description') }}</CardDescription>