diff --git a/Cargo.toml b/Cargo.toml index e1431c00..c246fdd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] } # HTTP client (for URL downloads) # 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" # Static file embedding @@ -118,7 +118,6 @@ hwcodec = { path = "libs/hwcodec" } protobuf = { version = "3.7", features = ["with-bytes"] } sodiumoxide = "0.2" sha2 = "0.10" - # High-performance pixel format conversion (libyuv) libyuv = { path = "res/vcpkg/libyuv" } diff --git a/src/atx/controller.rs b/src/atx/controller.rs index 31dd3841..7ca143cd 100644 --- a/src/atx/controller.rs +++ b/src/atx/controller.rs @@ -8,7 +8,7 @@ use tracing::{debug, info, warn}; use super::executor::{timing, AtxKeyExecutor}; 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}; /// ATX power control configuration diff --git a/src/audio/controller.rs b/src/audio/controller.rs index f7ebf82d..5191fd82 100644 --- a/src/audio/controller.rs +++ b/src/audio/controller.rs @@ -41,7 +41,6 @@ impl AudioQuality { /// Parse from string #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { "voice" | "low" => AudioQuality::Voice, "high" | "music" => AudioQuality::High, diff --git a/src/audio/monitor.rs b/src/audio/monitor.rs index 6abfb010..f764c775 100644 --- a/src/audio/monitor.rs +++ b/src/audio/monitor.rs @@ -16,8 +16,7 @@ use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// Audio health status -#[derive(Debug, Clone, PartialEq)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum AudioHealthStatus { /// Device is healthy and operational #[default] @@ -35,7 +34,6 @@ pub enum AudioHealthStatus { Disconnected, } - /// Audio health monitor configuration #[derive(Debug, Clone)] pub struct AudioMonitorConfig { diff --git a/src/audio/streamer.rs b/src/audio/streamer.rs index 462a3c7c..f05773fd 100644 --- a/src/audio/streamer.rs +++ b/src/audio/streamer.rs @@ -14,8 +14,7 @@ use super::encoder::{OpusConfig, OpusEncoder, OpusFrame}; use crate::error::{AppError, Result}; /// Audio stream state -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AudioStreamState { /// Stream is stopped #[default] @@ -28,10 +27,8 @@ pub enum AudioStreamState { Error, } - /// Audio streamer configuration -#[derive(Debug, Clone)] -#[derive(Default)] +#[derive(Debug, Clone, Default)] pub struct AudioStreamerConfig { /// Audio capture configuration pub capture: AudioConfig, @@ -39,7 +36,6 @@ pub struct AudioStreamerConfig { pub opus: OpusConfig, } - impl AudioStreamerConfig { /// Create config for a specific device with default quality pub fn for_device(device_name: &str) -> Self { @@ -280,7 +276,9 @@ impl AudioStreamer { // Encode to Opus let opus_result = { 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 { diff --git a/src/config/schema.rs b/src/config/schema.rs index ece7edd8..ff5fb131 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -39,7 +39,6 @@ pub struct AppConfig { pub rtsp: RtspConfig, } - /// Authentication configuration #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] @@ -113,7 +112,6 @@ pub enum HidBackend { None, } - /// OTG USB device descriptor configuration #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -165,7 +163,6 @@ pub enum OtgHidProfile { Custom, } - /// OTG HID function selection (used when profile is Custom) #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -354,7 +351,6 @@ pub struct AtxConfig { pub wol_interface: String, } - impl AtxConfig { /// Convert to AtxControllerConfig for the controller pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig { @@ -456,7 +452,6 @@ impl Default for RtspConfig { } } - /// Encoder type #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -482,7 +477,6 @@ pub enum EncoderType { V4l2m2m, } - impl EncoderType { /// Convert to EncoderBackend for registry queries pub fn to_backend(&self) -> Option { diff --git a/src/extensions/types.rs b/src/extensions/types.rs index 10358ddf..a5dc795c 100644 --- a/src/extensions/types.rs +++ b/src/extensions/types.rs @@ -162,7 +162,6 @@ pub struct EasytierConfig { pub virtual_ip: Option, } - /// Combined extensions configuration #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/hid/backend.rs b/src/hid/backend.rs index dbb8dcbb..62fd1bf5 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -31,7 +31,6 @@ pub enum HidBackendType { None, } - impl HidBackendType { /// Check if OTG backend is available on this system pub fn otg_available() -> bool { diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 85196ce8..13abf25e 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -232,7 +232,6 @@ pub enum WorkMode { CustomHid = 0x03, } - /// CH9329 serial communication mode #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[repr(u8)] @@ -247,7 +246,6 @@ pub enum SerialMode { Transparent = 0x02, } - /// CH9329 configuration parameters #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ch9329Config { diff --git a/src/hid/monitor.rs b/src/hid/monitor.rs index 1fc8834f..b5eedd72 100644 --- a/src/hid/monitor.rs +++ b/src/hid/monitor.rs @@ -16,8 +16,7 @@ use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// HID health status -#[derive(Debug, Clone, PartialEq)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum HidHealthStatus { /// Device is healthy and operational #[default] @@ -35,7 +34,6 @@ pub enum HidHealthStatus { Disconnected, } - /// HID health monitor configuration #[derive(Debug, Clone)] pub struct HidMonitorConfig { diff --git a/src/lib.rs b/src/lib.rs index e5cf69ae..e64e5da8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod rtsp; pub mod rustdesk; pub mod state; pub mod stream; +pub mod update; pub mod utils; pub mod video; pub mod web; diff --git a/src/main.rs b/src/main.rs index 6ffe5734..3cd9ef0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ use one_kvm::otg::{configfs, OtgService}; use one_kvm::rtsp::RtspService; use one_kvm::rustdesk::RustDeskService; use one_kvm::state::AppState; +use one_kvm::update::UpdateService; use one_kvm::utils::bind_tcp_listener; use one_kvm::video::codec_constraints::{ enforce_constraints_with_stream_manager, StreamCodecConstraints, @@ -554,6 +555,8 @@ async fn main() -> anyhow::Result<()> { }; // Create application state + let update_service = Arc::new(UpdateService::new(data_dir.join("updates"))); + let state = AppState::new( config_store.clone(), session_store, @@ -568,6 +571,7 @@ async fn main() -> anyhow::Result<()> { rtsp.clone(), extensions.clone(), events.clone(), + update_service, shutdown_tx.clone(), data_dir.clone(), ); diff --git a/src/msd/image.rs b/src/msd/image.rs index 8e83425c..e7a066f4 100644 --- a/src/msd/image.rs +++ b/src/msd/image.rs @@ -87,8 +87,7 @@ impl ImageManager { .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| { - chrono::DateTime::from_timestamp(d.as_secs() as i64, 0) - .unwrap_or_else(Utc::now) + chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_else(Utc::now) }) .unwrap_or_else(Utc::now); diff --git a/src/msd/monitor.rs b/src/msd/monitor.rs index 077dadaf..fd4a9f96 100644 --- a/src/msd/monitor.rs +++ b/src/msd/monitor.rs @@ -15,8 +15,7 @@ use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// MSD health status -#[derive(Debug, Clone, PartialEq)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum MsdHealthStatus { /// Device is healthy and operational #[default] @@ -30,7 +29,6 @@ pub enum MsdHealthStatus { }, } - /// MSD health monitor configuration #[derive(Debug, Clone)] pub struct MsdMonitorConfig { diff --git a/src/msd/types.rs b/src/msd/types.rs index 5a061c59..8f1e68cf 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -18,7 +18,6 @@ pub enum MsdMode { Drive, } - /// Image file metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageInfo { diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index 57d8f4a2..20d07494 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -328,9 +328,7 @@ impl VentoyDrive { let image = match VentoyImage::open(&path) { Ok(img) => img, Err(e) => { - let _ = rt.block_on(tx.send(Err(std::io::Error::other( - e.to_string(), - )))); + let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string())))); return; } }; @@ -340,9 +338,7 @@ impl VentoyDrive { // Stream the file through the 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( - e.to_string(), - )))); + let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string())))); } }); @@ -545,12 +541,10 @@ mod tests { .output()?; if !output.status.success() { - return Err(std::io::Error::other( - format!( - "xz decompress failed: {}", - String::from_utf8_lossy(&output.stderr) - ), - )); + return Err(std::io::Error::other(format!( + "xz decompress failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); } std::fs::write(dst, &output.stdout)?; diff --git a/src/otg/service.rs b/src/otg/service.rs index e0eeb37a..6daddd45 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -35,8 +35,7 @@ const FLAG_HID: u8 = 0b01; const FLAG_MSD: u8 = 0b10; /// HID device paths -#[derive(Debug, Clone)] -#[derive(Default)] +#[derive(Debug, Clone, Default)] pub struct HidDevicePaths { pub keyboard: Option, pub mouse_relative: Option, @@ -44,7 +43,6 @@ pub struct HidDevicePaths { pub consumer: Option, } - impl HidDevicePaths { pub fn existing_paths(&self) -> Vec { let mut paths = Vec::new(); @@ -230,13 +228,12 @@ impl OtgService { let requested_functions = self.hid_functions.read().await.clone(); { let state = self.state.read().await; - if state.hid_enabled - && state.hid_functions.as_ref() == Some(&requested_functions) { - if let Some(ref paths) = state.hid_paths { - info!("HID already enabled, returning existing paths"); - return Ok(paths.clone()); - } + if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) { + if let Some(ref paths) = state.hid_paths { + info!("HID already enabled, returning existing paths"); + return Ok(paths.clone()); } + } } // Recreate gadget with both HID and MSD if needed diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index c94bc3ac..83b41f36 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -681,7 +681,9 @@ impl Connection { 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 { video_manager.codec_constraints().await } else { @@ -772,8 +774,7 @@ impl Connection { // Check if this codec is different from current and available if self.negotiated_codec != Some(new_codec) { let constraints = self.current_codec_constraints().await; - if !constraints - .is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec)) + if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec)) { warn!( "Client requested codec {:?} but it's blocked by constraints: {}", diff --git a/src/rustdesk/frame_adapters.rs b/src/rustdesk/frame_adapters.rs index 3fa8c8e4..35785ca9 100644 --- a/src/rustdesk/frame_adapters.rs +++ b/src/rustdesk/frame_adapters.rs @@ -127,8 +127,7 @@ impl VideoFrameAdapter { // Inject cached SPS/PPS before IDR when missing 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()); out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(sps); diff --git a/src/state.rs b/src/state.rs index f1309171..18f4ead1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -15,6 +15,7 @@ use crate::msd::MsdController; use crate::otg::OtgService; use crate::rtsp::RtspService; use crate::rustdesk::RustDeskService; +use crate::update::UpdateService; use crate::video::VideoStreamManager; /// Application-wide state shared across handlers @@ -57,6 +58,8 @@ pub struct AppState { pub extensions: Arc, /// Event bus for real-time notifications pub events: Arc, + /// Online update service + pub update: Arc, /// Shutdown signal sender pub shutdown_tx: broadcast::Sender<()>, /// Recently revoked session IDs (for client kick detection) @@ -82,6 +85,7 @@ impl AppState { rtsp: Option>, extensions: Arc, events: Arc, + update: Arc, shutdown_tx: broadcast::Sender<()>, data_dir: std::path::PathBuf, ) -> Arc { @@ -99,6 +103,7 @@ impl AppState { rtsp: Arc::new(RwLock::new(rtsp)), extensions, events, + update, shutdown_tx, revoked_sessions: Arc::new(RwLock::new(VecDeque::new())), data_dir, diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 00000000..78084d0a --- /dev/null +++ b/src/update/mod.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseInfo { + pub version: String, + pub channel: UpdateChannel, + pub published_at: String, + #[serde(default)] + pub notes: Vec, + #[serde(default)] + pub artifacts: HashMap, +} + +#[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, +} + +#[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, + pub notes_between: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpgradeRequest { + pub channel: Option, + pub target_version: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +pub struct UpdateService { + client: reqwest::Client, + base_url: String, + work_dir: PathBuf, + status: RwLock, + upgrade_permit: Arc, +} + +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 { + 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, + 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 Deserialize<'de>>(&self, path: &str) -> Result { + 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::() + .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, + message: Option, + last_error: Option, + ) { + 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 { + 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 { + 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::() + .map_err(|e| AppError::Internal(format!("Invalid major version {}: {}", parts[0], e)))?; + let minor = parts[1] + .parse::() + .map_err(|e| AppError::Internal(format!("Invalid minor version {}: {}", parts[1], e)))?; + let patch = parts[2] + .parse::() + .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 { + 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 { + 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 { + 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 = 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); + } +} diff --git a/src/video/encoder/h264.rs b/src/video/encoder/h264.rs index f59a27b9..b7fdce24 100644 --- a/src/video/encoder/h264.rs +++ b/src/video/encoder/h264.rs @@ -32,8 +32,7 @@ fn init_hwcodec_logging() { } /// H.264 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum H264EncoderType { /// NVIDIA NVENC Nvenc, @@ -69,7 +68,6 @@ impl std::fmt::Display for H264EncoderType { } } - /// Map codec name to encoder type fn codec_name_to_type(name: &str) -> H264EncoderType { if name.contains("nvenc") { @@ -90,8 +88,7 @@ fn codec_name_to_type(name: &str) -> H264EncoderType { } /// Input pixel format for H264 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum H264InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, @@ -112,7 +109,6 @@ pub enum H264InputFormat { Bgr24, } - /// H.264 encoder configuration #[derive(Debug, Clone)] pub struct H264Config { diff --git a/src/video/encoder/h265.rs b/src/video/encoder/h265.rs index f96c6b62..11eaf537 100644 --- a/src/video/encoder/h265.rs +++ b/src/video/encoder/h265.rs @@ -30,8 +30,7 @@ fn init_hwcodec_logging() { } /// H.265 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum H265EncoderType { /// NVIDIA NVENC Nvenc, @@ -67,7 +66,6 @@ impl std::fmt::Display for H265EncoderType { } } - impl From for H265EncoderType { fn from(backend: EncoderBackend) -> Self { match backend { @@ -83,8 +81,7 @@ impl From for H265EncoderType { } /// Input pixel format for H265 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum H265InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, @@ -105,7 +102,6 @@ pub enum H265InputFormat { Bgr24, } - /// H.265 encoder configuration #[derive(Debug, Clone)] pub struct H265Config { diff --git a/src/video/encoder/traits.rs b/src/video/encoder/traits.rs index d4b5bd88..4b3b4cc9 100644 --- a/src/video/encoder/traits.rs +++ b/src/video/encoder/traits.rs @@ -76,7 +76,6 @@ impl BitratePreset { } } - impl std::fmt::Display for BitratePreset { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/video/encoder/vp8.rs b/src/video/encoder/vp8.rs index 302fe7ab..453fa133 100644 --- a/src/video/encoder/vp8.rs +++ b/src/video/encoder/vp8.rs @@ -30,8 +30,7 @@ fn init_hwcodec_logging() { } /// VP8 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum VP8EncoderType { /// VAAPI (Intel on Linux) Vaapi, @@ -52,7 +51,6 @@ impl std::fmt::Display for VP8EncoderType { } } - impl From for VP8EncoderType { fn from(backend: EncoderBackend) -> Self { match backend { @@ -64,8 +62,7 @@ impl From for VP8EncoderType { } /// Input pixel format for VP8 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum VP8InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, @@ -74,7 +71,6 @@ pub enum VP8InputFormat { Nv12, } - /// VP8 encoder configuration #[derive(Debug, Clone)] pub struct VP8Config { diff --git a/src/video/encoder/vp9.rs b/src/video/encoder/vp9.rs index 6ff4c589..aab23ff3 100644 --- a/src/video/encoder/vp9.rs +++ b/src/video/encoder/vp9.rs @@ -30,8 +30,7 @@ fn init_hwcodec_logging() { } /// VP9 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum VP9EncoderType { /// VAAPI (Intel on Linux) Vaapi, @@ -52,7 +51,6 @@ impl std::fmt::Display for VP9EncoderType { } } - impl From for VP9EncoderType { fn from(backend: EncoderBackend) -> Self { match backend { @@ -64,8 +62,7 @@ impl From for VP9EncoderType { } /// Input pixel format for VP9 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[derive(Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum VP9InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, @@ -74,7 +71,6 @@ pub enum VP9InputFormat { Nv12, } - /// VP9 encoder configuration #[derive(Debug, Clone)] pub struct VP9Config { diff --git a/src/video/v4l2r_capture.rs b/src/video/v4l2r_capture.rs index 0027896c..a44b841a 100644 --- a/src/video/v4l2r_capture.rs +++ b/src/video/v4l2r_capture.rs @@ -82,7 +82,8 @@ impl V4l2rCaptureStream { let actual_format = PixelFormat::from_v4l2r(actual_fmt.pixelformat).unwrap_or(format); let stride = actual_fmt - .plane_fmt.first() + .plane_fmt + .first() .map(|p| p.bytesperline) .unwrap_or_else(|| match actual_format.bytes_per_pixel() { Some(bpp) => actual_resolution.width * bpp as u32, diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 9e133e46..b2a9d872 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -24,8 +24,8 @@ mod audio; mod auth; mod hid; mod msd; -mod rustdesk; mod rtsp; +mod rustdesk; mod stream; pub(crate) mod video; 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 hid::{get_hid_config, update_hid_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::{ get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id, 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 video::{get_video_config, update_video_config}; pub use web::{get_web_config, update_web_config}; diff --git a/src/web/handlers/config/rtsp.rs b/src/web/handlers/config/rtsp.rs index 16b395fd..d8dcc846 100644 --- a/src/web/handlers/config/rtsp.rs +++ b/src/web/handlers/config/rtsp.rs @@ -54,7 +54,10 @@ pub async fn update_rtsp_config( }) .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!( "RTSP apply failed: {}; rollback failed: {}", err, rollback_err diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 10ed79bc..ce837f88 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -14,6 +14,7 @@ use crate::config::{AppConfig, StreamMode}; use crate::error::{AppError, Result}; use crate::events::SystemEvent; use crate::state::AppState; +use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest}; use crate::video::codec_constraints::codec_to_id; use crate::video::encoder::BitratePreset; @@ -751,7 +752,8 @@ pub async fn setup_init( // 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).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); } else { @@ -1641,7 +1643,10 @@ pub async fn stream_constraints_get( .into_iter() .map(str::to_string) .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, sources: ConstraintSources { rustdesk: constraints.rustdesk_enabled, @@ -1929,7 +1934,9 @@ pub async fn mjpeg_stream( 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; } } @@ -3130,3 +3137,37 @@ pub async fn system_restart(State(state): State>) -> Json, +} + +pub async fn update_overview( + State(state): State>, + axum::extract::Query(query): axum::extract::Query, +) -> Result> { + 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>, + Json(req): Json, +) -> Result> { + 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>) -> Json { + Json(state.update.status().await) +} diff --git a/src/web/routes.rs b/src/web/routes.rs index b67c93d3..864b3b56 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -136,6 +136,9 @@ pub fn create_router(state: Arc) -> Router { .route("/config/auth", patch(handlers::config::update_auth_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)) diff --git a/src/webrtc/config.rs b/src/webrtc/config.rs index 3bcf3c1c..86b41a91 100644 --- a/src/webrtc/config.rs +++ b/src/webrtc/config.rs @@ -117,7 +117,6 @@ pub enum VideoCodec { AV1, } - impl std::fmt::Display for VideoCodec { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 1d433ae1..72c823a6 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -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(`/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('/update/status'), +} + // Stream API export interface VideoCodecInfo { id: string diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 084717ee..d6ac6c0c 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -501,6 +501,24 @@ export default { restartRequired: 'Restart Required', restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.', 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: 'Access', authSettings: 'Access Settings', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 18e0cec6..7c598805 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -501,6 +501,24 @@ export default { restartRequired: '需要重启', restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。', restarting: '正在重启...', + onlineUpgrade: '在线升级', + onlineUpgradeDesc: '检查并升级 One-KVM', + updateChannel: '升级通道', + currentVersion: '当前版本', + latestVersion: '最新版本', + updateStatus: '升级状态', + updateStatusIdle: '空闲', + releaseNotes: '更新说明', + noUpdates: '当前通道暂无可升级新版本', + startUpgrade: '开始升级', + updatePhaseIdle: '空闲', + updatePhaseChecking: '检查中', + updatePhaseDownloading: '下载中', + updatePhaseVerifying: '校验中', + updatePhaseInstalling: '安装中', + updatePhaseRestarting: '重启中', + updatePhaseSuccess: '成功', + updatePhaseFailed: '失败', // Auth auth: '访问控制', authSettings: '访问设置', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index a91c534a..6075314a 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -11,6 +11,7 @@ import { atxConfigApi, extensionsApi, systemApi, + updateApi, type EncoderBackendInfo, type AuthConfig, type RustDeskConfigResponse, @@ -19,6 +20,9 @@ import { type RtspStatusResponse, type RtspConfigUpdate, type WebConfig, + type UpdateOverviewResponse, + type UpdateStatusResponse, + type UpdateChannel, } from '@/api' import type { ExtensionsStatus, @@ -222,6 +226,19 @@ const webServerConfig = ref({ const webServerLoading = ref(false) const showRestartDialog = ref(false) const restarting = ref(false) +const updateChannel = ref('stable') +const updateOverview = ref(null) +const updateStatus = ref(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' const bindMode = ref('all') 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() { loading.value = true saved.value = false @@ -1376,8 +1454,18 @@ onMounted(async () => { loadRustdeskPassword(), loadRtspConfig(), loadWebServerConfig(), + loadUpdateOverview(), + refreshUpdateStatus(), ]) usernameInput.value = authStore.user || '' + + if (updateRunning.value) { + startUpdatePolling() + } +}) + +watch(updateChannel, async () => { + await loadUpdateOverview() }) @@ -2755,14 +2843,80 @@ onMounted(async () => {
- - One-KVM - {{ t('settings.aboutDesc') }} - - -
- {{ t('settings.version') }} - {{ systemStore.version || t('common.unknown') }} ({{ systemStore.buildDate || t('common.unknown') }}) + +
+

{{ t('settings.onlineUpgrade') }}

+

{{ t('settings.onlineUpgradeDesc') }}

+
+ +
+
+ + + {{ updateOverview?.current_version || systemStore.version || t('common.unknown') }} + ({{ systemStore.buildDate || t('common.unknown') }}) + +
+
+ + {{ updateOverview?.latest_version || t('common.unknown') }} +
+
+ +
+ + +
+ +
+
+ + + {{ updateStatus?.message || updatePhaseText(updateStatus?.phase) }} + +
+
+
+
+

{{ updateStatus.last_error }}

+
+ +
+ +
{{ t('common.loading') }}
+
{{ t('settings.noUpdates') }}
+
+
+
+ v{{ item.version }} + {{ item.published_at }} +
+
    +
  • {{ note }}
  • +
+
+
+
+ +
+ +