scythe/src/updater.rs

461 lines
17 KiB
Rust

use std::sync::{Arc, Mutex};
use serde::Deserialize;
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const FORGEJO_HOST: &str = "https://git.oiupoyt.space";
pub const REPO_OWNER_NAME: &str = "oiupoyt/scythe";
pub const GITHUB_REPO: &str = "oiupoyt/scythe";
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default)]
pub struct ReleaseAsset {
pub name: String,
pub browser_download_url: String,
#[serde(default)]
pub size: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReleaseInfo {
pub tag_name: String,
pub version: String,
pub name: String,
pub html_url: String,
pub release_notes: String,
pub has_update: bool,
pub assets: Vec<ReleaseAsset>,
}
#[derive(Deserialize, Debug)]
struct ForgejoRelease {
tag_name: String,
name: Option<String>,
html_url: String,
body: Option<String>,
#[serde(default)]
assets: Vec<ReleaseAsset>,
}
pub fn is_newer(latest: &str, current: &str) -> bool {
let parse_nums = |s: &str| -> Vec<u64> {
let clean = s.trim().trim_start_matches('v');
clean
.split('.')
.filter_map(|p| p.split('-').next().and_then(|n| n.parse::<u64>().ok()))
.collect()
};
let l_nums = parse_nums(latest);
let c_nums = parse_nums(current);
for (l, c) in l_nums.iter().zip(c_nums.iter()) {
if l > c {
return true;
} else if l < c {
return false;
}
}
l_nums.len() > c_nums.len()
}
pub fn check_for_updates() -> Option<ReleaseInfo> {
let urls = [
format!("{}/api/v1/repos/{}/releases/latest", FORGEJO_HOST, REPO_OWNER_NAME),
format!("https://api.github.com/repos/{}/releases/latest", REPO_OWNER_NAME),
];
for url in urls {
let mut cmd = std::process::Command::new("curl");
cmd.args([
"-s",
"-H", "User-Agent: scythe-updater",
"-H", "Accept: application/json",
"--max-time", "6",
&url,
]);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
if let Ok(output) = cmd.output()
&& output.status.success()
&& let Ok(release) = serde_json::from_slice::<ForgejoRelease>(&output.stdout) {
let clean_ver = release.tag_name.trim().trim_start_matches('v').to_string();
let has_update = is_newer(&clean_ver, CURRENT_VERSION);
return Some(ReleaseInfo {
tag_name: release.tag_name,
version: clean_ver,
name: release.name.unwrap_or_else(|| format!("v{}", CURRENT_VERSION)),
html_url: release.html_url,
release_notes: release.body.unwrap_or_default(),
has_update,
assets: release.assets,
});
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum UpdateStatus {
#[default]
Idle,
Checking,
UpToDate { version: String },
Available(ReleaseInfo),
Updating { version: String, progress: String },
Updated { version: String, message: String },
Failed(String),
}
pub fn spawn_update_check(status: Arc<Mutex<UpdateStatus>>) {
if let Ok(mut lock) = status.lock() {
*lock = UpdateStatus::Checking;
}
std::thread::spawn(move || {
match check_for_updates() {
Some(info) => {
if let Ok(mut lock) = status.lock() {
if info.has_update {
*lock = UpdateStatus::Available(info);
} else {
*lock = UpdateStatus::UpToDate { version: info.version };
}
}
}
None => {
if let Ok(mut lock) = status.lock() {
*lock = UpdateStatus::Failed("Could not reach update server".to_string());
}
}
}
});
}
fn download_file(url: &str, dest: &std::path::Path) -> Result<(), String> {
let dest_str = dest.to_str().ok_or_else(|| "Invalid destination path".to_string())?;
let mut cmd = std::process::Command::new("curl");
cmd.args([
"-L",
"--fail",
"--silent",
"--show-error",
"-H", "User-Agent: scythe-updater",
"-o", dest_str,
url,
]);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
let output = cmd.output().map_err(|e| format!("Failed to run curl: {}", e))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(format!("Download failed: {}", err.trim()));
}
if !dest.exists() || std::fs::metadata(dest).map(|m| m.len() == 0).unwrap_or(true) {
return Err("Downloaded file is empty or missing".to_string());
}
Ok(())
}
pub fn spawn_auto_update(info: ReleaseInfo, status: Arc<Mutex<UpdateStatus>>) {
if let Ok(mut lock) = status.lock() {
*lock = UpdateStatus::Updating {
version: info.version.clone(),
progress: "Preparing update...".to_string(),
};
}
std::thread::spawn(move || {
let res = run_auto_update(&info, &status);
if let Ok(mut lock) = status.lock() {
match res {
Ok(msg) => {
*lock = UpdateStatus::Updated {
version: info.version.clone(),
message: msg,
};
}
Err(err) => {
*lock = UpdateStatus::Failed(err);
}
}
}
});
}
fn run_auto_update(info: &ReleaseInfo, status: &Arc<Mutex<UpdateStatus>>) -> Result<String, String> {
let set_progress = |msg: &str| {
if let Ok(mut lock) = status.lock() {
*lock = UpdateStatus::Updating {
version: info.version.clone(),
progress: msg.to_string(),
};
}
};
#[cfg(target_os = "linux")]
{
// 1. Check if running from AppImage
if let Ok(appimage_path) = std::env::var("APPIMAGE") {
let appimage_file = std::path::PathBuf::from(&appimage_path);
if appimage_file.exists() {
set_progress("Locating AppImage package...");
let asset = info
.assets
.iter()
.find(|a| a.name.ends_with(".AppImage"))
.ok_or_else(|| "Could not find AppImage in release assets".to_string())?;
set_progress(&format!("Downloading AppImage ({})...", asset.name));
let temp_download = appimage_file.with_extension("download");
download_file(&asset.browser_download_url, &temp_download)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&temp_download, std::fs::Permissions::from_mode(0o755));
}
set_progress("Applying update...");
std::fs::rename(&temp_download, &appimage_file)
.map_err(|e| format!("Failed to replace AppImage: {}", e))?;
return Ok(format!("Updated AppImage to v{}", info.version));
}
}
// 2. Standard binary update (.tar.gz containing scythe-ui, scythe-daemon)
set_progress("Locating Linux package...");
let asset = info
.assets
.iter()
.find(|a| a.name == "scythe-linux-x86_64.tar.gz")
.or_else(|| info.assets.iter().find(|a| a.name.contains("linux") && a.name.ends_with(".tar.gz")))
.ok_or_else(|| "Could not find scythe-linux-x86_64.tar.gz in release assets".to_string())?;
let temp_dir = std::env::temp_dir().join(format!("scythe_update_{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp_dir);
let archive_path = temp_dir.join("scythe-linux-x86_64.tar.gz");
set_progress(&format!("Downloading {}...", asset.name));
download_file(&asset.browser_download_url, &archive_path)?;
set_progress("Extracting binaries...");
let extract_status = std::process::Command::new("tar")
.args(["-xzf", archive_path.to_str().unwrap(), "-C", temp_dir.to_str().unwrap()])
.output()
.map_err(|e| format!("Failed to run tar: {}", e))?;
if !extract_status.status.success() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("tar extraction failed: {}", String::from_utf8_lossy(&extract_status.stderr)));
}
let extracted_dir = temp_dir.join("scythe-linux-x86_64");
if !extracted_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err("Extracted directory not found".to_string());
}
// Resolve current executable's directory
let current_exe = std::env::current_exe().map_err(|e| format!("Cannot locate current executable: {}", e))?;
let canonical_exe = std::fs::canonicalize(&current_exe).unwrap_or_else(|_| current_exe.clone());
let exe_dir = canonical_exe.parent().ok_or_else(|| "Invalid executable path".to_string())?;
set_progress(&format!("Installing binaries into {}...", exe_dir.display()));
for bin_name in ["scythe-ui", "scythe-daemon"] {
let src = extracted_dir.join(bin_name);
if src.exists() {
let dest = exe_dir.join(bin_name);
let dest_new = exe_dir.join(format!("{}.new", bin_name));
std::fs::copy(&src, &dest_new)
.map_err(|e| format!("Failed to write {}: {}. Check write permissions in {}", bin_name, e, exe_dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dest_new, std::fs::Permissions::from_mode(0o755));
}
std::fs::rename(&dest_new, &dest)
.map_err(|e| format!("Failed to atomically replace {}: {}", bin_name, e))?;
}
}
// Maintain aliases in ~/.local/bin if installed there
if exe_dir.ends_with(".local/bin") {
for link in ["scythe", "vrec", "vrec-ui"] {
let link_path = exe_dir.join(link);
let target_path = exe_dir.join("scythe-ui");
if !link_path.exists() {
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(&target_path, &link_path);
}
}
let daemon_link = exe_dir.join("vrec-daemon");
if !daemon_link.exists() {
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(exe_dir.join("scythe-daemon"), &daemon_link);
}
}
let _ = std::fs::remove_dir_all(&temp_dir);
Ok(format!("Installed v{} to {}", info.version, exe_dir.display()))
}
#[cfg(target_os = "windows")]
{
// 1. Check for official NSIS installer (scythe-setup.exe)
if let Some(asset) = info.assets.iter().find(|a| a.name == "scythe-setup.exe") {
let temp_dir = std::env::temp_dir();
let installer_path = temp_dir.join(format!("scythe-setup-v{}.exe", info.version));
set_progress(&format!("Downloading Windows installer ({})...", asset.name));
download_file(&asset.browser_download_url, &installer_path)?;
set_progress("Launching installer...");
let mut cmd = std::process::Command::new(&installer_path);
cmd.arg("/S");
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
let _ = cmd.spawn().map_err(|e| format!("Failed to launch installer: {}", e))?;
return Ok(format!("Update installer launched for v{}.", info.version));
}
// 2. Portable zip update
if let Some(asset) = info.assets.iter().find(|a| a.name == "scythe-windows-x86_64.zip") {
let temp_dir = std::env::temp_dir().join(format!("scythe_update_{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp_dir);
let zip_path = temp_dir.join("scythe-windows-x86_64.zip");
set_progress(&format!("Downloading {}...", asset.name));
download_file(&asset.browser_download_url, &zip_path)?;
set_progress("Extracting archive...");
let tar_out = std::process::Command::new("tar.exe")
.args(["-xf", zip_path.to_str().unwrap(), "-C", temp_dir.to_str().unwrap()])
.output();
let extracted_ok = tar_out.map(|o| o.status.success()).unwrap_or(false);
if !extracted_ok {
let ps_script = format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
zip_path.display(),
temp_dir.display()
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &ps_script])
.output()
.map_err(|e| format!("Extraction failed: {}", e))?;
}
let current_exe = std::env::current_exe().map_err(|e| format!("Cannot locate current executable: {}", e))?;
let exe_dir = current_exe.parent().ok_or_else(|| "Invalid executable path".to_string())?;
set_progress("Replacing binaries and libraries...");
let mut files_to_update = Vec::new();
let mut search_dirs = vec![temp_dir.clone()];
while let Some(dir) = search_dirs.pop() {
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
search_dirs.push(path);
} else if path.is_file() {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let lower = name.to_lowercase();
if lower.ends_with(".exe") || lower.ends_with(".dll") {
files_to_update.push((path, name));
}
}
}
}
}
for (src, name) in files_to_update {
let dest = exe_dir.join(&name);
let dest_old = exe_dir.join(format!("{}.old", name));
let _ = std::fs::remove_file(&dest_old);
let _ = std::fs::rename(&dest, &dest_old);
let _ = std::fs::copy(&src, &dest);
}
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(format!("Updated binaries to v{} in {}", info.version, exe_dir.display()));
}
Err("No compatible Windows package found in release assets".to_string())
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
Err("Automatic update is not supported on this operating system".to_string())
}
}
pub fn restart_application() {
if let Ok(exe) = std::env::current_exe() {
let mut cmd = std::process::Command::new(exe);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
let _ = cmd.spawn();
}
std::process::exit(0);
}
pub fn open_browser_url(url: &str) {
let u = url.to_string();
std::thread::spawn(move || {
#[cfg(target_os = "windows")]
{
use windows::core::HSTRING;
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
unsafe {
let _ = ShellExecuteW(
None,
windows::core::w!("open"),
&HSTRING::from(&u),
None,
None,
SW_SHOWNORMAL,
);
}
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open").arg(&u).spawn();
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = std::process::Command::new("xdg-open").arg(&u).spawn();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_comparisons() {
assert!(is_newer("0.2.0", "0.1.0"));
assert!(is_newer("v0.2.0", "0.1.0"));
assert!(is_newer("1.0.0", "0.9.9"));
assert!(is_newer("0.1.1", "0.1.0"));
assert!(!is_newer("0.1.0", "0.1.0"));
assert!(!is_newer("v0.1.0", "0.1.0"));
assert!(!is_newer("0.2.0", "0.2.0"));
assert!(is_newer("0.2.1", "0.2.0"));
assert!(!is_newer("0.0.9", "0.1.0"));
}
}