feat: implement Windows service for parental control application

This commit is contained in:
2026-08-02 16:24:53 +07:00
parent 32d2553380
commit 229a02b543
10 changed files with 1111 additions and 272 deletions
+185 -182
View File
@@ -1,20 +1,21 @@
//! Автообновление через GitHub Releases.
//!
//! Раз в `CHECK_INTERVAL` спрашиваем последнюю версию репозитория; если она
//! новее вшитой — молча скачиваем установщик и запускаем его.
//! Служба и так работает от SYSTEM, поэтому скачивает и ставит обновление
//! сама: ни задач Планировщика, ни отдельного привилегированного режима больше
//! не нужно. Каталог загрузки лежит в ProgramData и закрыт от записи обычным
//! пользователям (icacls в `installer/setup.iss`) — иначе ограниченная учётная
//! запись подменила бы установщик и получила SYSTEM.
//!
//! Тонкость с правами. Сервер запускается от обычного пользователя, а
//! установщик пишет в Program Files и требует администратора. Чтобы обновление
//! шло без окна UAC, установщик (`installer/setup.iss`) заранее регистрирует
//! задачу Планировщика «SysHelperUpdate» — запуск от SYSTEM с наивысшими
//! правами. Мы лишь кладём свежий `setup.exe` в фиксированное место
//! (`{ProgramData}\SysHelper\update\setup.exe`) и дёргаем эту задачу: она
//! запускает установщик повышенно и тихо. Путь должен совпадать с тем, что
//! прописан в `[Run]` задачи в `setup.iss`.
//! Останавливает и поднимает службу сам установщик (см. `[Code]` в setup.iss),
//! так что возвращать панель на место после обновления не требуется.
use std::{path::PathBuf, time::Duration};
use std::{
path::PathBuf,
sync::OnceLock,
time::{Duration, Instant},
};
#[cfg(windows)]
use std::process::Command;
use std::{os::windows::process::CommandExt, process::Command};
use serde::{Deserialize, Serialize};
@@ -23,43 +24,67 @@ 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";
/// Даём службе подняться, прежде чем лезть в сеть.
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: String,
/// Последняя известная версия (тег релиза) либо `current`, если проверка не находила новее.
current: &'static str,
/// Последний тег в релизах — как есть, даже если он не новее текущего.
latest: Option<String>,
available: bool,
/// idle | checking | downloading | installing
phase: String,
phase: Phase,
/// Время последней проверки, локальное.
checked_at: Option<String>,
error: Option<String>,
/// Идёт обновление — второй запуск не начинаем. В JSON не отдаём.
/// Когда в последний раз запускали установку.
#[serde(skip)]
busy: bool,
started_at: Option<Instant>,
}
impl UpdateStatus {
pub fn new() -> Self {
Self {
current: CURRENT_VERSION.to_string(),
current: CURRENT_VERSION,
latest: None,
available: false,
phase: "idle".to_string(),
phase: Phase::Idle,
checked_at: None,
error: None,
busy: false,
started_at: None,
}
}
}
@@ -79,24 +104,32 @@ struct Asset {
browser_download_url: String,
}
/// Найденное обновление: разобранная версия, исходный тег и ссылка на установщик.
struct Update {
/// Последний релиз: тег как есть, новее ли он текущей версии и ссылка на
/// установщик, если он в релизе приложен.
struct Latest {
tag: String,
installer_url: String,
newer: bool,
installer_url: Option<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())
/// Клиент переживает процесс: пересобирать его на каждую проверку значит
/// каждый раз заново разбирать корневые сертификаты.
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)
}
/// Возвращает `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");
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)
@@ -111,34 +144,27 @@ async fn fetch_update(client: &reqwest::Client) -> Result<Option<Update>, String
.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)
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}"))?;
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(),
}))
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),
})
}
/// Куда кладём скачанный установщик. Совпадает с путём задачи в `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")
/* ── загрузка и установка ───────────────────────────────────── */
/// Каталог загрузки: писать сюда может только служба и администраторы.
fn download_path() -> PathBuf {
crate::data_dir().join("update").join("setup.exe")
}
async fn download(client: &reqwest::Client, url: &str, dest: &PathBuf) -> Result<(), String> {
@@ -159,161 +185,138 @@ async fn download(client: &reqwest::Client, url: &str, dest: &PathBuf) -> Result
std::fs::write(dest, &bytes).map_err(|e| e.to_string())
}
/// Запускает установщик повышенно и тихо через заранее зарегистрированную задачу.
/// Если задачи нет (например, портативный запуск) — стартуем напрямую, тогда
/// Windows покажет запрос UAC.
fn launch_installer(dest: &PathBuf) -> Result<(), String> {
/// Запускает установщик и сразу отпускает: он остановит службу, то есть нас,
/// и ждать его завершения бессмысленно. Дочерний процесс переживает родителя.
fn launch_installer(installer: &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",
])
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 _ = (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();
let _ = installer;
Err("установка поддерживается только на Windows".to_string())
}
}
/* ── оркестрация ────────────────────────────────────────────── */
fn now_string() -> String {
chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
crate::stamp()
}
/// Одна проверка: смотрим релиз и, если версия новее, скачиваем и запускаем.
pub async fn check_now(state: Shared, client: reqwest::Client) {
/// Проверяет версию и, если вышла новее, ставит её.
/// `force` — нажатие кнопки в панели: игнорирует паузу между попытками.
pub async fn check(state: Shared, force: bool) {
{
let mut s = state.update.lock().await;
if s.busy {
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;
}
s.phase = "checking".to_string();
s.error = None;
status.phase = Phase::Checking;
}
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 http = client();
let fetched = match http {
Ok(client) => fetch_latest(client).await,
Err(ref e) => Err(e.clone()),
};
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 latest = {
let mut status = state.update.lock().await;
status.checked_at = Some(now_string());
{
let mut s = state.update.lock().await;
s.phase = "installing".to_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
}
// сперва планируем перезапуск, потом запускаем установщик: он вот-вот
// закроет этот процесс
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);
Err(e) => {
status.phase = Phase::Idle;
status.error = Some(e.clone());
drop(status);
state
.log(format!("обновление: не удалось запустить установщик{e}"))
.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;
}
};
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_now(state.clone(), client.clone()).await;
check(state.clone(), false).await;
tokio::time::sleep(CHECK_INTERVAL).await;
}
}