323 lines
12 KiB
Rust
323 lines
12 KiB
Rust
//! Автообновление через 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<String>,
|
|
available: bool,
|
|
phase: Phase,
|
|
/// Время последней проверки, локальное.
|
|
checked_at: Option<String>,
|
|
error: Option<String>,
|
|
/// Когда в последний раз запускали установку.
|
|
#[serde(skip)]
|
|
started_at: Option<Instant>,
|
|
}
|
|
|
|
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<Asset>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct Asset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
}
|
|
|
|
/// Последний релиз: тег как есть, новее ли он текущей версии и ссылка на
|
|
/// установщик, если он в релизе приложен.
|
|
struct Latest {
|
|
tag: String,
|
|
newer: bool,
|
|
installer_url: Option<String>,
|
|
}
|
|
|
|
/// Клиент переживает процесс: пересобирать его на каждую проверку значит
|
|
/// каждый раз заново разбирать корневые сертификаты.
|
|
fn client() -> Result<&'static reqwest::Client, String> {
|
|
static CLIENT: OnceLock<Result<reqwest::Client, String>> = 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<Latest, 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 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;
|
|
}
|
|
}
|