320 lines
12 KiB
Rust
320 lines
12 KiB
Rust
//! Автообновление через 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<String>,
|
|
available: bool,
|
|
/// idle | checking | downloading | installing
|
|
phase: String,
|
|
/// Время последней проверки, локальное.
|
|
checked_at: Option<String>,
|
|
error: Option<String>,
|
|
/// Идёт обновление — второй запуск не начинаем. В 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<Asset>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct Asset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
}
|
|
|
|
/// Найденное обновление: разобранная версия, исходный тег и ссылка на установщик.
|
|
struct Update {
|
|
tag: String,
|
|
installer_url: String,
|
|
}
|
|
|
|
fn build_client() -> Result<reqwest::Client, String> {
|
|
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<Option<Update>, 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;
|
|
}
|
|
}
|