mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 19:51:58 +08:00
init
This commit is contained in:
257
src/web/audio_ws.rs
Normal file
257
src/web/audio_ws.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! Audio WebSocket handler for MJPEG mode
|
||||
//!
|
||||
//! Provides a dedicated WebSocket endpoint (`/api/ws/audio`) for streaming
|
||||
//! Opus-encoded audio data in binary format.
|
||||
//!
|
||||
//! ## Binary Protocol
|
||||
//!
|
||||
//! Each audio packet is sent as a binary WebSocket message with the following format:
|
||||
//!
|
||||
//! ```text
|
||||
//! Byte 0: Type (0x02 = audio)
|
||||
//! Bytes 1-4: Timestamp (u32 LE, milliseconds since stream start)
|
||||
//! Bytes 5-6: Duration (u16 LE, milliseconds)
|
||||
//! Bytes 7-10: Sequence (u32 LE)
|
||||
//! Bytes 11-14: Data length (u32 LE)
|
||||
//! Bytes 15+: Opus encoded data
|
||||
//! ```
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::audio::OpusFrame;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Audio packet type identifier
|
||||
const AUDIO_PACKET_TYPE: u8 = 0x02;
|
||||
|
||||
/// Audio WebSocket upgrade handler
|
||||
///
|
||||
/// Upgrades HTTP connection to WebSocket for audio streaming.
|
||||
pub async fn audio_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_audio_socket(socket, state))
|
||||
}
|
||||
|
||||
/// Handle audio WebSocket connection
|
||||
async fn handle_audio_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Try to get Opus frame subscription
|
||||
let opus_rx = match state.audio.subscribe_opus_async().await {
|
||||
Some(rx) => rx,
|
||||
None => {
|
||||
warn!("Audio not streaming, rejecting WebSocket connection");
|
||||
// Send error message before closing
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
r#"{"error": "Audio not streaming"}"#.to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut opus_rx = opus_rx;
|
||||
let stream_start = Instant::now();
|
||||
|
||||
info!("Audio WebSocket client connected");
|
||||
|
||||
// Track connection for cleanup
|
||||
let mut closed = false;
|
||||
|
||||
// Use interval instead of sleep for more efficient keepalive
|
||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Receive Opus frames and send to client
|
||||
opus_result = opus_rx.recv() => {
|
||||
match opus_result {
|
||||
Ok(frame) => {
|
||||
let binary = encode_audio_packet(&frame, stream_start);
|
||||
if sender.send(Message::Binary(binary)).await.is_err() {
|
||||
debug!("Failed to send audio frame, client disconnected");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("Audio WebSocket client lagged by {} frames", n);
|
||||
// Continue - just skip the missed frames
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
info!("Audio stream closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle client messages (ping/close)
|
||||
msg = receiver.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Close(_))) => {
|
||||
debug!("Audio WebSocket client requested close");
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
if sender.send(Message::Pong(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
// Pong received, connection is alive
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// Handle potential control messages
|
||||
debug!("Received text message on audio WS: {}", text);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warn!("Audio WebSocket receive error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
// Connection closed
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic ping to keep connection alive (using interval)
|
||||
_ = ping_interval.tick() => {
|
||||
if sender.send(Message::Ping(vec![])).await.is_err() {
|
||||
warn!("Failed to send ping, disconnecting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !closed {
|
||||
// Try to send close message
|
||||
let _ = sender.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
info!("Audio WebSocket client disconnected");
|
||||
}
|
||||
|
||||
/// Encode Opus frame to binary packet format
|
||||
///
|
||||
/// ## Format
|
||||
///
|
||||
/// | Offset | Size | Description |
|
||||
/// |--------|------|-------------|
|
||||
/// | 0 | 1 | Packet type (0x02 for audio) |
|
||||
/// | 1 | 4 | Timestamp (u32 LE, ms since start) |
|
||||
/// | 5 | 2 | Duration (u16 LE, ms) |
|
||||
/// | 7 | 4 | Sequence number (u32 LE) |
|
||||
/// | 11 | 4 | Data length (u32 LE) |
|
||||
/// | 15 | N | Opus encoded data |
|
||||
fn encode_audio_packet(frame: &OpusFrame, stream_start: Instant) -> Vec<u8> {
|
||||
let timestamp_ms = stream_start.elapsed().as_millis() as u32;
|
||||
let data_len = frame.data.len() as u32;
|
||||
|
||||
let mut buf = Vec::with_capacity(15 + frame.data.len());
|
||||
|
||||
// Header
|
||||
buf.push(AUDIO_PACKET_TYPE);
|
||||
buf.extend_from_slice(×tamp_ms.to_le_bytes());
|
||||
buf.extend_from_slice(&(frame.duration_ms as u16).to_le_bytes());
|
||||
buf.extend_from_slice(&(frame.sequence as u32).to_le_bytes());
|
||||
buf.extend_from_slice(&data_len.to_le_bytes());
|
||||
|
||||
// Opus data
|
||||
buf.extend_from_slice(&frame.data);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Decode audio packet from binary format (for testing/debugging)
|
||||
#[allow(dead_code)]
|
||||
pub fn decode_audio_packet(data: &[u8]) -> Option<AudioPacketHeader> {
|
||||
if data.len() < 15 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if data[0] != AUDIO_PACKET_TYPE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let timestamp = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
|
||||
let duration_ms = u16::from_le_bytes([data[5], data[6]]);
|
||||
let sequence = u32::from_le_bytes([data[7], data[8], data[9], data[10]]);
|
||||
let data_length = u32::from_le_bytes([data[11], data[12], data[13], data[14]]);
|
||||
|
||||
Some(AudioPacketHeader {
|
||||
packet_type: data[0],
|
||||
timestamp,
|
||||
duration_ms,
|
||||
sequence,
|
||||
data_length,
|
||||
})
|
||||
}
|
||||
|
||||
/// Audio packet header (for decoding/testing)
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AudioPacketHeader {
|
||||
pub packet_type: u8,
|
||||
pub timestamp: u32,
|
||||
pub duration_ms: u16,
|
||||
pub sequence: u32,
|
||||
pub data_length: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
|
||||
#[test]
|
||||
fn test_encode_decode_packet() {
|
||||
let frame = OpusFrame {
|
||||
data: Bytes::from(vec![1, 2, 3, 4, 5]),
|
||||
duration_ms: 20,
|
||||
sequence: 42,
|
||||
timestamp: Instant::now(),
|
||||
rtp_timestamp: 0,
|
||||
};
|
||||
|
||||
let stream_start = Instant::now();
|
||||
let encoded = encode_audio_packet(&frame, stream_start);
|
||||
|
||||
assert!(encoded.len() >= 15);
|
||||
assert_eq!(encoded[0], AUDIO_PACKET_TYPE);
|
||||
|
||||
let header = decode_audio_packet(&encoded).unwrap();
|
||||
assert_eq!(header.packet_type, AUDIO_PACKET_TYPE);
|
||||
assert_eq!(header.duration_ms, 20);
|
||||
assert_eq!(header.sequence, 42);
|
||||
assert_eq!(header.data_length, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_invalid_packet() {
|
||||
// Too short
|
||||
assert!(decode_audio_packet(&[]).is_none());
|
||||
assert!(decode_audio_packet(&[0x02; 10]).is_none());
|
||||
|
||||
// Wrong type
|
||||
let mut bad = vec![0x01; 20];
|
||||
assert!(decode_audio_packet(&bad).is_none());
|
||||
}
|
||||
}
|
||||
362
src/web/handlers/config/apply.rs
Normal file
362
src/web/handlers/config/apply.rs
Normal file
@@ -0,0 +1,362 @@
|
||||
//! 配置热重载逻辑
|
||||
//!
|
||||
//! 从 handlers.rs 中抽取的配置应用函数,负责将配置变更应用到各个子系统。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::*;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 应用 Video 配置变更
|
||||
pub async fn apply_video_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &VideoConfig,
|
||||
new_config: &VideoConfig,
|
||||
) -> Result<()> {
|
||||
// 检查配置是否实际变更
|
||||
if old_config == new_config {
|
||||
tracing::info!("Video config unchanged, skipping reload");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!("Applying video config changes...");
|
||||
|
||||
let device = new_config
|
||||
.device
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::BadRequest("video_device is required".to_string()))?;
|
||||
|
||||
let format = new_config
|
||||
.format
|
||||
.as_ref()
|
||||
.and_then(|f| {
|
||||
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);
|
||||
|
||||
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions)
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.update_video_config(resolution, format, new_config.fps)
|
||||
.await;
|
||||
tracing::info!("WebRTC streamer config updated");
|
||||
|
||||
// Step 2: 应用视频配置到 streamer(重新创建 capturer)
|
||||
state
|
||||
.stream_manager
|
||||
.streamer()
|
||||
.apply_video_config(&device, format, resolution, new_config.fps)
|
||||
.await
|
||||
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
|
||||
tracing::info!("Video config applied to streamer");
|
||||
|
||||
// Step 3: 重启 streamer
|
||||
if let Err(e) = state.stream_manager.start().await {
|
||||
tracing::error!("Failed to start streamer after config change: {}", e);
|
||||
} else {
|
||||
tracing::info!("Streamer started after config change");
|
||||
}
|
||||
|
||||
// Step 4: 更新 WebRTC frame source
|
||||
if let Some(frame_tx) = state.stream_manager.frame_sender().await {
|
||||
let receiver_count = frame_tx.receiver_count();
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.set_video_source(frame_tx)
|
||||
.await;
|
||||
tracing::info!(
|
||||
"WebRTC streamer frame source updated (receiver_count={})",
|
||||
receiver_count
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("No frame source available after config change");
|
||||
}
|
||||
|
||||
tracing::info!("Video config applied successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用 Stream 配置变更
|
||||
pub async fn apply_stream_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &StreamConfig,
|
||||
new_config: &StreamConfig,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying stream config changes...");
|
||||
|
||||
// 更新编码器后端
|
||||
if old_config.encoder != new_config.encoder {
|
||||
let encoder_backend = new_config.encoder.to_backend();
|
||||
tracing::info!(
|
||||
"Updating encoder backend to: {:?} (from config: {:?})",
|
||||
encoder_backend,
|
||||
new_config.encoder
|
||||
);
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.update_encoder_backend(encoder_backend)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 更新码率
|
||||
if old_config.bitrate_kbps != new_config.bitrate_kbps {
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.set_bitrate(new_config.bitrate_kbps)
|
||||
.await
|
||||
.ok(); // Ignore error if no active stream
|
||||
}
|
||||
|
||||
// 更新 ICE 配置 (STUN/TURN)
|
||||
let ice_changed = old_config.stun_server != new_config.stun_server
|
||||
|| old_config.turn_server != new_config.turn_server
|
||||
|| old_config.turn_username != new_config.turn_username
|
||||
|| old_config.turn_password != new_config.turn_password;
|
||||
|
||||
if ice_changed {
|
||||
tracing::info!(
|
||||
"Updating ICE config: STUN={:?}, TURN={:?}",
|
||||
new_config.stun_server,
|
||||
new_config.turn_server
|
||||
);
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.update_ice_config(
|
||||
new_config.stun_server.clone(),
|
||||
new_config.turn_server.clone(),
|
||||
new_config.turn_username.clone(),
|
||||
new_config.turn_password.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Stream config applied: encoder={:?}, bitrate={} kbps",
|
||||
new_config.encoder,
|
||||
new_config.bitrate_kbps
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用 HID 配置变更
|
||||
pub async fn apply_hid_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &HidConfig,
|
||||
new_config: &HidConfig,
|
||||
) -> Result<()> {
|
||||
// 检查是否需要重载
|
||||
if old_config.backend == new_config.backend
|
||||
&& old_config.ch9329_port == new_config.ch9329_port
|
||||
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
||||
&& old_config.otg_udc == new_config.otg_udc
|
||||
{
|
||||
tracing::info!("HID config unchanged, skipping reload");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!("Applying HID config changes...");
|
||||
|
||||
let new_hid_backend = match new_config.backend {
|
||||
HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
||||
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
||||
port: new_config.ch9329_port.clone(),
|
||||
baud_rate: new_config.ch9329_baudrate,
|
||||
},
|
||||
HidBackend::None => crate::hid::HidBackendType::None,
|
||||
};
|
||||
|
||||
state
|
||||
.hid
|
||||
.reload(new_hid_backend)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
||||
|
||||
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
|
||||
if new_config.backend == HidBackend::Otg && old_config.backend != HidBackend::Otg {
|
||||
let msd_guard = state.msd.read().await;
|
||||
if msd_guard.is_none() {
|
||||
drop(msd_guard); // Release read lock before acquiring write lock
|
||||
|
||||
tracing::info!("OTG HID enabled, automatically initializing MSD...");
|
||||
|
||||
// Get MSD config from store
|
||||
let config = state.config.get();
|
||||
|
||||
let msd = crate::msd::MsdController::new(
|
||||
state.otg_service.clone(),
|
||||
&config.msd.images_path,
|
||||
&config.msd.drive_path,
|
||||
);
|
||||
|
||||
if let Err(e) = msd.init().await {
|
||||
tracing::warn!("Failed to auto-initialize MSD for OTG: {}", e);
|
||||
} else {
|
||||
let events = state.events.clone();
|
||||
msd.set_event_bus(events).await;
|
||||
*state.msd.write().await = Some(msd);
|
||||
tracing::info!("MSD automatically initialized for OTG mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用 MSD 配置变更
|
||||
pub async fn apply_msd_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &MsdConfig,
|
||||
new_config: &MsdConfig,
|
||||
) -> Result<()> {
|
||||
tracing::info!("MSD config sent, checking if reload needed...");
|
||||
tracing::debug!("Old MSD config: {:?}", old_config);
|
||||
tracing::debug!("New MSD config: {:?}", new_config);
|
||||
|
||||
// Check if MSD enabled state changed
|
||||
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);
|
||||
|
||||
if old_msd_enabled != new_msd_enabled {
|
||||
if new_msd_enabled {
|
||||
// MSD was disabled, now enabled - need to initialize
|
||||
tracing::info!("MSD enabled in config, initializing...");
|
||||
|
||||
let msd = crate::msd::MsdController::new(
|
||||
state.otg_service.clone(),
|
||||
&new_config.images_path,
|
||||
&new_config.drive_path,
|
||||
);
|
||||
msd.init().await.map_err(|e| {
|
||||
AppError::Config(format!("MSD initialization failed: {}", e))
|
||||
})?;
|
||||
|
||||
// Set event bus
|
||||
let events = state.events.clone();
|
||||
msd.set_event_bus(events).await;
|
||||
|
||||
// Store the initialized controller
|
||||
*state.msd.write().await = Some(msd);
|
||||
tracing::info!("MSD initialized successfully");
|
||||
} else {
|
||||
// MSD was enabled, now disabled - shutdown
|
||||
tracing::info!("MSD disabled in config, shutting down...");
|
||||
|
||||
if let Some(msd) = state.msd.write().await.as_mut() {
|
||||
if let Err(e) = msd.shutdown().await {
|
||||
tracing::warn!("MSD shutdown failed: {}", e);
|
||||
}
|
||||
}
|
||||
*state.msd.write().await = None;
|
||||
tracing::info!("MSD shutdown complete");
|
||||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
"MSD enabled state unchanged ({}), no reload needed",
|
||||
new_msd_enabled
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用 ATX 配置变更
|
||||
pub async fn apply_atx_config(
|
||||
state: &Arc<AppState>,
|
||||
_old_config: &AtxConfig,
|
||||
new_config: &AtxConfig,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying ATX config changes...");
|
||||
|
||||
// Convert AtxConfig to AtxControllerConfig
|
||||
let controller_config = new_config.to_controller_config();
|
||||
|
||||
// Reload the ATX controller with new configuration
|
||||
let atx_guard = state.atx.read().await;
|
||||
if let Some(atx) = atx_guard.as_ref() {
|
||||
if let Err(e) = atx.reload(controller_config).await {
|
||||
tracing::error!("ATX reload failed: {}", e);
|
||||
return Err(AppError::Config(format!("ATX reload failed: {}", e)));
|
||||
}
|
||||
tracing::info!("ATX controller reloaded successfully");
|
||||
} else {
|
||||
// ATX controller not initialized, create a new one if enabled
|
||||
drop(atx_guard);
|
||||
|
||||
if new_config.enabled {
|
||||
tracing::info!("ATX enabled in config, initializing...");
|
||||
|
||||
let atx = crate::atx::AtxController::new(controller_config);
|
||||
if let Err(e) = atx.init().await {
|
||||
tracing::warn!("ATX initialization failed: {}", e);
|
||||
} else {
|
||||
*state.atx.write().await = Some(atx);
|
||||
tracing::info!("ATX controller initialized successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用 Audio 配置变更
|
||||
pub async fn apply_audio_config(
|
||||
state: &Arc<AppState>,
|
||||
_old_config: &AudioConfig,
|
||||
new_config: &AudioConfig,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying audio config changes...");
|
||||
|
||||
// Create audio controller config from new config
|
||||
let audio_config = crate::audio::AudioControllerConfig {
|
||||
enabled: new_config.enabled,
|
||||
device: new_config.device.clone(),
|
||||
quality: crate::audio::AudioQuality::from_str(&new_config.quality),
|
||||
};
|
||||
|
||||
// Update audio controller
|
||||
if let Err(e) = state.audio.update_config(audio_config).await {
|
||||
tracing::error!("Audio config update failed: {}", e);
|
||||
// Don't fail - audio errors are not critical
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Audio config applied: enabled={}, device={}",
|
||||
new_config.enabled,
|
||||
new_config.device
|
||||
);
|
||||
}
|
||||
|
||||
// Also update WebRTC audio enabled state
|
||||
if let Err(e) = state
|
||||
.stream_manager
|
||||
.set_webrtc_audio_enabled(new_config.enabled)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to update WebRTC audio state: {}", e);
|
||||
} else {
|
||||
tracing::info!("WebRTC audio enabled: {}", new_config.enabled);
|
||||
}
|
||||
|
||||
// Reconnect audio sources for existing WebRTC sessions
|
||||
if new_config.enabled {
|
||||
state.stream_manager.reconnect_webrtc_audio_sources().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
46
src/web/handlers/config/atx.rs
Normal file
46
src/web/handlers/config/atx.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! ATX 配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::AtxConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_atx_config;
|
||||
use super::types::AtxConfigUpdate;
|
||||
|
||||
/// 获取 ATX 配置
|
||||
pub async fn get_atx_config(State(state): State<Arc<AppState>>) -> Json<AtxConfig> {
|
||||
Json(state.config.get().atx.clone())
|
||||
}
|
||||
|
||||
/// 更新 ATX 配置
|
||||
pub async fn update_atx_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AtxConfigUpdate>,
|
||||
) -> Result<Json<AtxConfig>> {
|
||||
// 1. 验证请求
|
||||
req.validate()?;
|
||||
|
||||
// 2. 获取旧配置
|
||||
let old_atx_config = state.config.get().atx.clone();
|
||||
|
||||
// 3. 应用更新到配置存储
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.atx);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 4. 获取新配置
|
||||
let new_atx_config = state.config.get().atx.clone();
|
||||
|
||||
// 5. 应用到子系统(热重载)
|
||||
if let Err(e) = apply_atx_config(&state, &old_atx_config, &new_atx_config).await {
|
||||
tracing::error!("Failed to apply ATX config: {}", e);
|
||||
}
|
||||
|
||||
Ok(Json(new_atx_config))
|
||||
}
|
||||
46
src/web/handlers/config/audio.rs
Normal file
46
src/web/handlers/config/audio.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Audio 配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::AudioConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_audio_config;
|
||||
use super::types::AudioConfigUpdate;
|
||||
|
||||
/// 获取 Audio 配置
|
||||
pub async fn get_audio_config(State(state): State<Arc<AppState>>) -> Json<AudioConfig> {
|
||||
Json(state.config.get().audio.clone())
|
||||
}
|
||||
|
||||
/// 更新 Audio 配置
|
||||
pub async fn update_audio_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AudioConfigUpdate>,
|
||||
) -> Result<Json<AudioConfig>> {
|
||||
// 1. 验证请求
|
||||
req.validate()?;
|
||||
|
||||
// 2. 获取旧配置
|
||||
let old_audio_config = state.config.get().audio.clone();
|
||||
|
||||
// 3. 应用更新到配置存储
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.audio);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 4. 获取新配置
|
||||
let new_audio_config = state.config.get().audio.clone();
|
||||
|
||||
// 5. 应用到子系统(热重载)
|
||||
if let Err(e) = apply_audio_config(&state, &old_audio_config, &new_audio_config).await {
|
||||
tracing::error!("Failed to apply audio config: {}", e);
|
||||
}
|
||||
|
||||
Ok(Json(new_audio_config))
|
||||
}
|
||||
46
src/web/handlers/config/hid.rs
Normal file
46
src/web/handlers/config/hid.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! HID 配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::HidConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_hid_config;
|
||||
use super::types::HidConfigUpdate;
|
||||
|
||||
/// 获取 HID 配置
|
||||
pub async fn get_hid_config(State(state): State<Arc<AppState>>) -> Json<HidConfig> {
|
||||
Json(state.config.get().hid.clone())
|
||||
}
|
||||
|
||||
/// 更新 HID 配置
|
||||
pub async fn update_hid_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<HidConfigUpdate>,
|
||||
) -> Result<Json<HidConfig>> {
|
||||
// 1. 验证请求
|
||||
req.validate()?;
|
||||
|
||||
// 2. 获取旧配置
|
||||
let old_hid_config = state.config.get().hid.clone();
|
||||
|
||||
// 3. 应用更新到配置存储
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.hid);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 4. 获取新配置
|
||||
let new_hid_config = state.config.get().hid.clone();
|
||||
|
||||
// 5. 应用到子系统(热重载)
|
||||
if let Err(e) = apply_hid_config(&state, &old_hid_config, &new_hid_config).await {
|
||||
tracing::error!("Failed to apply HID config: {}", e);
|
||||
}
|
||||
|
||||
Ok(Json(new_hid_config))
|
||||
}
|
||||
48
src/web/handlers/config/mod.rs
Normal file
48
src/web/handlers/config/mod.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! 配置管理 Handler 模块
|
||||
//!
|
||||
//! 提供 RESTful 域分离的配置 API:
|
||||
//! - GET /api/config/video - 获取视频配置
|
||||
//! - PATCH /api/config/video - 更新视频配置
|
||||
//! - GET /api/config/stream - 获取流配置
|
||||
//! - PATCH /api/config/stream - 更新流配置
|
||||
//! - GET /api/config/hid - 获取 HID 配置
|
||||
//! - PATCH /api/config/hid - 更新 HID 配置
|
||||
//! - GET /api/config/msd - 获取 MSD 配置
|
||||
//! - PATCH /api/config/msd - 更新 MSD 配置
|
||||
//! - GET /api/config/atx - 获取 ATX 配置
|
||||
//! - PATCH /api/config/atx - 更新 ATX 配置
|
||||
//! - GET /api/config/audio - 获取音频配置
|
||||
//! - PATCH /api/config/audio - 更新音频配置
|
||||
|
||||
mod apply;
|
||||
mod types;
|
||||
|
||||
mod video;
|
||||
mod stream;
|
||||
mod hid;
|
||||
mod msd;
|
||||
mod atx;
|
||||
mod audio;
|
||||
|
||||
// 导出 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};
|
||||
|
||||
// 保留全局配置查询(向后兼容)
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 获取完整配置
|
||||
pub async fn get_all_config(State(state): State<Arc<AppState>>) -> Json<AppConfig> {
|
||||
let mut config = (*state.config.get()).clone();
|
||||
// 不暴露敏感信息
|
||||
config.auth.totp_secret = None;
|
||||
Json(config)
|
||||
}
|
||||
46
src/web/handlers/config/msd.rs
Normal file
46
src/web/handlers/config/msd.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! MSD 配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::MsdConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_msd_config;
|
||||
use super::types::MsdConfigUpdate;
|
||||
|
||||
/// 获取 MSD 配置
|
||||
pub async fn get_msd_config(State(state): State<Arc<AppState>>) -> Json<MsdConfig> {
|
||||
Json(state.config.get().msd.clone())
|
||||
}
|
||||
|
||||
/// 更新 MSD 配置
|
||||
pub async fn update_msd_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<MsdConfigUpdate>,
|
||||
) -> Result<Json<MsdConfig>> {
|
||||
// 1. 验证请求
|
||||
req.validate()?;
|
||||
|
||||
// 2. 获取旧配置
|
||||
let old_msd_config = state.config.get().msd.clone();
|
||||
|
||||
// 3. 应用更新到配置存储
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.msd);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 4. 获取新配置
|
||||
let new_msd_config = state.config.get().msd.clone();
|
||||
|
||||
// 5. 应用到子系统(热重载)
|
||||
if let Err(e) = apply_msd_config(&state, &old_msd_config, &new_msd_config).await {
|
||||
tracing::error!("Failed to apply MSD config: {}", e);
|
||||
}
|
||||
|
||||
Ok(Json(new_msd_config))
|
||||
}
|
||||
46
src/web/handlers/config/stream.rs
Normal file
46
src/web/handlers/config/stream.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Stream 配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_stream_config;
|
||||
use super::types::{StreamConfigResponse, StreamConfigUpdate};
|
||||
|
||||
/// 获取 Stream 配置
|
||||
pub async fn get_stream_config(State(state): State<Arc<AppState>>) -> Json<StreamConfigResponse> {
|
||||
let config = state.config.get();
|
||||
Json(StreamConfigResponse::from(&config.stream))
|
||||
}
|
||||
|
||||
/// 更新 Stream 配置
|
||||
pub async fn update_stream_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<StreamConfigUpdate>,
|
||||
) -> Result<Json<StreamConfigResponse>> {
|
||||
// 1. 验证请求
|
||||
req.validate()?;
|
||||
|
||||
// 2. 获取旧配置
|
||||
let old_stream_config = state.config.get().stream.clone();
|
||||
|
||||
// 3. 应用更新到配置存储
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.stream);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 4. 获取新配置
|
||||
let new_stream_config = state.config.get().stream.clone();
|
||||
|
||||
// 5. 应用到子系统(热重载)
|
||||
if let Err(e) = apply_stream_config(&state, &old_stream_config, &new_stream_config).await {
|
||||
tracing::error!("Failed to apply stream config: {}", e);
|
||||
}
|
||||
|
||||
Ok(Json(StreamConfigResponse::from(&new_stream_config)))
|
||||
}
|
||||
396
src/web/handlers/config/types.rs
Normal file
396
src/web/handlers/config/types.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
use serde::Deserialize;
|
||||
use typeshare::typeshare;
|
||||
use crate::config::*;
|
||||
use crate::error::AppError;
|
||||
|
||||
// ===== Video Config =====
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VideoConfigUpdate {
|
||||
pub device: Option<String>,
|
||||
pub format: Option<String>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub fps: Option<u32>,
|
||||
pub quality: Option<u32>,
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
if let Some(height) = self.height {
|
||||
if !(240..=4320).contains(&height) {
|
||||
return Err(AppError::BadRequest("Invalid height: must be 240-4320".into()));
|
||||
}
|
||||
}
|
||||
if let Some(fps) = self.fps {
|
||||
if !(1..=120).contains(&fps) {
|
||||
return Err(AppError::BadRequest("Invalid fps: must be 1-120".into()));
|
||||
}
|
||||
}
|
||||
if let Some(quality) = self.quality {
|
||||
if !(1..=100).contains(&quality) {
|
||||
return Err(AppError::BadRequest("Invalid quality: must be 1-100".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, config: &mut VideoConfig) {
|
||||
if let Some(ref device) = self.device {
|
||||
config.device = Some(device.clone());
|
||||
}
|
||||
if let Some(ref format) = self.format {
|
||||
config.format = Some(format.clone());
|
||||
}
|
||||
if let Some(width) = self.width {
|
||||
config.width = width;
|
||||
}
|
||||
if let Some(height) = self.height {
|
||||
config.height = height;
|
||||
}
|
||||
if let Some(fps) = self.fps {
|
||||
config.fps = fps;
|
||||
}
|
||||
if let Some(quality) = self.quality {
|
||||
config.quality = quality;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Stream Config =====
|
||||
|
||||
/// Stream 配置响应(包含 has_turn_password 字段)
|
||||
#[typeshare]
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct StreamConfigResponse {
|
||||
pub mode: StreamMode,
|
||||
pub encoder: EncoderType,
|
||||
pub bitrate_kbps: u32,
|
||||
pub gop_size: u32,
|
||||
pub stun_server: Option<String>,
|
||||
pub turn_server: Option<String>,
|
||||
pub turn_username: Option<String>,
|
||||
/// 指示是否已设置 TURN 密码(实际密码不返回)
|
||||
pub has_turn_password: bool,
|
||||
}
|
||||
|
||||
impl From<&StreamConfig> for StreamConfigResponse {
|
||||
fn from(config: &StreamConfig) -> Self {
|
||||
Self {
|
||||
mode: config.mode.clone(),
|
||||
encoder: config.encoder.clone(),
|
||||
bitrate_kbps: config.bitrate_kbps,
|
||||
gop_size: config.gop_size,
|
||||
stun_server: config.stun_server.clone(),
|
||||
turn_server: config.turn_server.clone(),
|
||||
turn_username: config.turn_username.clone(),
|
||||
has_turn_password: config.turn_password.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StreamConfigUpdate {
|
||||
pub mode: Option<StreamMode>,
|
||||
pub encoder: Option<EncoderType>,
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
pub gop_size: Option<u32>,
|
||||
/// STUN server URL (e.g., "stun:stun.l.google.com:19302")
|
||||
pub stun_server: Option<String>,
|
||||
/// TURN server URL (e.g., "turn:turn.example.com:3478")
|
||||
pub turn_server: Option<String>,
|
||||
/// TURN username
|
||||
pub turn_username: Option<String>,
|
||||
/// TURN password
|
||||
pub turn_password: Option<String>,
|
||||
}
|
||||
|
||||
impl StreamConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
if let Some(bitrate) = self.bitrate_kbps {
|
||||
if !(1000..=15000).contains(&bitrate) {
|
||||
return Err(AppError::BadRequest("Bitrate must be 1000-15000 kbps".into()));
|
||||
}
|
||||
}
|
||||
if let Some(gop) = self.gop_size {
|
||||
if !(10..=300).contains(&gop) {
|
||||
return Err(AppError::BadRequest("GOP size must be 10-300".into()));
|
||||
}
|
||||
}
|
||||
// Validate STUN server format
|
||||
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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Validate TURN server format
|
||||
if let Some(ref turn) = self.turn_server {
|
||||
if !turn.is_empty() && !turn.starts_with("turn:") && !turn.starts_with("turns:") {
|
||||
return Err(AppError::BadRequest(
|
||||
"TURN server must start with 'turn:' or 'turns:' (e.g., turn:turn.example.com:3478)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, config: &mut StreamConfig) {
|
||||
if let Some(mode) = self.mode.clone() {
|
||||
config.mode = mode;
|
||||
}
|
||||
if let Some(encoder) = self.encoder.clone() {
|
||||
config.encoder = encoder;
|
||||
}
|
||||
if let Some(bitrate) = self.bitrate_kbps {
|
||||
config.bitrate_kbps = bitrate;
|
||||
}
|
||||
if let Some(gop) = self.gop_size {
|
||||
config.gop_size = gop;
|
||||
}
|
||||
// STUN/TURN settings - empty string means clear, Some("value") means set
|
||||
if let Some(ref stun) = self.stun_server {
|
||||
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()) };
|
||||
}
|
||||
if let Some(ref username) = self.turn_username {
|
||||
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()) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HID Config =====
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HidConfigUpdate {
|
||||
pub backend: Option<HidBackend>,
|
||||
pub ch9329_port: Option<String>,
|
||||
pub ch9329_baudrate: Option<u32>,
|
||||
pub otg_udc: Option<String>,
|
||||
pub mouse_absolute: Option<bool>,
|
||||
}
|
||||
|
||||
impl HidConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
if let Some(baudrate) = self.ch9329_baudrate {
|
||||
let valid_rates = [9600, 19200, 38400, 57600, 115200];
|
||||
if !valid_rates.contains(&baudrate) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Invalid baudrate: must be 9600, 19200, 38400, 57600, or 115200".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, config: &mut HidConfig) {
|
||||
if let Some(backend) = self.backend.clone() {
|
||||
config.backend = backend;
|
||||
}
|
||||
if let Some(ref port) = self.ch9329_port {
|
||||
config.ch9329_port = port.clone();
|
||||
}
|
||||
if let Some(baudrate) = self.ch9329_baudrate {
|
||||
config.ch9329_baudrate = baudrate;
|
||||
}
|
||||
if let Some(ref udc) = self.otg_udc {
|
||||
config.otg_udc = Some(udc.clone());
|
||||
}
|
||||
if let Some(absolute) = self.mouse_absolute {
|
||||
config.mouse_absolute = absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MSD Config =====
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MsdConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub images_path: Option<String>,
|
||||
pub drive_path: Option<String>,
|
||||
pub virtual_drive_size_mb: Option<u32>,
|
||||
}
|
||||
|
||||
impl MsdConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
if let Some(size) = self.virtual_drive_size_mb {
|
||||
if !(1..=10240).contains(&size) {
|
||||
return Err(AppError::BadRequest("Drive size must be 1-10240 MB".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, config: &mut MsdConfig) {
|
||||
if let Some(enabled) = self.enabled {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(ref path) = self.images_path {
|
||||
config.images_path = path.clone();
|
||||
}
|
||||
if let Some(ref path) = self.drive_path {
|
||||
config.drive_path = path.clone();
|
||||
}
|
||||
if let Some(size) = self.virtual_drive_size_mb {
|
||||
config.virtual_drive_size_mb = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ATX Config =====
|
||||
|
||||
/// Update for a single ATX key configuration
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AtxKeyConfigUpdate {
|
||||
pub driver: Option<crate::atx::AtxDriverType>,
|
||||
pub device: Option<String>,
|
||||
pub pin: Option<u32>,
|
||||
pub active_level: Option<crate::atx::ActiveLevel>,
|
||||
}
|
||||
|
||||
/// Update for LED sensing configuration
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AtxLedConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub gpio_chip: Option<String>,
|
||||
pub gpio_pin: Option<u32>,
|
||||
pub inverted: Option<bool>,
|
||||
}
|
||||
|
||||
/// ATX configuration update request
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AtxConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
/// Power button configuration
|
||||
pub power: Option<AtxKeyConfigUpdate>,
|
||||
/// Reset button configuration
|
||||
pub reset: Option<AtxKeyConfigUpdate>,
|
||||
/// LED sensing configuration
|
||||
pub led: Option<AtxLedConfigUpdate>,
|
||||
/// Network interface for WOL packets (empty = auto)
|
||||
pub wol_interface: Option<String>,
|
||||
}
|
||||
|
||||
impl AtxConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
// Validate power key config if present
|
||||
if let Some(ref power) = self.power {
|
||||
Self::validate_key_config(power, "power")?;
|
||||
}
|
||||
// Validate reset key config if present
|
||||
if let Some(ref reset) = self.reset {
|
||||
Self::validate_key_config(reset, "reset")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_key_config(key: &AtxKeyConfigUpdate, name: &str) -> crate::error::Result<()> {
|
||||
if let Some(ref device) = key.device {
|
||||
if !device.is_empty() && !std::path::Path::new(device).exists() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} device '{}' does not exist",
|
||||
name, device
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, config: &mut AtxConfig) {
|
||||
if let Some(enabled) = self.enabled {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(ref power) = self.power {
|
||||
Self::apply_key_update(power, &mut config.power);
|
||||
}
|
||||
if let Some(ref reset) = self.reset {
|
||||
Self::apply_key_update(reset, &mut config.reset);
|
||||
}
|
||||
if let Some(ref led) = self.led {
|
||||
Self::apply_led_update(led, &mut config.led);
|
||||
}
|
||||
if let Some(ref wol_interface) = self.wol_interface {
|
||||
config.wol_interface = wol_interface.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_key_update(update: &AtxKeyConfigUpdate, config: &mut crate::atx::AtxKeyConfig) {
|
||||
if let Some(driver) = update.driver {
|
||||
config.driver = driver;
|
||||
}
|
||||
if let Some(ref device) = update.device {
|
||||
config.device = device.clone();
|
||||
}
|
||||
if let Some(pin) = update.pin {
|
||||
config.pin = pin;
|
||||
}
|
||||
if let Some(level) = update.active_level {
|
||||
config.active_level = level;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_led_update(update: &AtxLedConfigUpdate, config: &mut crate::atx::AtxLedConfig) {
|
||||
if let Some(enabled) = update.enabled {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(ref chip) = update.gpio_chip {
|
||||
config.gpio_chip = chip.clone();
|
||||
}
|
||||
if let Some(pin) = update.gpio_pin {
|
||||
config.gpio_pin = pin;
|
||||
}
|
||||
if let Some(inverted) = update.inverted {
|
||||
config.inverted = inverted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Audio Config =====
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AudioConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub device: Option<String>,
|
||||
pub quality: Option<String>,
|
||||
}
|
||||
|
||||
impl AudioConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
if let Some(ref quality) = self.quality {
|
||||
if !["voice", "balanced", "high"].contains(&quality.as_str()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Invalid quality: must be 'voice', 'balanced', or 'high'".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, config: &mut AudioConfig) {
|
||||
if let Some(enabled) = self.enabled {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(ref device) = self.device {
|
||||
config.device = device.clone();
|
||||
}
|
||||
if let Some(ref quality) = self.quality {
|
||||
config.quality = quality.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/web/handlers/config/video.rs
Normal file
47
src/web/handlers/config/video.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! Video 配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::VideoConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_video_config;
|
||||
use super::types::VideoConfigUpdate;
|
||||
|
||||
/// 获取 Video 配置
|
||||
pub async fn get_video_config(State(state): State<Arc<AppState>>) -> Json<VideoConfig> {
|
||||
Json(state.config.get().video.clone())
|
||||
}
|
||||
|
||||
/// 更新 Video 配置
|
||||
pub async fn update_video_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<VideoConfigUpdate>,
|
||||
) -> Result<Json<VideoConfig>> {
|
||||
// 1. 验证请求
|
||||
req.validate()?;
|
||||
|
||||
// 2. 获取旧配置
|
||||
let old_video_config = state.config.get().video.clone();
|
||||
|
||||
// 3. 应用更新到配置存储
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.video);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 4. 获取新配置
|
||||
let new_video_config = state.config.get().video.clone();
|
||||
|
||||
// 5. 应用到子系统(热重载)
|
||||
if let Err(e) = apply_video_config(&state, &old_video_config, &new_video_config).await {
|
||||
tracing::error!("Failed to apply video config: {}", e);
|
||||
// 根据用户选择,仅记录错误,不回滚
|
||||
}
|
||||
|
||||
Ok(Json(new_video_config))
|
||||
}
|
||||
14
src/web/handlers/devices.rs
Normal file
14
src/web/handlers/devices.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! Device discovery handlers
|
||||
//!
|
||||
//! Provides API endpoints for discovering available hardware devices.
|
||||
|
||||
use axum::Json;
|
||||
|
||||
use crate::atx::{discover_devices, AtxDevices};
|
||||
|
||||
/// GET /api/devices/atx - List available ATX devices
|
||||
///
|
||||
/// Returns lists of available GPIO chips and USB HID relay devices.
|
||||
pub async fn list_atx_devices() -> Json<AtxDevices> {
|
||||
Json(discover_devices())
|
||||
}
|
||||
352
src/web/handlers/extensions.rs
Normal file
352
src/web/handlers/extensions.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
//! Extension management API handlers
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::extensions::{
|
||||
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs,
|
||||
ExtensionsStatus, GostcConfig, GostcInfo, TtydConfig, TtydInfo,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// Get all extensions status
|
||||
// ============================================================================
|
||||
|
||||
/// Get status of all extensions
|
||||
/// GET /api/extensions
|
||||
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> {
|
||||
let config = state.config.get();
|
||||
let mgr = &state.extensions;
|
||||
|
||||
Json(ExtensionsStatus {
|
||||
ttyd: TtydInfo {
|
||||
available: mgr.check_available(ExtensionId::Ttyd),
|
||||
status: mgr.status(ExtensionId::Ttyd).await,
|
||||
config: config.extensions.ttyd.clone(),
|
||||
},
|
||||
gostc: GostcInfo {
|
||||
available: mgr.check_available(ExtensionId::Gostc),
|
||||
status: mgr.status(ExtensionId::Gostc).await,
|
||||
config: config.extensions.gostc.clone(),
|
||||
},
|
||||
easytier: EasytierInfo {
|
||||
available: mgr.check_available(ExtensionId::Easytier),
|
||||
status: mgr.status(ExtensionId::Easytier).await,
|
||||
config: config.extensions.easytier.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Individual extension status
|
||||
// ============================================================================
|
||||
|
||||
/// Get status of a single extension
|
||||
/// GET /api/extensions/:id
|
||||
pub async fn get_extension(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ExtensionInfo>> {
|
||||
let ext_id: ExtensionId = id
|
||||
.parse()
|
||||
.map_err(|_| AppError::NotFound(format!("Unknown extension: {}", id)))?;
|
||||
|
||||
let mgr = &state.extensions;
|
||||
|
||||
Ok(Json(ExtensionInfo {
|
||||
available: mgr.check_available(ext_id),
|
||||
status: mgr.status(ext_id).await,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Start/Stop extensions
|
||||
// ============================================================================
|
||||
|
||||
/// Start an extension
|
||||
/// POST /api/extensions/:id/start
|
||||
pub async fn start_extension(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ExtensionInfo>> {
|
||||
let ext_id: ExtensionId = id
|
||||
.parse()
|
||||
.map_err(|_| AppError::NotFound(format!("Unknown extension: {}", id)))?;
|
||||
|
||||
let config = state.config.get();
|
||||
let mgr = &state.extensions;
|
||||
|
||||
// Start the extension
|
||||
mgr.start(ext_id, &config.extensions)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e))?;
|
||||
|
||||
// Return updated status
|
||||
Ok(Json(ExtensionInfo {
|
||||
available: mgr.check_available(ext_id),
|
||||
status: mgr.status(ext_id).await,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Stop an extension
|
||||
/// POST /api/extensions/:id/stop
|
||||
pub async fn stop_extension(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ExtensionInfo>> {
|
||||
let ext_id: ExtensionId = id
|
||||
.parse()
|
||||
.map_err(|_| AppError::NotFound(format!("Unknown extension: {}", id)))?;
|
||||
|
||||
let mgr = &state.extensions;
|
||||
|
||||
// Stop the extension
|
||||
mgr.stop(ext_id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e))?;
|
||||
|
||||
// Return updated status
|
||||
Ok(Json(ExtensionInfo {
|
||||
available: mgr.check_available(ext_id),
|
||||
status: mgr.status(ext_id).await,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extension logs
|
||||
// ============================================================================
|
||||
|
||||
/// Query parameters for logs
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct LogsQuery {
|
||||
/// Number of lines to return (default: 100, max: 500)
|
||||
pub lines: Option<usize>,
|
||||
}
|
||||
|
||||
/// Get extension logs
|
||||
/// GET /api/extensions/:id/logs
|
||||
pub async fn get_extension_logs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<LogsQuery>,
|
||||
) -> Result<Json<ExtensionLogs>> {
|
||||
let ext_id: ExtensionId = id
|
||||
.parse()
|
||||
.map_err(|_| AppError::NotFound(format!("Unknown extension: {}", id)))?;
|
||||
|
||||
let lines = params.lines.unwrap_or(100).min(500);
|
||||
let logs = state.extensions.logs(ext_id, lines).await;
|
||||
|
||||
Ok(Json(ExtensionLogs { id: ext_id, logs }))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update extension config
|
||||
// ============================================================================
|
||||
|
||||
/// Update ttyd config
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TtydConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub port: Option<u16>,
|
||||
pub shell: Option<String>,
|
||||
pub credential: Option<String>,
|
||||
}
|
||||
|
||||
/// Update gostc config
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GostcConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub addr: Option<String>,
|
||||
pub key: Option<String>,
|
||||
pub tls: Option<bool>,
|
||||
}
|
||||
|
||||
/// Update easytier config
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EasytierConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub network_name: Option<String>,
|
||||
pub network_secret: Option<String>,
|
||||
pub peer_urls: Option<Vec<String>>,
|
||||
pub virtual_ip: Option<String>,
|
||||
}
|
||||
|
||||
/// Update ttyd configuration
|
||||
/// PATCH /api/extensions/ttyd/config
|
||||
pub async fn update_ttyd_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<TtydConfigUpdate>,
|
||||
) -> Result<Json<TtydConfig>> {
|
||||
// Get current config
|
||||
let was_enabled = state.config.get().extensions.ttyd.enabled;
|
||||
|
||||
// Update config
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
let ttyd = &mut config.extensions.ttyd;
|
||||
if let Some(enabled) = req.enabled {
|
||||
ttyd.enabled = enabled;
|
||||
}
|
||||
if let Some(port) = req.port {
|
||||
ttyd.port = port;
|
||||
}
|
||||
if let Some(ref shell) = req.shell {
|
||||
ttyd.shell = shell.clone();
|
||||
}
|
||||
if req.credential.is_some() {
|
||||
ttyd.credential = req.credential.clone();
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let new_config = state.config.get();
|
||||
let is_enabled = new_config.extensions.ttyd.enabled;
|
||||
|
||||
// Handle enable/disable state change
|
||||
if was_enabled && !is_enabled {
|
||||
// Was running, now disabled - stop it
|
||||
state.extensions.stop(ExtensionId::Ttyd).await.ok();
|
||||
} else if !was_enabled && is_enabled {
|
||||
// Was disabled, now enabled - start it
|
||||
if state.extensions.check_available(ExtensionId::Ttyd) {
|
||||
state
|
||||
.extensions
|
||||
.start(ExtensionId::Ttyd, &new_config.extensions)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(new_config.extensions.ttyd.clone()))
|
||||
}
|
||||
|
||||
/// Update gostc configuration
|
||||
/// PATCH /api/extensions/gostc/config
|
||||
pub async fn update_gostc_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<GostcConfigUpdate>,
|
||||
) -> Result<Json<GostcConfig>> {
|
||||
let was_enabled = state.config.get().extensions.gostc.enabled;
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
let gostc = &mut config.extensions.gostc;
|
||||
if let Some(enabled) = req.enabled {
|
||||
gostc.enabled = enabled;
|
||||
}
|
||||
if let Some(ref addr) = req.addr {
|
||||
gostc.addr = addr.clone();
|
||||
}
|
||||
if let Some(ref key) = req.key {
|
||||
gostc.key = key.clone();
|
||||
}
|
||||
if let Some(tls) = req.tls {
|
||||
gostc.tls = tls;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let new_config = state.config.get();
|
||||
let is_enabled = new_config.extensions.gostc.enabled;
|
||||
let has_key = !new_config.extensions.gostc.key.is_empty();
|
||||
|
||||
if was_enabled && !is_enabled {
|
||||
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
||||
} else if !was_enabled && is_enabled && has_key {
|
||||
if state.extensions.check_available(ExtensionId::Gostc) {
|
||||
state
|
||||
.extensions
|
||||
.start(ExtensionId::Gostc, &new_config.extensions)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(new_config.extensions.gostc.clone()))
|
||||
}
|
||||
|
||||
/// Update easytier configuration
|
||||
/// PATCH /api/extensions/easytier/config
|
||||
pub async fn update_easytier_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<EasytierConfigUpdate>,
|
||||
) -> Result<Json<EasytierConfig>> {
|
||||
let was_enabled = state.config.get().extensions.easytier.enabled;
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
let et = &mut config.extensions.easytier;
|
||||
if let Some(enabled) = req.enabled {
|
||||
et.enabled = enabled;
|
||||
}
|
||||
if let Some(ref name) = req.network_name {
|
||||
et.network_name = name.clone();
|
||||
}
|
||||
if let Some(ref secret) = req.network_secret {
|
||||
et.network_secret = secret.clone();
|
||||
}
|
||||
if let Some(ref peers) = req.peer_urls {
|
||||
et.peer_urls = peers.clone();
|
||||
}
|
||||
if req.virtual_ip.is_some() {
|
||||
et.virtual_ip = req.virtual_ip.clone();
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let new_config = state.config.get();
|
||||
let is_enabled = new_config.extensions.easytier.enabled;
|
||||
let has_name = !new_config.extensions.easytier.network_name.is_empty();
|
||||
|
||||
if was_enabled && !is_enabled {
|
||||
state.extensions.stop(ExtensionId::Easytier).await.ok();
|
||||
} else if !was_enabled && is_enabled && has_name {
|
||||
if state.extensions.check_available(ExtensionId::Easytier) {
|
||||
state
|
||||
.extensions
|
||||
.start(ExtensionId::Easytier, &new_config.extensions)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(new_config.extensions.easytier.clone()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ttyd status for console (simplified)
|
||||
// ============================================================================
|
||||
|
||||
/// Simple ttyd status for console view
|
||||
#[typeshare]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TtydStatus {
|
||||
pub available: bool,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Get ttyd status for console view
|
||||
/// GET /api/extensions/ttyd/status
|
||||
pub async fn get_ttyd_status(State(state): State<Arc<AppState>>) -> Json<TtydStatus> {
|
||||
let mgr = &state.extensions;
|
||||
let status = mgr.status(ExtensionId::Ttyd).await;
|
||||
|
||||
Json(TtydStatus {
|
||||
available: mgr.check_available(ExtensionId::Ttyd),
|
||||
running: status.is_running(),
|
||||
})
|
||||
}
|
||||
2583
src/web/handlers/mod.rs
Normal file
2583
src/web/handlers/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
239
src/web/handlers/terminal.rs
Normal file
239
src/web/handlers/terminal.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
//! Terminal proxy handler - reverse proxy to ttyd via Unix socket
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{
|
||||
ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade},
|
||||
OriginalUri, Path, State,
|
||||
},
|
||||
http::{Request, StatusCode},
|
||||
response::Response,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio_tungstenite::tungstenite::{
|
||||
client::IntoClientRequest,
|
||||
http::HeaderValue,
|
||||
Message as TungsteniteMessage,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::extensions::TTYD_SOCKET_PATH;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Handle WebSocket upgrade for terminal
|
||||
pub async fn terminal_ws(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
OriginalUri(original_uri): OriginalUri,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
let query_string = original_uri
|
||||
.query()
|
||||
.map(|q| format!("?{}", q))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Use the tty subprotocol that ttyd expects
|
||||
ws.protocols(["tty"])
|
||||
.on_upgrade(move |socket| handle_terminal_websocket(socket, query_string))
|
||||
}
|
||||
|
||||
/// Handle terminal WebSocket connection - bridge browser and ttyd
|
||||
async fn handle_terminal_websocket(client_ws: WebSocket, query_string: String) {
|
||||
// Connect to ttyd Unix socket
|
||||
let unix_stream = match UnixStream::connect(TTYD_SOCKET_PATH).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to connect to ttyd socket: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Build WebSocket request for ttyd with tty subprotocol
|
||||
let uri_str = format!("ws://localhost/api/terminal/ws{}", query_string);
|
||||
let mut request = match uri_str.into_client_request() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create WebSocket request: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
Ok((ws, _)) => ws,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to establish WebSocket with ttyd: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Split both WebSocket connections
|
||||
let (mut client_tx, mut client_rx) = client_ws.split();
|
||||
let (mut ttyd_tx, mut ttyd_rx) = ws_stream.split();
|
||||
|
||||
// Forward messages from browser to ttyd
|
||||
let client_to_ttyd = tokio::spawn(async move {
|
||||
while let Some(msg) = client_rx.next().await {
|
||||
let ttyd_msg = match msg {
|
||||
Ok(AxumMessage::Text(text)) => TungsteniteMessage::Text(text),
|
||||
Ok(AxumMessage::Binary(data)) => TungsteniteMessage::Binary(data),
|
||||
Ok(AxumMessage::Ping(data)) => TungsteniteMessage::Ping(data),
|
||||
Ok(AxumMessage::Pong(data)) => TungsteniteMessage::Pong(data),
|
||||
Ok(AxumMessage::Close(_)) => {
|
||||
let _ = ttyd_tx.send(TungsteniteMessage::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if ttyd_tx.send(ttyd_msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Forward messages from ttyd to browser
|
||||
let ttyd_to_client = tokio::spawn(async move {
|
||||
while let Some(msg) = ttyd_rx.next().await {
|
||||
let client_msg = match msg {
|
||||
Ok(TungsteniteMessage::Text(text)) => AxumMessage::Text(text),
|
||||
Ok(TungsteniteMessage::Binary(data)) => AxumMessage::Binary(data),
|
||||
Ok(TungsteniteMessage::Ping(data)) => AxumMessage::Ping(data),
|
||||
Ok(TungsteniteMessage::Pong(data)) => AxumMessage::Pong(data),
|
||||
Ok(TungsteniteMessage::Close(_)) => {
|
||||
let _ = client_tx.send(AxumMessage::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
Ok(TungsteniteMessage::Frame(_)) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if client_tx.send(client_msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either direction to complete
|
||||
tokio::select! {
|
||||
_ = client_to_ttyd => {}
|
||||
_ = ttyd_to_client => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy HTTP requests to ttyd
|
||||
pub async fn terminal_proxy(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
path: Option<Path<String>>,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let path_str = path.map(|p| p.0).unwrap_or_default();
|
||||
|
||||
// Connect to ttyd Unix socket
|
||||
let mut unix_stream = UnixStream::connect(TTYD_SOCKET_PATH)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("ttyd not running: {}", e)))?;
|
||||
|
||||
// Build HTTP request to forward
|
||||
let method = req.method().as_str();
|
||||
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 {
|
||||
format!("/api/terminal/{}{}", path_str, query)
|
||||
};
|
||||
|
||||
// Forward relevant headers
|
||||
let mut headers_str = String::new();
|
||||
for (name, value) in req.headers() {
|
||||
if let Ok(v) = value.to_str() {
|
||||
let name_lower = name.as_str().to_lowercase();
|
||||
if !matches!(
|
||||
name_lower.as_str(),
|
||||
"connection" | "keep-alive" | "transfer-encoding" | "upgrade"
|
||||
) {
|
||||
headers_str.push_str(&format!("{}: {}\r\n", name, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let http_request = format!(
|
||||
"{} {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n{}\r\n",
|
||||
method, uri_path, headers_str
|
||||
);
|
||||
|
||||
// Send request
|
||||
unix_stream
|
||||
.write_all(http_request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to send request: {}", e)))?;
|
||||
|
||||
// Read response
|
||||
let mut response_buf = Vec::new();
|
||||
unix_stream
|
||||
.read_to_end(&mut response_buf)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to read response: {}", e)))?;
|
||||
|
||||
// Parse HTTP response
|
||||
let response_str = String::from_utf8_lossy(&response_buf);
|
||||
let header_end = response_str
|
||||
.find("\r\n\r\n")
|
||||
.ok_or_else(|| AppError::Internal("Invalid HTTP response".to_string()))?;
|
||||
|
||||
let headers_part = &response_str[..header_end];
|
||||
let body_start = header_end + 4;
|
||||
|
||||
// Parse status line
|
||||
let status_line = headers_part
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| AppError::Internal("Missing status line".to_string()))?;
|
||||
let status_code: u16 = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
// Build response
|
||||
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) {
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
let name = name.trim();
|
||||
let value = value.trim();
|
||||
if !matches!(
|
||||
name.to_lowercase().as_str(),
|
||||
"connection" | "keep-alive" | "transfer-encoding"
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body = if body_start < response_buf.len() {
|
||||
Body::from(response_buf[body_start..].to_vec())
|
||||
} else {
|
||||
Body::empty()
|
||||
};
|
||||
|
||||
builder
|
||||
.body(body)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to build response: {}", e)))
|
||||
}
|
||||
|
||||
/// Terminal index page
|
||||
pub async fn terminal_index(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
terminal_proxy(State(state), None, req).await
|
||||
}
|
||||
12
src/web/mod.rs
Normal file
12
src/web/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
mod audio_ws;
|
||||
mod routes;
|
||||
mod handlers;
|
||||
mod static_files;
|
||||
mod ws;
|
||||
|
||||
pub use audio_ws::audio_ws_handler;
|
||||
pub use routes::create_router;
|
||||
// StaticAssets is only available in release mode (embedded assets)
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use static_files::StaticAssets;
|
||||
pub use ws::ws_handler;
|
||||
178
src/web/routes.rs
Normal file
178
src/web/routes.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{any, delete, get, patch, post, put},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
use super::audio_ws::audio_ws_handler;
|
||||
use super::handlers;
|
||||
use super::ws::ws_handler;
|
||||
use crate::auth::{auth_middleware, require_admin};
|
||||
use crate::hid::websocket::ws_hid_handler;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Create the main application router
|
||||
pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
// Public routes (no auth required)
|
||||
// Note: /info moved to user_routes for security (contains hostname, IPs, etc.)
|
||||
let public_routes = Router::new()
|
||||
.route("/health", get(handlers::health_check))
|
||||
.route("/auth/login", post(handlers::login))
|
||||
.route("/setup", get(handlers::setup_status))
|
||||
.route("/setup/init", post(handlers::setup_init));
|
||||
|
||||
// User routes (authenticated users - both regular and admin)
|
||||
let user_routes = Router::new()
|
||||
.route("/info", get(handlers::system_info))
|
||||
.route("/auth/logout", post(handlers::logout))
|
||||
.route("/auth/check", get(handlers::auth_check))
|
||||
.route("/devices", get(handlers::list_devices))
|
||||
// WebSocket endpoint for real-time events
|
||||
.route("/ws", any(ws_handler))
|
||||
// Stream control endpoints
|
||||
.route("/stream/status", get(handlers::stream_state))
|
||||
.route("/stream/start", post(handlers::stream_start))
|
||||
.route("/stream/stop", post(handlers::stream_stop))
|
||||
.route("/stream/mode", get(handlers::stream_mode_get))
|
||||
.route("/stream/mode", post(handlers::stream_mode_set))
|
||||
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
||||
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
||||
// WebRTC endpoints
|
||||
.route("/webrtc/session", post(handlers::webrtc_create_session))
|
||||
.route("/webrtc/offer", post(handlers::webrtc_offer))
|
||||
.route("/webrtc/ice", post(handlers::webrtc_ice_candidate))
|
||||
.route("/webrtc/ice-servers", get(handlers::webrtc_ice_servers))
|
||||
.route("/webrtc/status", get(handlers::webrtc_status))
|
||||
.route("/webrtc/close", post(handlers::webrtc_close_session))
|
||||
// HID endpoints
|
||||
.route("/hid/status", get(handlers::hid_status))
|
||||
.route("/hid/reset", post(handlers::hid_reset))
|
||||
// WebSocket HID endpoint (for MJPEG mode)
|
||||
.route("/ws/hid", any(ws_hid_handler))
|
||||
// Audio endpoints
|
||||
.route("/audio/status", get(handlers::audio_status))
|
||||
.route("/audio/start", post(handlers::start_audio_streaming))
|
||||
.route("/audio/stop", post(handlers::stop_audio_streaming))
|
||||
.route("/audio/quality", post(handlers::set_audio_quality))
|
||||
.route("/audio/device", post(handlers::select_audio_device))
|
||||
.route("/audio/devices", get(handlers::list_audio_devices))
|
||||
// Audio WebSocket endpoint
|
||||
.route("/ws/audio", any(audio_ws_handler))
|
||||
// User can change their own password (handler will check ownership)
|
||||
.route("/users/:id/password", post(handlers::change_user_password));
|
||||
|
||||
// Admin-only routes (require admin privileges)
|
||||
let admin_routes = Router::new()
|
||||
// Configuration management (domain-separated endpoints)
|
||||
.route("/config", get(handlers::config::get_all_config))
|
||||
.route("/config", post(handlers::update_config))
|
||||
.route("/config/video", get(handlers::config::get_video_config))
|
||||
.route("/config/video", patch(handlers::config::update_video_config))
|
||||
.route("/config/stream", get(handlers::config::get_stream_config))
|
||||
.route("/config/stream", patch(handlers::config::update_stream_config))
|
||||
.route("/config/hid", get(handlers::config::get_hid_config))
|
||||
.route("/config/hid", patch(handlers::config::update_hid_config))
|
||||
.route("/config/msd", get(handlers::config::get_msd_config))
|
||||
.route("/config/msd", patch(handlers::config::update_msd_config))
|
||||
.route("/config/atx", get(handlers::config::get_atx_config))
|
||||
.route("/config/atx", patch(handlers::config::update_atx_config))
|
||||
.route("/config/audio", get(handlers::config::get_audio_config))
|
||||
.route("/config/audio", patch(handlers::config::update_audio_config))
|
||||
// MSD (Mass Storage Device) endpoints
|
||||
.route("/msd/status", get(handlers::msd_status))
|
||||
.route("/msd/images", get(handlers::msd_images_list))
|
||||
.route("/msd/images/download", post(handlers::msd_image_download))
|
||||
.route("/msd/images/download/cancel", post(handlers::msd_image_download_cancel))
|
||||
.route("/msd/images/:id", get(handlers::msd_image_get))
|
||||
.route("/msd/images/:id", delete(handlers::msd_image_delete))
|
||||
.route("/msd/connect", post(handlers::msd_connect))
|
||||
.route("/msd/disconnect", post(handlers::msd_disconnect))
|
||||
// MSD Virtual Drive endpoints
|
||||
.route("/msd/drive", get(handlers::msd_drive_info))
|
||||
.route("/msd/drive", delete(handlers::msd_drive_delete))
|
||||
.route("/msd/drive/init", post(handlers::msd_drive_init))
|
||||
.route("/msd/drive/files", get(handlers::msd_drive_files))
|
||||
.route("/msd/drive/files/*path", get(handlers::msd_drive_download))
|
||||
.route("/msd/drive/files/*path", delete(handlers::msd_drive_file_delete))
|
||||
.route("/msd/drive/mkdir/*path", post(handlers::msd_drive_mkdir))
|
||||
// ATX (Power Control) endpoints
|
||||
.route("/atx/status", get(handlers::atx_status))
|
||||
.route("/atx/power", post(handlers::atx_power))
|
||||
.route("/atx/wol", post(handlers::atx_wol))
|
||||
// Device discovery endpoints
|
||||
.route("/devices/atx", get(handlers::devices::list_atx_devices))
|
||||
// User management endpoints
|
||||
.route("/users", get(handlers::list_users))
|
||||
.route("/users", post(handlers::create_user))
|
||||
.route("/users/:id", put(handlers::update_user))
|
||||
.route("/users/:id", delete(handlers::delete_user))
|
||||
// Extension management endpoints
|
||||
.route("/extensions", get(handlers::extensions::list_extensions))
|
||||
.route("/extensions/:id", get(handlers::extensions::get_extension))
|
||||
.route("/extensions/:id/start", post(handlers::extensions::start_extension))
|
||||
.route("/extensions/:id/stop", post(handlers::extensions::stop_extension))
|
||||
.route("/extensions/:id/logs", get(handlers::extensions::get_extension_logs))
|
||||
.route("/extensions/ttyd/config", patch(handlers::extensions::update_ttyd_config))
|
||||
.route("/extensions/ttyd/status", get(handlers::extensions::get_ttyd_status))
|
||||
.route("/extensions/gostc/config", patch(handlers::extensions::update_gostc_config))
|
||||
.route("/extensions/easytier/config", patch(handlers::extensions::update_easytier_config))
|
||||
// Terminal (ttyd) reverse proxy - WebSocket and HTTP
|
||||
.route("/terminal", get(handlers::terminal::terminal_index))
|
||||
.route("/terminal/", get(handlers::terminal::terminal_index))
|
||||
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
|
||||
.route("/terminal/*path", get(handlers::terminal::terminal_proxy))
|
||||
// Apply admin middleware to all admin routes
|
||||
.layer(middleware::from_fn_with_state(state.clone(), require_admin));
|
||||
|
||||
// Combine protected routes (user + admin)
|
||||
let protected_routes = Router::new()
|
||||
.merge(user_routes)
|
||||
.merge(admin_routes);
|
||||
|
||||
// Stream endpoints (accessible with auth, but typically embedded in pages)
|
||||
let stream_routes = Router::new()
|
||||
.route("/stream", get(handlers::mjpeg_stream))
|
||||
.route("/stream/mjpeg", get(handlers::mjpeg_stream))
|
||||
.route("/snapshot", get(handlers::snapshot));
|
||||
|
||||
// Large file upload routes (MSD images and drive files)
|
||||
// Use streaming upload to support files larger than available RAM
|
||||
// Disable body limit for streaming uploads - files are written directly to disk
|
||||
let upload_routes = Router::new()
|
||||
.route("/msd/images", post(handlers::msd_image_upload))
|
||||
.route("/msd/drive/files", post(handlers::msd_drive_upload))
|
||||
.layer(DefaultBodyLimit::disable());
|
||||
|
||||
// Combine API routes
|
||||
let api_routes = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
.merge(stream_routes)
|
||||
.merge(upload_routes)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
// Static file serving
|
||||
let static_routes = super::static_files::static_file_router();
|
||||
|
||||
// Main router
|
||||
Router::new()
|
||||
.nest("/api", api_routes)
|
||||
.merge(static_routes)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
204
src/web/static_files.rs
Normal file
204
src/web/static_files.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, Response, StatusCode, Uri},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use tracing;
|
||||
|
||||
// Only embed assets in release mode
|
||||
#[cfg(not(debug_assertions))]
|
||||
use rust_embed::Embed;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
/// Embedded static assets (frontend files) - only in release mode
|
||||
#[derive(Embed)]
|
||||
#[folder = "web/dist"]
|
||||
#[prefix = ""]
|
||||
pub struct StaticAssets;
|
||||
|
||||
/// Get the base directory for static files
|
||||
/// In debug mode: relative to executable directory
|
||||
/// In release mode: not used (embedded assets)
|
||||
fn get_static_base_dir() -> PathBuf {
|
||||
static BASE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
BASE_DIR.get_or_init(|| {
|
||||
// Try to get executable directory
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
return exe_dir.join("web").join("dist");
|
||||
}
|
||||
}
|
||||
// Fallback to current directory
|
||||
PathBuf::from("web/dist")
|
||||
}).clone()
|
||||
}
|
||||
|
||||
/// Create router for static file serving
|
||||
pub fn static_file_router<S>() -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/*path", get(static_handler))
|
||||
}
|
||||
|
||||
/// Serve index.html for root path
|
||||
async fn index_handler() -> Response<Body> {
|
||||
serve_file("index.html")
|
||||
}
|
||||
|
||||
/// Serve static files
|
||||
async fn static_handler(uri: Uri) -> Response<Body> {
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
|
||||
// Try to serve the exact file
|
||||
if let Some(response) = try_serve_file(path) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// For SPA routing, serve index.html for non-asset paths
|
||||
if !path.contains('.') {
|
||||
if let Some(response) = try_serve_file("index.html") {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// If no embedded assets found, return placeholder page
|
||||
// This happens when web/dist was not built before compilation
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.body(Body::from(placeholder_html()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn serve_file(path: &str) -> Response<Body> {
|
||||
try_serve_file(path).unwrap_or_else(|| {
|
||||
// If index.html not found in embedded assets, return placeholder
|
||||
if path == "index.html" {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.body(Body::from(placeholder_html()))
|
||||
.unwrap()
|
||||
} else {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("Not Found"))
|
||||
.unwrap()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn try_serve_file(path: &str) -> Option<Response<Body>> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
// Debug mode: read from file system
|
||||
let base_dir = get_static_base_dir();
|
||||
let file_path = base_dir.join(path);
|
||||
|
||||
// Check if file exists and is within base directory (prevent directory traversal)
|
||||
if !file_path.starts_with(&base_dir) {
|
||||
tracing::warn!("Path traversal attempt blocked: {}", path);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Normalize path to prevent directory traversal (only if file exists)
|
||||
if let (Ok(normalized_path), Ok(normalized_base)) =
|
||||
(file_path.canonicalize(), base_dir.canonicalize())
|
||||
{
|
||||
if !normalized_path.starts_with(&normalized_base) {
|
||||
tracing::warn!("Path traversal attempt blocked (canonicalized): {}", path);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(data) => {
|
||||
let mime = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
|
||||
return Some(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(data))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"Failed to read static file '{}' from '{}': {}",
|
||||
path,
|
||||
file_path.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
// Release mode: use embedded assets
|
||||
let asset = StaticAssets::get(path)?;
|
||||
|
||||
let mime = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
|
||||
Some(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(asset.data.to_vec()))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder index.html when frontend is not built
|
||||
pub fn placeholder_html() -> &'static str {
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>One-KVM</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 { font-size: 2.5rem; margin-bottom: 1rem; }
|
||||
p { color: #888; font-size: 1.1rem; }
|
||||
.version { color: #666; margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<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.0</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#
|
||||
}
|
||||
251
src/web/ws.rs
Normal file
251
src/web/ws.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
//! WebSocket handler for real-time event streaming
|
||||
//!
|
||||
//! This module provides a WebSocket endpoint at `/api/ws` that:
|
||||
//! - Broadcasts system events to connected clients
|
||||
//! - Supports topic-based event filtering
|
||||
//! - Handles client subscription management
|
||||
//! - Includes heartbeat (ping/pong) mechanism
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::events::SystemEvent;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Client-to-server message
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", content = "payload")]
|
||||
enum ClientMessage {
|
||||
/// Subscribe to event topics
|
||||
#[serde(rename = "subscribe")]
|
||||
Subscribe { topics: Vec<String> },
|
||||
|
||||
/// Unsubscribe from event topics
|
||||
#[serde(rename = "unsubscribe")]
|
||||
Unsubscribe { topics: Vec<String> },
|
||||
|
||||
/// Ping (keep-alive)
|
||||
#[serde(rename = "ping")]
|
||||
Ping,
|
||||
}
|
||||
|
||||
/// WebSocket upgrade handler
|
||||
///
|
||||
/// This is the entry point for WebSocket connections at `/api/ws`.
|
||||
/// Authentication is handled by the middleware.
|
||||
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
/// Handle WebSocket connection
|
||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Subscribe to event bus
|
||||
let mut event_rx = state.events.subscribe();
|
||||
|
||||
// Track subscribed topics (default: none until client subscribes)
|
||||
let mut subscribed_topics: Vec<String> = vec![];
|
||||
|
||||
// Flag to send device info after first subscribe
|
||||
let mut device_info_sent = false;
|
||||
|
||||
info!("WebSocket client connected");
|
||||
|
||||
// Heartbeat interval (30 seconds)
|
||||
let mut heartbeat_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Receive message from client
|
||||
msg = receiver.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
||||
warn!("Failed to handle client message: {}", e);
|
||||
}
|
||||
|
||||
// Send device info after first subscribe
|
||||
if !device_info_sent && !subscribed_topics.is_empty() {
|
||||
let device_info = state.get_device_info().await;
|
||||
if let Ok(json) = serialize_event(&device_info) {
|
||||
if sender.send(Message::Text(json)).await.is_err() {
|
||||
warn!("Failed to send device info to client");
|
||||
break;
|
||||
}
|
||||
}
|
||||
device_info_sent = true;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(_))) => {
|
||||
// WebSocket automatically handles ping/pong
|
||||
debug!("Received ping from client");
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
debug!("Received pong from client");
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => {
|
||||
info!("WebSocket client disconnected");
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warn!("WebSocket receive error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Receive event from event bus
|
||||
event = event_rx.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
// Filter event based on subscribed topics
|
||||
if should_send_event(&event, &subscribed_topics) {
|
||||
if let Ok(json) = serialize_event(&event) {
|
||||
if sender.send(Message::Text(json)).await.is_err() {
|
||||
warn!("Failed to send event to client, disconnecting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("WebSocket client lagged by {} events", n);
|
||||
// Send error notification to client using SystemEvent::Error
|
||||
let error_event = SystemEvent::Error {
|
||||
message: format!("Lagged by {} events", n),
|
||||
};
|
||||
if let Ok(json) = serialize_event(&error_event) {
|
||||
let _ = sender.send(Message::Text(json)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Event bus closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat
|
||||
_ = heartbeat_interval.tick() => {
|
||||
if sender.send(Message::Ping(vec![])).await.is_err() {
|
||||
warn!("Failed to send ping, disconnecting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("WebSocket handler exiting");
|
||||
}
|
||||
|
||||
/// Handle message from client
|
||||
async fn handle_client_message(
|
||||
text: &str,
|
||||
topics: &mut Vec<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let msg: ClientMessage = serde_json::from_str(text)?;
|
||||
|
||||
match msg {
|
||||
ClientMessage::Subscribe { topics: new_topics } => {
|
||||
*topics = new_topics.clone();
|
||||
info!("Client subscribed to topics: {:?}", new_topics);
|
||||
}
|
||||
ClientMessage::Unsubscribe {
|
||||
topics: remove_topics,
|
||||
} => {
|
||||
topics.retain(|t| !remove_topics.contains(t));
|
||||
info!("Client unsubscribed from topics: {:?}", remove_topics);
|
||||
}
|
||||
ClientMessage::Ping => {
|
||||
debug!("Received ping from client");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an event should be sent based on subscribed topics
|
||||
fn should_send_event(event: &SystemEvent, topics: &[String]) -> bool {
|
||||
if topics.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fast path: check for wildcard subscription (avoid String allocation)
|
||||
if topics.iter().any(|t| t == "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if event matches any subscribed topic
|
||||
topics.iter().any(|topic| event.matches_topic(topic))
|
||||
}
|
||||
|
||||
/// Serialize event to JSON string
|
||||
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(event)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::SystemEvent;
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_wildcard() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
|
||||
assert!(should_send_event(&event, &["*".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_prefix() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
|
||||
assert!(should_send_event(&event, &["stream.*".to_string()]));
|
||||
assert!(!should_send_event(&event, &["msd.*".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_exact() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
|
||||
assert!(should_send_event(
|
||||
&event,
|
||||
&["stream.state_changed".to_string()]
|
||||
));
|
||||
assert!(!should_send_event(
|
||||
&event,
|
||||
&["stream.config_changed".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_empty_topics() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
|
||||
assert!(!should_send_event(&event, &[]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user