From 91f603918d453652afcde970a7da1c0f76575550 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Fri, 18 Sep 2026 20:44:12 +0530 Subject: [PATCH] feat(updater): implement cross-platform in-app automatic software update --- src/overlay_egui.rs | 243 ++++++++++++++++++++++++------------- src/updater.rs | 286 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 444 insertions(+), 85 deletions(-) diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index 05fb7dd..a2804a2 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -2625,10 +2625,34 @@ impl ScytheOverlayApp { .strong(), ); ui.add_space(6.0); - if squared_button_sized(ui, "VIEW RELEASE / DOWNLOAD", true, accent, Some(egui::vec2(180.0, 26.0))) { + if squared_button_sized(ui, "UPDATE NOW", true, accent, Some(egui::vec2(110.0, 26.0))) { + crate::updater::spawn_auto_update(info.clone(), self.update_status.clone()); + } + ui.add_space(6.0); + if squared_button_sized(ui, "Release Notes", false, accent, Some(egui::vec2(110.0, 26.0))) { crate::updater::open_browser_url(&info.html_url); } } + crate::updater::UpdateStatus::Updating { version, progress } => { + ui.label( + egui::RichText::new(format!("Updating to v{}: {}", version, progress)) + .size(11.5) + .color(Color32::from_rgb(96, 165, 250)) + .strong(), + ); + } + crate::updater::UpdateStatus::Updated { version, message: _ } => { + ui.label( + egui::RichText::new(format!("Updated to v{}! Restart required.", version)) + .size(11.5) + .color(Color32::from_rgb(34, 197, 94)) + .strong(), + ); + ui.add_space(6.0); + if squared_button_sized(ui, "Restart Scythe", true, accent, Some(egui::vec2(120.0, 26.0))) { + crate::updater::restart_application(); + } + } crate::updater::UpdateStatus::Failed(err) => { ui.label( egui::RichText::new(format!("Offline or error: {}", err)) @@ -3026,25 +3050,58 @@ impl ScytheOverlayApp { } fn render_update_popup(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { - let (has_update, rel_info) = { - let cur = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default(); - match cur { - crate::updater::UpdateStatus::Available(info) => (true, Some(info)), - _ => (false, None), + let cur = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default(); + let (should_render, title, version_badge, body_lines, show_do_not_ask) = match &cur { + crate::updater::UpdateStatus::Available(info) => { + if self.update_dismissed || self.config.do_not_ask_updates { + (false, "", "", vec![], false) + } else { + ( + true, + "Update Available", + info.version.as_str(), + vec![ + format!("A new version of Scythe is available (v{}). Installed: v{}.", info.version, crate::updater::CURRENT_VERSION), + "Would you like to download and install the update locally?".to_string(), + ], + true, + ) + } } + crate::updater::UpdateStatus::Updating { version, progress } => { + ( + true, + "Updating Scythe...", + version.as_str(), + vec![ + format!("Downloading and applying update v{}...", version), + progress.clone(), + ], + false, + ) + } + crate::updater::UpdateStatus::Updated { version, message } => { + ( + true, + "Update Complete!", + version.as_str(), + vec![ + format!("Scythe has been updated to v{}.", version), + message.clone(), + ], + false, + ) + } + _ => (false, "", "", vec![], false), }; - if !has_update || self.update_dismissed || self.config.do_not_ask_updates { + if !should_render { return; } - let info = match rel_info { - Some(i) => i, - None => return, - }; let screen_rect = ctx.screen_rect(); - let modal_w = 460.0_f32; - let modal_h = 210.0_f32; + let modal_w = 480.0_f32; + let modal_h = 220.0_f32; let modal_x = ((screen_rect.width() - modal_w) * 0.5).floor(); let modal_y = ((screen_rect.height() - modal_h) * 0.40).floor(); let modal_rect = egui::Rect::from_min_size(egui::pos2(modal_x, modal_y), Vec2::new(modal_w, modal_h)); @@ -3074,92 +3131,114 @@ impl ScytheOverlayApp { let inner_rect = modal_rect.shrink(18.0); let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(inner_rect)); - // Header: Scythe Icon + "Update Available" + version badge + // Header: Scythe Icon + Title + version badge child_ui.horizontal(|ui| { let icon_center = egui::pos2(ui.cursor().min.x + 14.0, ui.cursor().min.y + 12.0); draw_scythe_icon(ui.painter(), icon_center, 18.0, accent); ui.add_space(32.0); ui.label( - egui::RichText::new("Update Available") + egui::RichText::new(title) .size(15.0) .strong() .color(Color32::WHITE), ); - ui.add_space(8.0); - let badge_text = format!("v{}", info.version); - let badge_bg = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 35); - let badge_stroke = Stroke::new(1.0_f32, accent); - let galley = ui.painter().layout_no_wrap( - badge_text.clone(), - FontId::proportional(11.0), - accent, - ); - let badge_rect = egui::Rect::from_min_size( - ui.cursor().min + egui::vec2(0.0, 3.0), - galley.size() + egui::vec2(12.0, 6.0), - ); - ui.painter().rect(badge_rect, CornerRadius::ZERO, badge_bg, badge_stroke, egui::StrokeKind::Inside); - ui.painter().text( - badge_rect.center(), - egui::Align2::CENTER_CENTER, - badge_text, - FontId::proportional(11.0), - accent, - ); + if !version_badge.is_empty() { + ui.add_space(8.0); + let badge_text = format!("v{}", version_badge); + let badge_bg = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 35); + let badge_stroke = Stroke::new(1.0_f32, accent); + let galley = ui.painter().layout_no_wrap( + badge_text.clone(), + FontId::proportional(11.0), + accent, + ); + let badge_rect = egui::Rect::from_min_size( + ui.cursor().min + egui::vec2(0.0, 3.0), + galley.size() + egui::vec2(12.0, 6.0), + ); + ui.painter().rect(badge_rect, CornerRadius::ZERO, badge_bg, badge_stroke, egui::StrokeKind::Inside); + ui.painter().text( + badge_rect.center(), + egui::Align2::CENTER_CENTER, + badge_text, + FontId::proportional(11.0), + accent, + ); + } }); child_ui.add_space(14.0); - // Body message - child_ui.label( - egui::RichText::new(format!( - "A new version of Scythe is available (v{}). Installed: v{}.", - info.version, - crate::updater::CURRENT_VERSION - )) - .size(12.0) - .color(Color32::from_rgb(220, 225, 235)), - ); - child_ui.add_space(2.0); - child_ui.label( - egui::RichText::new("Would you like to open the release page to download and update now?") - .size(11.5) - .color(Color32::from_rgb(155, 160, 170)), - ); + // Body lines + if let Some(first) = body_lines.first() { + child_ui.label( + egui::RichText::new(first) + .size(12.0) + .color(Color32::from_rgb(220, 225, 235)), + ); + } + if let Some(second) = body_lines.get(1) { + child_ui.add_space(2.0); + child_ui.label( + egui::RichText::new(second) + .size(11.5) + .color(Color32::from_rgb(155, 160, 170)), + ); + } - child_ui.add_space(14.0); + child_ui.add_space(12.0); - // Smaller toggle option: "Do not ask next time" - child_ui.checkbox( - &mut self.popup_do_not_ask, - egui::RichText::new("Do not ask next time") - .size(10.5) - .color(Color32::from_rgb(175, 180, 190)), - ); + if show_do_not_ask { + child_ui.checkbox( + &mut self.popup_do_not_ask, + egui::RichText::new("Do not ask next time") + .size(10.5) + .color(Color32::from_rgb(175, 180, 190)), + ); + child_ui.add_space(12.0); + } - child_ui.add_space(14.0); - - // Action buttons: "Update Now" and "Later" + // Action buttons based on current state child_ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if squared_button(ui, "Update Now", true, accent) { - if self.popup_do_not_ask { - self.config.do_not_ask_updates = true; - let _ = self.config.save(); - } - self.update_dismissed = true; - crate::updater::open_browser_url(&info.html_url); - self.show_hud_notification("UPDATER", "Opening release in browser...", crate::overlay::ToastIcon::Info); - } + match &cur { + crate::updater::UpdateStatus::Available(info) => { + if squared_button_sized(ui, "Update Now", true, accent, Some(egui::vec2(100.0, 28.0))) { + if self.popup_do_not_ask { + self.config.do_not_ask_updates = true; + let _ = self.config.save(); + } + crate::updater::spawn_auto_update(info.clone(), self.update_status.clone()); + self.show_hud_notification("UPDATER", "Downloading and applying update locally...", crate::overlay::ToastIcon::Info); + } - ui.add_space(10.0); + ui.add_space(10.0); - if squared_button(ui, "Later", false, accent) { - if self.popup_do_not_ask { - self.config.do_not_ask_updates = true; - let _ = self.config.save(); + if squared_button_sized(ui, "Later", false, accent, Some(egui::vec2(70.0, 28.0))) { + if self.popup_do_not_ask { + self.config.do_not_ask_updates = true; + let _ = self.config.save(); + } + self.update_dismissed = true; + } } - self.update_dismissed = true; + crate::updater::UpdateStatus::Updating { .. } => { + ui.label( + egui::RichText::new("Please wait while update completes...") + .size(11.5) + .color(Color32::from_rgb(96, 165, 250)), + ); + } + crate::updater::UpdateStatus::Updated { .. } => { + if squared_button_sized(ui, "Restart Now", true, accent, Some(egui::vec2(110.0, 28.0))) { + crate::updater::restart_application(); + } + ui.add_space(10.0); + if squared_button_sized(ui, "Close", false, accent, Some(egui::vec2(70.0, 28.0))) { + self.update_dismissed = true; + } + } + _ => {} } }); }); @@ -3454,8 +3533,12 @@ impl eframe::App for ScytheOverlayApp { } else { let modal_active = { let cur = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default(); - matches!(cur, crate::updater::UpdateStatus::Available(_)) - && !self.update_dismissed + matches!( + cur, + crate::updater::UpdateStatus::Available(_) + | crate::updater::UpdateStatus::Updating { .. } + | crate::updater::UpdateStatus::Updated { .. } + ) && !self.update_dismissed && !self.config.do_not_ask_updates }; diff --git a/src/updater.rs b/src/updater.rs index b6625c9..c7a4b4a 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -4,6 +4,14 @@ use serde::Deserialize; pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); 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, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ReleaseInfo { pub tag_name: String, @@ -12,6 +20,7 @@ pub struct ReleaseInfo { pub html_url: String, pub release_notes: String, pub has_update: bool, + pub assets: Vec, } #[derive(Deserialize, Debug)] @@ -20,6 +29,8 @@ struct GitHubRelease { name: Option, html_url: String, body: Option, + #[serde(default)] + assets: Vec, } pub fn is_newer(latest: &str, current: &str) -> bool { @@ -45,13 +56,13 @@ pub fn is_newer(latest: &str, current: &str) -> bool { pub fn check_for_updates() -> Option { let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO); - // Run curl with a 4-second timeout to avoid blocking + // Run curl with a 6-second timeout to avoid blocking let mut cmd = std::process::Command::new("curl"); cmd.args([ "-s", "-H", "User-Agent: scythe-updater", "-H", "Accept: application/vnd.github.v3+json", - "--max-time", "4", + "--max-time", "6", &url, ]); #[cfg(target_os = "windows")] @@ -76,21 +87,22 @@ pub fn check_for_updates() -> Option { html_url: release.html_url, release_notes: release.body.unwrap_or_default(), has_update, + assets: release.assets, }) } -#[derive(Debug, Clone, PartialEq, Eq)] -#[derive(Default)] +#[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>) { if let Ok(mut lock) = status.lock() { *lock = UpdateStatus::Checking; @@ -115,6 +127,270 @@ pub fn spawn_update_check(status: Arc>) { }); } +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>) { + 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>) -> Result { + 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(¤t_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..."); + for bin_name in ["scythe-ui.exe", "scythe-daemon.exe"] { + let src = temp_dir.join(bin_name); + if src.exists() { + let dest = exe_dir.join(bin_name); + let dest_old = exe_dir.join(format!("{}.old", bin_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 || {