feat(updater): implement cross-platform in-app automatic software update

This commit is contained in:
katR 2026-09-18 20:44:12 +05:30
parent b2d18297da
commit 91f603918d
2 changed files with 444 additions and 85 deletions

View file

@ -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
};

View file

@ -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<u64>,
}
#[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<ReleaseAsset>,
}
#[derive(Deserialize, Debug)]
@ -20,6 +29,8 @@ struct GitHubRelease {
name: Option<String>,
html_url: String,
body: Option<String>,
#[serde(default)]
assets: Vec<ReleaseAsset>,
}
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<ReleaseInfo> {
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<ReleaseInfo> {
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<Mutex<UpdateStatus>>) {
if let Ok(mut lock) = status.lock() {
*lock = UpdateStatus::Checking;
@ -115,6 +127,270 @@ pub fn spawn_update_check(status: Arc<Mutex<UpdateStatus>>) {
});
}
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...");
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 || {