feat: 初步增加 Windows 支持

This commit is contained in:
mofeng-git
2026-05-18 22:43:28 +08:00
parent 0b9d94f53f
commit 935fa823f2
163 changed files with 11419 additions and 7581 deletions

151
src/web/handlers/account.rs Normal file
View 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(&current_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(&current_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
View 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 }))
}

View 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
View 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,
})
}

View File

@@ -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,

View File

@@ -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, &current_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());
}
}

View File

@@ -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};

View File

@@ -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?;

View File

@@ -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;

View File

@@ -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!({

View File

@@ -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

View 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()),
}))
}

View 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
View 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
View 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
View 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
View 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,
})
}

View File

@@ -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>,

View 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
View 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,
})
}

View File

@@ -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()