feat(video): 事务化切换与前端统一编排,增强视频输入格式支持

- 后端:切换事务+transition_id,/stream/mode 返回 switching/transition_id 与实际 codec

- 事件:新增 mode_switching/mode_ready,config/webrtc_ready/mode_changed 关联事务

- 编码/格式:扩展 NV21/NV16/NV24/RGB/BGR 输入与转换链路,RKMPP direct input 优化

- 前端:useVideoSession 统一切换,失败回退真实切回 MJPEG,菜单格式同步修复

- 清理:useVideoStream 降级为 MJPEG-only
This commit is contained in:
mofeng-git
2026-01-11 10:41:57 +08:00
parent 9feb74b72c
commit 206594e292
110 changed files with 3955 additions and 2251 deletions

View File

@@ -31,15 +31,14 @@ pub async fn apply_video_config(
.format
.as_ref()
.and_then(|f| {
serde_json::from_value::<crate::video::format::PixelFormat>(
serde_json::Value::String(f.clone()),
)
serde_json::from_value::<crate::video::format::PixelFormat>(serde_json::Value::String(
f.clone(),
))
.ok()
})
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
let resolution =
crate::video::format::Resolution::new(new_config.width, new_config.height);
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions
state
@@ -162,9 +161,16 @@ pub async fn apply_hid_config(
// 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget
if descriptor_changed && new_config.backend == HidBackend::Otg {
tracing::info!("OTG descriptor changed, updating gadget...");
if let Err(e) = state.otg_service.update_descriptor(&new_config.otg_descriptor).await {
if let Err(e) = state
.otg_service
.update_descriptor(&new_config.otg_descriptor)
.await
{
tracing::error!("Failed to update OTG descriptor: {}", e);
return Err(AppError::Config(format!("OTG descriptor update failed: {}", e)));
return Err(AppError::Config(format!(
"OTG descriptor update failed: {}",
e
)));
}
tracing::info!("OTG descriptor updated successfully");
}
@@ -197,7 +203,10 @@ pub async fn apply_hid_config(
.await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
tracing::info!("HID backend reloaded successfully: {:?}", new_config.backend);
tracing::info!(
"HID backend reloaded successfully: {:?}",
new_config.backend
);
// When switching to OTG backend, automatically enable MSD if not already enabled
// OTG HID and MSD share the same USB gadget, so it makes sense to enable both
@@ -245,7 +254,11 @@ pub async fn apply_msd_config(
let old_msd_enabled = old_config.enabled;
let new_msd_enabled = new_config.enabled;
tracing::info!("MSD enabled: old={}, new={}", old_msd_enabled, new_msd_enabled);
tracing::info!(
"MSD enabled: old={}, new={}",
old_msd_enabled,
new_msd_enabled
);
if old_msd_enabled != new_msd_enabled {
if new_msd_enabled {
@@ -257,9 +270,9 @@ pub async fn apply_msd_config(
&new_config.images_path,
&new_config.drive_path,
);
msd.init().await.map_err(|e| {
AppError::Config(format!("MSD initialization failed: {}", e))
})?;
msd.init()
.await
.map_err(|e| AppError::Config(format!("MSD initialization failed: {}", e)))?;
// Set event bus
let events = state.events.clone();
@@ -429,7 +442,10 @@ pub async fn apply_rustdesk_config(
if let Err(e) = service.restart(new_config.clone()).await {
tracing::error!("Failed to restart RustDesk service: {}", e);
} else {
tracing::info!("RustDesk service restarted with ID: {}", new_config.device_id);
tracing::info!(
"RustDesk service restarted with ID: {}",
new_config.device_id
);
// Save generated keypair and UUID to config
credentials_to_save = service.save_credentials();
}

View File

@@ -19,26 +19,26 @@
pub(crate) mod apply;
mod types;
pub(crate) mod video;
mod stream;
mod hid;
mod msd;
mod atx;
mod audio;
mod hid;
mod msd;
mod rustdesk;
mod stream;
pub(crate) mod video;
mod web;
// 导出 handler 函数
pub use video::{get_video_config, update_video_config};
pub use stream::{get_stream_config, update_stream_config};
pub use hid::{get_hid_config, update_hid_config};
pub use msd::{get_msd_config, update_msd_config};
pub use atx::{get_atx_config, update_atx_config};
pub use audio::{get_audio_config, update_audio_config};
pub use hid::{get_hid_config, update_hid_config};
pub use msd::{get_msd_config, update_msd_config};
pub use rustdesk::{
get_rustdesk_config, get_rustdesk_status, update_rustdesk_config,
regenerate_device_id, regenerate_device_password, get_device_password,
get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id,
regenerate_device_password, update_rustdesk_config,
};
pub use stream::{get_stream_config, update_stream_config};
pub use video::{get_video_config, update_video_config};
pub use web::{get_web_config, update_web_config};
// 保留全局配置查询(向后兼容)

View File

@@ -48,12 +48,16 @@ pub struct RustDeskStatusResponse {
}
/// 获取 RustDesk 配置
pub async fn get_rustdesk_config(State(state): State<Arc<AppState>>) -> Json<RustDeskConfigResponse> {
pub async fn get_rustdesk_config(
State(state): State<Arc<AppState>>,
) -> Json<RustDeskConfigResponse> {
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
}
/// 获取 RustDesk 完整状态(配置 + 服务状态)
pub async fn get_rustdesk_status(State(state): State<Arc<AppState>>) -> Json<RustDeskStatusResponse> {
pub async fn get_rustdesk_status(
State(state): State<Arc<AppState>>,
) -> Json<RustDeskStatusResponse> {
let config = state.config.get().rustdesk.clone();
// 获取服务状态

View File

@@ -1,9 +1,9 @@
use serde::Deserialize;
use typeshare::typeshare;
use crate::config::*;
use crate::error::AppError;
use crate::rustdesk::config::RustDeskConfig;
use crate::video::encoder::BitratePreset;
use serde::Deserialize;
use typeshare::typeshare;
// ===== Video Config =====
#[typeshare]
@@ -21,12 +21,16 @@ impl VideoConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> {
if let Some(width) = self.width {
if !(320..=7680).contains(&width) {
return Err(AppError::BadRequest("Invalid width: must be 320-7680".into()));
return Err(AppError::BadRequest(
"Invalid width: must be 320-7680".into(),
));
}
}
if let Some(height) = self.height {
if !(240..=4320).contains(&height) {
return Err(AppError::BadRequest("Invalid height: must be 240-4320".into()));
return Err(AppError::BadRequest(
"Invalid height: must be 240-4320".into(),
));
}
}
if let Some(fps) = self.fps {
@@ -36,7 +40,9 @@ impl VideoConfigUpdate {
}
if let Some(quality) = self.quality {
if !(1..=100).contains(&quality) {
return Err(AppError::BadRequest("Invalid quality: must be 1-100".into()));
return Err(AppError::BadRequest(
"Invalid quality: must be 1-100".into(),
));
}
}
Ok(())
@@ -126,7 +132,8 @@ impl StreamConfigUpdate {
if let Some(ref stun) = self.stun_server {
if !stun.is_empty() && !stun.starts_with("stun:") {
return Err(AppError::BadRequest(
"STUN server must start with 'stun:' (e.g., stun:stun.l.google.com:19302)".into(),
"STUN server must start with 'stun:' (e.g., stun:stun.l.google.com:19302)"
.into(),
));
}
}
@@ -153,16 +160,32 @@ impl StreamConfigUpdate {
}
// STUN/TURN settings - empty string means clear (use public servers), Some("value") means set custom
if let Some(ref stun) = self.stun_server {
config.stun_server = if stun.is_empty() { None } else { Some(stun.clone()) };
config.stun_server = if stun.is_empty() {
None
} else {
Some(stun.clone())
};
}
if let Some(ref turn) = self.turn_server {
config.turn_server = if turn.is_empty() { None } else { Some(turn.clone()) };
config.turn_server = if turn.is_empty() {
None
} else {
Some(turn.clone())
};
}
if let Some(ref username) = self.turn_username {
config.turn_username = if username.is_empty() { None } else { Some(username.clone()) };
config.turn_username = if username.is_empty() {
None
} else {
Some(username.clone())
};
}
if let Some(ref password) = self.turn_password {
config.turn_password = if password.is_empty() { None } else { Some(password.clone()) };
config.turn_password = if password.is_empty() {
None
} else {
Some(password.clone())
};
}
}
}
@@ -185,19 +208,25 @@ impl OtgDescriptorConfigUpdate {
// Validate manufacturer string length
if let Some(ref s) = self.manufacturer {
if s.len() > 126 {
return Err(AppError::BadRequest("Manufacturer string too long (max 126 chars)".into()));
return Err(AppError::BadRequest(
"Manufacturer string too long (max 126 chars)".into(),
));
}
}
// Validate product string length
if let Some(ref s) = self.product {
if s.len() > 126 {
return Err(AppError::BadRequest("Product string too long (max 126 chars)".into()));
return Err(AppError::BadRequest(
"Product string too long (max 126 chars)".into(),
));
}
}
// Validate serial number string length
if let Some(ref s) = self.serial_number {
if s.len() > 126 {
return Err(AppError::BadRequest("Serial number string too long (max 126 chars)".into()));
return Err(AppError::BadRequest(
"Serial number string too long (max 126 chars)".into(),
));
}
}
Ok(())
@@ -469,7 +498,8 @@ impl RustDeskConfigUpdate {
if let Some(ref server) = self.rendezvous_server {
if !server.is_empty() && !server.contains(':') {
return Err(AppError::BadRequest(
"Rendezvous server must be in format 'host:port' (e.g., rs.example.com:21116)".into(),
"Rendezvous server must be in format 'host:port' (e.g., rs.example.com:21116)"
.into(),
));
}
}
@@ -477,7 +507,8 @@ impl RustDeskConfigUpdate {
if let Some(ref server) = self.relay_server {
if !server.is_empty() && !server.contains(':') {
return Err(AppError::BadRequest(
"Relay server must be in format 'host:port' (e.g., rs.example.com:21117)".into(),
"Relay server must be in format 'host:port' (e.g., rs.example.com:21117)"
.into(),
));
}
}
@@ -500,10 +531,18 @@ impl RustDeskConfigUpdate {
config.rendezvous_server = server.clone();
}
if let Some(ref server) = self.relay_server {
config.relay_server = if server.is_empty() { None } else { Some(server.clone()) };
config.relay_server = if server.is_empty() {
None
} else {
Some(server.clone())
};
}
if let Some(ref key) = self.relay_key {
config.relay_key = if key.is_empty() { None } else { Some(key.clone()) };
config.relay_key = if key.is_empty() {
None
} else {
Some(key.clone())
};
}
if let Some(ref password) = self.device_password {
if !password.is_empty() {

View File

@@ -10,8 +10,8 @@ use typeshare::typeshare;
use crate::error::{AppError, Result};
use crate::extensions::{
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs,
ExtensionsStatus, GostcConfig, GostcInfo, TtydConfig, TtydInfo,
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
GostcConfig, GostcInfo, TtydConfig, TtydInfo,
};
use crate::state::AppState;
@@ -108,9 +108,7 @@ pub async fn stop_extension(
let mgr = &state.extensions;
// Stop the extension
mgr.stop(ext_id)
.await
.map_err(|e| AppError::Internal(e))?;
mgr.stop(ext_id).await.map_err(|e| AppError::Internal(e))?;
// Return updated status
Ok(Json(ExtensionInfo {

View File

@@ -124,8 +124,7 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
backend: if config.atx.enabled {
Some(format!(
"power: {:?}, reset: {:?}",
config.atx.power.driver,
config.atx.reset.driver
config.atx.power.driver, config.atx.reset.driver
))
} else {
None
@@ -208,7 +207,8 @@ fn get_cpu_model() -> String {
}
/// CPU usage state for calculating usage between samples
static CPU_PREV_STATS: std::sync::OnceLock<std::sync::Mutex<(u64, u64)>> = std::sync::OnceLock::new();
static CPU_PREV_STATS: std::sync::OnceLock<std::sync::Mutex<(u64, u64)>> =
std::sync::OnceLock::new();
/// Get CPU usage percentage (0.0 - 100.0)
fn get_cpu_usage() -> f32 {
@@ -268,7 +268,12 @@ struct MemInfo {
fn get_meminfo() -> MemInfo {
let content = match std::fs::read_to_string("/proc/meminfo") {
Ok(c) => c,
Err(_) => return MemInfo { total: 0, available: 0 },
Err(_) => {
return MemInfo {
total: 0,
available: 0,
}
}
};
let mut total = 0u64;
@@ -276,11 +281,19 @@ fn get_meminfo() -> MemInfo {
for line in content.lines() {
if line.starts_with("MemTotal:") {
if let Some(kb) = line.split_whitespace().nth(1).and_then(|v| v.parse::<u64>().ok()) {
if let Some(kb) = line
.split_whitespace()
.nth(1)
.and_then(|v| v.parse::<u64>().ok())
{
total = kb * 1024;
}
} else if line.starts_with("MemAvailable:") {
if let Some(kb) = line.split_whitespace().nth(1).and_then(|v| v.parse::<u64>().ok()) {
if let Some(kb) = line
.split_whitespace()
.nth(1)
.and_then(|v| v.parse::<u64>().ok())
{
available = kb * 1024;
}
}
@@ -312,10 +325,7 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
if !ipv4_map.contains_key(&ifaddr.interface_name) {
if let Some(addr) = ifaddr.address {
if let Some(sockaddr_in) = addr.as_sockaddr_in() {
ipv4_map.insert(
ifaddr.interface_name.clone(),
sockaddr_in.ip().to_string(),
);
ipv4_map.insert(ifaddr.interface_name.clone(), sockaddr_in.ip().to_string());
}
}
}
@@ -624,10 +634,7 @@ pub async fn setup_init(
if new_config.extensions.ttyd.enabled {
if let Err(e) = state
.extensions
.start(
crate::extensions::ExtensionId::Ttyd,
&new_config.extensions,
)
.start(crate::extensions::ExtensionId::Ttyd, &new_config.extensions)
.await
{
tracing::warn!("Failed to start ttyd during setup: {}", e);
@@ -658,7 +665,10 @@ pub async fn setup_init(
if let Err(e) = state.audio.update_config(audio_config).await {
tracing::warn!("Failed to start audio during setup: {}", e);
} else {
tracing::info!("Audio started during setup: device={}", new_config.audio.device);
tracing::info!(
"Audio started during setup: device={}",
new_config.audio.device
);
}
// Also enable WebRTC audio
if let Err(e) = state.stream_manager.set_webrtc_audio_enabled(true).await {
@@ -666,7 +676,10 @@ pub async fn setup_init(
}
}
tracing::info!("System initialized successfully with admin user: {}", req.username);
tracing::info!(
"System initialized successfully with admin user: {}",
req.username
);
Ok(Json(LoginResponse {
success: true,
@@ -798,10 +811,19 @@ pub async fn update_config(
if let Some(frame_tx) = state.stream_manager.frame_sender().await {
let receiver_count = frame_tx.receiver_count();
// Use WebRtcStreamer (new unified interface)
state.stream_manager.webrtc_streamer().set_video_source(frame_tx).await;
tracing::info!("WebRTC streamer frame source updated with new capturer (receiver_count={})", receiver_count);
state
.stream_manager
.webrtc_streamer()
.set_video_source(frame_tx)
.await;
tracing::info!(
"WebRTC streamer frame source updated with new capturer (receiver_count={})",
receiver_count
);
} else {
tracing::warn!("No frame source available after config change - streamer may not be running");
tracing::warn!(
"No frame source available after config change - streamer may not be running"
);
}
}
@@ -831,8 +853,11 @@ pub async fn update_config(
.await
.ok(); // Ignore error if no active stream
tracing::info!("Stream config applied: encoder={:?}, bitrate={}",
new_config.stream.encoder, new_config.stream.bitrate_preset);
tracing::info!(
"Stream config applied: encoder={:?}, bitrate={}",
new_config.stream.encoder,
new_config.stream.bitrate_preset
);
}
// HID config processing - always reload if section was sent
@@ -860,7 +885,10 @@ pub async fn update_config(
}));
}
tracing::info!("HID backend reloaded successfully: {:?}", new_config.hid.backend);
tracing::info!(
"HID backend reloaded successfully: {:?}",
new_config.hid.backend
);
}
// Audio config processing - always reload if section was sent
@@ -888,7 +916,11 @@ pub async fn update_config(
}
// Also update WebRTC audio enabled state
if let Err(e) = state.stream_manager.set_webrtc_audio_enabled(new_config.audio.enabled).await {
if let Err(e) = state
.stream_manager
.set_webrtc_audio_enabled(new_config.audio.enabled)
.await
{
tracing::warn!("Failed to update WebRTC audio state: {}", e);
} else {
tracing::info!("WebRTC audio enabled: {}", new_config.audio.enabled);
@@ -911,7 +943,11 @@ pub async fn update_config(
let old_msd_enabled = old_config.msd.enabled;
let new_msd_enabled = new_config.msd.enabled;
tracing::info!("MSD enabled: old={}, new={}", old_msd_enabled, new_msd_enabled);
tracing::info!(
"MSD enabled: old={}, new={}",
old_msd_enabled,
new_msd_enabled
);
if old_msd_enabled != new_msd_enabled {
if new_msd_enabled {
@@ -953,7 +989,10 @@ pub async fn update_config(
tracing::info!("MSD shutdown complete");
}
} else {
tracing::info!("MSD enabled state unchanged ({}), no reload needed", new_msd_enabled);
tracing::info!(
"MSD enabled state unchanged ({}), no reload needed",
new_msd_enabled
);
}
}
@@ -1060,7 +1099,12 @@ fn extract_usb_bus_from_bus_info(bus_info: &str) -> Option<String> {
if parts.len() == 2 {
let port = parts[0];
// Verify it looks like a USB port (starts with digit)
if port.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
if port
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
return Some(port.to_string());
}
}
@@ -1115,7 +1159,10 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
None => continue,
};
// Check if matches any prefix
if serial_prefixes.iter().any(|prefix| name.starts_with(prefix)) {
if serial_prefixes
.iter()
.any(|prefix| name.starts_with(prefix))
{
let path = entry.path();
if let Some(p) = path.to_str() {
serial_devices.push(SerialDevice {
@@ -1156,7 +1203,9 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
};
// Check extension availability
let ttyd_available = state.extensions.check_available(crate::extensions::ExtensionId::Ttyd);
let ttyd_available = state
.extensions
.check_available(crate::extensions::ExtensionId::Ttyd);
Json(DeviceList {
video: video_devices,
@@ -1174,12 +1223,12 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
// Stream Control
// ============================================================================
use crate::video::streamer::StreamerStats;
use axum::{
body::Body,
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use crate::video::streamer::StreamerStats;
/// Get stream state
pub async fn stream_state(State(state): State<Arc<AppState>>) -> Json<StreamerStats> {
@@ -1216,6 +1265,9 @@ pub struct SetStreamModeRequest {
pub struct StreamModeResponse {
pub success: bool,
pub mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub transition_id: Option<String>,
pub switching: bool,
pub message: Option<String>,
}
@@ -1223,12 +1275,27 @@ pub struct StreamModeResponse {
pub async fn stream_mode_get(State(state): State<Arc<AppState>>) -> Json<StreamModeResponse> {
let mode = state.stream_manager.current_mode().await;
let mode_str = match mode {
StreamMode::Mjpeg => "mjpeg",
StreamMode::WebRTC => "webrtc",
StreamMode::Mjpeg => "mjpeg".to_string(),
StreamMode::WebRTC => {
use crate::video::encoder::VideoCodecType;
let codec = state
.stream_manager
.webrtc_streamer()
.current_video_codec()
.await;
match codec {
VideoCodecType::H264 => "h264".to_string(),
VideoCodecType::H265 => "h265".to_string(),
VideoCodecType::VP8 => "vp8".to_string(),
VideoCodecType::VP9 => "vp9".to_string(),
}
}
};
Json(StreamModeResponse {
success: true,
mode: mode_str.to_string(),
mode: mode_str,
transition_id: state.stream_manager.current_transition_id().await,
switching: state.stream_manager.is_switching(),
message: None,
})
}
@@ -1258,15 +1325,24 @@ pub async fn stream_mode_set(
// Set video codec if switching to WebRTC mode with specific codec
if let Some(codec) = video_codec {
info!("Setting WebRTC video codec to {:?}", codec);
if let Err(e) = state.stream_manager.webrtc_streamer().set_video_codec(codec).await {
if let Err(e) = state
.stream_manager
.webrtc_streamer()
.set_video_codec(codec)
.await
{
warn!("Failed to set video codec: {}", e);
}
}
state.stream_manager.switch_mode(new_mode.clone()).await?;
let tx = state
.stream_manager
.switch_mode_transaction(new_mode.clone())
.await?;
// Return the actual codec being used
let mode_str = match (&new_mode, &video_codec) {
// 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",
@@ -1275,10 +1351,39 @@ pub async fn stream_mode_set(
(StreamMode::WebRTC, None) => "webrtc",
};
let active_mode_str = match state.stream_manager.current_mode().await {
StreamMode::Mjpeg => "mjpeg".to_string(),
StreamMode::WebRTC => {
let codec = state
.stream_manager
.webrtc_streamer()
.current_video_codec()
.await;
match codec {
VideoCodecType::H264 => "h264".to_string(),
VideoCodecType::H265 => "h265".to_string(),
VideoCodecType::VP8 => "vp8".to_string(),
VideoCodecType::VP9 => "vp9".to_string(),
}
}
};
Ok(Json(StreamModeResponse {
success: true,
mode: mode_str.to_string(),
message: Some(format!("Switched to {} mode", mode_str)),
success: tx.accepted,
mode: if tx.accepted {
requested_mode_str.to_string()
} else {
active_mode_str
},
transition_id: tx.transition_id,
switching: tx.switching,
message: Some(if tx.accepted {
format!("Switching to {} mode", requested_mode_str)
} else if tx.switching {
"Mode switch already in progress".to_string()
} else {
"No switch needed".to_string()
}),
}))
}
@@ -1470,7 +1575,9 @@ pub async fn mjpeg_stream(
return axum::response::Response::builder()
.status(axum::http::StatusCode::SERVICE_UNAVAILABLE)
.header("Content-Type", "application/json")
.body(axum::body::Body::from(r#"{"error":"MJPEG mode not active. Current mode is WebRTC."}"#))
.body(axum::body::Body::from(
r#"{"error":"MJPEG mode not active. Current mode is WebRTC."}"#,
))
.unwrap();
}
@@ -1479,7 +1586,9 @@ pub async fn mjpeg_stream(
return axum::response::Response::builder()
.status(axum::http::StatusCode::SERVICE_UNAVAILABLE)
.header("Content-Type", "application/json")
.body(axum::body::Body::from(r#"{"error":"Video configuration is being changed. Please retry shortly."}"#))
.body(axum::body::Body::from(
r#"{"error":"Video configuration is being changed. Please retry shortly."}"#,
))
.unwrap();
}
@@ -1493,8 +1602,9 @@ pub async fn mjpeg_stream(
let handler = state.stream_manager.mjpeg_handler();
// Use provided client ID or generate a new one
let client_id = query.client_id
.filter(|id| !id.is_empty() && id.len() <= 64) // Validate: non-empty, max 64 chars
let client_id = query
.client_id
.filter(|id| !id.is_empty() && id.len() <= 64) // Validate: non-empty, max 64 chars
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Create RAII guard - this will automatically register and unregister the client
@@ -1538,10 +1648,8 @@ pub async fn mjpeg_stream(
}
// Wait for new frame notification with timeout
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
notify_rx.recv()
).await;
let result =
tokio::time::timeout(std::time::Duration::from_secs(5), notify_rx.recv()).await;
match result {
Ok(Ok(())) => {
@@ -1622,7 +1730,10 @@ pub async fn mjpeg_stream(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "multipart/x-mixed-replace; boundary=frame")
.header(
header::CONTENT_TYPE,
"multipart/x-mixed-replace; boundary=frame",
)
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.header(header::PRAGMA, "no-cache")
.header(header::EXPIRES, "0")
@@ -1636,14 +1747,12 @@ pub async fn snapshot(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let handler = state.stream_manager.mjpeg_handler();
match handler.current_frame() {
Some(frame) if frame.is_valid_jpeg() => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(frame.data_bytes()))
.unwrap()
}
Some(frame) if frame.is_valid_jpeg() => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(frame.data_bytes()))
.unwrap(),
_ => Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.body(Body::from("No frame available"))
@@ -1674,7 +1783,7 @@ fn create_mjpeg_part(jpeg_data: &[u8]) -> bytes::Bytes {
// WebRTC
// ============================================================================
use crate::webrtc::signaling::{IceCandidateRequest, OfferRequest, AnswerResponse};
use crate::webrtc::signaling::{AnswerResponse, IceCandidateRequest, OfferRequest};
/// Create WebRTC session
#[derive(Serialize)]
@@ -1692,7 +1801,11 @@ pub async fn webrtc_create_session(
));
}
let session_id = state.stream_manager.webrtc_streamer().create_session().await?;
let session_id = state
.stream_manager
.webrtc_streamer()
.create_session()
.await?;
Ok(Json(CreateSessionResponse { session_id }))
}
@@ -1986,7 +2099,9 @@ pub async fn msd_image_upload(
// Use streaming upload - chunks are written directly to disk
// This avoids loading the entire file into memory
let image = manager.create_from_multipart_field(&filename, field).await?;
let image = manager
.create_from_multipart_field(&filename, field)
.await?;
return Ok(Json(image));
}
}
@@ -2033,9 +2148,7 @@ pub async fn msd_image_download(
.as_ref()
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
let progress = controller
.download_image(req.url, req.filename)
.await?;
let progress = controller.download_image(req.url, req.filename).await?;
Ok(Json(progress))
}
@@ -2076,9 +2189,9 @@ pub async fn msd_connect(
match req.mode {
MsdMode::Image => {
let image_id = req
.image_id
.ok_or_else(|| AppError::BadRequest("image_id required for image mode".to_string()))?;
let image_id = req.image_id.ok_or_else(|| {
AppError::BadRequest("image_id required for image mode".to_string())
})?;
// Get image info from ImageManager
let images_path = std::path::PathBuf::from(&config.msd.images_path);
@@ -2170,9 +2283,8 @@ pub async fn msd_drive_delete(State(state): State<Arc<AppState>>) -> Result<Json
// Delete the drive file
let drive_path = std::path::PathBuf::from(&config.msd.drive_path);
if drive_path.exists() {
std::fs::remove_file(&drive_path).map_err(|e| {
AppError::Internal(format!("Failed to delete drive file: {}", e))
})?;
std::fs::remove_file(&drive_path)
.map_err(|e| AppError::Internal(format!("Failed to delete drive file: {}", e)))?;
}
Ok(Json(LoginResponse {
@@ -2227,7 +2339,9 @@ pub async fn msd_drive_upload(
// Use streaming upload - chunks are written directly to disk
// This avoids loading the entire file into memory
drive.write_file_from_multipart_field(&file_path, field).await?;
drive
.write_file_from_multipart_field(&file_path, field)
.await?;
return Ok(Json(LoginResponse {
success: true,
@@ -2561,7 +2675,10 @@ pub async fn list_users(
Extension(session): Extension<Session>,
) -> Result<Json<Vec<UserResponse>>> {
// Check if current user is admin
let current_user = state.users.get(&session.user_id).await?
let current_user = state
.users
.get(&session.user_id)
.await?
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
if !current_user.is_admin {
@@ -2588,7 +2705,10 @@ pub async fn create_user(
Json(req): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>> {
// Check if current user is admin
let current_user = state.users.get(&session.user_id).await?
let current_user = state
.users
.get(&session.user_id)
.await?
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
if !current_user.is_admin {
@@ -2597,13 +2717,20 @@ pub async fn create_user(
// Validate input
if req.username.len() < 2 {
return Err(AppError::BadRequest("Username must be at least 2 characters".to_string()));
return Err(AppError::BadRequest(
"Username must be at least 2 characters".to_string(),
));
}
if req.password.len() < 4 {
return Err(AppError::BadRequest("Password must be at least 4 characters".to_string()));
return Err(AppError::BadRequest(
"Password must be at least 4 characters".to_string(),
));
}
let user = state.users.create(&req.username, &req.password, req.is_admin).await?;
let user = state
.users
.create(&req.username, &req.password, req.is_admin)
.await?;
info!("User created: {} (admin: {})", user.username, user.is_admin);
Ok(Json(UserResponse::from(user)))
}
@@ -2623,7 +2750,10 @@ pub async fn update_user(
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>> {
// Check if current user is admin
let current_user = state.users.get(&session.user_id).await?
let current_user = state
.users
.get(&session.user_id)
.await?
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
if !current_user.is_admin {
@@ -2631,13 +2761,18 @@ pub async fn update_user(
}
// Get target user
let mut user = state.users.get(&user_id).await?
let mut user = state
.users
.get(&user_id)
.await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
// Update fields if provided
if let Some(username) = req.username {
if username.len() < 2 {
return Err(AppError::BadRequest("Username must be at least 2 characters".to_string()));
return Err(AppError::BadRequest(
"Username must be at least 2 characters".to_string(),
));
}
user.username = username;
}
@@ -2647,7 +2782,9 @@ pub async fn update_user(
// Note: We need to add an update method to UserStore
// For now, return error
Err(AppError::Internal("User update not yet implemented".to_string()))
Err(AppError::Internal(
"User update not yet implemented".to_string(),
))
}
/// Delete user (admin only)
@@ -2657,7 +2794,10 @@ pub async fn delete_user(
Path(user_id): Path<String>,
) -> Result<Json<LoginResponse>> {
// Check if current user is admin
let current_user = state.users.get(&session.user_id).await?
let current_user = state
.users
.get(&session.user_id)
.await?
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
if !current_user.is_admin {
@@ -2666,17 +2806,24 @@ pub async fn delete_user(
// Prevent deleting self
if user_id == session.user_id {
return Err(AppError::BadRequest("Cannot delete your own account".to_string()));
return Err(AppError::BadRequest(
"Cannot delete your own account".to_string(),
));
}
// Check if this is the last admin
let users = state.users.list().await?;
let admin_count = users.iter().filter(|u| u.is_admin).count();
let target_user = state.users.get(&user_id).await?
let target_user = state
.users
.get(&user_id)
.await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
if target_user.is_admin && admin_count <= 1 {
return Err(AppError::BadRequest("Cannot delete the last admin user".to_string()));
return Err(AppError::BadRequest(
"Cannot delete the last admin user".to_string(),
));
}
state.users.delete(&user_id).await?;
@@ -2703,30 +2850,45 @@ pub async fn change_user_password(
Json(req): Json<ChangePasswordRequest>,
) -> Result<Json<LoginResponse>> {
// Check if current user is admin or changing own password
let current_user = state.users.get(&session.user_id).await?
let current_user = state
.users
.get(&session.user_id)
.await?
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
let is_self = user_id == session.user_id;
let is_admin = current_user.is_admin;
if !is_self && !is_admin {
return Err(AppError::Forbidden("Cannot change other user's password".to_string()));
return Err(AppError::Forbidden(
"Cannot change other user's password".to_string(),
));
}
// Validate new password
if req.new_password.len() < 4 {
return Err(AppError::BadRequest("Password must be at least 4 characters".to_string()));
return Err(AppError::BadRequest(
"Password must be at least 4 characters".to_string(),
));
}
// If changing own password, verify current password
if is_self {
let verified = state.users.verify(&current_user.username, &req.current_password).await?;
let verified = state
.users
.verify(&current_user.username, &req.current_password)
.await?;
if verified.is_none() {
return Err(AppError::AuthError("Current password is incorrect".to_string()));
return Err(AppError::AuthError(
"Current password is incorrect".to_string(),
));
}
}
state.users.update_password(&user_id, &req.new_password).await?;
state
.users
.update_password(&user_id, &req.new_password)
.await?;
info!("Password changed for user ID: {}", user_id);
Ok(Json(LoginResponse {

View File

@@ -14,9 +14,7 @@ use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
use tokio_tungstenite::tungstenite::{
client::IntoClientRequest,
http::HeaderValue,
Message as TungsteniteMessage,
client::IntoClientRequest, http::HeaderValue, Message as TungsteniteMessage,
};
use crate::error::AppError;
@@ -60,10 +58,9 @@ async fn handle_terminal_websocket(client_ws: WebSocket, query_string: String) {
}
};
request.headers_mut().insert(
"Sec-WebSocket-Protocol",
HeaderValue::from_static("tty"),
);
request
.headers_mut()
.insert("Sec-WebSocket-Protocol", HeaderValue::from_static("tty"));
// Create WebSocket connection to ttyd
let ws_stream = match tokio_tungstenite::client_async(request, unix_stream).await {
@@ -143,7 +140,11 @@ pub async fn terminal_proxy(
// Build HTTP request to forward
let method = req.method().as_str();
let query = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default();
let query = req
.uri()
.query()
.map(|q| format!("?{}", q))
.unwrap_or_default();
let uri_path = if path_str.is_empty() {
format!("/api/terminal/{}", query)
} else {
@@ -203,7 +204,8 @@ pub async fn terminal_proxy(
.unwrap_or(200);
// Build response
let mut builder = Response::builder().status(StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK));
let mut builder =
Response::builder().status(StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK));
// Forward response headers
for line in headers_part.lines().skip(1) {