//! Автообновление через GitHub Releases. //! //! Служба и так работает от SYSTEM, поэтому скачивает и ставит обновление //! сама: ни задач Планировщика, ни отдельного привилегированного режима больше //! не нужно. Каталог загрузки лежит в ProgramData и закрыт от записи обычным //! пользователям (icacls в `installer/setup.iss`) — иначе ограниченная учётная //! запись подменила бы установщик и получила SYSTEM. //! //! Останавливает и поднимает службу сам установщик (см. `[Code]` в setup.iss), //! так что возвращать панель на место после обновления не требуется. use std::{ path::PathBuf, sync::OnceLock, time::{Duration, Instant}, }; #[cfg(windows)] use std::{os::windows::process::CommandExt, process::Command}; use serde::{Deserialize, Serialize}; use crate::Shared; /// Версия из Cargo.toml — её же CI подставляет в тег релиза и имя установщика. const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); // Координаты репозитория. const GITHUB_OWNER: &str = "robonen"; const GITHUB_REPO: &str = "syshelper"; const CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); /// Даём службе подняться, прежде чем лезть в сеть. const STARTUP_DELAY: Duration = Duration::from_secs(20); /// Повторять установку раньше этого срока бессмысленно: либо она идёт, либо /// сломалась и нужен человек. Кнопка в панели ограничение обходит. const RETRY_AFTER: Duration = Duration::from_secs(60 * 60); /// Ключи тихой установки. `/FORCECLOSEAPPLICATIONS` оставляем страховкой на /// случай, если остановка службы почему-то не сработала и файлы заняты. #[cfg(windows)] const INSTALL_ARGS: &[&str] = &[ "/VERYSILENT", "/SUPPRESSMSGBOXES", "/FORCECLOSEAPPLICATIONS", "/NORESTART", ]; #[cfg(windows)] use crate::CREATE_NO_WINDOW; /* ── статус для веб-панели ──────────────────────────────────── */ #[derive(Debug, Clone, Copy, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Phase { Idle, Checking, Downloading, Installing, } #[derive(Debug, Clone, Serialize)] pub struct UpdateStatus { /// Установленная сейчас версия. current: &'static str, /// Последний тег в релизах — как есть, даже если он не новее текущего. latest: Option, available: bool, phase: Phase, /// Время последней проверки, локальное. checked_at: Option, error: Option, /// Когда в последний раз запускали установку. #[serde(skip)] started_at: Option, } impl UpdateStatus { pub fn new() -> Self { Self { current: CURRENT_VERSION, latest: None, available: false, phase: Phase::Idle, checked_at: None, error: None, started_at: None, } } } /* ── работа с GitHub ────────────────────────────────────────── */ #[derive(Deserialize)] struct Release { tag_name: String, #[serde(default)] assets: Vec, } #[derive(Deserialize)] struct Asset { name: String, browser_download_url: String, } /// Последний релиз: тег как есть, новее ли он текущей версии и ссылка на /// установщик, если он в релизе приложен. struct Latest { tag: String, newer: bool, installer_url: Option, } /// Клиент переживает процесс: пересобирать его на каждую проверку значит /// каждый раз заново разбирать корневые сертификаты. fn client() -> Result<&'static reqwest::Client, String> { static CLIENT: OnceLock> = OnceLock::new(); CLIENT .get_or_init(|| { reqwest::Client::builder() .user_agent(concat!("SysHelper/", env!("CARGO_PKG_VERSION"))) .timeout(Duration::from_secs(300)) .build() .map_err(|e| e.to_string()) }) .as_ref() .map_err(Clone::clone) } async fn fetch_latest(client: &reqwest::Client) -> Result { 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 latest = semver::Version::parse(release.tag_name.trim_start_matches('v')) .map_err(|e| format!("не разобрать версию «{}»: {e}", release.tag_name))?; let current = semver::Version::parse(CURRENT_VERSION) .map_err(|e| format!("своя версия «{CURRENT_VERSION}» некорректна: {e}"))?; Ok(Latest { newer: latest > current, tag: release.tag_name, installer_url: release .assets .into_iter() .find(|a| a.name.to_lowercase().ends_with("-setup.exe")) .map(|a| a.browser_download_url), }) } /* ── загрузка и установка ───────────────────────────────────── */ /// Каталог загрузки: писать сюда может только служба и администраторы. fn download_path() -> PathBuf { crate::data_dir().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()) } /// Запускает установщик и сразу отпускает: он остановит службу, то есть нас, /// и ждать его завершения бессмысленно. Дочерний процесс переживает родителя. fn launch_installer(installer: &PathBuf) -> Result<(), String> { #[cfg(windows)] { let log = format!("/LOG={}", crate::data_dir().join("install.log").display()); Command::new(installer) .args(INSTALL_ARGS) .arg(log) .creation_flags(CREATE_NO_WINDOW) .spawn() .map(|_| ()) .map_err(|e| e.to_string()) } #[cfg(not(windows))] { let _ = installer; Err("установка поддерживается только на Windows".to_string()) } } /* ── оркестрация ────────────────────────────────────────────── */ fn now_string() -> String { crate::stamp() } /// Проверяет версию и, если вышла новее, ставит её. /// `force` — нажатие кнопки в панели: игнорирует паузу между попытками. pub async fn check(state: Shared, force: bool) { { let mut status = state.update.lock().await; if status.phase != Phase::Idle && status.phase != Phase::Installing { return; // проверка или загрузка уже идёт } let waiting = status .started_at .is_some_and(|at| at.elapsed() < RETRY_AFTER); if waiting && !force { return; } status.phase = Phase::Checking; } let http = client(); let fetched = match http { Ok(client) => fetch_latest(client).await, Err(ref e) => Err(e.clone()), }; let latest = { let mut status = state.update.lock().await; status.checked_at = Some(now_string()); match fetched { Ok(latest) => { status.error = None; status.available = latest.newer; status.latest = Some(latest.tag.clone()); if !latest.newer { status.phase = Phase::Idle; return; } status.phase = Phase::Downloading; status.started_at = Some(Instant::now()); latest } Err(e) => { status.phase = Phase::Idle; status.error = Some(e.clone()); drop(status); state .log(format!("обновление: не удалось проверить — {e}")) .await; return; } } }; let Some(url) = latest.installer_url else { let mut status = state.update.lock().await; status.phase = Phase::Idle; status.error = Some("в релизе нет установщика *-setup.exe".to_string()); drop(status); state .log(format!("обновление {}: нет установщика в релизе", latest.tag)) .await; return; }; state .log(format!("обновление: найдена версия {}, скачиваю", latest.tag)) .await; // сюда попадаем только после удачного fetch_latest, значит клиент есть let Ok(http) = http else { return }; let dest = download_path(); if let Err(e) = download(http, &url, &dest).await { let mut status = state.update.lock().await; status.phase = Phase::Idle; status.error = Some(e.clone()); drop(status); state.log(format!("обновление: загрузка не удалась — {e}")).await; return; } { let mut status = state.update.lock().await; status.phase = Phase::Installing; } // пишем до запуска: установщик вот-вот остановит службу state .log(format!("обновление {}: установка запущена", latest.tag)) .await; if let Err(e) = launch_installer(&dest) { let mut status = state.update.lock().await; status.phase = Phase::Idle; status.error = Some(e.clone()); drop(status); state .log(format!("обновление: установщик не запустился — {e}")) .await; } } /// Фоновый цикл проверки обновлений. pub async fn run(state: Shared) { tokio::time::sleep(STARTUP_DELAY).await; loop { check(state.clone(), false).await; tokio::time::sleep(CHECK_INTERVAL).await; } }