mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-15 07:26:44 +08:00
feat: 支持在线升级功能
This commit is contained in:
@@ -47,7 +47,7 @@ nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] }
|
|||||||
|
|
||||||
# HTTP client (for URL downloads)
|
# HTTP client (for URL downloads)
|
||||||
# Use rustls by default, but allow native-tls for systems with older GLIBC
|
# Use rustls by default, but allow native-tls for systems with older GLIBC
|
||||||
reqwest = { version = "0.13", features = ["stream", "rustls"], default-features = false }
|
reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
# Static file embedding
|
# Static file embedding
|
||||||
@@ -118,7 +118,6 @@ hwcodec = { path = "libs/hwcodec" }
|
|||||||
protobuf = { version = "3.7", features = ["with-bytes"] }
|
protobuf = { version = "3.7", features = ["with-bytes"] }
|
||||||
sodiumoxide = "0.2"
|
sodiumoxide = "0.2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|
||||||
# High-performance pixel format conversion (libyuv)
|
# High-performance pixel format conversion (libyuv)
|
||||||
libyuv = { path = "res/vcpkg/libyuv" }
|
libyuv = { path = "res/vcpkg/libyuv" }
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use tracing::{debug, info, warn};
|
|||||||
|
|
||||||
use super::executor::{timing, AtxKeyExecutor};
|
use super::executor::{timing, AtxKeyExecutor};
|
||||||
use super::led::LedSensor;
|
use super::led::LedSensor;
|
||||||
use super::types::{AtxKeyConfig, AtxLedConfig, AtxState, AtxAction, PowerStatus};
|
use super::types::{AtxAction, AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
/// ATX power control configuration
|
/// ATX power control configuration
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ impl AudioQuality {
|
|||||||
/// Parse from string
|
/// Parse from string
|
||||||
#[allow(clippy::should_implement_trait)]
|
#[allow(clippy::should_implement_trait)]
|
||||||
pub fn from_str(s: &str) -> Self {
|
pub fn from_str(s: &str) -> Self {
|
||||||
|
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"voice" | "low" => AudioQuality::Voice,
|
"voice" | "low" => AudioQuality::Voice,
|
||||||
"high" | "music" => AudioQuality::High,
|
"high" | "music" => AudioQuality::High,
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ use crate::events::{EventBus, SystemEvent};
|
|||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
/// Audio health status
|
/// Audio health status
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum AudioHealthStatus {
|
pub enum AudioHealthStatus {
|
||||||
/// Device is healthy and operational
|
/// Device is healthy and operational
|
||||||
#[default]
|
#[default]
|
||||||
@@ -35,7 +34,6 @@ pub enum AudioHealthStatus {
|
|||||||
Disconnected,
|
Disconnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Audio health monitor configuration
|
/// Audio health monitor configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AudioMonitorConfig {
|
pub struct AudioMonitorConfig {
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
|||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
/// Audio stream state
|
/// Audio stream state
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum AudioStreamState {
|
pub enum AudioStreamState {
|
||||||
/// Stream is stopped
|
/// Stream is stopped
|
||||||
#[default]
|
#[default]
|
||||||
@@ -28,10 +27,8 @@ pub enum AudioStreamState {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Audio streamer configuration
|
/// Audio streamer configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub struct AudioStreamerConfig {
|
pub struct AudioStreamerConfig {
|
||||||
/// Audio capture configuration
|
/// Audio capture configuration
|
||||||
pub capture: AudioConfig,
|
pub capture: AudioConfig,
|
||||||
@@ -39,7 +36,6 @@ pub struct AudioStreamerConfig {
|
|||||||
pub opus: OpusConfig,
|
pub opus: OpusConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl AudioStreamerConfig {
|
impl AudioStreamerConfig {
|
||||||
/// Create config for a specific device with default quality
|
/// Create config for a specific device with default quality
|
||||||
pub fn for_device(device_name: &str) -> Self {
|
pub fn for_device(device_name: &str) -> Self {
|
||||||
@@ -280,7 +276,9 @@ impl AudioStreamer {
|
|||||||
// Encode to Opus
|
// Encode to Opus
|
||||||
let opus_result = {
|
let opus_result = {
|
||||||
let mut enc_guard = encoder.lock().await;
|
let mut enc_guard = encoder.lock().await;
|
||||||
(*enc_guard).as_mut().map(|enc| enc.encode_frame(&audio_frame))
|
(*enc_guard)
|
||||||
|
.as_mut()
|
||||||
|
.map(|enc| enc.encode_frame(&audio_frame))
|
||||||
};
|
};
|
||||||
|
|
||||||
match opus_result {
|
match opus_result {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ pub struct AppConfig {
|
|||||||
pub rtsp: RtspConfig,
|
pub rtsp: RtspConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Authentication configuration
|
/// Authentication configuration
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -113,7 +112,6 @@ pub enum HidBackend {
|
|||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// OTG USB device descriptor configuration
|
/// OTG USB device descriptor configuration
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -165,7 +163,6 @@ pub enum OtgHidProfile {
|
|||||||
Custom,
|
Custom,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// OTG HID function selection (used when profile is Custom)
|
/// OTG HID function selection (used when profile is Custom)
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -354,7 +351,6 @@ pub struct AtxConfig {
|
|||||||
pub wol_interface: String,
|
pub wol_interface: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl AtxConfig {
|
impl AtxConfig {
|
||||||
/// Convert to AtxControllerConfig for the controller
|
/// Convert to AtxControllerConfig for the controller
|
||||||
pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig {
|
pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig {
|
||||||
@@ -456,7 +452,6 @@ impl Default for RtspConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Encoder type
|
/// Encoder type
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -482,7 +477,6 @@ pub enum EncoderType {
|
|||||||
V4l2m2m,
|
V4l2m2m,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl EncoderType {
|
impl EncoderType {
|
||||||
/// Convert to EncoderBackend for registry queries
|
/// Convert to EncoderBackend for registry queries
|
||||||
pub fn to_backend(&self) -> Option<crate::video::encoder::registry::EncoderBackend> {
|
pub fn to_backend(&self) -> Option<crate::video::encoder::registry::EncoderBackend> {
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ pub struct EasytierConfig {
|
|||||||
pub virtual_ip: Option<String>,
|
pub virtual_ip: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Combined extensions configuration
|
/// Combined extensions configuration
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ pub enum HidBackendType {
|
|||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl HidBackendType {
|
impl HidBackendType {
|
||||||
/// Check if OTG backend is available on this system
|
/// Check if OTG backend is available on this system
|
||||||
pub fn otg_available() -> bool {
|
pub fn otg_available() -> bool {
|
||||||
|
|||||||
@@ -232,7 +232,6 @@ pub enum WorkMode {
|
|||||||
CustomHid = 0x03,
|
CustomHid = 0x03,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// CH9329 serial communication mode
|
/// CH9329 serial communication mode
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
@@ -247,7 +246,6 @@ pub enum SerialMode {
|
|||||||
Transparent = 0x02,
|
Transparent = 0x02,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// CH9329 configuration parameters
|
/// CH9329 configuration parameters
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Ch9329Config {
|
pub struct Ch9329Config {
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ use crate::events::{EventBus, SystemEvent};
|
|||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
/// HID health status
|
/// HID health status
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum HidHealthStatus {
|
pub enum HidHealthStatus {
|
||||||
/// Device is healthy and operational
|
/// Device is healthy and operational
|
||||||
#[default]
|
#[default]
|
||||||
@@ -35,7 +34,6 @@ pub enum HidHealthStatus {
|
|||||||
Disconnected,
|
Disconnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// HID health monitor configuration
|
/// HID health monitor configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HidMonitorConfig {
|
pub struct HidMonitorConfig {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod rtsp;
|
|||||||
pub mod rustdesk;
|
pub mod rustdesk;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
|
pub mod update;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use one_kvm::otg::{configfs, OtgService};
|
|||||||
use one_kvm::rtsp::RtspService;
|
use one_kvm::rtsp::RtspService;
|
||||||
use one_kvm::rustdesk::RustDeskService;
|
use one_kvm::rustdesk::RustDeskService;
|
||||||
use one_kvm::state::AppState;
|
use one_kvm::state::AppState;
|
||||||
|
use one_kvm::update::UpdateService;
|
||||||
use one_kvm::utils::bind_tcp_listener;
|
use one_kvm::utils::bind_tcp_listener;
|
||||||
use one_kvm::video::codec_constraints::{
|
use one_kvm::video::codec_constraints::{
|
||||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
||||||
@@ -554,6 +555,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create application state
|
// Create application state
|
||||||
|
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
||||||
|
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
config_store.clone(),
|
config_store.clone(),
|
||||||
session_store,
|
session_store,
|
||||||
@@ -568,6 +571,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
rtsp.clone(),
|
rtsp.clone(),
|
||||||
extensions.clone(),
|
extensions.clone(),
|
||||||
events.clone(),
|
events.clone(),
|
||||||
|
update_service,
|
||||||
shutdown_tx.clone(),
|
shutdown_tx.clone(),
|
||||||
data_dir.clone(),
|
data_dir.clone(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ impl ImageManager {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
|
chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_else(Utc::now)
|
||||||
.unwrap_or_else(Utc::now)
|
|
||||||
})
|
})
|
||||||
.unwrap_or_else(Utc::now);
|
.unwrap_or_else(Utc::now);
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ use crate::events::{EventBus, SystemEvent};
|
|||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
/// MSD health status
|
/// MSD health status
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum MsdHealthStatus {
|
pub enum MsdHealthStatus {
|
||||||
/// Device is healthy and operational
|
/// Device is healthy and operational
|
||||||
#[default]
|
#[default]
|
||||||
@@ -30,7 +29,6 @@ pub enum MsdHealthStatus {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// MSD health monitor configuration
|
/// MSD health monitor configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MsdMonitorConfig {
|
pub struct MsdMonitorConfig {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ pub enum MsdMode {
|
|||||||
Drive,
|
Drive,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Image file metadata
|
/// Image file metadata
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ImageInfo {
|
pub struct ImageInfo {
|
||||||
|
|||||||
@@ -328,9 +328,7 @@ impl VentoyDrive {
|
|||||||
let image = match VentoyImage::open(&path) {
|
let image = match VentoyImage::open(&path) {
|
||||||
Ok(img) => img,
|
Ok(img) => img,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = rt.block_on(tx.send(Err(std::io::Error::other(
|
let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string()))));
|
||||||
e.to_string(),
|
|
||||||
))));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -340,9 +338,7 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
// Stream the file through the writer
|
// Stream the file through the writer
|
||||||
if let Err(e) = image.read_file_to_writer(&file_path_owned, &mut chunk_writer) {
|
if let Err(e) = image.read_file_to_writer(&file_path_owned, &mut chunk_writer) {
|
||||||
let _ = rt.block_on(tx.send(Err(std::io::Error::other(
|
let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string()))));
|
||||||
e.to_string(),
|
|
||||||
))));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -545,12 +541,10 @@ mod tests {
|
|||||||
.output()?;
|
.output()?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(std::io::Error::other(
|
return Err(std::io::Error::other(format!(
|
||||||
format!(
|
|
||||||
"xz decompress failed: {}",
|
"xz decompress failed: {}",
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
),
|
)));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::write(dst, &output.stdout)?;
|
std::fs::write(dst, &output.stdout)?;
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ const FLAG_HID: u8 = 0b01;
|
|||||||
const FLAG_MSD: u8 = 0b10;
|
const FLAG_MSD: u8 = 0b10;
|
||||||
|
|
||||||
/// HID device paths
|
/// HID device paths
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub struct HidDevicePaths {
|
pub struct HidDevicePaths {
|
||||||
pub keyboard: Option<PathBuf>,
|
pub keyboard: Option<PathBuf>,
|
||||||
pub mouse_relative: Option<PathBuf>,
|
pub mouse_relative: Option<PathBuf>,
|
||||||
@@ -44,7 +43,6 @@ pub struct HidDevicePaths {
|
|||||||
pub consumer: Option<PathBuf>,
|
pub consumer: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl HidDevicePaths {
|
impl HidDevicePaths {
|
||||||
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
||||||
let mut paths = Vec::new();
|
let mut paths = Vec::new();
|
||||||
@@ -230,8 +228,7 @@ impl OtgService {
|
|||||||
let requested_functions = self.hid_functions.read().await.clone();
|
let requested_functions = self.hid_functions.read().await.clone();
|
||||||
{
|
{
|
||||||
let state = self.state.read().await;
|
let state = self.state.read().await;
|
||||||
if state.hid_enabled
|
if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) {
|
||||||
&& state.hid_functions.as_ref() == Some(&requested_functions) {
|
|
||||||
if let Some(ref paths) = state.hid_paths {
|
if let Some(ref paths) = state.hid_paths {
|
||||||
info!("HID already enabled, returning existing paths");
|
info!("HID already enabled, returning existing paths");
|
||||||
return Ok(paths.clone());
|
return Ok(paths.clone());
|
||||||
|
|||||||
@@ -681,7 +681,9 @@ impl Connection {
|
|||||||
video_codec_to_encoder_codec(preferred)
|
video_codec_to_encoder_codec(preferred)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_codec_constraints(&self) -> crate::video::codec_constraints::StreamCodecConstraints {
|
async fn current_codec_constraints(
|
||||||
|
&self,
|
||||||
|
) -> crate::video::codec_constraints::StreamCodecConstraints {
|
||||||
if let Some(ref video_manager) = self.video_manager {
|
if let Some(ref video_manager) = self.video_manager {
|
||||||
video_manager.codec_constraints().await
|
video_manager.codec_constraints().await
|
||||||
} else {
|
} else {
|
||||||
@@ -772,8 +774,7 @@ impl Connection {
|
|||||||
// Check if this codec is different from current and available
|
// Check if this codec is different from current and available
|
||||||
if self.negotiated_codec != Some(new_codec) {
|
if self.negotiated_codec != Some(new_codec) {
|
||||||
let constraints = self.current_codec_constraints().await;
|
let constraints = self.current_codec_constraints().await;
|
||||||
if !constraints
|
if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec))
|
||||||
.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec))
|
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
"Client requested codec {:?} but it's blocked by constraints: {}",
|
"Client requested codec {:?} but it's blocked by constraints: {}",
|
||||||
|
|||||||
@@ -127,8 +127,7 @@ impl VideoFrameAdapter {
|
|||||||
|
|
||||||
// Inject cached SPS/PPS before IDR when missing
|
// Inject cached SPS/PPS before IDR when missing
|
||||||
if is_keyframe && (!has_sps || !has_pps) {
|
if is_keyframe && (!has_sps || !has_pps) {
|
||||||
if let (Some(sps), Some(pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref())
|
if let (Some(sps), Some(pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) {
|
||||||
{
|
|
||||||
let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len());
|
let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len());
|
||||||
out.extend_from_slice(&[0, 0, 0, 1]);
|
out.extend_from_slice(&[0, 0, 0, 1]);
|
||||||
out.extend_from_slice(sps);
|
out.extend_from_slice(sps);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::msd::MsdController;
|
|||||||
use crate::otg::OtgService;
|
use crate::otg::OtgService;
|
||||||
use crate::rtsp::RtspService;
|
use crate::rtsp::RtspService;
|
||||||
use crate::rustdesk::RustDeskService;
|
use crate::rustdesk::RustDeskService;
|
||||||
|
use crate::update::UpdateService;
|
||||||
use crate::video::VideoStreamManager;
|
use crate::video::VideoStreamManager;
|
||||||
|
|
||||||
/// Application-wide state shared across handlers
|
/// Application-wide state shared across handlers
|
||||||
@@ -57,6 +58,8 @@ pub struct AppState {
|
|||||||
pub extensions: Arc<ExtensionManager>,
|
pub extensions: Arc<ExtensionManager>,
|
||||||
/// Event bus for real-time notifications
|
/// Event bus for real-time notifications
|
||||||
pub events: Arc<EventBus>,
|
pub events: Arc<EventBus>,
|
||||||
|
/// Online update service
|
||||||
|
pub update: Arc<UpdateService>,
|
||||||
/// Shutdown signal sender
|
/// Shutdown signal sender
|
||||||
pub shutdown_tx: broadcast::Sender<()>,
|
pub shutdown_tx: broadcast::Sender<()>,
|
||||||
/// Recently revoked session IDs (for client kick detection)
|
/// Recently revoked session IDs (for client kick detection)
|
||||||
@@ -82,6 +85,7 @@ impl AppState {
|
|||||||
rtsp: Option<Arc<RtspService>>,
|
rtsp: Option<Arc<RtspService>>,
|
||||||
extensions: Arc<ExtensionManager>,
|
extensions: Arc<ExtensionManager>,
|
||||||
events: Arc<EventBus>,
|
events: Arc<EventBus>,
|
||||||
|
update: Arc<UpdateService>,
|
||||||
shutdown_tx: broadcast::Sender<()>,
|
shutdown_tx: broadcast::Sender<()>,
|
||||||
data_dir: std::path::PathBuf,
|
data_dir: std::path::PathBuf,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
@@ -99,6 +103,7 @@ impl AppState {
|
|||||||
rtsp: Arc::new(RwLock::new(rtsp)),
|
rtsp: Arc::new(RwLock::new(rtsp)),
|
||||||
extensions,
|
extensions,
|
||||||
events,
|
events,
|
||||||
|
update,
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||||
data_dir,
|
data_dir,
|
||||||
|
|||||||
606
src/update/mod.rs
Normal file
606
src/update/mod.rs
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::sync::{broadcast, RwLock, Semaphore};
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
const DEFAULT_UPDATE_BASE_URL: &str = "https://update.one-kvm.cn";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum UpdateChannel {
|
||||||
|
Stable,
|
||||||
|
Beta,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UpdateChannel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Stable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelsManifest {
|
||||||
|
pub stable: String,
|
||||||
|
pub beta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReleasesManifest {
|
||||||
|
pub releases: Vec<ReleaseInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReleaseInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub channel: UpdateChannel,
|
||||||
|
pub published_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artifacts: HashMap<String, ArtifactInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ArtifactInfo {
|
||||||
|
pub url: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReleaseNotesItem {
|
||||||
|
pub version: String,
|
||||||
|
pub published_at: String,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateOverviewResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub current_version: String,
|
||||||
|
pub channel: UpdateChannel,
|
||||||
|
pub latest_version: String,
|
||||||
|
pub upgrade_available: bool,
|
||||||
|
pub target_version: Option<String>,
|
||||||
|
pub notes_between: Vec<ReleaseNotesItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpgradeRequest {
|
||||||
|
pub channel: Option<UpdateChannel>,
|
||||||
|
pub target_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum UpdatePhase {
|
||||||
|
Idle,
|
||||||
|
Checking,
|
||||||
|
Downloading,
|
||||||
|
Verifying,
|
||||||
|
Installing,
|
||||||
|
Restarting,
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateStatusResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub phase: UpdatePhase,
|
||||||
|
pub progress: u8,
|
||||||
|
pub current_version: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_version: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub message: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpdateService {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
work_dir: PathBuf,
|
||||||
|
status: RwLock<UpdateStatusResponse>,
|
||||||
|
upgrade_permit: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateService {
|
||||||
|
pub fn new(work_dir: PathBuf) -> Self {
|
||||||
|
let base_url = std::env::var("ONE_KVM_UPDATE_BASE_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|url| !url.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_UPDATE_BASE_URL.to_string());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
base_url,
|
||||||
|
work_dir,
|
||||||
|
status: RwLock::new(UpdateStatusResponse {
|
||||||
|
success: true,
|
||||||
|
phase: UpdatePhase::Idle,
|
||||||
|
progress: 0,
|
||||||
|
current_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
target_version: None,
|
||||||
|
message: None,
|
||||||
|
last_error: None,
|
||||||
|
}),
|
||||||
|
upgrade_permit: Arc::new(Semaphore::new(1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> UpdateStatusResponse {
|
||||||
|
self.status.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn overview(&self, channel: UpdateChannel) -> Result<UpdateOverviewResponse> {
|
||||||
|
let channels: ChannelsManifest = self.fetch_json("/v1/channels.json").await?;
|
||||||
|
let releases: ReleasesManifest = self.fetch_json("/v1/releases.json").await?;
|
||||||
|
|
||||||
|
let current_version = parse_version(env!("CARGO_PKG_VERSION"))?;
|
||||||
|
let latest_version_str = match channel {
|
||||||
|
UpdateChannel::Stable => channels.stable,
|
||||||
|
UpdateChannel::Beta => channels.beta,
|
||||||
|
};
|
||||||
|
let latest_version = parse_version(&latest_version_str)?;
|
||||||
|
let current_parts = parse_version_parts(¤t_version)?;
|
||||||
|
let latest_parts = parse_version_parts(&latest_version)?;
|
||||||
|
|
||||||
|
let mut notes_between = Vec::new();
|
||||||
|
for release in &releases.releases {
|
||||||
|
if release.channel != channel {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let version = match parse_version(&release.version) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let version_parts = match parse_version_parts(&version) {
|
||||||
|
Ok(parts) => parts,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if compare_version_parts(&version_parts, ¤t_parts) == std::cmp::Ordering::Greater
|
||||||
|
&& compare_version_parts(&version_parts, &latest_parts)
|
||||||
|
!= std::cmp::Ordering::Greater
|
||||||
|
{
|
||||||
|
notes_between.push((
|
||||||
|
version_parts,
|
||||||
|
ReleaseNotesItem {
|
||||||
|
version: release.version.clone(),
|
||||||
|
published_at: release.published_at.clone(),
|
||||||
|
notes: release.notes.clone(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notes_between.sort_by(|a, b| compare_version_parts(&a.0, &b.0));
|
||||||
|
let notes_between = notes_between.into_iter().map(|(_, item)| item).collect();
|
||||||
|
|
||||||
|
let upgrade_available =
|
||||||
|
compare_versions(&latest_version, ¤t_version)? == std::cmp::Ordering::Greater;
|
||||||
|
|
||||||
|
Ok(UpdateOverviewResponse {
|
||||||
|
success: true,
|
||||||
|
current_version: current_version.to_string(),
|
||||||
|
channel,
|
||||||
|
latest_version: latest_version.clone(),
|
||||||
|
upgrade_available,
|
||||||
|
target_version: if upgrade_available {
|
||||||
|
Some(latest_version)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
notes_between,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_upgrade(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
req: UpgradeRequest,
|
||||||
|
shutdown_tx: broadcast::Sender<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if req.channel.is_none() == req.target_version.is_none() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Provide exactly one of channel or target_version".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let permit = self
|
||||||
|
.upgrade_permit
|
||||||
|
.clone()
|
||||||
|
.try_acquire_owned()
|
||||||
|
.map_err(|_| AppError::BadRequest("Upgrade is already running".to_string()))?;
|
||||||
|
|
||||||
|
let service = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
if let Err(e) = service.execute_upgrade(req, shutdown_tx).await {
|
||||||
|
service
|
||||||
|
.set_status(
|
||||||
|
UpdatePhase::Failed,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
Some(e.to_string()),
|
||||||
|
Some(e.to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_upgrade(
|
||||||
|
&self,
|
||||||
|
req: UpgradeRequest,
|
||||||
|
shutdown_tx: broadcast::Sender<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.set_status(
|
||||||
|
UpdatePhase::Checking,
|
||||||
|
5,
|
||||||
|
None,
|
||||||
|
Some("Checking for updates".to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let channels: ChannelsManifest = self.fetch_json("/v1/channels.json").await?;
|
||||||
|
let releases: ReleasesManifest = self.fetch_json("/v1/releases.json").await?;
|
||||||
|
|
||||||
|
let current_version = parse_version(env!("CARGO_PKG_VERSION"))?;
|
||||||
|
let target_version = if let Some(channel) = req.channel {
|
||||||
|
let version_str = match channel {
|
||||||
|
UpdateChannel::Stable => channels.stable,
|
||||||
|
UpdateChannel::Beta => channels.beta,
|
||||||
|
};
|
||||||
|
parse_version(&version_str)?
|
||||||
|
} else {
|
||||||
|
parse_version(req.target_version.as_deref().unwrap_or_default())?
|
||||||
|
};
|
||||||
|
|
||||||
|
if compare_versions(&target_version, ¤t_version)? != std::cmp::Ordering::Greater {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Target version {} must be greater than current version {}",
|
||||||
|
target_version, current_version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_release = releases
|
||||||
|
.releases
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.version == target_version)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Release {} not found", target_version)))?;
|
||||||
|
|
||||||
|
let target_triple = current_target_triple()?;
|
||||||
|
let artifact = target_release
|
||||||
|
.artifacts
|
||||||
|
.get(&target_triple)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::NotFound(format!(
|
||||||
|
"No binary for target {} in version {}",
|
||||||
|
target_triple, target_version
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
self.set_status(
|
||||||
|
UpdatePhase::Downloading,
|
||||||
|
10,
|
||||||
|
Some(target_version.clone()),
|
||||||
|
Some("Downloading binary".to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(&self.work_dir).await?;
|
||||||
|
let staging_path = self
|
||||||
|
.work_dir
|
||||||
|
.join(format!("one-kvm-{}-download", target_version));
|
||||||
|
|
||||||
|
let artifact_url = self.resolve_url(&artifact.url);
|
||||||
|
self.download_and_verify(&artifact_url, &staging_path, &artifact)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.set_status(
|
||||||
|
UpdatePhase::Installing,
|
||||||
|
80,
|
||||||
|
Some(target_version.clone()),
|
||||||
|
Some("Replacing binary".to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
self.install_binary(&staging_path).await?;
|
||||||
|
|
||||||
|
self.set_status(
|
||||||
|
UpdatePhase::Restarting,
|
||||||
|
95,
|
||||||
|
Some(target_version),
|
||||||
|
Some("Restarting service".to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = shutdown_tx.send(());
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
restart_current_process()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_and_verify(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
output_path: &Path,
|
||||||
|
artifact: &ArtifactInfo,
|
||||||
|
) -> Result<()> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to download {}: {}", url, e)))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Download request failed: {}", e)))?;
|
||||||
|
|
||||||
|
let mut file = tokio::fs::File::create(output_path).await?;
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk
|
||||||
|
.map_err(|e| AppError::Internal(format!("Read download stream failed: {}", e)))?;
|
||||||
|
file.write_all(&chunk).await?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
|
||||||
|
if artifact.size > 0 {
|
||||||
|
let ratio = (downloaded as f64 / artifact.size as f64).clamp(0.0, 1.0);
|
||||||
|
let progress = 10 + (ratio * 60.0) as u8;
|
||||||
|
self.set_status(
|
||||||
|
UpdatePhase::Downloading,
|
||||||
|
progress,
|
||||||
|
None,
|
||||||
|
Some(format!(
|
||||||
|
"Downloading binary ({} / {} bytes)",
|
||||||
|
downloaded, artifact.size
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.flush().await?;
|
||||||
|
|
||||||
|
if artifact.size > 0 && downloaded != artifact.size {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Downloaded size mismatch: expected {}, got {}",
|
||||||
|
artifact.size, downloaded
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_status(
|
||||||
|
UpdatePhase::Verifying,
|
||||||
|
72,
|
||||||
|
None,
|
||||||
|
Some("Verifying sha256".to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let actual_sha256 = compute_file_sha256(output_path).await?;
|
||||||
|
let expected_sha256 = normalize_sha256(&artifact.sha256).ok_or_else(|| {
|
||||||
|
AppError::Internal(format!(
|
||||||
|
"Invalid sha256 format in manifest: {}",
|
||||||
|
artifact.sha256
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if actual_sha256 != expected_sha256 {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"SHA256 mismatch: expected {}, got {}",
|
||||||
|
expected_sha256, actual_sha256
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_binary(&self, staging_path: &Path) -> Result<()> {
|
||||||
|
let current_exe = std::env::current_exe()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to get current exe path: {}", e)))?;
|
||||||
|
let exe_dir = current_exe.parent().ok_or_else(|| {
|
||||||
|
AppError::Internal("Failed to determine executable directory".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let install_path = exe_dir.join("one-kvm.upgrade.new");
|
||||||
|
|
||||||
|
tokio::fs::copy(staging_path, &install_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to stage binary into install path: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = tokio::fs::metadata(&install_path).await?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
tokio::fs::set_permissions(&install_path, perms).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::rename(&install_path, ¤t_exe)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to replace executable {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_json<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T> {
|
||||||
|
let url = format!("{}{}", self.base_url.trim_end_matches('/'), path);
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to fetch {}: {}", url, e)))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Request failed {}: {}", url, e)))?;
|
||||||
|
|
||||||
|
response
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Invalid update response {}: {}", url, e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_url(&self, url: &str) -> String {
|
||||||
|
if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
url.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.base_url.trim_end_matches('/'),
|
||||||
|
url.trim_start_matches('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_status(
|
||||||
|
&self,
|
||||||
|
phase: UpdatePhase,
|
||||||
|
progress: u8,
|
||||||
|
target_version: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
last_error: Option<String>,
|
||||||
|
) {
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.phase = phase;
|
||||||
|
status.progress = progress;
|
||||||
|
if target_version.is_some() {
|
||||||
|
status.target_version = target_version;
|
||||||
|
}
|
||||||
|
status.message = message;
|
||||||
|
status.last_error = last_error;
|
||||||
|
status.success = status.phase != UpdatePhase::Failed;
|
||||||
|
status.current_version = env!("CARGO_PKG_VERSION").to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version(input: &str) -> Result<String> {
|
||||||
|
let parts: Vec<&str> = input.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Invalid version {}, expected x.x.x",
|
||||||
|
input
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if parts
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
|
||||||
|
{
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Invalid version {}, expected numeric x.x.x",
|
||||||
|
input
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(input.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_versions(a: &str, b: &str) -> Result<std::cmp::Ordering> {
|
||||||
|
let pa = parse_version_parts(a)?;
|
||||||
|
let pb = parse_version_parts(b)?;
|
||||||
|
Ok(compare_version_parts(&pa, &pb))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version_parts(input: &str) -> Result<[u64; 3]> {
|
||||||
|
let parts: Vec<&str> = input.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Invalid version {}, expected x.x.x",
|
||||||
|
input
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let major = parts[0]
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Invalid major version {}: {}", parts[0], e)))?;
|
||||||
|
let minor = parts[1]
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Invalid minor version {}: {}", parts[1], e)))?;
|
||||||
|
let patch = parts[2]
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Invalid patch version {}: {}", parts[2], e)))?;
|
||||||
|
Ok([major, minor, patch])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_version_parts(a: &[u64; 3], b: &[u64; 3]) -> std::cmp::Ordering {
|
||||||
|
a[0].cmp(&b[0]).then(a[1].cmp(&b[1])).then(a[2].cmp(&b[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_file_sha256(path: &Path) -> Result<String> {
|
||||||
|
let mut file = tokio::fs::File::open(path).await?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = [0u8; 8192];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bytes_read = file.read(&mut buffer).await?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..bytes_read]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_sha256(input: &str) -> Option<String> {
|
||||||
|
let token = input.split_whitespace().next()?.trim().to_lowercase();
|
||||||
|
if token.len() != 64 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_target_triple() -> Result<String> {
|
||||||
|
let triple = match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||||
|
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
|
||||||
|
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
|
||||||
|
("linux", "arm") => "armv7-unknown-linux-gnueabihf",
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Unsupported platform {}-{}",
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(triple.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart_current_process() -> Result<()> {
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to get current exe: {}", e)))?;
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let err = std::process::Command::new(&exe).args(&args).exec();
|
||||||
|
Err(AppError::Internal(format!("Failed to restart: {}", err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
std::process::Command::new(&exe)
|
||||||
|
.args(&args)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to spawn restart process: {}", e)))?;
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,8 +32,7 @@ fn init_hwcodec_logging() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// H.264 encoder type (detected from hwcodec)
|
/// H.264 encoder type (detected from hwcodec)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum H264EncoderType {
|
pub enum H264EncoderType {
|
||||||
/// NVIDIA NVENC
|
/// NVIDIA NVENC
|
||||||
Nvenc,
|
Nvenc,
|
||||||
@@ -69,7 +68,6 @@ impl std::fmt::Display for H264EncoderType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Map codec name to encoder type
|
/// Map codec name to encoder type
|
||||||
fn codec_name_to_type(name: &str) -> H264EncoderType {
|
fn codec_name_to_type(name: &str) -> H264EncoderType {
|
||||||
if name.contains("nvenc") {
|
if name.contains("nvenc") {
|
||||||
@@ -90,8 +88,7 @@ fn codec_name_to_type(name: &str) -> H264EncoderType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Input pixel format for H264 encoder
|
/// Input pixel format for H264 encoder
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum H264InputFormat {
|
pub enum H264InputFormat {
|
||||||
/// YUV420P (I420) - planar Y, U, V
|
/// YUV420P (I420) - planar Y, U, V
|
||||||
Yuv420p,
|
Yuv420p,
|
||||||
@@ -112,7 +109,6 @@ pub enum H264InputFormat {
|
|||||||
Bgr24,
|
Bgr24,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// H.264 encoder configuration
|
/// H.264 encoder configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct H264Config {
|
pub struct H264Config {
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ fn init_hwcodec_logging() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// H.265 encoder type (detected from hwcodec)
|
/// H.265 encoder type (detected from hwcodec)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum H265EncoderType {
|
pub enum H265EncoderType {
|
||||||
/// NVIDIA NVENC
|
/// NVIDIA NVENC
|
||||||
Nvenc,
|
Nvenc,
|
||||||
@@ -67,7 +66,6 @@ impl std::fmt::Display for H265EncoderType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl From<EncoderBackend> for H265EncoderType {
|
impl From<EncoderBackend> for H265EncoderType {
|
||||||
fn from(backend: EncoderBackend) -> Self {
|
fn from(backend: EncoderBackend) -> Self {
|
||||||
match backend {
|
match backend {
|
||||||
@@ -83,8 +81,7 @@ impl From<EncoderBackend> for H265EncoderType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Input pixel format for H265 encoder
|
/// Input pixel format for H265 encoder
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum H265InputFormat {
|
pub enum H265InputFormat {
|
||||||
/// YUV420P (I420) - planar Y, U, V
|
/// YUV420P (I420) - planar Y, U, V
|
||||||
Yuv420p,
|
Yuv420p,
|
||||||
@@ -105,7 +102,6 @@ pub enum H265InputFormat {
|
|||||||
Bgr24,
|
Bgr24,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// H.265 encoder configuration
|
/// H.265 encoder configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct H265Config {
|
pub struct H265Config {
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ impl BitratePreset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl std::fmt::Display for BitratePreset {
|
impl std::fmt::Display for BitratePreset {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ fn init_hwcodec_logging() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// VP8 encoder type (detected from hwcodec)
|
/// VP8 encoder type (detected from hwcodec)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum VP8EncoderType {
|
pub enum VP8EncoderType {
|
||||||
/// VAAPI (Intel on Linux)
|
/// VAAPI (Intel on Linux)
|
||||||
Vaapi,
|
Vaapi,
|
||||||
@@ -52,7 +51,6 @@ impl std::fmt::Display for VP8EncoderType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl From<EncoderBackend> for VP8EncoderType {
|
impl From<EncoderBackend> for VP8EncoderType {
|
||||||
fn from(backend: EncoderBackend) -> Self {
|
fn from(backend: EncoderBackend) -> Self {
|
||||||
match backend {
|
match backend {
|
||||||
@@ -64,8 +62,7 @@ impl From<EncoderBackend> for VP8EncoderType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Input pixel format for VP8 encoder
|
/// Input pixel format for VP8 encoder
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum VP8InputFormat {
|
pub enum VP8InputFormat {
|
||||||
/// YUV420P (I420) - planar Y, U, V
|
/// YUV420P (I420) - planar Y, U, V
|
||||||
Yuv420p,
|
Yuv420p,
|
||||||
@@ -74,7 +71,6 @@ pub enum VP8InputFormat {
|
|||||||
Nv12,
|
Nv12,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// VP8 encoder configuration
|
/// VP8 encoder configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct VP8Config {
|
pub struct VP8Config {
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ fn init_hwcodec_logging() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// VP9 encoder type (detected from hwcodec)
|
/// VP9 encoder type (detected from hwcodec)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum VP9EncoderType {
|
pub enum VP9EncoderType {
|
||||||
/// VAAPI (Intel on Linux)
|
/// VAAPI (Intel on Linux)
|
||||||
Vaapi,
|
Vaapi,
|
||||||
@@ -52,7 +51,6 @@ impl std::fmt::Display for VP9EncoderType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl From<EncoderBackend> for VP9EncoderType {
|
impl From<EncoderBackend> for VP9EncoderType {
|
||||||
fn from(backend: EncoderBackend) -> Self {
|
fn from(backend: EncoderBackend) -> Self {
|
||||||
match backend {
|
match backend {
|
||||||
@@ -64,8 +62,7 @@ impl From<EncoderBackend> for VP9EncoderType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Input pixel format for VP9 encoder
|
/// Input pixel format for VP9 encoder
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub enum VP9InputFormat {
|
pub enum VP9InputFormat {
|
||||||
/// YUV420P (I420) - planar Y, U, V
|
/// YUV420P (I420) - planar Y, U, V
|
||||||
Yuv420p,
|
Yuv420p,
|
||||||
@@ -74,7 +71,6 @@ pub enum VP9InputFormat {
|
|||||||
Nv12,
|
Nv12,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// VP9 encoder configuration
|
/// VP9 encoder configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct VP9Config {
|
pub struct VP9Config {
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ impl V4l2rCaptureStream {
|
|||||||
let actual_format = PixelFormat::from_v4l2r(actual_fmt.pixelformat).unwrap_or(format);
|
let actual_format = PixelFormat::from_v4l2r(actual_fmt.pixelformat).unwrap_or(format);
|
||||||
|
|
||||||
let stride = actual_fmt
|
let stride = actual_fmt
|
||||||
.plane_fmt.first()
|
.plane_fmt
|
||||||
|
.first()
|
||||||
.map(|p| p.bytesperline)
|
.map(|p| p.bytesperline)
|
||||||
.unwrap_or_else(|| match actual_format.bytes_per_pixel() {
|
.unwrap_or_else(|| match actual_format.bytes_per_pixel() {
|
||||||
Some(bpp) => actual_resolution.width * bpp as u32,
|
Some(bpp) => actual_resolution.width * bpp as u32,
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ mod audio;
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod hid;
|
mod hid;
|
||||||
mod msd;
|
mod msd;
|
||||||
mod rustdesk;
|
|
||||||
mod rtsp;
|
mod rtsp;
|
||||||
|
mod rustdesk;
|
||||||
mod stream;
|
mod stream;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
mod web;
|
mod web;
|
||||||
@@ -36,11 +36,11 @@ pub use audio::{get_audio_config, update_audio_config};
|
|||||||
pub use auth::{get_auth_config, update_auth_config};
|
pub use auth::{get_auth_config, update_auth_config};
|
||||||
pub use hid::{get_hid_config, update_hid_config};
|
pub use hid::{get_hid_config, update_hid_config};
|
||||||
pub use msd::{get_msd_config, update_msd_config};
|
pub use msd::{get_msd_config, update_msd_config};
|
||||||
|
pub use rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config};
|
||||||
pub use rustdesk::{
|
pub use rustdesk::{
|
||||||
get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id,
|
get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id,
|
||||||
regenerate_device_password, update_rustdesk_config,
|
regenerate_device_password, update_rustdesk_config,
|
||||||
};
|
};
|
||||||
pub use rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config};
|
|
||||||
pub use stream::{get_stream_config, update_stream_config};
|
pub use stream::{get_stream_config, update_stream_config};
|
||||||
pub use video::{get_video_config, update_video_config};
|
pub use video::{get_video_config, update_video_config};
|
||||||
pub use web::{get_web_config, update_web_config};
|
pub use web::{get_web_config, update_web_config};
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ pub async fn update_rtsp_config(
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Failed to rollback RTSP config after apply failure: {}", rollback_err);
|
tracing::error!(
|
||||||
|
"Failed to rollback RTSP config after apply failure: {}",
|
||||||
|
rollback_err
|
||||||
|
);
|
||||||
return Err(AppError::ServiceUnavailable(format!(
|
return Err(AppError::ServiceUnavailable(format!(
|
||||||
"RTSP apply failed: {}; rollback failed: {}",
|
"RTSP apply failed: {}; rollback failed: {}",
|
||||||
err, rollback_err
|
err, rollback_err
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::config::{AppConfig, StreamMode};
|
|||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::SystemEvent;
|
use crate::events::SystemEvent;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
||||||
use crate::video::codec_constraints::codec_to_id;
|
use crate::video::codec_constraints::codec_to_id;
|
||||||
use crate::video::encoder::BitratePreset;
|
use crate::video::encoder::BitratePreset;
|
||||||
|
|
||||||
@@ -751,7 +752,8 @@ pub async fn setup_init(
|
|||||||
// Start RTSP if enabled
|
// Start RTSP if enabled
|
||||||
if new_config.rtsp.enabled {
|
if new_config.rtsp.enabled {
|
||||||
let empty_config = crate::config::RtspConfig::default();
|
let empty_config = crate::config::RtspConfig::default();
|
||||||
if let Err(e) = config::apply::apply_rtsp_config(&state, &empty_config, &new_config.rtsp).await
|
if let Err(e) =
|
||||||
|
config::apply::apply_rtsp_config(&state, &empty_config, &new_config.rtsp).await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to start RTSP during setup: {}", e);
|
tracing::warn!("Failed to start RTSP during setup: {}", e);
|
||||||
} else {
|
} else {
|
||||||
@@ -1641,7 +1643,10 @@ pub async fn stream_constraints_get(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect(),
|
.collect(),
|
||||||
locked_codec: constraints.locked_codec.map(codec_to_id).map(str::to_string),
|
locked_codec: constraints
|
||||||
|
.locked_codec
|
||||||
|
.map(codec_to_id)
|
||||||
|
.map(str::to_string),
|
||||||
disallow_mjpeg: !constraints.allow_mjpeg,
|
disallow_mjpeg: !constraints.allow_mjpeg,
|
||||||
sources: ConstraintSources {
|
sources: ConstraintSources {
|
||||||
rustdesk: constraints.rustdesk_enabled,
|
rustdesk: constraints.rustdesk_enabled,
|
||||||
@@ -1929,7 +1934,9 @@ pub async fn mjpeg_stream(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if frame.is_valid_jpeg() && tx.send(create_mjpeg_part(frame.data())).await.is_err() {
|
if frame.is_valid_jpeg()
|
||||||
|
&& tx.send(create_mjpeg_part(frame.data())).await.is_err()
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3130,3 +3137,37 @@ pub async fn system_restart(State(state): State<Arc<AppState>>) -> Json<LoginRes
|
|||||||
message: Some("Restarting...".to_string()),
|
message: Some("Restarting...".to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Online Update
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/config/auth", patch(handlers::config::update_auth_config))
|
.route("/config/auth", patch(handlers::config::update_auth_config))
|
||||||
// System control
|
// System control
|
||||||
.route("/system/restart", post(handlers::system_restart))
|
.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
|
// MSD (Mass Storage Device) endpoints
|
||||||
.route("/msd/status", get(handlers::msd_status))
|
.route("/msd/status", get(handlers::msd_status))
|
||||||
.route("/msd/images", get(handlers::msd_images_list))
|
.route("/msd/images", get(handlers::msd_images_list))
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ pub enum VideoCodec {
|
|||||||
AV1,
|
AV1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl std::fmt::Display for VideoCodec {
|
impl std::fmt::Display for VideoCodec {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -101,6 +101,46 @@ export const systemApi = {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateChannel = 'stable' | 'beta'
|
||||||
|
|
||||||
|
export interface UpdateOverviewResponse {
|
||||||
|
success: boolean
|
||||||
|
current_version: string
|
||||||
|
channel: UpdateChannel
|
||||||
|
latest_version: string
|
||||||
|
upgrade_available: boolean
|
||||||
|
target_version?: string
|
||||||
|
notes_between: Array<{
|
||||||
|
version: string
|
||||||
|
published_at: string
|
||||||
|
notes: string[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStatusResponse {
|
||||||
|
success: boolean
|
||||||
|
phase: 'idle' | 'checking' | 'downloading' | 'verifying' | 'installing' | 'restarting' | 'success' | 'failed'
|
||||||
|
progress: number
|
||||||
|
current_version: string
|
||||||
|
target_version?: string
|
||||||
|
message?: string
|
||||||
|
last_error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateApi = {
|
||||||
|
overview: (channel: UpdateChannel = 'stable') =>
|
||||||
|
request<UpdateOverviewResponse>(`/update/overview?channel=${encodeURIComponent(channel)}`),
|
||||||
|
|
||||||
|
upgrade: (payload: { channel?: UpdateChannel; target_version?: string }) =>
|
||||||
|
request<{ success: boolean; message?: string }>('/update/upgrade', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
status: () =>
|
||||||
|
request<UpdateStatusResponse>('/update/status'),
|
||||||
|
}
|
||||||
|
|
||||||
// Stream API
|
// Stream API
|
||||||
export interface VideoCodecInfo {
|
export interface VideoCodecInfo {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -501,6 +501,24 @@ export default {
|
|||||||
restartRequired: 'Restart Required',
|
restartRequired: 'Restart Required',
|
||||||
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
|
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
|
||||||
restarting: 'Restarting...',
|
restarting: 'Restarting...',
|
||||||
|
onlineUpgrade: 'Online Upgrade',
|
||||||
|
onlineUpgradeDesc: 'Check and upgrade One-KVM',
|
||||||
|
updateChannel: 'Update Channel',
|
||||||
|
currentVersion: 'Current Version',
|
||||||
|
latestVersion: 'Latest Version',
|
||||||
|
updateStatus: 'Update Status',
|
||||||
|
updateStatusIdle: 'Idle',
|
||||||
|
releaseNotes: 'Release Notes',
|
||||||
|
noUpdates: 'No new version available for current channel',
|
||||||
|
startUpgrade: 'Start Upgrade',
|
||||||
|
updatePhaseIdle: 'Idle',
|
||||||
|
updatePhaseChecking: 'Checking',
|
||||||
|
updatePhaseDownloading: 'Downloading',
|
||||||
|
updatePhaseVerifying: 'Verifying',
|
||||||
|
updatePhaseInstalling: 'Installing',
|
||||||
|
updatePhaseRestarting: 'Restarting',
|
||||||
|
updatePhaseSuccess: 'Success',
|
||||||
|
updatePhaseFailed: 'Failed',
|
||||||
// Auth
|
// Auth
|
||||||
auth: 'Access',
|
auth: 'Access',
|
||||||
authSettings: 'Access Settings',
|
authSettings: 'Access Settings',
|
||||||
|
|||||||
@@ -501,6 +501,24 @@ export default {
|
|||||||
restartRequired: '需要重启',
|
restartRequired: '需要重启',
|
||||||
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
|
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
|
||||||
restarting: '正在重启...',
|
restarting: '正在重启...',
|
||||||
|
onlineUpgrade: '在线升级',
|
||||||
|
onlineUpgradeDesc: '检查并升级 One-KVM',
|
||||||
|
updateChannel: '升级通道',
|
||||||
|
currentVersion: '当前版本',
|
||||||
|
latestVersion: '最新版本',
|
||||||
|
updateStatus: '升级状态',
|
||||||
|
updateStatusIdle: '空闲',
|
||||||
|
releaseNotes: '更新说明',
|
||||||
|
noUpdates: '当前通道暂无可升级新版本',
|
||||||
|
startUpgrade: '开始升级',
|
||||||
|
updatePhaseIdle: '空闲',
|
||||||
|
updatePhaseChecking: '检查中',
|
||||||
|
updatePhaseDownloading: '下载中',
|
||||||
|
updatePhaseVerifying: '校验中',
|
||||||
|
updatePhaseInstalling: '安装中',
|
||||||
|
updatePhaseRestarting: '重启中',
|
||||||
|
updatePhaseSuccess: '成功',
|
||||||
|
updatePhaseFailed: '失败',
|
||||||
// Auth
|
// Auth
|
||||||
auth: '访问控制',
|
auth: '访问控制',
|
||||||
authSettings: '访问设置',
|
authSettings: '访问设置',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
atxConfigApi,
|
atxConfigApi,
|
||||||
extensionsApi,
|
extensionsApi,
|
||||||
systemApi,
|
systemApi,
|
||||||
|
updateApi,
|
||||||
type EncoderBackendInfo,
|
type EncoderBackendInfo,
|
||||||
type AuthConfig,
|
type AuthConfig,
|
||||||
type RustDeskConfigResponse,
|
type RustDeskConfigResponse,
|
||||||
@@ -19,6 +20,9 @@ import {
|
|||||||
type RtspStatusResponse,
|
type RtspStatusResponse,
|
||||||
type RtspConfigUpdate,
|
type RtspConfigUpdate,
|
||||||
type WebConfig,
|
type WebConfig,
|
||||||
|
type UpdateOverviewResponse,
|
||||||
|
type UpdateStatusResponse,
|
||||||
|
type UpdateChannel,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import type {
|
import type {
|
||||||
ExtensionsStatus,
|
ExtensionsStatus,
|
||||||
@@ -222,6 +226,19 @@ const webServerConfig = ref<WebConfig>({
|
|||||||
const webServerLoading = ref(false)
|
const webServerLoading = ref(false)
|
||||||
const showRestartDialog = ref(false)
|
const showRestartDialog = ref(false)
|
||||||
const restarting = ref(false)
|
const restarting = ref(false)
|
||||||
|
const updateChannel = ref<UpdateChannel>('stable')
|
||||||
|
const updateOverview = ref<UpdateOverviewResponse | null>(null)
|
||||||
|
const updateStatus = ref<UpdateStatusResponse | null>(null)
|
||||||
|
const updateLoading = ref(false)
|
||||||
|
const updateRunning = computed(() => {
|
||||||
|
const phase = updateStatus.value?.phase
|
||||||
|
return phase === 'checking'
|
||||||
|
|| phase === 'downloading'
|
||||||
|
|| phase === 'verifying'
|
||||||
|
|| phase === 'installing'
|
||||||
|
|| phase === 'restarting'
|
||||||
|
})
|
||||||
|
let updateStatusTimer: number | null = null
|
||||||
type BindMode = 'all' | 'loopback' | 'custom'
|
type BindMode = 'all' | 'loopback' | 'custom'
|
||||||
const bindMode = ref<BindMode>('all')
|
const bindMode = ref<BindMode>('all')
|
||||||
const bindAllIpv6 = ref(false)
|
const bindAllIpv6 = ref(false)
|
||||||
@@ -1117,6 +1134,67 @@ async function restartServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUpdateOverview() {
|
||||||
|
updateLoading.value = true
|
||||||
|
try {
|
||||||
|
updateOverview.value = await updateApi.overview(updateChannel.value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load update overview:', e)
|
||||||
|
} finally {
|
||||||
|
updateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUpdateStatus() {
|
||||||
|
try {
|
||||||
|
updateStatus.value = await updateApi.status()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh update status:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopUpdatePolling() {
|
||||||
|
if (updateStatusTimer !== null) {
|
||||||
|
window.clearInterval(updateStatusTimer)
|
||||||
|
updateStatusTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpdatePolling() {
|
||||||
|
if (updateStatusTimer !== null) return
|
||||||
|
updateStatusTimer = window.setInterval(async () => {
|
||||||
|
await refreshUpdateStatus()
|
||||||
|
if (!updateRunning.value) {
|
||||||
|
stopUpdatePolling()
|
||||||
|
await loadUpdateOverview()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOnlineUpgrade() {
|
||||||
|
try {
|
||||||
|
await updateApi.upgrade({ channel: updateChannel.value })
|
||||||
|
await refreshUpdateStatus()
|
||||||
|
startUpdatePolling()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to start upgrade:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePhaseText(phase?: string): string {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle': return t('settings.updatePhaseIdle')
|
||||||
|
case 'checking': return t('settings.updatePhaseChecking')
|
||||||
|
case 'downloading': return t('settings.updatePhaseDownloading')
|
||||||
|
case 'verifying': return t('settings.updatePhaseVerifying')
|
||||||
|
case 'installing': return t('settings.updatePhaseInstalling')
|
||||||
|
case 'restarting': return t('settings.updatePhaseRestarting')
|
||||||
|
case 'success': return t('settings.updatePhaseSuccess')
|
||||||
|
case 'failed': return t('settings.updatePhaseFailed')
|
||||||
|
default: return t('common.unknown')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRustdeskConfig() {
|
async function saveRustdeskConfig() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
saved.value = false
|
saved.value = false
|
||||||
@@ -1376,8 +1454,18 @@ onMounted(async () => {
|
|||||||
loadRustdeskPassword(),
|
loadRustdeskPassword(),
|
||||||
loadRtspConfig(),
|
loadRtspConfig(),
|
||||||
loadWebServerConfig(),
|
loadWebServerConfig(),
|
||||||
|
loadUpdateOverview(),
|
||||||
|
refreshUpdateStatus(),
|
||||||
])
|
])
|
||||||
usernameInput.value = authStore.user || ''
|
usernameInput.value = authStore.user || ''
|
||||||
|
|
||||||
|
if (updateRunning.value) {
|
||||||
|
startUpdatePolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(updateChannel, async () => {
|
||||||
|
await loadUpdateOverview()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -2755,14 +2843,80 @@ onMounted(async () => {
|
|||||||
<!-- About Section -->
|
<!-- About Section -->
|
||||||
<div v-show="activeSection === 'about'" class="space-y-6">
|
<div v-show="activeSection === 'about'" class="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent class="space-y-4">
|
||||||
<CardTitle>One-KVM</CardTitle>
|
<div>
|
||||||
<CardDescription>{{ t('settings.aboutDesc') }}</CardDescription>
|
<p class="text-sm font-medium">{{ t('settings.onlineUpgrade') }}</p>
|
||||||
</CardHeader>
|
<p class="text-xs text-muted-foreground mt-1">{{ t('settings.onlineUpgradeDesc') }}</p>
|
||||||
<CardContent>
|
</div>
|
||||||
<div class="flex justify-between items-center py-2">
|
|
||||||
<span class="text-sm text-muted-foreground">{{ t('settings.version') }}</span>
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<Badge>{{ systemStore.version || t('common.unknown') }} ({{ systemStore.buildDate || t('common.unknown') }})</Badge>
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('settings.currentVersion') }}</Label>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ updateOverview?.current_version || systemStore.version || t('common.unknown') }}
|
||||||
|
({{ systemStore.buildDate || t('common.unknown') }})
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('settings.latestVersion') }}</Label>
|
||||||
|
<Badge variant="outline">{{ updateOverview?.latest_version || t('common.unknown') }}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('settings.updateChannel') }}</Label>
|
||||||
|
<select v-model="updateChannel" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="updateRunning">
|
||||||
|
<option value="stable">Stable</option>
|
||||||
|
<option value="beta">Beta</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>{{ t('settings.updateStatus') }}</Label>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="max-w-[60%] truncate"
|
||||||
|
:title="updateStatus?.message || updatePhaseText(updateStatus?.phase)"
|
||||||
|
>
|
||||||
|
{{ updateStatus?.message || updatePhaseText(updateStatus?.phase) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-if="updateRunning || updateStatus?.phase === 'failed' || updateStatus?.phase === 'success'" class="w-full h-2 bg-muted rounded overflow-hidden">
|
||||||
|
<div class="h-full bg-primary transition-all" :style="{ width: `${Math.max(0, Math.min(100, updateStatus?.progress || 0))}%` }" />
|
||||||
|
</div>
|
||||||
|
<p v-if="updateStatus?.last_error" class="text-xs text-destructive">{{ updateStatus.last_error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('settings.releaseNotes') }}</Label>
|
||||||
|
<div v-if="updateLoading" class="text-sm text-muted-foreground">{{ t('common.loading') }}</div>
|
||||||
|
<div v-else-if="!updateOverview?.notes_between?.length" class="text-sm text-muted-foreground">{{ t('settings.noUpdates') }}</div>
|
||||||
|
<div v-else class="space-y-3 max-h-56 overflow-y-auto pr-1">
|
||||||
|
<div v-for="item in updateOverview.notes_between" :key="item.version" class="rounded border p-3 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">v{{ item.version }}</span>
|
||||||
|
<span class="text-xs text-muted-foreground">{{ item.published_at }}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li v-for="(note, idx) in item.notes" :key="`${item.version}-${idx}`">{{ note }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" :disabled="updateRunning" @click="loadUpdateOverview">
|
||||||
|
<RefreshCw class="h-4 w-4 mr-2" />
|
||||||
|
{{ t('common.refresh') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="updateRunning || !updateOverview?.upgrade_available"
|
||||||
|
@click="startOnlineUpgrade"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4 mr-2" :class="updateRunning ? 'animate-spin' : ''" />
|
||||||
|
{{ t('settings.startUpgrade') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user