//! Автообновление через GitHub Releases. //! //! Раз в `CHECK_INTERVAL` спрашиваем последнюю версию репозитория; если она //! новее вшитой — молча скачиваем установщик и запускаем его. //! //! Тонкость с правами. Сервер запускается от обычного пользователя, а //! установщик пишет в Program Files и требует администратора. Чтобы обновление //! шло без окна UAC, установщик (`installer/setup.iss`) заранее регистрирует //! задачу Планировщика «SysHelperUpdate» — запуск от SYSTEM с наивысшими //! правами. Мы лишь кладём свежий `setup.exe` в фиксированное место //! (`{ProgramData}\SysHelper\update\setup.exe`) и дёргаем эту задачу: она //! запускает установщик повышенно и тихо. Путь должен совпадать с тем, что //! прописан в `[Run]` задачи в `setup.iss`. use std::{path::PathBuf, time::Duration}; #[cfg(windows)] use std::process::Command; use serde::{Deserialize, Serialize}; use crate::Shared; /// Версия из Cargo.toml — её же CI подставляет в тег релиза и имя установщика. const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); // Координаты репозитория. Поправьте, если owner/имя другие. const GITHUB_OWNER: &str = "robonen"; const GITHUB_REPO: &str = "syshelper"; const CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); /// Имя задачи Планировщика — должно совпадать с `setup.iss`. const TASK_NAME: &str = "SysHelperUpdate"; /* ── статус для веб-панели ──────────────────────────────────── */ #[derive(Debug, Clone, Serialize)] pub struct UpdateStatus { /// Установленная сейчас версия. current: String, /// Последняя известная версия (тег релиза) либо `current`, если проверка не находила новее. latest: Option, available: bool, /// idle | checking | downloading | installing phase: String, /// Время последней проверки, локальное. checked_at: Option, error: Option, /// Идёт обновление — второй запуск не начинаем. В JSON не отдаём. #[serde(skip)] busy: bool, } impl UpdateStatus { pub fn new() -> Self { Self { current: CURRENT_VERSION.to_string(), latest: None, available: false, phase: "idle".to_string(), checked_at: None, error: None, busy: false, } } } /* ── работа с GitHub ────────────────────────────────────────── */ #[derive(Deserialize)] struct Release { tag_name: String, #[serde(default)] assets: Vec, } #[derive(Deserialize)] struct Asset { name: String, browser_download_url: String, } /// Найденное обновление: разобранная версия, исходный тег и ссылка на установщик. struct Update { tag: String, installer_url: String, } fn build_client() -> Result { reqwest::Client::builder() .user_agent(concat!("SysHelper/", env!("CARGO_PKG_VERSION"))) .timeout(Duration::from_secs(120)) .build() .map_err(|e| e.to_string()) } /// Возвращает `Some(update)`, если в последнем релизе версия новее текущей. async fn fetch_update(client: &reqwest::Client) -> Result, String> { let url = format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest"); let release: Release = client .get(url) .header("Accept", "application/vnd.github+json") .send() .await .map_err(|e| e.to_string())? .error_for_status() .map_err(|e| e.to_string())? .json() .await .map_err(|e| e.to_string())?; // теги вида v0.2.0 — semver парсит без ведущей v let tag = release.tag_name.trim_start_matches('v'); let latest = semver::Version::parse(tag) .map_err(|e| format!("не разобрать версию «{}»: {e}", release.tag_name))?; let current = semver::Version::parse(CURRENT_VERSION) .map_err(|e| format!("своя версия «{CURRENT_VERSION}» некорректна: {e}"))?; if latest <= current { return Ok(None); } let installer = release .assets .iter() .find(|a| a.name.to_lowercase().ends_with("-setup.exe")) .ok_or_else(|| "в релизе нет установщика *-setup.exe".to_string())?; Ok(Some(Update { tag: release.tag_name.clone(), installer_url: installer.browser_download_url.clone(), })) } /// Куда кладём скачанный установщик. Совпадает с путём задачи в `setup.iss`. fn installer_path() -> PathBuf { let base = std::env::var_os("ProgramData") .map(PathBuf::from) .unwrap_or_else(std::env::temp_dir); base.join("SysHelper").join("update").join("setup.exe") } async fn download(client: &reqwest::Client, url: &str, dest: &PathBuf) -> Result<(), String> { let bytes = client .get(url) .send() .await .map_err(|e| e.to_string())? .error_for_status() .map_err(|e| e.to_string())? .bytes() .await .map_err(|e| e.to_string())?; if let Some(dir) = dest.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } std::fs::write(dest, &bytes).map_err(|e| e.to_string()) } /// Запускает установщик повышенно и тихо через заранее зарегистрированную задачу. /// Если задачи нет (например, портативный запуск) — стартуем напрямую, тогда /// Windows покажет запрос UAC. fn launch_installer(dest: &PathBuf) -> Result<(), String> { #[cfg(windows)] { let ran = Command::new("schtasks") .args(["/Run", "/TN", TASK_NAME]) .status() .map(|s| s.success()) .unwrap_or(false); if ran { return Ok(()); } Command::new(dest) .args([ "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/NOCANCEL", ]) .spawn() .map(|_| ()) .map_err(|e| e.to_string()) } #[cfg(not(windows))] { let _ = (dest, TASK_NAME); Err("автоустановка поддерживается только на Windows".to_string()) } } /// Отцепляет короткий скрипт: он переждёт установку и поднимет уже новую /// версию в текущей пользовательской сессии. Установщик закроет наш процесс, /// а этот дочерний cmd переживёт закрытие, так как не держит файлы программы. fn schedule_restart() { #[cfg(windows)] if let Ok(exe) = std::env::current_exe() { let exe = exe.display().to_string(); // ping вместо timeout — надёжнее работает в отцепленном процессе let script = format!("ping -n 30 127.0.0.1 >nul & start \"\" \"{exe}\""); let _ = Command::new("cmd").args(["/C", &script]).spawn(); } } /* ── оркестрация ────────────────────────────────────────────── */ fn now_string() -> String { chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string() } /// Одна проверка: смотрим релиз и, если версия новее, скачиваем и запускаем. pub async fn check_now(state: Shared, client: reqwest::Client) { { let mut s = state.update.lock().await; if s.busy { return; } s.phase = "checking".to_string(); s.error = None; } match fetch_update(&client).await { Err(e) => { { let mut s = state.update.lock().await; s.phase = "idle".to_string(); s.checked_at = Some(now_string()); s.error = Some(e.clone()); } state.log(format!("обновление: не удалось проверить — {e}")).await; } Ok(None) => { let mut s = state.update.lock().await; s.phase = "idle".to_string(); s.checked_at = Some(now_string()); s.latest = Some(CURRENT_VERSION.to_string()); s.available = false; } Ok(Some(update)) => { { let mut s = state.update.lock().await; s.busy = true; s.available = true; s.latest = Some(update.tag.clone()); s.checked_at = Some(now_string()); s.phase = "downloading".to_string(); } state .log(format!("обновление: найдена версия {} — устанавливаю", update.tag)) .await; let dest = installer_path(); if let Err(e) = download(&client, &update.installer_url, &dest).await { let mut s = state.update.lock().await; s.busy = false; s.phase = "idle".to_string(); s.error = Some(e.clone()); drop(s); state.log(format!("обновление: ошибка загрузки — {e}")).await; return; } { let mut s = state.update.lock().await; s.phase = "installing".to_string(); } // сперва планируем перезапуск, потом запускаем установщик: он вот-вот // закроет этот процесс schedule_restart(); if let Err(e) = launch_installer(&dest) { let mut s = state.update.lock().await; s.busy = false; s.phase = "idle".to_string(); s.error = Some(e.clone()); drop(s); state .log(format!("обновление: не удалось запустить установщик — {e}")) .await; return; } state .log(format!( "обновление {} запущено; панель перезапустится автоматически", update.tag )) .await; } } } /// Проверка по запросу из панели. pub async fn check_spawn(state: Shared) { match build_client() { Ok(client) => check_now(state, client).await, Err(e) => state.log(format!("обновление: {e}")).await, } } /// Фоновый цикл: проверяем при старте и далее раз в `CHECK_INTERVAL`. pub async fn run(state: Shared) { // даём серверу подняться, прежде чем лезть в сеть tokio::time::sleep(Duration::from_secs(20)).await; let client = match build_client() { Ok(client) => client, Err(e) => { state.log(format!("автообновление отключено: {e}")).await; return; } }; loop { check_now(state.clone(), client.clone()).await; tokio::time::sleep(CHECK_INTERVAL).await; } }