mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 11:42:02 +08:00
feat: 初步增加 Windows 支持
This commit is contained in:
151
src/web/handlers/account.rs
Normal file
151
src/web/handlers/account.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use super::*;
|
||||
|
||||
/// Change password request
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Change current user's password
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(session): axum::Extension<Session>,
|
||||
Json(req): Json<ChangePasswordRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let current_user = state
|
||||
.users
|
||||
.single_user()
|
||||
.await?
|
||||
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
||||
|
||||
if current_user.id != session.user_id {
|
||||
return Err(AppError::AuthError("Invalid session".to_string()));
|
||||
}
|
||||
|
||||
if req.new_password.len() < 4 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Password must be at least 4 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let verified = state
|
||||
.users
|
||||
.verify(¤t_user.username, &req.current_password)
|
||||
.await?;
|
||||
if verified.is_none() {
|
||||
return Err(AppError::AuthError(
|
||||
"Current password is incorrect".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.users
|
||||
.update_password(&session.user_id, &req.new_password)
|
||||
.await?;
|
||||
info!("Password changed for user ID: {}", session.user_id);
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Password changed successfully".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Change username request
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangeUsernameRequest {
|
||||
pub username: String,
|
||||
pub current_password: String,
|
||||
}
|
||||
|
||||
/// Change current user's username
|
||||
pub async fn change_username(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(session): axum::Extension<Session>,
|
||||
Json(req): Json<ChangeUsernameRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let current_user = state
|
||||
.users
|
||||
.single_user()
|
||||
.await?
|
||||
.ok_or_else(|| AppError::AuthError("User not found".to_string()))?;
|
||||
|
||||
if current_user.id != session.user_id {
|
||||
return Err(AppError::AuthError("Invalid session".to_string()));
|
||||
}
|
||||
|
||||
if req.username.len() < 2 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Username must be at least 2 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let verified = state
|
||||
.users
|
||||
.verify(¤t_user.username, &req.current_password)
|
||||
.await?;
|
||||
if verified.is_none() {
|
||||
return Err(AppError::AuthError(
|
||||
"Current password is incorrect".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if current_user.username != req.username {
|
||||
state
|
||||
.users
|
||||
.update_username(&session.user_id, &req.username)
|
||||
.await?;
|
||||
}
|
||||
info!("Username changed for user ID: {}", session.user_id);
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Username changed successfully".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restart the application
|
||||
pub async fn system_restart(State(state): State<Arc<AppState>>) -> Json<LoginResponse> {
|
||||
info!("System restart requested via API");
|
||||
|
||||
// Send shutdown signal
|
||||
let _ = state.shutdown_tx.send(());
|
||||
|
||||
// Spawn restart task in background
|
||||
tokio::spawn(async {
|
||||
// Wait for resources to be released (OTG, video, etc.)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Get current executable and args
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get current exe: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
info!("Restarting: {:?} {:?}", exe, args);
|
||||
|
||||
// Use exec to replace current process (Unix)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = std::process::Command::new(&exe).args(&args).exec();
|
||||
tracing::error!("Failed to restart: {}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = std::process::Command::new(&exe).args(&args).spawn();
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Restarting...".to_string()),
|
||||
})
|
||||
}
|
||||
197
src/web/handlers/atx_api.rs
Normal file
197
src/web/handlers/atx_api.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
use super::*;
|
||||
|
||||
use crate::atx::{AtxState, PowerStatus};
|
||||
|
||||
const WOL_HISTORY_DEFAULT_LIMIT: usize = 5;
|
||||
const WOL_HISTORY_MAX_LIMIT: usize = 50;
|
||||
|
||||
/// ATX state response
|
||||
#[derive(Serialize)]
|
||||
pub struct AtxStateResponse {
|
||||
pub available: bool,
|
||||
pub backend: String,
|
||||
pub initialized: bool,
|
||||
pub power_status: String,
|
||||
pub led_supported: bool,
|
||||
}
|
||||
|
||||
impl From<AtxState> for AtxStateResponse {
|
||||
fn from(state: AtxState) -> Self {
|
||||
Self {
|
||||
available: state.available,
|
||||
backend: if state.power_configured || state.reset_configured {
|
||||
format!(
|
||||
"power: {}, reset: {}",
|
||||
if state.power_configured { "yes" } else { "no" },
|
||||
if state.reset_configured { "yes" } else { "no" }
|
||||
)
|
||||
} else {
|
||||
"none".to_string()
|
||||
},
|
||||
initialized: state.power_configured || state.reset_configured,
|
||||
power_status: match state.power_status {
|
||||
PowerStatus::On => "on".to_string(),
|
||||
PowerStatus::Off => "off".to_string(),
|
||||
PowerStatus::Unknown => "unknown".to_string(),
|
||||
},
|
||||
led_supported: state.led_supported,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get ATX status
|
||||
pub async fn atx_status(State(state): State<Arc<AppState>>) -> Result<Json<AtxStateResponse>> {
|
||||
let atx_guard = state.atx.read().await;
|
||||
|
||||
match atx_guard.as_ref() {
|
||||
Some(atx) => {
|
||||
let atx_state = atx.state().await;
|
||||
Ok(Json(AtxStateResponse::from(atx_state)))
|
||||
}
|
||||
None => Ok(Json(AtxStateResponse {
|
||||
available: false,
|
||||
backend: "none".to_string(),
|
||||
initialized: false,
|
||||
power_status: "unknown".to_string(),
|
||||
led_supported: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// ATX power control request
|
||||
#[derive(Deserialize)]
|
||||
pub struct AtxPowerControlRequest {
|
||||
pub action: String, // "short", "long", "reset"
|
||||
}
|
||||
|
||||
/// Control ATX power
|
||||
pub async fn atx_power(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AtxPowerControlRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let atx_guard = state.atx.read().await;
|
||||
let atx = atx_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::Internal("ATX controller not initialized".to_string()))?;
|
||||
|
||||
match req.action.as_str() {
|
||||
"short" => {
|
||||
atx.power_short().await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Power short press executed".to_string()),
|
||||
}))
|
||||
}
|
||||
"long" => {
|
||||
atx.power_long().await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Power long press (force off) executed".to_string()),
|
||||
}))
|
||||
}
|
||||
"reset" => {
|
||||
atx.reset().await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Reset button pressed".to_string()),
|
||||
}))
|
||||
}
|
||||
_ => Err(AppError::BadRequest(format!(
|
||||
"Unknown ATX action: {}. Valid actions: short, long, reset",
|
||||
req.action
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// WOL request body
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WolRequest {
|
||||
/// Target MAC address (e.g., "AA:BB:CC:DD:EE:FF" or "AA-BB-CC-DD-EE-FF")
|
||||
pub mac_address: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct WolHistoryQuery {
|
||||
/// Maximum history entries to return
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WolHistoryEntry {
|
||||
pub mac_address: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WolHistoryResponse {
|
||||
pub history: Vec<WolHistoryEntry>,
|
||||
}
|
||||
|
||||
fn normalize_wol_mac_address(mac_address: &str) -> String {
|
||||
let normalized = mac_address.trim().to_uppercase().replace('-', ":");
|
||||
|
||||
if normalized.len() == 12 && normalized.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let mut mac_with_separator = String::with_capacity(17);
|
||||
for (index, chunk) in normalized.as_bytes().chunks(2).enumerate() {
|
||||
if index > 0 {
|
||||
mac_with_separator.push(':');
|
||||
}
|
||||
mac_with_separator.push(chunk[0] as char);
|
||||
mac_with_separator.push(chunk[1] as char);
|
||||
}
|
||||
mac_with_separator
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
/// Send Wake-on-LAN magic packet
|
||||
pub async fn atx_wol(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<WolRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let mac_address = normalize_wol_mac_address(&req.mac_address);
|
||||
|
||||
// Get WOL interface from config
|
||||
let config = state.config.get();
|
||||
let interface = if config.atx.wol_interface.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(config.atx.wol_interface.as_str())
|
||||
};
|
||||
|
||||
// Send WOL packet
|
||||
crate::atx::send_wol(&mac_address, interface)?;
|
||||
|
||||
if let Err(error) = crate::atx::record_wol_history(state.db.pool(), &mac_address).await {
|
||||
warn!("Failed to persist WOL history: {}", error);
|
||||
}
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some(format!("WOL packet sent to {}", mac_address)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get WOL history
|
||||
pub async fn atx_wol_history(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<WolHistoryQuery>,
|
||||
) -> Result<Json<WolHistoryResponse>> {
|
||||
let limit = query
|
||||
.limit
|
||||
.unwrap_or(WOL_HISTORY_DEFAULT_LIMIT)
|
||||
.clamp(1, WOL_HISTORY_MAX_LIMIT);
|
||||
|
||||
let rows = crate::atx::list_wol_history(state.db.pool(), limit).await?;
|
||||
|
||||
let history = rows
|
||||
.into_iter()
|
||||
.map(|(mac_address, updated_at)| WolHistoryEntry {
|
||||
mac_address,
|
||||
updated_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(WolHistoryResponse { history }))
|
||||
}
|
||||
83
src/web/handlers/audio_api.rs
Normal file
83
src/web/handlers/audio_api.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use super::*;
|
||||
|
||||
use crate::audio::{AudioQuality, AudioStatus};
|
||||
|
||||
/// Audio status response (re-exports AudioStatus from audio module)
|
||||
pub type AudioStatusResponse = AudioStatus;
|
||||
|
||||
/// Get audio status
|
||||
pub async fn audio_status(State(state): State<Arc<AppState>>) -> Json<AudioStatusResponse> {
|
||||
Json(state.audio.status().await)
|
||||
}
|
||||
|
||||
/// Start audio streaming
|
||||
pub async fn start_audio_streaming(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
state.audio.start_streaming().await?;
|
||||
|
||||
// Reconnect audio sources for existing WebRTC sessions
|
||||
// This ensures sessions created before audio was enabled will receive audio
|
||||
state.stream_manager.reconnect_webrtc_audio_sources().await;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Audio streaming started".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Stop audio streaming
|
||||
pub async fn stop_audio_streaming(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
state.audio.stop_streaming().await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Audio streaming stopped".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set audio quality request
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetAudioQualityRequest {
|
||||
pub quality: String,
|
||||
}
|
||||
|
||||
/// Set audio quality
|
||||
pub async fn set_audio_quality(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SetAudioQualityRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let quality = req.quality.parse::<AudioQuality>()?;
|
||||
state.audio.set_quality(quality).await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some(format!("Audio quality set to {}", quality)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Select audio device request
|
||||
#[derive(Deserialize)]
|
||||
pub struct SelectAudioDeviceRequest {
|
||||
pub device: String,
|
||||
}
|
||||
|
||||
/// Select audio device
|
||||
pub async fn select_audio_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SelectAudioDeviceRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
state.audio.select_device(&req.device).await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some(format!("Audio device selected: {}", req.device)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List audio devices
|
||||
pub async fn list_audio_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<crate::audio::AudioDeviceInfo>>> {
|
||||
let devices = state.audio.list_devices().await?;
|
||||
Ok(Json(devices))
|
||||
}
|
||||
107
src/web/handlers/auth.rs
Normal file
107
src/web/handlers/auth.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub success: bool,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<(CookieJar, Json<LoginResponse>)> {
|
||||
let config = state.config.get();
|
||||
|
||||
// Check if system is initialized
|
||||
if !config.initialized {
|
||||
return Err(AppError::BadRequest("System not initialized".to_string()));
|
||||
}
|
||||
|
||||
// Verify user credentials
|
||||
let user = state
|
||||
.users
|
||||
.verify(&req.username, &req.password)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::AuthError("Invalid username or password".to_string()))?;
|
||||
|
||||
if !config.auth.single_user_allow_multiple_sessions {
|
||||
// Kick existing sessions before creating a new one.
|
||||
let revoked_ids = state.sessions.list_ids().await?;
|
||||
state.sessions.delete_all().await?;
|
||||
state.remember_revoked_sessions(revoked_ids).await;
|
||||
}
|
||||
|
||||
// Create session
|
||||
let session = state.sessions.create(&user.id).await?;
|
||||
|
||||
// Set session cookie
|
||||
let cookie = Cookie::build((SESSION_COOKIE, session.id))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(time::Duration::seconds(
|
||||
config.auth.session_timeout_secs as i64,
|
||||
))
|
||||
.build();
|
||||
|
||||
Ok((
|
||||
cookies.add(cookie),
|
||||
Json(LoginResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
) -> Result<(CookieJar, Json<LoginResponse>)> {
|
||||
// Get session ID from cookie
|
||||
if let Some(cookie) = cookies.get(SESSION_COOKIE) {
|
||||
state.sessions.delete(cookie.value()).await?;
|
||||
}
|
||||
|
||||
// Remove cookie
|
||||
let cookie = Cookie::build((SESSION_COOKIE, ""))
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
Ok((
|
||||
cookies.remove(cookie),
|
||||
Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Logged out".to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AuthCheckResponse {
|
||||
pub authenticated: bool,
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn auth_check(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(session): axum::Extension<Session>,
|
||||
) -> Json<AuthCheckResponse> {
|
||||
// Get user info from user_id
|
||||
let username = match state.users.single_user().await {
|
||||
Ok(Some(user)) if user.id == session.user_id => Some(user.username),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Json(AuthCheckResponse {
|
||||
authenticated: true,
|
||||
user: username,
|
||||
})
|
||||
}
|
||||
@@ -39,12 +39,20 @@ fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType {
|
||||
}
|
||||
|
||||
async fn reconcile_otg_from_store(state: &Arc<AppState>) -> Result<()> {
|
||||
let config = state.config.get();
|
||||
state
|
||||
.otg_service
|
||||
.apply_config(&config.hid, &config.msd)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = state;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let config = state.config.get();
|
||||
state
|
||||
.otg_service
|
||||
.apply_config(&config.hid, &config.msd)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_video_config(
|
||||
@@ -207,6 +215,7 @@ pub async fn apply_hid_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn apply_msd_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &MsdConfig,
|
||||
|
||||
@@ -25,6 +25,7 @@ pub async fn update_atx_config(
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
|
||||
let mut merged_atx_config = old_atx_config.clone();
|
||||
req.apply_to(&mut merged_atx_config);
|
||||
validate_windows_atx_backends(&merged_atx_config)?;
|
||||
validate_serial_device_conflict(&merged_atx_config, ¤t_config.hid)?;
|
||||
|
||||
state
|
||||
@@ -41,6 +42,23 @@ pub async fn update_atx_config(
|
||||
Ok(Json(new_atx_config))
|
||||
}
|
||||
|
||||
fn validate_windows_atx_backends(atx: &AtxConfig) -> Result<()> {
|
||||
if !cfg!(windows) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for (name, key) in [("power", &atx.power), ("reset", &atx.reset)] {
|
||||
if !matches!(key.driver, AtxDriverType::Serial | AtxDriverType::None) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Windows ATX {} only supports serial relay or none",
|
||||
name
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<()> {
|
||||
if hid.backend != HidBackend::Ch9329 {
|
||||
return Ok(());
|
||||
@@ -91,4 +109,13 @@ mod tests {
|
||||
|
||||
assert!(validate_serial_device_conflict(&atx, &hid).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_windows_atx_backends_allows_serial() {
|
||||
let mut atx = AtxConfig::default();
|
||||
atx.power.driver = AtxDriverType::Serial;
|
||||
atx.reset.driver = AtxDriverType::None;
|
||||
|
||||
assert!(validate_windows_atx_backends(&atx).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod atx;
|
||||
mod audio;
|
||||
mod auth;
|
||||
mod hid;
|
||||
#[cfg(unix)]
|
||||
mod msd;
|
||||
mod redfish;
|
||||
mod rtsp;
|
||||
@@ -17,6 +18,7 @@ pub use atx::{get_atx_config, update_atx_config};
|
||||
pub use audio::{get_audio_config, update_audio_config};
|
||||
pub use auth::{get_auth_config, update_auth_config};
|
||||
pub use hid::{get_hid_config, update_hid_config};
|
||||
#[cfg(unix)]
|
||||
pub use msd::{get_msd_config, update_msd_config};
|
||||
pub use redfish::{get_redfish_config, update_redfish_config};
|
||||
pub use rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config};
|
||||
|
||||
@@ -77,11 +77,14 @@ pub async fn update_rustdesk_config(
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?;
|
||||
let old_config = state.config.get().rustdesk.clone();
|
||||
let mut merged_config = old_config.clone();
|
||||
req.apply_to(&mut merged_config);
|
||||
req.validate_merged(&merged_config)?;
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.rustdesk);
|
||||
config.rustdesk = merged_config.clone();
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::rtsp::RtspServiceStatus;
|
||||
use crate::rustdesk::config::RustDeskConfig;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
use typeshare::typeshare;
|
||||
|
||||
@@ -358,12 +359,14 @@ impl HidConfigUpdate {
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MsdConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub msd_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl MsdConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
if let Some(ref dir) = self.msd_dir {
|
||||
@@ -472,7 +475,8 @@ impl AtxConfigUpdate {
|
||||
|
||||
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() {
|
||||
if !device.trim().is_empty() && !cfg!(windows) && !std::path::Path::new(device).exists()
|
||||
{
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} device '{}' does not exist",
|
||||
name, device
|
||||
@@ -542,6 +546,12 @@ impl AtxConfigUpdate {
|
||||
) -> crate::error::Result<()> {
|
||||
match key.driver {
|
||||
crate::atx::AtxDriverType::Serial => {
|
||||
if key.device.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} serial device cannot be empty",
|
||||
name
|
||||
)));
|
||||
}
|
||||
if key.pin == 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} serial channel must be 1-based (>= 1)",
|
||||
@@ -739,6 +749,15 @@ impl RustDeskConfigUpdate {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_merged(&self, config: &RustDeskConfig) -> crate::error::Result<()> {
|
||||
if config.enabled && config.rendezvous_server.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"RustDesk ID server is required".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, config: &mut RustDeskConfig) {
|
||||
if let Some(enabled) = self.enabled {
|
||||
config.enabled = enabled;
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
use axum::Json;
|
||||
#[cfg(unix)]
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::atx::{discover_devices, AtxDevices};
|
||||
#[cfg(unix)]
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::usb_reset;
|
||||
#[cfg(unix)]
|
||||
use crate::platform::usb_reset;
|
||||
|
||||
pub async fn list_atx_devices() -> Json<AtxDevices> {
|
||||
Json(discover_devices())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn list_usb_devices() -> Json<Vec<usb_reset::UsbDeviceInfo>> {
|
||||
Json(usb_reset::list_usb_devices())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct UsbResetRequest {
|
||||
pub bus_num: u32,
|
||||
pub dev_num: u32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn reset_usb_device(Json(req): Json<UsbResetRequest>) -> Result<Json<serde_json::Value>> {
|
||||
usb_reset::reset_usb_device(req.bus_num, req.dev_num).map_err(|e| {
|
||||
AppError::VideoError(format!(
|
||||
"USB reset failed for device {}-{}: {}",
|
||||
req.bus_num, req.dev_num, e
|
||||
AppError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!(
|
||||
"USB reset failed for device {}-{}: {}",
|
||||
req.bus_num, req.dev_num, e
|
||||
),
|
||||
))
|
||||
})?;
|
||||
Ok(Json(serde_json::json!({
|
||||
|
||||
@@ -13,6 +13,27 @@ use crate::extensions::{
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
fn validate_gostc_enabled(config: &GostcConfig) -> Result<()> {
|
||||
if config.addr.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"GOSTC server address is required".into(),
|
||||
));
|
||||
}
|
||||
if config.key.is_empty() {
|
||||
return Err(AppError::BadRequest("GOSTC client key is required".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_easytier_enabled(config: &EasytierConfig) -> Result<()> {
|
||||
if config.network_name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"EasyTier network name is required".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> {
|
||||
let config = state.config.get();
|
||||
let mgr = &state.extensions;
|
||||
@@ -179,40 +200,40 @@ 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;
|
||||
let current_config = state.config.get();
|
||||
let was_enabled = current_config.extensions.gostc.enabled;
|
||||
let mut next_gostc = current_config.extensions.gostc.clone();
|
||||
|
||||
if let Some(enabled) = req.enabled {
|
||||
next_gostc.enabled = enabled;
|
||||
}
|
||||
if let Some(ref addr) = req.addr {
|
||||
next_gostc.addr = addr.clone();
|
||||
}
|
||||
if let Some(ref key) = req.key {
|
||||
next_gostc.key = key.clone();
|
||||
}
|
||||
if let Some(tls) = req.tls {
|
||||
next_gostc.tls = tls;
|
||||
}
|
||||
|
||||
if next_gostc.enabled {
|
||||
validate_gostc_enabled(&next_gostc)?;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
config.extensions.gostc = next_gostc.clone();
|
||||
})
|
||||
.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();
|
||||
let has_addr = !new_config.extensions.gostc.addr.trim().is_empty();
|
||||
|
||||
if was_enabled && !is_enabled {
|
||||
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
||||
} else if !was_enabled
|
||||
&& is_enabled
|
||||
&& has_key
|
||||
&& has_addr
|
||||
&& state.extensions.check_available(ExtensionId::Gostc)
|
||||
{
|
||||
} else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Gostc) {
|
||||
state
|
||||
.extensions
|
||||
.start(ExtensionId::Gostc, &new_config.extensions)
|
||||
@@ -227,40 +248,43 @@ 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;
|
||||
let current_config = state.config.get();
|
||||
let was_enabled = current_config.extensions.easytier.enabled;
|
||||
let mut next_easytier = current_config.extensions.easytier.clone();
|
||||
|
||||
if let Some(enabled) = req.enabled {
|
||||
next_easytier.enabled = enabled;
|
||||
}
|
||||
if let Some(ref name) = req.network_name {
|
||||
next_easytier.network_name = name.clone();
|
||||
}
|
||||
if let Some(ref secret) = req.network_secret {
|
||||
next_easytier.network_secret = secret.clone();
|
||||
}
|
||||
if let Some(ref peers) = req.peer_urls {
|
||||
next_easytier.peer_urls = peers.clone();
|
||||
}
|
||||
if req.virtual_ip.is_some() {
|
||||
next_easytier.virtual_ip = req.virtual_ip.clone();
|
||||
}
|
||||
|
||||
if next_easytier.enabled {
|
||||
validate_easytier_enabled(&next_easytier)?;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
config.extensions.easytier = next_easytier.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
|
||||
&& state.extensions.check_available(ExtensionId::Easytier)
|
||||
} else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Easytier)
|
||||
{
|
||||
state
|
||||
.extensions
|
||||
|
||||
53
src/web/handlers/hid_api.rs
Normal file
53
src/web/handlers/hid_api.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HidStatus {
|
||||
pub available: bool,
|
||||
pub backend: String,
|
||||
pub initialized: bool,
|
||||
pub online: bool,
|
||||
pub supports_absolute_mouse: bool,
|
||||
pub keyboard_leds_enabled: bool,
|
||||
pub led_state: crate::hid::LedState,
|
||||
pub screen_resolution: Option<(u32, u32)>,
|
||||
pub device: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
/// OTG self-check status for troubleshooting USB gadget issues
|
||||
#[cfg(unix)]
|
||||
pub async fn hid_otg_self_check(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<crate::otg::self_check::OtgSelfCheckResponse> {
|
||||
let config = state.config.get();
|
||||
Json(crate::otg::self_check::run(config.as_ref()))
|
||||
}
|
||||
|
||||
/// Get HID status
|
||||
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
||||
let hid = state.hid.snapshot().await;
|
||||
Json(HidStatus {
|
||||
available: hid.available,
|
||||
backend: hid.backend,
|
||||
initialized: hid.initialized,
|
||||
online: hid.online,
|
||||
supports_absolute_mouse: hid.supports_absolute_mouse,
|
||||
keyboard_leds_enabled: hid.keyboard_leds_enabled,
|
||||
led_state: hid.led_state,
|
||||
screen_resolution: hid.screen_resolution,
|
||||
device: hid.device,
|
||||
error: hid.error,
|
||||
error_code: hid.error_code,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset HID state
|
||||
pub async fn hid_reset(State(state): State<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
state.hid.reset().await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("HID state reset".to_string()),
|
||||
}))
|
||||
}
|
||||
182
src/web/handlers/inventory.rs
Normal file
182
src/web/handlers/inventory.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeviceList {
|
||||
pub video: Vec<VideoDevice>,
|
||||
pub serial: Vec<SerialDevice>,
|
||||
pub audio: Vec<AudioDevice>,
|
||||
pub udc: Vec<UdcDevice>,
|
||||
pub extensions: ExtensionsAvailability,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ExtensionsAvailability {
|
||||
pub ttyd_available: bool,
|
||||
pub rustdesk_available: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoDevice {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub driver: String,
|
||||
pub formats: Vec<VideoFormat>,
|
||||
pub usb_bus: Option<String>,
|
||||
pub has_signal: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoFormat {
|
||||
pub format: String,
|
||||
pub description: String,
|
||||
pub resolutions: Vec<VideoResolution>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoResolution {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SerialDevice {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_hdmi: bool,
|
||||
pub usb_bus: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UdcDevice {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Extract USB bus port from V4L2 bus_info string
|
||||
/// Examples:
|
||||
/// - "usb-0000:00:14.0-1" -> Some("1")
|
||||
/// - "usb-xhci-hcd.0-1.2" -> Some("1.2")
|
||||
/// - "usb-0000:00:14.0-1.3.2" -> Some("1.3.2")
|
||||
/// - "platform:..." -> None
|
||||
fn extract_usb_bus_from_bus_info(bus_info: &str) -> Option<String> {
|
||||
if !bus_info.starts_with("usb-") {
|
||||
return None;
|
||||
}
|
||||
// Find the last '-' which separates the USB port
|
||||
// e.g., "usb-0000:00:14.0-1" -> "1"
|
||||
// e.g., "usb-xhci-hcd.0-1.2" -> "1.2"
|
||||
let parts: Vec<&str> = bus_info.rsplitn(2, '-').collect();
|
||||
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)
|
||||
{
|
||||
return Some(port.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList> {
|
||||
let platform = PlatformCapabilities::current();
|
||||
|
||||
// Detect video devices
|
||||
let video_devices = match state.stream_manager.list_devices().await {
|
||||
Ok(devices) => devices
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
// Extract USB bus from bus_info (e.g., "usb-0000:00:14.0-1" -> "1")
|
||||
// or "usb-xhci-hcd.0-1.2" -> "1.2"
|
||||
let usb_bus = extract_usb_bus_from_bus_info(&d.bus_info);
|
||||
VideoDevice {
|
||||
path: d.path.to_string_lossy().to_string(),
|
||||
name: d.name,
|
||||
driver: d.driver,
|
||||
formats: d
|
||||
.formats
|
||||
.iter()
|
||||
.map(|f| VideoFormat {
|
||||
format: format!("{}", f.format),
|
||||
description: f.description.clone(),
|
||||
resolutions: f
|
||||
.resolutions
|
||||
.iter()
|
||||
.map(|r| VideoResolution {
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
fps: r.fps.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
usb_bus,
|
||||
has_signal: d.has_signal,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Video device enumeration failed; returning empty video list for /api/devices");
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let serial_devices = list_serial_ports()
|
||||
.into_iter()
|
||||
.map(|path| SerialDevice {
|
||||
name: std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(&path)
|
||||
.to_string(),
|
||||
path,
|
||||
})
|
||||
.collect();
|
||||
|
||||
#[cfg(unix)]
|
||||
let udc_devices = crate::otg::list_udc_devices()
|
||||
.into_iter()
|
||||
.map(|name| UdcDevice { name })
|
||||
.collect();
|
||||
#[cfg(not(unix))]
|
||||
let udc_devices = Vec::new();
|
||||
|
||||
// Detect audio devices
|
||||
let audio_devices = match state.audio.list_devices().await {
|
||||
Ok(devices) => devices
|
||||
.into_iter()
|
||||
.map(|d| AudioDevice {
|
||||
name: d.name,
|
||||
description: d.description,
|
||||
is_hdmi: d.is_hdmi,
|
||||
usb_bus: d.usb_bus,
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
};
|
||||
|
||||
// Check extension availability
|
||||
let ttyd_available = state
|
||||
.extensions
|
||||
.check_available(crate::extensions::ExtensionId::Ttyd);
|
||||
|
||||
Json(DeviceList {
|
||||
video: video_devices,
|
||||
serial: serial_devices,
|
||||
audio: audio_devices,
|
||||
udc: udc_devices,
|
||||
extensions: ExtensionsAvailability {
|
||||
ttyd_available,
|
||||
rustdesk_available: platform.rustdesk.available,
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
405
src/web/handlers/msd_api.rs
Normal file
405
src/web/handlers/msd_api.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
use super::*;
|
||||
|
||||
use crate::msd::{
|
||||
DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo,
|
||||
ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use axum::extract::{Multipart, Path as AxumPath};
|
||||
#[cfg(unix)]
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// MSD status response
|
||||
#[cfg(unix)]
|
||||
#[derive(Serialize)]
|
||||
pub struct MsdStatus {
|
||||
pub available: bool,
|
||||
pub state: MsdState,
|
||||
}
|
||||
|
||||
/// Get MSD status
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_status(State(state): State<Arc<AppState>>) -> Result<Json<MsdStatus>> {
|
||||
let msd_guard = state.msd.read().await;
|
||||
match msd_guard.as_ref() {
|
||||
Some(controller) => {
|
||||
let msd_state = controller.state().await;
|
||||
Ok(Json(MsdStatus {
|
||||
available: true,
|
||||
state: msd_state,
|
||||
}))
|
||||
}
|
||||
None => Ok(Json(MsdStatus {
|
||||
available: false,
|
||||
state: MsdState::default(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available images
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_images_list(State(state): State<Arc<AppState>>) -> Result<Json<Vec<ImageInfo>>> {
|
||||
let config = state.config.get();
|
||||
let images_path = config.msd.images_dir();
|
||||
let manager = ImageManager::new(images_path);
|
||||
|
||||
let images = manager.list()?;
|
||||
Ok(Json(images))
|
||||
}
|
||||
|
||||
/// Upload new image (streaming - memory efficient for large files)
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_image_upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ImageInfo>> {
|
||||
let config = state.config.get();
|
||||
let images_path = config.msd.images_dir();
|
||||
let manager = ImageManager::new(images_path);
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Multipart error: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or("file").to_string();
|
||||
if name == "file" {
|
||||
let filename = field
|
||||
.file_name()
|
||||
.ok_or_else(|| AppError::BadRequest("Missing filename".to_string()))?
|
||||
.to_string();
|
||||
|
||||
// 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?;
|
||||
return Ok(Json(image));
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::BadRequest("No file provided".to_string()))
|
||||
}
|
||||
|
||||
/// Get image by ID
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_image_get(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Result<Json<ImageInfo>> {
|
||||
let config = state.config.get();
|
||||
let images_path = config.msd.images_dir();
|
||||
let manager = ImageManager::new(images_path);
|
||||
|
||||
let image = manager.get(&id)?;
|
||||
Ok(Json(image))
|
||||
}
|
||||
|
||||
/// Delete image by ID
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_image_delete(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let config = state.config.get();
|
||||
let images_path = config.msd.images_dir();
|
||||
let manager = ImageManager::new(images_path);
|
||||
|
||||
manager.delete(&id)?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Image deleted".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Download image from URL
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_image_download(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ImageDownloadRequest>,
|
||||
) -> Result<Json<DownloadProgress>> {
|
||||
let msd_guard = state.msd.read().await;
|
||||
let controller = msd_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||
|
||||
let progress = controller.download_image(req.url, req.filename).await?;
|
||||
|
||||
Ok(Json(progress))
|
||||
}
|
||||
|
||||
/// Cancel image download
|
||||
#[cfg(unix)]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CancelDownloadRequest {
|
||||
pub download_id: String,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_image_download_cancel(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CancelDownloadRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let msd_guard = state.msd.read().await;
|
||||
let controller = msd_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||
|
||||
controller.cancel_download(&req.download_id).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Download cancelled".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Connect MSD (image or drive)
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_connect(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<MsdConnectRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let config = state.config.get();
|
||||
let mut msd_guard = state.msd.write().await;
|
||||
let controller = msd_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||
|
||||
match req.mode {
|
||||
MsdMode::Image => {
|
||||
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 = config.msd.images_dir();
|
||||
let manager = ImageManager::new(images_path);
|
||||
let image = manager.get(&image_id)?;
|
||||
|
||||
// Get mount options from request (defaults: cdrom=false, read_only=false)
|
||||
let cdrom = req.cdrom.unwrap_or(false);
|
||||
let read_only = req.read_only.unwrap_or(false);
|
||||
|
||||
controller.connect_image(&image, cdrom, read_only).await?;
|
||||
}
|
||||
MsdMode::Drive => {
|
||||
controller.connect_drive().await?;
|
||||
}
|
||||
MsdMode::None => {
|
||||
return Err(AppError::BadRequest("Invalid mode: none".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("MSD connected".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Disconnect MSD
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_disconnect(State(state): State<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
let mut msd_guard = state.msd.write().await;
|
||||
let controller = msd_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
||||
|
||||
controller.disconnect().await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("MSD disconnected".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get drive info
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_info(State(state): State<Arc<AppState>>) -> Result<Json<DriveInfo>> {
|
||||
let config = state.config.get();
|
||||
let drive_path = config.msd.drive_path();
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
if !drive.exists() {
|
||||
return Err(AppError::NotFound("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let info = drive.info().await?;
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// Initialize Ventoy drive
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_init(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<DriveInitRequest>,
|
||||
) -> Result<Json<DriveInfo>> {
|
||||
let config = state.config.get();
|
||||
let drive_path = config.msd.drive_path();
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
let info = drive.init(req.size_mb).await?;
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// Delete virtual drive
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_delete(State(state): State<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
let config = state.config.get();
|
||||
|
||||
// Check if drive is currently connected
|
||||
let msd_guard = state.msd.write().await;
|
||||
if let Some(controller) = msd_guard.as_ref() {
|
||||
let msd_state = controller.state().await;
|
||||
if msd_state.connected && msd_state.mode == crate::msd::types::MsdMode::Drive {
|
||||
return Err(AppError::BadRequest(
|
||||
"Cannot delete drive while connected. Disconnect first.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
drop(msd_guard);
|
||||
|
||||
// Delete the drive file
|
||||
let drive_path = 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)))?;
|
||||
}
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Virtual drive deleted".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List drive files
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<Json<Vec<DriveFile>>> {
|
||||
let config = state.config.get();
|
||||
let drive_path = config.msd.drive_path();
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
let dir_path = params.get("path").map(|s| s.as_str()).unwrap_or("/");
|
||||
let files = drive.list_files(dir_path).await?;
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
/// Upload file to drive (streaming - memory efficient for large files)
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let config = state.config.get();
|
||||
let drive_path = config.msd.drive_path();
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
let target_dir = params.get("path").map(|s| s.as_str()).unwrap_or("/");
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Multipart error: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or("file").to_string();
|
||||
if name == "file" {
|
||||
let filename = field
|
||||
.file_name()
|
||||
.ok_or_else(|| AppError::BadRequest("Missing filename".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let file_path = if target_dir == "/" {
|
||||
format!("/{}", filename)
|
||||
} else {
|
||||
format!("{}/{}", target_dir.trim_end_matches('/'), filename)
|
||||
};
|
||||
|
||||
// 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?;
|
||||
|
||||
return Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some(format!("File uploaded: {}", file_path)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::BadRequest("No file provided".to_string()))
|
||||
}
|
||||
|
||||
/// Download file from drive (streaming for large files)
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_download(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(file_path): AxumPath<String>,
|
||||
) -> Result<Response> {
|
||||
let config = state.config.get();
|
||||
let drive_path = config.msd.drive_path();
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
// Get file stream (returns file size and channel receiver)
|
||||
let (file_size, mut rx) = drive.read_file_stream(&file_path).await?;
|
||||
|
||||
// Extract filename for Content-Disposition
|
||||
let filename = file_path.split('/').next_back().unwrap_or("download");
|
||||
|
||||
// Create a stream from the channel receiver
|
||||
let body_stream = async_stream::stream! {
|
||||
while let Some(chunk) = rx.recv().await {
|
||||
yield chunk;
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CONTENT_LENGTH, file_size)
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
)
|
||||
.body(Body::from_stream(body_stream))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// Delete file from drive
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_file_delete(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(file_path): AxumPath<String>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let config = state.config.get();
|
||||
let drive_path = config.msd.drive_path();
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
drive.delete(&file_path).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some(format!("Deleted: {}", file_path)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create directory in drive
|
||||
#[cfg(unix)]
|
||||
pub async fn msd_drive_mkdir(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(dir_path): AxumPath<String>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let config = state.config.get();
|
||||
let drive_path = config.msd.drive_path();
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
drive.mkdir(&dir_path).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some(format!("Directory created: {}", dir_path)),
|
||||
}))
|
||||
}
|
||||
261
src/web/handlers/setup.rs
Normal file
261
src/web/handlers/setup.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SetupStatus {
|
||||
pub initialized: bool,
|
||||
pub needs_setup: bool,
|
||||
pub platform: PlatformCapabilities,
|
||||
}
|
||||
|
||||
pub async fn setup_status(State(state): State<Arc<AppState>>) -> Json<SetupStatus> {
|
||||
let initialized = state.config.is_initialized();
|
||||
Json(SetupStatus {
|
||||
initialized,
|
||||
needs_setup: !initialized,
|
||||
platform: PlatformCapabilities::current(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetupRequest {
|
||||
// Account settings
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
// Video settings
|
||||
pub video_device: Option<String>,
|
||||
pub video_format: Option<String>,
|
||||
pub video_width: Option<u32>,
|
||||
pub video_height: Option<u32>,
|
||||
pub video_fps: Option<u32>,
|
||||
// Audio settings
|
||||
pub audio_device: Option<String>,
|
||||
// HID settings
|
||||
pub hid_backend: Option<String>,
|
||||
pub hid_ch9329_port: Option<String>,
|
||||
pub hid_ch9329_baudrate: Option<u32>,
|
||||
pub hid_otg_udc: Option<String>,
|
||||
pub hid_otg_profile: Option<String>,
|
||||
pub hid_otg_endpoint_budget: Option<crate::config::OtgEndpointBudget>,
|
||||
pub hid_otg_keyboard_leds: Option<bool>,
|
||||
pub msd_enabled: Option<bool>,
|
||||
// Extension settings
|
||||
pub ttyd_enabled: Option<bool>,
|
||||
pub rustdesk_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn setup_init(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SetupRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
// Check if already initialized
|
||||
if state.config.is_initialized() {
|
||||
return Err(AppError::BadRequest("Already initialized".to_string()));
|
||||
}
|
||||
|
||||
// Validate username
|
||||
if req.username.len() < 2 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Username must be at least 2 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if req.password.len() < 4 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Password must be at least 4 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create single system user
|
||||
state
|
||||
.users
|
||||
.create_first_user(&req.username, &req.password)
|
||||
.await?;
|
||||
|
||||
// Update config
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
config.initialized = true;
|
||||
|
||||
// Video settings
|
||||
if let Some(device) = req.video_device.clone() {
|
||||
config.video.device = Some(device);
|
||||
}
|
||||
if let Some(format) = req.video_format.clone() {
|
||||
config.video.format = Some(format);
|
||||
}
|
||||
if let Some(width) = req.video_width {
|
||||
config.video.width = width;
|
||||
}
|
||||
if let Some(height) = req.video_height {
|
||||
config.video.height = height;
|
||||
}
|
||||
if let Some(fps) = req.video_fps {
|
||||
config.video.fps = fps;
|
||||
}
|
||||
|
||||
// Audio settings
|
||||
if let Some(device) = req.audio_device.clone() {
|
||||
config.audio.device = device;
|
||||
config.audio.enabled = true;
|
||||
}
|
||||
|
||||
// HID settings
|
||||
if let Some(backend) = req.hid_backend.clone() {
|
||||
config.hid.backend = match backend.as_str() {
|
||||
"otg" => crate::config::HidBackend::Otg,
|
||||
"ch9329" => crate::config::HidBackend::Ch9329,
|
||||
_ => crate::config::HidBackend::None,
|
||||
};
|
||||
}
|
||||
if let Some(port) = req.hid_ch9329_port.clone() {
|
||||
config.hid.ch9329_port = port;
|
||||
}
|
||||
if let Some(baudrate) = req.hid_ch9329_baudrate {
|
||||
config.hid.ch9329_baudrate = baudrate;
|
||||
}
|
||||
if let Some(udc) = req.hid_otg_udc.clone() {
|
||||
config.hid.otg_udc = Some(udc);
|
||||
}
|
||||
if let Some(profile) = req.hid_otg_profile.clone() {
|
||||
if let Some(parsed) = crate::config::OtgHidProfile::from_legacy_str(&profile) {
|
||||
config.hid.otg_profile = parsed;
|
||||
}
|
||||
}
|
||||
if let Some(budget) = req.hid_otg_endpoint_budget {
|
||||
config.hid.otg_endpoint_budget = budget;
|
||||
}
|
||||
if let Some(enabled) = req.hid_otg_keyboard_leds {
|
||||
config.hid.otg_keyboard_leds = enabled;
|
||||
}
|
||||
if let Some(enabled) = req.msd_enabled {
|
||||
config.msd.enabled = enabled;
|
||||
}
|
||||
|
||||
// Extension settings
|
||||
if let Some(enabled) = req.ttyd_enabled {
|
||||
config.extensions.ttyd.enabled = enabled;
|
||||
}
|
||||
if let Some(enabled) = req.rustdesk_enabled {
|
||||
config.rustdesk.enabled = enabled;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Get updated config for HID reload
|
||||
let new_config = state.config.get();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Err(e) = state
|
||||
.otg_service
|
||||
.apply_config(&new_config.hid, &new_config.msd)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to apply OTG config during setup: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Extension config after save: ttyd.enabled={}, rustdesk.enabled={}",
|
||||
new_config.extensions.ttyd.enabled,
|
||||
new_config.rustdesk.enabled
|
||||
);
|
||||
|
||||
// Initialize HID backend with new config
|
||||
let new_hid_backend = match new_config.hid.backend {
|
||||
crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
||||
crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
||||
port: new_config.hid.ch9329_port.clone(),
|
||||
baud_rate: new_config.hid.ch9329_baudrate,
|
||||
},
|
||||
crate::config::HidBackend::None => crate::hid::HidBackendType::None,
|
||||
};
|
||||
|
||||
// Reload HID backend
|
||||
if let Err(e) = state.hid.reload(new_hid_backend).await {
|
||||
tracing::warn!("Failed to initialize HID backend during setup: {}", e);
|
||||
// Don't fail setup, just warn
|
||||
} else {
|
||||
tracing::info!("HID backend initialized: {:?}", new_config.hid.backend);
|
||||
}
|
||||
|
||||
// Start extensions if enabled
|
||||
if new_config.extensions.ttyd.enabled {
|
||||
if let Err(e) = state
|
||||
.extensions
|
||||
.start(crate::extensions::ExtensionId::Ttyd, &new_config.extensions)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to start ttyd during setup: {}", e);
|
||||
} else {
|
||||
tracing::info!("ttyd started during setup");
|
||||
}
|
||||
}
|
||||
|
||||
// Start RustDesk if enabled
|
||||
if new_config.rustdesk.enabled {
|
||||
let empty_config = crate::rustdesk::config::RustDeskConfig::default();
|
||||
if let Err(e) = config::apply::apply_rustdesk_config(
|
||||
&state,
|
||||
&empty_config,
|
||||
&new_config.rustdesk,
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to start RustDesk during setup: {}", e);
|
||||
} else {
|
||||
tracing::info!("RustDesk started during setup");
|
||||
}
|
||||
}
|
||||
|
||||
// Start RTSP if enabled
|
||||
if new_config.rtsp.enabled {
|
||||
let empty_config = crate::config::RtspConfig::default();
|
||||
if let Err(e) = config::apply::apply_rtsp_config(
|
||||
&state,
|
||||
&empty_config,
|
||||
&new_config.rtsp,
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to start RTSP during setup: {}", e);
|
||||
} else {
|
||||
tracing::info!("RTSP started during setup");
|
||||
}
|
||||
}
|
||||
|
||||
// Start audio streaming if audio device was selected during setup
|
||||
if new_config.audio.enabled {
|
||||
let audio_config = crate::audio::AudioControllerConfig {
|
||||
enabled: true,
|
||||
device: new_config.audio.device.clone(),
|
||||
quality: new_config
|
||||
.audio
|
||||
.quality
|
||||
.parse::<crate::audio::AudioQuality>()?,
|
||||
};
|
||||
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
|
||||
);
|
||||
}
|
||||
// Also enable WebRTC audio
|
||||
if let Err(e) = state.stream_manager.set_webrtc_audio_enabled(true).await {
|
||||
tracing::warn!("Failed to enable WebRTC audio during setup: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("System initialized successfully");
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Setup completed".to_string()),
|
||||
}))
|
||||
}
|
||||
626
src/web/handlers/stream.rs
Normal file
626
src/web/handlers/stream.rs
Normal file
@@ -0,0 +1,626 @@
|
||||
use super::*;
|
||||
|
||||
use crate::video::streamer::StreamerStats;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
fn stream_mode_label(mode: StreamMode, codec: crate::video::codec::VideoCodecType) -> &'static str {
|
||||
match mode {
|
||||
StreamMode::Mjpeg => "mjpeg",
|
||||
StreamMode::WebRTC => codec_to_id(codec),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stream state
|
||||
pub async fn stream_state(State(state): State<Arc<AppState>>) -> Json<StreamerStats> {
|
||||
Json(state.stream_manager.stats().await)
|
||||
}
|
||||
|
||||
/// Start streaming
|
||||
pub async fn stream_start(State(state): State<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
state.stream_manager.start().await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Streaming started".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Stop streaming
|
||||
pub async fn stream_stop(State(state): State<Arc<AppState>>) -> Result<Json<LoginResponse>> {
|
||||
state.stream_manager.stop().await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Streaming stopped".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Stream mode request
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetStreamModeRequest {
|
||||
/// Target mode: "mjpeg" or "webrtc"
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
/// Stream mode response
|
||||
#[derive(Serialize)]
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Get current stream mode
|
||||
pub async fn stream_mode_get(State(state): State<Arc<AppState>>) -> Json<StreamModeResponse> {
|
||||
let mode = state.stream_manager.current_mode().await;
|
||||
let codec = state.stream_manager.current_video_codec().await;
|
||||
let mode_str = stream_mode_label(mode, codec).to_string();
|
||||
|
||||
Json(StreamModeResponse {
|
||||
success: true,
|
||||
mode: mode_str,
|
||||
transition_id: state.stream_manager.current_transition_id().await,
|
||||
switching: state.stream_manager.is_switching(),
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set stream mode (switch between MJPEG and WebRTC)
|
||||
pub async fn stream_mode_set(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SetStreamModeRequest>,
|
||||
) -> Result<Json<StreamModeResponse>> {
|
||||
use crate::video::codec::VideoCodecType;
|
||||
|
||||
let constraints = state.stream_manager.codec_constraints().await;
|
||||
|
||||
let mode_lower = req.mode.to_lowercase();
|
||||
let (new_mode, video_codec) = match mode_lower.as_str() {
|
||||
"mjpeg" => (StreamMode::Mjpeg, None),
|
||||
"webrtc" | "h264" => (StreamMode::WebRTC, Some(VideoCodecType::H264)),
|
||||
"h265" => (StreamMode::WebRTC, Some(VideoCodecType::H265)),
|
||||
"vp8" => (StreamMode::WebRTC, Some(VideoCodecType::VP8)),
|
||||
"vp9" => (StreamMode::WebRTC, Some(VideoCodecType::VP9)),
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Invalid mode '{}'. Valid modes: mjpeg, h264, h265, vp8, vp9",
|
||||
req.mode
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if new_mode == StreamMode::Mjpeg && !constraints.is_mjpeg_allowed() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Codec 'mjpeg' is not allowed: {}",
|
||||
constraints.reason
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(codec) = video_codec {
|
||||
if !constraints.is_webrtc_codec_allowed(codec) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Codec '{}' is not allowed: {}",
|
||||
codec_to_id(codec),
|
||||
constraints.reason
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let requested_mode_str = match (&new_mode, &video_codec) {
|
||||
(StreamMode::Mjpeg, _) => "mjpeg",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::VP8)) => "vp8",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::VP9)) => "vp9",
|
||||
(StreamMode::WebRTC, None) => "webrtc",
|
||||
};
|
||||
|
||||
// Detect codec-only switch: already in WebRTC mode, just changing codec.
|
||||
// switch_mode_transaction treats this as "no switch needed" since StreamMode
|
||||
// is still WebRTC, so we handle codec change + event emission here.
|
||||
let current_mode = state.stream_manager.current_mode().await;
|
||||
let prev_codec = state.stream_manager.current_video_codec().await;
|
||||
|
||||
let codec_changed = video_codec.is_some_and(|c| c != prev_codec);
|
||||
let is_codec_only_switch =
|
||||
current_mode == StreamMode::WebRTC && new_mode == StreamMode::WebRTC && codec_changed;
|
||||
|
||||
if let Some(codec) = video_codec {
|
||||
info!("Setting WebRTC video codec to {:?}", codec);
|
||||
if let Err(e) = state.stream_manager.set_video_codec(codec).await {
|
||||
warn!("Failed to set video codec: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// For codec-only switch, emit events directly instead of going through
|
||||
// switch_mode_transaction (which short-circuits when mode is unchanged).
|
||||
if is_codec_only_switch {
|
||||
let transition_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
state
|
||||
.stream_manager
|
||||
.notify_codec_switch(&transition_id, requested_mode_str, &codec_to_id(prev_codec))
|
||||
.await;
|
||||
|
||||
return Ok(Json(StreamModeResponse {
|
||||
success: true,
|
||||
mode: requested_mode_str.to_string(),
|
||||
transition_id: Some(transition_id),
|
||||
switching: false,
|
||||
message: Some(format!("Codec switched to {}", requested_mode_str)),
|
||||
}));
|
||||
}
|
||||
|
||||
let tx = state
|
||||
.stream_manager
|
||||
.switch_mode_transaction(new_mode.clone())
|
||||
.await?;
|
||||
|
||||
let active_mode = state.stream_manager.current_mode().await;
|
||||
let active_codec = state.stream_manager.current_video_codec().await;
|
||||
let active_mode_str = stream_mode_label(active_mode, active_codec).to_string();
|
||||
|
||||
let no_switch_needed = !tx.accepted && !tx.switching && tx.transition_id.is_none();
|
||||
Ok(Json(StreamModeResponse {
|
||||
success: tx.accepted || no_switch_needed,
|
||||
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()
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Available video codec info
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoCodecInfo {
|
||||
/// Codec identifier (mjpeg, h264, h265, vp8, vp9)
|
||||
pub id: String,
|
||||
/// Display name
|
||||
pub name: String,
|
||||
/// Protocol (http or webrtc)
|
||||
pub protocol: String,
|
||||
/// Whether hardware accelerated
|
||||
pub hardware: bool,
|
||||
/// Encoder backend name (e.g., "vaapi", "nvenc", "software")
|
||||
pub backend: Option<String>,
|
||||
/// Whether this codec is available
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Encoder backend info
|
||||
#[derive(Serialize)]
|
||||
pub struct EncoderBackendInfo {
|
||||
/// Backend identifier (vaapi, nvenc, qsv, amf, rkmpp, v4l2m2m, software)
|
||||
pub id: String,
|
||||
/// Display name
|
||||
pub name: String,
|
||||
/// Whether this is a hardware backend
|
||||
pub is_hardware: bool,
|
||||
/// Supported video formats (h264, h265, vp8, vp9)
|
||||
pub supported_formats: Vec<String>,
|
||||
}
|
||||
|
||||
/// Available codecs response
|
||||
#[derive(Serialize)]
|
||||
pub struct AvailableCodecsResponse {
|
||||
pub success: bool,
|
||||
/// Available encoder backends
|
||||
pub backends: Vec<EncoderBackendInfo>,
|
||||
/// Available codecs (for backward compatibility)
|
||||
pub codecs: Vec<VideoCodecInfo>,
|
||||
}
|
||||
|
||||
/// Stream constraints response
|
||||
#[derive(Serialize)]
|
||||
pub struct StreamConstraintsResponse {
|
||||
pub success: bool,
|
||||
pub allowed_codecs: Vec<String>,
|
||||
pub locked_codec: Option<String>,
|
||||
pub disallow_mjpeg: bool,
|
||||
pub sources: ConstraintSources,
|
||||
pub reason: String,
|
||||
pub current_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ConstraintSources {
|
||||
pub rustdesk: bool,
|
||||
pub rtsp: bool,
|
||||
}
|
||||
|
||||
/// Get stream codec constraints derived from enabled services.
|
||||
pub async fn stream_constraints_get(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<StreamConstraintsResponse> {
|
||||
let constraints = state.stream_manager.codec_constraints().await;
|
||||
let current_mode = state.stream_manager.current_mode().await;
|
||||
let current_codec = state.stream_manager.current_video_codec().await;
|
||||
let current_mode = stream_mode_label(current_mode, current_codec).to_string();
|
||||
|
||||
Json(StreamConstraintsResponse {
|
||||
success: true,
|
||||
allowed_codecs: constraints
|
||||
.allowed_codecs_for_api()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
locked_codec: constraints
|
||||
.locked_codec
|
||||
.map(codec_to_id)
|
||||
.map(str::to_string),
|
||||
disallow_mjpeg: !constraints.allow_mjpeg,
|
||||
sources: ConstraintSources {
|
||||
rustdesk: constraints.rustdesk_enabled,
|
||||
rtsp: constraints.rtsp_enabled,
|
||||
},
|
||||
reason: constraints.reason,
|
||||
current_mode,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set bitrate request
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetBitrateRequest {
|
||||
pub bitrate_preset: BitratePreset,
|
||||
}
|
||||
|
||||
/// Set stream bitrate (real-time adjustment)
|
||||
pub async fn stream_set_bitrate(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SetBitrateRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
// Update config
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
config.stream.bitrate_preset = req.bitrate_preset;
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Apply to WebRTC streamer (real-time adjustment)
|
||||
if let Err(e) = state
|
||||
.stream_manager
|
||||
.set_bitrate_preset(req.bitrate_preset)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to set bitrate dynamically: {}", e);
|
||||
// Don't fail the request - config is saved, will apply on next connection
|
||||
} else {
|
||||
info!("Bitrate updated to {}", req.bitrate_preset);
|
||||
}
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some(format!("Bitrate set to {}", req.bitrate_preset)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get available video codecs
|
||||
pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
||||
use crate::video::codec::registry::{EncoderRegistry, VideoEncoderType};
|
||||
|
||||
let registry = EncoderRegistry::global();
|
||||
|
||||
// Build backends list
|
||||
let mut backends = Vec::new();
|
||||
for backend in registry.available_backends() {
|
||||
let formats = registry.formats_for_backend(backend);
|
||||
let format_ids: Vec<String> = formats
|
||||
.iter()
|
||||
.copied()
|
||||
.map(crate::video::codec_constraints::encoder_codec_to_id)
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
backends.push(EncoderBackendInfo {
|
||||
id: format!("{:?}", backend).to_lowercase(),
|
||||
name: backend.display_name().to_string(),
|
||||
is_hardware: backend.is_hardware(),
|
||||
supported_formats: format_ids,
|
||||
});
|
||||
}
|
||||
|
||||
// Build codecs list (for backward compatibility)
|
||||
let mut codecs = Vec::new();
|
||||
|
||||
// MJPEG is always available (HTTP streaming)
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "mjpeg".to_string(),
|
||||
name: "MJPEG / HTTP".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
hardware: false,
|
||||
backend: Some("software".to_string()),
|
||||
available: true,
|
||||
});
|
||||
|
||||
// Check H264 availability (supports software fallback)
|
||||
let h264_encoder = registry.best_available_encoder(VideoEncoderType::H264);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "h264".to_string(),
|
||||
name: "H.264 / WebRTC".to_string(),
|
||||
protocol: "webrtc".to_string(),
|
||||
hardware: h264_encoder.map(|e| e.is_hardware).unwrap_or(false),
|
||||
backend: h264_encoder.map(|e| e.backend.to_string()),
|
||||
available: h264_encoder.is_some(),
|
||||
});
|
||||
|
||||
// Check H265 availability (now supports software too)
|
||||
let h265_encoder = registry.best_available_encoder(VideoEncoderType::H265);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "h265".to_string(),
|
||||
name: "H.265 / WebRTC".to_string(),
|
||||
protocol: "webrtc".to_string(),
|
||||
hardware: h265_encoder.map(|e| e.is_hardware).unwrap_or(false),
|
||||
backend: h265_encoder.map(|e| e.backend.to_string()),
|
||||
available: h265_encoder.is_some(),
|
||||
});
|
||||
|
||||
// Check VP8 availability (now supports software too)
|
||||
let vp8_encoder = registry.best_available_encoder(VideoEncoderType::VP8);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "vp8".to_string(),
|
||||
name: "VP8 / WebRTC".to_string(),
|
||||
protocol: "webrtc".to_string(),
|
||||
hardware: vp8_encoder.map(|e| e.is_hardware).unwrap_or(false),
|
||||
backend: vp8_encoder.map(|e| e.backend.to_string()),
|
||||
available: vp8_encoder.is_some(),
|
||||
});
|
||||
|
||||
// Check VP9 availability (now supports software too)
|
||||
let vp9_encoder = registry.best_available_encoder(VideoEncoderType::VP9);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "vp9".to_string(),
|
||||
name: "VP9 / WebRTC".to_string(),
|
||||
protocol: "webrtc".to_string(),
|
||||
hardware: vp9_encoder.map(|e| e.is_hardware).unwrap_or(false),
|
||||
backend: vp9_encoder.map(|e| e.backend.to_string()),
|
||||
available: vp9_encoder.is_some(),
|
||||
});
|
||||
|
||||
Json(AvailableCodecsResponse {
|
||||
success: true,
|
||||
backends,
|
||||
codecs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run hardware encoder smoke tests across common resolutions/codecs.
|
||||
pub async fn video_encoder_self_check() -> Json<VideoEncoderSelfCheckResponse> {
|
||||
let response = tokio::task::spawn_blocking(run_hardware_self_check)
|
||||
.await
|
||||
.unwrap_or_else(|_| build_hardware_self_check_runtime_error());
|
||||
|
||||
Json(response)
|
||||
}
|
||||
|
||||
/// Query parameters for MJPEG stream
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct MjpegStreamQuery {
|
||||
/// Optional client ID (if not provided, a random UUID will be generated)
|
||||
pub client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// MJPEG stream endpoint
|
||||
pub async fn mjpeg_stream(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<MjpegStreamQuery>,
|
||||
) -> impl IntoResponse {
|
||||
// Check if MJPEG mode is active
|
||||
if !state.stream_manager.is_mjpeg_enabled().await {
|
||||
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."}"#,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Check if config is being changed - reject new connections during config change
|
||||
if state.stream_manager.is_config_changing() {
|
||||
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."}"#,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Ensure stream is started (but not during config change)
|
||||
if !state.stream_manager.is_streaming().await && !state.stream_manager.is_config_changing() {
|
||||
if let Err(e) = state.stream_manager.start().await {
|
||||
tracing::error!("Failed to auto-start stream: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
// Create RAII guard - this will automatically register and unregister the client
|
||||
let guard = Arc::new(crate::stream::mjpeg::ClientGuard::new(
|
||||
client_id.clone(),
|
||||
handler.clone(),
|
||||
));
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(1);
|
||||
|
||||
let guard_clone = guard.clone();
|
||||
let handler_clone = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard_clone; // Keep guard alive
|
||||
let mut notify_rx = handler_clone.subscribe();
|
||||
let mut last_seq = 0u64;
|
||||
let mut timeout_count = 0;
|
||||
|
||||
// Send initial frame if available
|
||||
if let Some(frame) = handler_clone.current_frame() {
|
||||
if frame.is_valid_jpeg() {
|
||||
let data = create_mjpeg_part(frame.data());
|
||||
// send() blocks until receiver is ready (backpressure)
|
||||
if tx.send(data).await.is_ok() {
|
||||
// FPS recording moved to async_stream after yield
|
||||
last_seq = frame.sequence;
|
||||
} else {
|
||||
return; // Receiver closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
// Check if stream went offline (e.g., during config change)
|
||||
if !handler_clone.is_online() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait for new frame notification with timeout
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), notify_rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
// Check online status after receiving notification
|
||||
// set_offline() sends a notification, so we need to check here
|
||||
if !handler_clone.is_online() {
|
||||
break;
|
||||
}
|
||||
timeout_count = 0;
|
||||
if let Some(frame) = handler_clone.current_frame() {
|
||||
// Use != instead of > to handle sequence reset when capturer restarts
|
||||
// (e.g., after video config change, new capturer starts from seq=0)
|
||||
if frame.sequence != last_seq && frame.is_valid_jpeg() {
|
||||
let data = create_mjpeg_part(frame.data());
|
||||
if tx.send(data).await.is_ok() {
|
||||
last_seq = frame.sequence;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
|
||||
break;
|
||||
}
|
||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {
|
||||
// Receiver was too slow - skip missed frames and jump to latest
|
||||
if !handler_clone.is_online() {
|
||||
break;
|
||||
}
|
||||
timeout_count = 0;
|
||||
|
||||
if let Some(frame) = handler_clone.current_frame() {
|
||||
if frame.is_valid_jpeg() {
|
||||
// Send current frame immediately and reset sequence tracking
|
||||
if tx.send(create_mjpeg_part(frame.data())).await.is_ok() {
|
||||
last_seq = frame.sequence;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout - check if still online
|
||||
timeout_count += 1;
|
||||
if timeout_count > 6 || !handler_clone.is_online() {
|
||||
break;
|
||||
}
|
||||
// Send last frame again to keep connection alive
|
||||
let Some(frame) = handler_clone.current_frame() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if frame.is_valid_jpeg()
|
||||
&& tx.send(create_mjpeg_part(frame.data())).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create stream that receives from channel and forwards to the HTTP
|
||||
// body. Record FPS *before* yield so the final frame of a session
|
||||
// still gets counted (after-yield code in async_stream! only runs
|
||||
// when the consumer polls again, which never happens for the last
|
||||
// frame of a closing connection).
|
||||
let handler_for_stream = handler.clone();
|
||||
let guard_for_stream = guard.clone();
|
||||
let body_stream = async_stream::stream! {
|
||||
while let Some(data) = rx.recv().await {
|
||||
handler_for_stream.record_frame_sent(guard_for_stream.id());
|
||||
yield Ok::<bytes::Bytes, std::io::Error>(data);
|
||||
}
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.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")
|
||||
.header(header::CONNECTION, "keep-alive")
|
||||
.body(Body::from_stream(body_stream))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Single JPEG snapshot
|
||||
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(),
|
||||
_ => Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body(Body::from("No frame available"))
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create MJPEG multipart frame bytes
|
||||
fn create_mjpeg_part(jpeg_data: &[u8]) -> bytes::Bytes {
|
||||
use bytes::{BufMut, BytesMut};
|
||||
|
||||
let mut buf = BytesMut::with_capacity(128 + jpeg_data.len());
|
||||
|
||||
// Write boundary and headers
|
||||
buf.put_slice(b"--frame\r\n");
|
||||
buf.put_slice(b"Content-Type: image/jpeg\r\n");
|
||||
buf.put_slice(format!("Content-Length: {}\r\n", jpeg_data.len()).as_bytes());
|
||||
buf.put_slice(b"\r\n");
|
||||
|
||||
// Write JPEG data
|
||||
buf.put_slice(jpeg_data);
|
||||
buf.put_slice(b"\r\n");
|
||||
|
||||
buf.freeze()
|
||||
}
|
||||
113
src/web/handlers/system.rs
Normal file
113
src/web/handlers/system.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use super::*;
|
||||
|
||||
/// Health check response
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: &'static str,
|
||||
pub version: &'static str,
|
||||
}
|
||||
|
||||
pub async fn health_check() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
/// System info response
|
||||
#[derive(Serialize)]
|
||||
pub struct SystemInfo {
|
||||
pub version: &'static str,
|
||||
pub build_date: &'static str,
|
||||
pub initialized: bool,
|
||||
pub platform: PlatformCapabilities,
|
||||
pub capabilities: Capabilities,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disk_space: Option<DiskSpaceInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_info: Option<DeviceInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Capabilities {
|
||||
pub video: CapabilityInfo,
|
||||
pub hid: CapabilityInfo,
|
||||
pub msd: CapabilityInfo,
|
||||
pub atx: CapabilityInfo,
|
||||
pub audio: CapabilityInfo,
|
||||
pub rustdesk: CapabilityInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CapabilityInfo {
|
||||
pub available: bool,
|
||||
pub backend: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo> {
|
||||
let config = state.config.get();
|
||||
let platform = PlatformCapabilities::current();
|
||||
|
||||
// Get disk space information for MSD base directory
|
||||
let disk_space = {
|
||||
let msd_dir = config.msd.msd_dir_path();
|
||||
if msd_dir.as_os_str().is_empty() {
|
||||
None
|
||||
} else {
|
||||
get_disk_space(&msd_dir).ok()
|
||||
}
|
||||
};
|
||||
|
||||
// Get device information (hostname, CPU, memory, network)
|
||||
let device_info = Some(get_device_info());
|
||||
|
||||
Json(SystemInfo {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
build_date: env!("BUILD_DATE"),
|
||||
initialized: config.initialized,
|
||||
platform: platform.clone(),
|
||||
capabilities: Capabilities {
|
||||
video: CapabilityInfo {
|
||||
available: config.video.device.is_some(),
|
||||
backend: config.video.device.clone(),
|
||||
reason: None,
|
||||
},
|
||||
hid: CapabilityInfo {
|
||||
available: config.hid.backend != crate::config::HidBackend::None,
|
||||
backend: Some(format!("{:?}", config.hid.backend)),
|
||||
reason: None,
|
||||
},
|
||||
msd: CapabilityInfo {
|
||||
available: config.msd.enabled && platform.msd.available,
|
||||
backend: None,
|
||||
reason: platform.msd.reason.clone(),
|
||||
},
|
||||
atx: CapabilityInfo {
|
||||
available: config.atx.enabled,
|
||||
backend: if config.atx.enabled {
|
||||
Some(format!(
|
||||
"power: {:?}, reset: {:?}",
|
||||
config.atx.power.driver, config.atx.reset.driver
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
reason: None,
|
||||
},
|
||||
audio: CapabilityInfo {
|
||||
available: config.audio.enabled && platform.audio.available,
|
||||
backend: Some(config.audio.device.clone()),
|
||||
reason: platform.audio.reason.clone(),
|
||||
},
|
||||
rustdesk: CapabilityInfo {
|
||||
available: config.rustdesk.enabled && platform.rustdesk.available,
|
||||
backend: platform.rustdesk.selected_backend.clone(),
|
||||
reason: platform.rustdesk.reason.clone(),
|
||||
},
|
||||
},
|
||||
disk_space,
|
||||
device_info,
|
||||
})
|
||||
}
|
||||
@@ -10,13 +10,19 @@ use axum::{
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
#[cfg(windows)]
|
||||
use tokio::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixStream;
|
||||
use tokio_tungstenite::tungstenite::{
|
||||
client::IntoClientRequest, http::HeaderValue, Message as TungsteniteMessage,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
#[cfg(unix)]
|
||||
use crate::extensions::TTYD_SOCKET_PATH;
|
||||
#[cfg(windows)]
|
||||
use crate::extensions::TTYD_TCP_ADDR;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn terminal_ws(
|
||||
@@ -35,10 +41,10 @@ pub async fn terminal_ws(
|
||||
}
|
||||
|
||||
async fn handle_terminal_websocket(client_ws: WebSocket, query_string: String) {
|
||||
let unix_stream = match UnixStream::connect(TTYD_SOCKET_PATH).await {
|
||||
let ttyd_stream = match connect_ttyd().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to connect to ttyd socket: {}", e);
|
||||
tracing::error!("Failed to connect to ttyd: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -56,7 +62,7 @@ async fn handle_terminal_websocket(client_ws: WebSocket, query_string: String) {
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Protocol", HeaderValue::from_static("tty"));
|
||||
|
||||
let ws_stream = match tokio_tungstenite::client_async(request, unix_stream).await {
|
||||
let ws_stream = match tokio_tungstenite::client_async(request, ttyd_stream).await {
|
||||
Ok((ws, _)) => ws,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to establish WebSocket with ttyd: {}", e);
|
||||
@@ -121,7 +127,7 @@ pub async fn terminal_proxy(
|
||||
) -> Result<Response, AppError> {
|
||||
let path_str = path.map(|p| p.0).unwrap_or_default();
|
||||
|
||||
let mut unix_stream = UnixStream::connect(TTYD_SOCKET_PATH)
|
||||
let mut ttyd_stream = connect_ttyd()
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("ttyd not running: {}", e)))?;
|
||||
|
||||
@@ -155,13 +161,13 @@ pub async fn terminal_proxy(
|
||||
method, uri_path, headers_str
|
||||
);
|
||||
|
||||
unix_stream
|
||||
ttyd_stream
|
||||
.write_all(http_request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to send request: {}", e)))?;
|
||||
|
||||
let mut response_buf = Vec::new();
|
||||
unix_stream
|
||||
ttyd_stream
|
||||
.read_to_end(&mut response_buf)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to read response: {}", e)))?;
|
||||
@@ -211,6 +217,16 @@ pub async fn terminal_proxy(
|
||||
.map_err(|e| AppError::Internal(format!("Failed to build response: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn connect_ttyd() -> std::io::Result<UnixStream> {
|
||||
UnixStream::connect(TTYD_SOCKET_PATH).await
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn connect_ttyd() -> std::io::Result<TcpStream> {
|
||||
TcpStream::connect(TTYD_TCP_ADDR).await
|
||||
}
|
||||
|
||||
pub async fn terminal_index(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request<Body>,
|
||||
|
||||
31
src/web/handlers/update_api.rs
Normal file
31
src/web/handlers/update_api.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateOverviewQuery {
|
||||
pub channel: Option<UpdateChannel>,
|
||||
}
|
||||
|
||||
pub async fn update_overview(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Query(query): axum::extract::Query<UpdateOverviewQuery>,
|
||||
) -> Result<Json<UpdateOverviewResponse>> {
|
||||
let channel = query.channel.unwrap_or(UpdateChannel::Stable);
|
||||
let response = state.update.overview(channel).await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn update_upgrade(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UpgradeRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
state.update.start_upgrade(req, state.shutdown_tx.clone())?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Upgrade started".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_status(State(state): State<Arc<AppState>>) -> Json<UpdateStatusResponse> {
|
||||
Json(state.update.status().await)
|
||||
}
|
||||
194
src/web/handlers/webrtc.rs
Normal file
194
src/web/handlers/webrtc.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use super::*;
|
||||
|
||||
use crate::webrtc::signaling::{AnswerResponse, IceCandidateRequest, OfferRequest};
|
||||
|
||||
/// Create WebRTC session
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateSessionResponse {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
pub async fn webrtc_create_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<CreateSessionResponse>> {
|
||||
// Check if WebRTC mode is active
|
||||
if !state.stream_manager.is_webrtc_enabled().await {
|
||||
return Err(AppError::ServiceUnavailable(
|
||||
"WebRTC mode not active. Current mode is MJPEG.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let session_id = state.webrtc.create_session().await?;
|
||||
Ok(Json(CreateSessionResponse { session_id }))
|
||||
}
|
||||
|
||||
/// Handle WebRTC offer
|
||||
pub async fn webrtc_offer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<OfferRequest>,
|
||||
) -> Result<Json<AnswerResponse>> {
|
||||
// Check if WebRTC mode is active
|
||||
if !state.stream_manager.is_webrtc_enabled().await {
|
||||
return Err(AppError::ServiceUnavailable(
|
||||
"WebRTC mode not active. Current mode is MJPEG.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Backward compatibility: `client_id` is treated as an existing session_id hint.
|
||||
// New clients should not pass it; each offer creates a fresh session.
|
||||
let webrtc = &state.webrtc;
|
||||
let session_id = if let Some(client_id) = &req.client_id {
|
||||
// Reuse only when it matches an active session ID.
|
||||
if webrtc.get_session(client_id).await.is_some() {
|
||||
client_id.clone()
|
||||
} else {
|
||||
webrtc.create_session().await?
|
||||
}
|
||||
} else {
|
||||
webrtc.create_session().await?
|
||||
};
|
||||
|
||||
// Handle offer
|
||||
let offer = crate::webrtc::SdpOffer::new(req.sdp);
|
||||
let answer = webrtc.handle_offer(&session_id, offer).await?;
|
||||
|
||||
Ok(Json(AnswerResponse::new(
|
||||
answer.sdp,
|
||||
session_id,
|
||||
answer.ice_candidates.unwrap_or_default(),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Add ICE candidate
|
||||
pub async fn webrtc_ice_candidate(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<IceCandidateRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
state
|
||||
.webrtc
|
||||
.add_ice_candidate(&req.session_id, req.candidate)
|
||||
.await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get WebRTC session info
|
||||
#[derive(Serialize)]
|
||||
pub struct WebRtcSessionInfo {
|
||||
pub session_id: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WebRtcStatus {
|
||||
pub session_count: usize,
|
||||
pub sessions: Vec<WebRtcSessionInfo>,
|
||||
}
|
||||
|
||||
pub async fn webrtc_status(State(state): State<Arc<AppState>>) -> Json<WebRtcStatus> {
|
||||
let sessions = state.webrtc.list_sessions().await;
|
||||
Json(WebRtcStatus {
|
||||
session_count: sessions.len(),
|
||||
sessions: sessions
|
||||
.into_iter()
|
||||
.map(|s| WebRtcSessionInfo {
|
||||
session_id: s.session_id,
|
||||
state: s.state,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Close WebRTC session
|
||||
#[derive(Deserialize)]
|
||||
pub struct CloseSessionRequest {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
pub async fn webrtc_close_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CloseSessionRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
state.webrtc.close_session(&req.session_id).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Session closed".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// ICE servers configuration for WebRTC
|
||||
#[derive(Serialize)]
|
||||
pub struct IceServersResponse {
|
||||
pub ice_servers: Vec<IceServerInfo>,
|
||||
pub mdns_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IceServerInfo {
|
||||
pub urls: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential: Option<String>,
|
||||
}
|
||||
|
||||
fn non_empty_config_value(value: &Option<String>) -> Option<&str> {
|
||||
value.as_deref().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Get ICE servers configuration for client-side WebRTC
|
||||
/// Returns user-configured servers, or Google STUN as fallback if none configured
|
||||
pub async fn webrtc_ice_servers(State(state): State<Arc<AppState>>) -> Json<IceServersResponse> {
|
||||
use crate::webrtc::config::public_ice;
|
||||
use crate::webrtc::mdns::{mdns_mode, mdns_mode_label};
|
||||
|
||||
let config = state.config.get();
|
||||
let mut ice_servers = Vec::new();
|
||||
|
||||
// Check if user has configured custom ICE servers
|
||||
let stun_server = non_empty_config_value(&config.stream.stun_server);
|
||||
let turn_server = non_empty_config_value(&config.stream.turn_server);
|
||||
|
||||
if stun_server.is_some() || turn_server.is_some() {
|
||||
// Use user-configured ICE servers
|
||||
if let Some(stun) = stun_server {
|
||||
ice_servers.push(IceServerInfo {
|
||||
urls: vec![stun.to_string()],
|
||||
username: None,
|
||||
credential: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(turn) = turn_server {
|
||||
let username = config.stream.turn_username.clone();
|
||||
let credential = config.stream.turn_password.clone();
|
||||
if username.is_some() && credential.is_some() {
|
||||
ice_servers.push(IceServerInfo {
|
||||
urls: vec![turn.to_string()],
|
||||
username,
|
||||
credential,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No custom servers — baked-in public STUN
|
||||
ice_servers.push(IceServerInfo {
|
||||
urls: vec![public_ice::stun_server().to_string()],
|
||||
username: None,
|
||||
credential: None,
|
||||
});
|
||||
// Note: TURN servers are not provided - users must configure their own
|
||||
}
|
||||
|
||||
let mdns_mode = mdns_mode();
|
||||
let mdns_mode = mdns_mode_label(mdns_mode).to_string();
|
||||
|
||||
Json(IceServersResponse {
|
||||
ice_servers,
|
||||
mdns_mode,
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
#[cfg(unix)]
|
||||
use axum::{extract::DefaultBodyLimit, routing::delete};
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{any, delete, get, patch, post},
|
||||
routing::{any, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -72,7 +73,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/webrtc/close", post(handlers::webrtc_close_session))
|
||||
// HID endpoints
|
||||
.route("/hid/status", get(handlers::hid_status))
|
||||
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
|
||||
.route("/hid/reset", post(handlers::hid_reset))
|
||||
// WebSocket HID endpoint (for MJPEG mode)
|
||||
.route("/ws/hid", any(ws_hid_handler))
|
||||
@@ -99,8 +99,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
)
|
||||
.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))
|
||||
@@ -148,38 +146,15 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/config/auth", patch(handlers::config::update_auth_config))
|
||||
// Redfish configuration
|
||||
.route("/config/redfish", get(handlers::config::get_redfish_config))
|
||||
.route("/config/redfish", patch(handlers::config::update_redfish_config))
|
||||
.route(
|
||||
"/config/redfish",
|
||||
patch(handlers::config::update_redfish_config),
|
||||
)
|
||||
// System control
|
||||
.route("/system/restart", post(handlers::system_restart))
|
||||
.route("/update/overview", get(handlers::update_overview))
|
||||
.route("/update/upgrade", post(handlers::update_upgrade))
|
||||
.route("/update/status", get(handlers::update_status))
|
||||
// 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))
|
||||
@@ -187,11 +162,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/atx/wol/history", get(handlers::atx_wol_history))
|
||||
// Device discovery endpoints
|
||||
.route("/devices/atx", get(handlers::devices::list_atx_devices))
|
||||
.route("/devices/usb", get(handlers::devices::list_usb_devices))
|
||||
.route(
|
||||
"/devices/usb/reset",
|
||||
post(handlers::devices::reset_usb_device),
|
||||
)
|
||||
// Extension management endpoints
|
||||
.route("/extensions", get(handlers::extensions::list_extensions))
|
||||
.route("/extensions/{id}", get(handlers::extensions::get_extension))
|
||||
@@ -225,6 +195,43 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
|
||||
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
|
||||
|
||||
#[cfg(unix)]
|
||||
let user_routes = {
|
||||
user_routes
|
||||
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
|
||||
.route("/config/msd", get(handlers::config::get_msd_config))
|
||||
.route("/config/msd", patch(handlers::config::update_msd_config))
|
||||
.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))
|
||||
.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))
|
||||
.route("/devices/usb", get(handlers::devices::list_usb_devices))
|
||||
.route(
|
||||
"/devices/usb/reset",
|
||||
post(handlers::devices::reset_usb_device),
|
||||
)
|
||||
};
|
||||
|
||||
// Protected routes (all authenticated users)
|
||||
let protected_routes = user_routes;
|
||||
|
||||
@@ -237,10 +244,13 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
// 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
|
||||
#[cfg(unix)]
|
||||
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());
|
||||
#[cfg(not(unix))]
|
||||
let upload_routes = Router::new();
|
||||
|
||||
// Combine API routes
|
||||
let api_routes = Router::new()
|
||||
|
||||
Reference in New Issue
Block a user