use anyhow::{Context, Result};
use chrono::DateTime;
use rust_i18n::t;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use tokio_stream::StreamExt;
use tracing::{debug, error, info, warn};
fn parse_github_repo() -> (&'static str, &'static str) {
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
if let Some(path) = REPOSITORY.strip_prefix("https://github.com/")
&& let Some((owner, repo)) = path.split_once('/')
{
let repo = repo.trim_end_matches(".git");
return (owner, repo);
}
panic!(
"{}",
t!("check_update.parse_repo_error", repository = REPOSITORY)
);
}
pub const CLI_API_URL: &str =
"https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/nuwax-cli/latest/latest.json";
pub fn get_cli_api_url() -> String {
CLI_API_URL.to_string()
}
use crate::cli::CheckUpdateCommand;
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
#[allow(dead_code)]
pub name: String,
pub body: String,
#[allow(dead_code)]
pub draft: bool,
#[allow(dead_code)]
pub prerelease: bool,
pub published_at: String,
#[allow(dead_code)]
#[serde(default)]
pub html_url: Option<String>,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubAsset {
pub name: String,
#[allow(dead_code)]
pub size: u64,
#[allow(dead_code)]
pub download_count: u64,
pub browser_download_url: String,
#[allow(dead_code)]
pub content_type: String,
}
#[derive(Debug, Deserialize)]
pub struct TauriUpdaterResponse {
pub version: String,
pub notes: String,
pub pub_date: String,
pub platforms: HashMap<String, TauriPlatformInfo>,
}
#[derive(Debug, Deserialize)]
pub struct TauriPlatformInfo {
pub url: String,
}
#[derive(Debug, Serialize)]
pub struct VersionInfo {
pub current_version: String,
pub latest_version: String,
pub is_update_available: bool,
pub release_notes: String,
pub download_url: Option<String>,
pub published_at: String,
}
#[derive(Debug, Clone)]
pub enum UpdateSource {
VersionServer,
GitHub,
}
pub struct UpdateSourceManager {
sources: Vec<UpdateSource>,
}
fn convert_tauri_to_github_release(tauri_response: TauriUpdaterResponse) -> GitHubRelease {
use tracing::debug;
let assets: Vec<GitHubAsset> = tauri_response
.platforms
.into_iter()
.map(|(platform, info)| {
let name = info
.url
.split('/')
.next_back()
.unwrap_or(&platform)
.to_string();
debug!(
"Converting platform asset: platform={}, name={}, url={}",
platform, name, info.url
);
let content_type = if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
"application/gzip".to_string()
} else if name.ends_with(".zip") {
"application/zip".to_string()
} else if name.ends_with(".msi") {
"application/x-msi".to_string()
} else if name.ends_with(".AppImage") {
"application/x-executable".to_string()
} else {
"application/octet-stream".to_string()
};
GitHubAsset {
name: format!("{platform}|{name}"), size: 0, download_count: 0, browser_download_url: info.url,
content_type,
}
})
.collect();
GitHubRelease {
tag_name: tauri_response.version.clone(),
name: format!("Release {}", tauri_response.version),
body: tauri_response.notes,
draft: false,
prerelease: false,
published_at: tauri_response.pub_date,
html_url: None,
assets,
}
}
impl UpdateSourceManager {
pub fn new() -> Self {
Self {
sources: vec![UpdateSource::VersionServer, UpdateSource::GitHub],
}
}
pub async fn fetch_latest_version(&self) -> Result<GitHubRelease> {
let mut last_error = None;
for source in &self.sources {
match source {
UpdateSource::VersionServer => {
info!("📡 Trying version check server API...");
match self.fetch_from_version_server().await {
Ok(release) => {
info!("✅ Version check server API success");
return Ok(release);
}
Err(e) => {
warn!(
"⚠️ Version check server API failed: {error}",
error = e.to_string()
);
last_error = Some(e);
}
}
}
UpdateSource::GitHub => {
info!("📡 Trying GitHub API...");
match self.fetch_from_github().await {
Ok(release) => {
info!("✅ GitHub API success");
return Ok(release);
}
Err(e) => {
warn!("⚠️ GitHub API failed: {error}", error = e.to_string());
last_error = Some(e);
}
}
}
}
}
Err(last_error
.unwrap_or_else(|| anyhow::anyhow!("{}", t!("check_update.all_sources_failed"))))
}
async fn fetch_from_version_server(&self) -> Result<GitHubRelease> {
let client = reqwest::Client::new();
let url = get_cli_api_url();
info!("📡 Checking latest version: {url}", url = url.as_str());
let response = client
.get(&url)
.header("User-Agent", format!("nuwax-cli/{}", get_current_version()))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.context(t!("check_update.connect_server_failed"))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"{}",
t!(
"check_update.server_api_failed",
status = status.to_string(),
error = error_text
)
));
}
let tauri_response: TauriUpdaterResponse = response
.json()
.await
.context(t!("check_update.parse_server_response_failed"))?;
let release = convert_tauri_to_github_release(tauri_response);
Ok(release)
}
async fn fetch_from_github(&self) -> Result<GitHubRelease> {
let repo = GitHubRepo::default();
let client = reqwest::Client::new();
let url = repo.latest_release_url();
info!("📡 Checking latest version: {url}", url = url.as_str());
let response = client
.get(&url)
.header("User-Agent", format!("nuwax-cli/{}", get_current_version()))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.context(t!("check_update.connect_github_failed"))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"{}",
t!(
"check_update.github_api_failed",
status = status.to_string(),
error = error_text
)
));
}
let release: GitHubRelease = response
.json()
.await
.context(t!("check_update.parse_github_response_failed"))?;
Ok(release)
}
}
pub struct GitHubRepo {
pub owner: String,
pub repo: String,
}
impl GitHubRepo {
pub fn new(owner: &str, repo: &str) -> Self {
Self {
owner: owner.to_string(),
repo: repo.to_string(),
}
}
pub fn default() -> Self {
let (owner, repo) = parse_github_repo();
Self::new(owner, repo)
}
pub fn latest_release_url(&self) -> String {
format!(
"https://api.github.com/repos/{}/{}/releases/latest",
self.owner, self.repo
)
}
}
pub fn get_current_version() -> String {
format!("v{}", env!("CARGO_PKG_VERSION"))
}
pub async fn fetch_latest_version_multi_source() -> Result<GitHubRelease> {
let source_manager = UpdateSourceManager::new();
source_manager.fetch_latest_version().await
}
pub fn compare_versions(current: &str, latest: &str) -> std::cmp::Ordering {
let normalize_version = |v: &str| -> String { v.trim_start_matches('v').to_string() };
let current_norm = normalize_version(current);
let latest_norm = normalize_version(latest);
let parse_version = |v: &str| -> Vec<u32> {
v.split('.')
.map(|s| s.parse::<u32>().unwrap_or(0))
.collect()
};
let current_parts = parse_version(¤t_norm);
let latest_parts = parse_version(&latest_norm);
current_parts.cmp(&latest_parts)
}
pub async fn check_for_updates() -> Result<VersionInfo> {
info!("🔍 Starting nuwax-cli update check...");
let current_version = get_current_version();
info!(
"📋 Current detected version: {version}",
version = current_version
);
info!("🌐 Fetching latest version info...");
let latest_release = fetch_latest_version_multi_source().await?;
let latest_version = latest_release.tag_name.clone();
info!(
"📋 Server latest version: {version}",
version = latest_version
);
let comparison = compare_versions(¤t_version, &latest_version);
info!(
"📊 Version comparison result: {result} ({current} vs {latest})",
result = format!("{:?}", comparison),
current = current_version,
latest = latest_version
);
let is_update_available = comparison == std::cmp::Ordering::Less;
if is_update_available {
info!(
"✅ New version available! Need to upgrade from {current} to {latest}",
current = current_version,
latest = latest_version
);
} else {
info!(
"✅ Current version is latest: {current} = {latest}",
current = current_version,
latest = latest_version
);
}
debug!("🔍 Finding platform-specific download package...");
let download_url = find_platform_asset(&latest_release.assets);
if let Some(url) = &download_url {
debug!("📦 Found platform-specific package: {url}", url = url);
} else {
warn!("⚠️ No platform-specific download package found");
}
let version_info = VersionInfo {
current_version,
latest_version,
is_update_available,
release_notes: latest_release.body,
download_url,
published_at: latest_release.published_at,
};
info!(
"✅ Version check complete, update available: {available}",
available = is_update_available
);
Ok(version_info)
}
fn find_platform_asset(assets: &[GitHubAsset]) -> Option<String> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
info!(
"🖥️ Detected platform: OS={os}, ARCH={arch}",
os = os,
arch = arch
);
info!("📦 Available assets: {count}", count = assets.len());
let target_platform = match (os, arch) {
("windows", "x86_64") => "windows-x86_64",
("windows", "x86") => "windows-x86",
("linux", "x86_64") => "linux-x86_64",
("linux", "aarch64") => "linux-aarch64",
("macos", "x86_64") => "darwin-x86_64",
("macos", "aarch64") => "darwin-aarch64",
_ => return None,
};
info!(
"🎯 Target platform key: {platform}",
platform = target_platform
);
for (index, asset) in assets.iter().enumerate() {
info!(
"📋 Checking asset[{index}]: name={name}, size={size}, url={url}",
index = index,
name = asset.name,
size = asset.size,
url = asset.browser_download_url
);
if asset.name.contains(target_platform) {
info!("✅ Found exact platform match: {name}", name = asset.name);
return Some(asset.browser_download_url.clone());
}
}
let platform_patterns = match (os, arch) {
("windows", "x86_64") => vec!["windows", "win64", "x86_64-pc-windows", "x64"],
("windows", "x86") => vec!["windows", "win32", "i686-pc-windows", "x86"],
("linux", "x86_64") => vec!["linux", "x86_64-unknown-linux", "x64", "amd64"],
("linux", "aarch64") => vec!["linux", "aarch64-unknown-linux", "arm64", "aarch64"],
("macos", "x86_64") => vec!["macos", "darwin", "x86_64-apple-darwin", "x64"],
("macos", "aarch64") => vec![
"macos",
"darwin",
"aarch64-apple-darwin",
"arm64",
"aarch64",
],
_ => vec![os, arch],
};
info!(
"🎯 Platform match patterns: {patterns}",
patterns = format!("{:?}", platform_patterns)
);
for (index, asset) in assets.iter().enumerate() {
let name_lower = asset.name.to_lowercase();
let url_lower = asset.browser_download_url.to_lowercase();
info!(
"🔍 Pattern match check[{index}]: name={name}, url={url}",
index = index,
name = asset.name,
url = asset.browser_download_url
);
if platform_patterns
.iter()
.any(|pattern| name_lower.contains(pattern) || url_lower.contains(pattern))
{
info!("✅ Found pattern match: {name}", name = asset.name);
if name_lower.contains("nuwax-cli")
|| name_lower.ends_with(".exe")
|| name_lower.ends_with(".tar.gz")
|| name_lower.ends_with(".msi")
|| name_lower.ends_with(".appimage")
{
info!("🎯 Selected asset: {name}", name = asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
warn!("⚠️ No matching assets found, trying to find executable...");
for (index, asset) in assets.iter().enumerate() {
let name = asset.name.to_lowercase();
let is_executable = name.contains("nuwax-cli")
|| name.ends_with(".exe")
|| name.ends_with(".tar.gz")
|| name.ends_with(".msi")
|| name.ends_with(".appimage");
info!(
"🔍 Checking executable[{index}]: {name} -> executable: {is_executable}",
index = index,
name = asset.name,
is_executable = is_executable
);
if is_executable {
info!("✅ Found executable: {name}", name = asset.name);
return Some(asset.browser_download_url.clone());
}
}
warn!("❌ No executable files found");
None
}
pub fn display_version_info(version_info: &VersionInfo) {
info!("🦆 Nuwax CLI Version Info");
info!(
"Current version: {version}",
version = version_info.current_version
);
info!(
"Latest version: {version}",
version = version_info.latest_version
);
if version_info.is_update_available {
info!("✅ New version available!");
if let Some(ref url) = version_info.download_url {
info!("Download URL: {url}", url = url);
}
if !version_info.release_notes.is_empty() {
let notes = if version_info.release_notes.len() > 500 {
format!("{}...", &version_info.release_notes[..500])
} else {
version_info.release_notes.clone()
};
info!(
"Release notes:
{notes}",
notes = notes
);
}
if let Ok(published_time) = DateTime::parse_from_rfc3339(&version_info.published_at) {
info!(
"Published at: {time}",
time = published_time.format("%Y-%m-%d %H:%M:%S")
);
}
info!("💡 Use the following command to install update:");
} else {
info!("✅ You are using the latest version!");
}
}
pub async fn should_install(target_version: Option<&str>, force: bool) -> Result<(String, String)> {
let current_version = get_current_version();
let target_version = if let Some(version) = target_version {
version.to_string()
} else {
let latest_release = fetch_latest_version_multi_source().await?;
latest_release.tag_name
};
if !force && compare_versions(¤t_version, &target_version) != std::cmp::Ordering::Less {
return Err(anyhow::anyhow!(
"{}",
t!(
"check_update.already_latest_or_higher",
current = current_version,
target = target_version
)
));
}
Ok((current_version, target_version))
}
pub async fn install_release(url: &str, version: &str) -> Result<()> {
let client = reqwest::Client::new();
let temp_dir = std::env::temp_dir().join("nuwax-cli-updates");
std::fs::create_dir_all(&temp_dir)?;
let default_filename = format!("nuwax-cli-{version}");
let filename = url.split('/').next_back().unwrap_or(&default_filename);
let download_path = temp_dir.join(filename);
info!(
"📥 Downloading version {version}: {url}",
version = version,
url = url
);
info!("💾 Temp save to: {path}", path = download_path.display());
let response = client
.get(url)
.header("User-Agent", format!("nuwax-cli/{}", get_current_version()))
.send()
.await
.context(t!("check_update.download_failed"))?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"{}",
t!(
"check_update.download_http_failed",
status = response.status()
)
));
}
let total_size = response.content_length().unwrap_or(0);
info!("📦 File size: {size} bytes", size = total_size);
let mut file = tokio::fs::File::create(&download_path).await?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
let started_at = Instant::now();
let mut last_log_at = Instant::now();
let mut last_logged_downloaded: u64 = 0;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.context(t!("check_update.download_failed"))?;
file.write_all(&chunk).await?;
downloaded += chunk.len() as u64;
let now = Instant::now();
let should_log_progress = now.duration_since(last_log_at) >= Duration::from_secs(1)
|| (total_size > 0 && downloaded >= total_size);
if should_log_progress {
let elapsed_secs = started_at.elapsed().as_secs_f64().max(0.001);
let avg_speed_bps = downloaded as f64 / elapsed_secs;
let recent_elapsed_secs = now.duration_since(last_log_at).as_secs_f64().max(0.001);
let recent_speed_bps =
(downloaded - last_logged_downloaded) as f64 / recent_elapsed_secs;
let speed_bps = if recent_speed_bps > 0.0 {
recent_speed_bps
} else {
avg_speed_bps
};
if total_size > 0 {
let progress_percent = (downloaded as f64 / total_size as f64 * 100.0).min(100.0);
let remaining_bytes = total_size.saturating_sub(downloaded);
let eta_secs = if speed_bps > 0.0 {
(remaining_bytes as f64 / speed_bps).round() as u64
} else {
0
};
info!(
"📥 Download progress: {percent:.1}% ({downloaded_mb:.1}/{total_mb:.1} MB) | {speed_mb:.2} MB/s | ETA {eta}s",
percent = progress_percent,
downloaded_mb = downloaded as f64 / 1024.0 / 1024.0,
total_mb = total_size as f64 / 1024.0 / 1024.0,
speed_mb = speed_bps / 1024.0 / 1024.0,
eta = eta_secs
);
} else {
info!(
"📥 Downloading... {downloaded_mb:.1} MB | {speed_mb:.2} MB/s",
downloaded_mb = downloaded as f64 / 1024.0 / 1024.0,
speed_mb = speed_bps / 1024.0 / 1024.0
);
}
last_log_at = now;
last_logged_downloaded = downloaded;
}
}
file.flush().await?;
let total_elapsed_secs = started_at.elapsed().as_secs_f64().max(0.001);
info!(
"✅ Downloaded {size_mb:.1} MB in {elapsed:.1}s (avg {speed_mb:.2} MB/s)",
size_mb = downloaded as f64 / 1024.0 / 1024.0,
elapsed = total_elapsed_secs,
speed_mb = (downloaded as f64 / total_elapsed_secs) / 1024.0 / 1024.0
);
info!("✅ Download complete, starting installation...");
let current_exe = std::env::current_exe().context(t!("check_update.cannot_get_exe_path"))?;
info!(
"🔧 Current executable: {path}",
path = current_exe.display()
);
install_downloaded_file(&download_path, ¤t_exe, version).await?;
if let Err(e) = std::fs::remove_file(&download_path) {
warn!(
"Failed to cleanup temp file: {error}",
error = e.to_string()
);
}
info!(
"🎉 Installation complete! Nuwax CLI has been updated to version {version}, run 'nuwax-cli --version' to verify",
version = version
);
info!("💡 Please run 'nuwax-cli auto-upgrade-deploy run' again to complete final deployment");
Ok(())
}
async fn install_downloaded_file(
download_path: &PathBuf,
current_exe: &PathBuf,
version: &str,
) -> Result<()> {
let download_name = download_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if download_name.ends_with(".tar.gz") || download_name.ends_with(".tgz") {
install_from_archive(download_path, current_exe, version).await
} else if download_name.ends_with(".exe") || download_name.contains("nuwax-cli") {
install_executable(download_path, current_exe).await
} else {
Err(anyhow::anyhow!(
"{}",
t!("check_update.unsupported_format", format = download_name)
))
}
}
async fn install_executable(download_path: &PathBuf, current_exe: &PathBuf) -> Result<()> {
let backup_path = if cfg!(target_os = "windows") {
current_exe.with_extension("exe.backup")
} else {
PathBuf::from(format!("{}.backup", current_exe.display()))
};
if let Err(e) = std::fs::copy(current_exe, &backup_path) {
warn!("Failed to create backup: {error}", error = e.to_string());
} else {
info!("✅ Backup created: {path}", path = backup_path.display());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(download_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(download_path, perms)?;
}
info!("🔧 Replacing executable...");
match self_replace::self_replace(download_path) {
Ok(()) => {
info!("✅ Executable replaced successfully");
Ok(())
}
Err(e) => {
warn!("❌ File replacement failed: {error}", error = e.to_string());
if backup_path.exists() {
info!("🔄 Trying to restore from backup...");
match std::fs::copy(&backup_path, current_exe) {
Ok(_) => {
warn!("✅ Restored from backup");
return Err(anyhow::anyhow!(
"{}",
t!(
"check_update.replace_failed_restored",
error = e.to_string()
)
));
}
Err(restore_err) => {
error!(
"❌ Backup restore also failed: {error}",
error = restore_err.to_string()
);
return Err(anyhow::anyhow!(
"{}",
t!(
"check_update.replace_and_restore_failed",
error = e.to_string(),
restore_error = restore_err.to_string()
)
));
}
}
}
Err(anyhow::anyhow!(
"{}",
t!("check_update.replace_failed", error = e.to_string())
))
}
}
}
async fn install_from_archive(
archive_path: &Path,
current_exe: &PathBuf,
_version: &str,
) -> Result<()> {
use std::process::Command;
let temp_dir = std::env::temp_dir().join("nuwax-cli-extract");
std::fs::create_dir_all(&temp_dir)?;
info!("📦 Extracting archive...");
let output = Command::new("tar")
.args([
"-xzf",
&archive_path.to_string_lossy(),
"-C",
&temp_dir.to_string_lossy(),
])
.output()
.context(t!("check_update.extract_failed_tar"))?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"{}",
t!(
"check_update.extract_failed",
error = String::from_utf8_lossy(&output.stderr)
)
));
}
let executable_path = find_executable_in_dir(&temp_dir)?;
install_executable(&executable_path, current_exe).await?;
if let Err(e) = std::fs::remove_dir_all(&temp_dir) {
warn!(
"Failed to cleanup extract directory: {error}",
error = e.to_string()
);
}
Ok(())
}
fn find_executable_in_dir(dir: &PathBuf) -> Result<PathBuf> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.contains("nuwax-cli") || name.ends_with(".exe") {
return Ok(path);
}
}
if path.is_dir()
&& let Ok(found) = find_executable_in_dir(&path)
{
return Ok(found);
}
}
Err(anyhow::anyhow!(
"{}",
t!("check_update.no_executable_in_archive")
))
}
pub async fn handle_check_update_command(command: CheckUpdateCommand) -> Result<()> {
match command {
CheckUpdateCommand::Check => {
info!("🔍 Checking Nuwax CLI updates...");
match check_for_updates().await {
Ok(version_info) => {
display_version_info(&version_info);
}
Err(e) => {
warn!("❌ Check update failed: {error}", error = e.to_string());
info!(
"Current version: {version}",
version = get_current_version()
);
info!("💡 Possible reasons:");
info!(" - Network connection issue");
info!(" - Version check server temporarily unavailable");
info!(" - GitHub API temporarily unavailable");
info!(" - Project has not released any version yet");
return Err(e);
}
}
}
CheckUpdateCommand::Install { version, force } => {
info!("🚀 Starting Nuwax CLI installation...");
let (current_version, target_version) =
match should_install(version.as_deref(), force).await {
Ok(versions) => versions,
Err(e) => {
if force {
warn!("⚠️ {}", e);
info!("🔧 Continuing installation with --force flag...");
if version.is_none() {
return Err(anyhow::anyhow!(
"{}",
t!("check_update.force_needs_version")
));
}
(get_current_version(), version.as_ref().unwrap().clone())
} else {
warn!("❌ {}", e);
return Err(e);
}
}
};
info!(
"Preparing to update from {current} to {target}",
current = current_version,
target = target_version
);
let download_url = if let Some(ref ver) = version {
get_version_download_url(ver).await?
} else {
let version_info = check_for_updates().await?;
version_info.download_url.ok_or_else(|| {
anyhow::anyhow!("{}", t!("check_update.no_platform_download_url"))
})?
};
info!(
"📥 Starting download and install version {version}...",
version = target_version
);
match install_release(&download_url, &target_version).await {
Ok(_) => {
info!("🎉 Installation successful!");
info!("Please restart terminal to verify installation");
}
Err(e) => {
warn!("❌ Installation failed: {error}", error = e.to_string());
info!("💡 Possible solutions:");
info!(" - Check network connection");
info!(" - Ensure sufficient disk space");
info!(" - Run with administrator privileges");
return Err(e);
}
}
}
}
Ok(())
}
async fn get_version_download_url(version: &str) -> Result<String> {
let version_info = check_for_updates().await?;
version_info.download_url.ok_or_else(|| {
anyhow::anyhow!(
"{}",
t!("check_update.no_version_download_url", version = version)
)
})
}