feat: initial commit
This commit is contained in:
+475
@@ -0,0 +1,475 @@
|
||||
//! Родительский контроль для Windows.
|
||||
//! Веб-интерфейс и API на http://127.0.0.1:8787
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::header,
|
||||
response::{Html, IntoResponse},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
use sysinfo::{Pid, System};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
mod update;
|
||||
|
||||
const SCAN_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const LOG_LIMIT: usize = 500;
|
||||
|
||||
// фронт вшивается в exe на этапе компиляции
|
||||
const INDEX_HTML: &str = include_str!("../static/index.html");
|
||||
const APP_JS: &str = include_str!("../static/app.js");
|
||||
|
||||
/// Нельзя убивать никогда — иначе BSOD или сломанный рабочий стол.
|
||||
const PROTECTED: &[&str] = &[
|
||||
"system", "system idle process", "registry", "memory compression",
|
||||
"smss.exe", "csrss.exe", "wininit.exe", "winlogon.exe", "services.exe",
|
||||
"lsass.exe", "lsaiso.exe", "svchost.exe", "dwm.exe", "explorer.exe",
|
||||
"fontdrvhost.exe", "sihost.exe", "ctfmon.exe", "taskhostw.exe",
|
||||
"runtimebroker.exe", "conhost.exe", "spoolsv.exe", "audiodg.exe",
|
||||
"searchindexer.exe", "searchhost.exe", "shellexperiencehost.exe",
|
||||
"startmenuexperiencehost.exe", "wudfhost.exe", "syshelper.exe",
|
||||
];
|
||||
|
||||
fn is_protected(name_lower: &str) -> bool {
|
||||
PROTECTED.contains(&name_lower)
|
||||
}
|
||||
|
||||
/// Правила храним в профиле пользователя, а не рядом с exe: установленная
|
||||
/// программа лежит в Program Files, куда без прав администратора не записать.
|
||||
fn rules_path() -> &'static PathBuf {
|
||||
static PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
PATH.get_or_init(|| {
|
||||
let base = std::env::var_os("APPDATA") // Windows
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")));
|
||||
|
||||
match base {
|
||||
Some(dir) => {
|
||||
let dir = dir.join("SysHelper");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.join("rules.json")
|
||||
}
|
||||
// без домашнего каталога остаётся только рабочая директория
|
||||
None => PathBuf::from("rules.json"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ── модель данных ─────────────────────────────────────────── */
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Mode {
|
||||
Blacklist,
|
||||
Whitelist,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Rules {
|
||||
mode: Mode,
|
||||
blocked: HashSet<String>, // всегда в нижнем регистре
|
||||
allowed: HashSet<String>,
|
||||
enforce: bool,
|
||||
}
|
||||
|
||||
impl Default for Rules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: Mode::Blacklist,
|
||||
blocked: HashSet::new(),
|
||||
allowed: HashSet::new(),
|
||||
enforce: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
fn load() -> Self {
|
||||
std::fs::read_to_string(rules_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
if let Ok(s) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(rules_path(), s);
|
||||
}
|
||||
}
|
||||
|
||||
fn violates(&self, name_lower: &str) -> bool {
|
||||
match self.mode {
|
||||
Mode::Blacklist => self.blocked.contains(name_lower),
|
||||
Mode::Whitelist => !self.allowed.contains(name_lower),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProcInfo {
|
||||
pid: u32,
|
||||
name: String,
|
||||
exe: Option<String>,
|
||||
memory_mb: u64,
|
||||
blocked: bool,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
rules: Mutex<Rules>,
|
||||
sys: Mutex<System>,
|
||||
log: Mutex<Vec<String>>,
|
||||
update: Mutex<update::UpdateStatus>,
|
||||
}
|
||||
|
||||
type Shared = Arc<AppState>;
|
||||
|
||||
impl AppState {
|
||||
async fn log(&self, msg: impl Into<String>) {
|
||||
let line = format!(
|
||||
"[{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
msg.into()
|
||||
);
|
||||
println!("{line}");
|
||||
let mut log = self.log.lock().await;
|
||||
log.push(line);
|
||||
if log.len() > LOG_LIMIT {
|
||||
let extra = log.len() - LOG_LIMIT;
|
||||
log.drain(0..extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── завершение процессов ──────────────────────────────────── */
|
||||
|
||||
/// Убивает все процессы с данным именем, возвращает убитые PID.
|
||||
async fn kill_by_name(state: &Shared, name: &str) -> Vec<u32> {
|
||||
let target = name.trim().to_lowercase();
|
||||
if is_protected(&target) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let me = std::process::id();
|
||||
let mut killed = Vec::new();
|
||||
|
||||
{
|
||||
let mut sys = state.sys.lock().await;
|
||||
sys.refresh_processes();
|
||||
for (pid, proc_) in sys.processes() {
|
||||
let pid_u32 = pid.as_u32();
|
||||
if pid_u32 == me {
|
||||
continue;
|
||||
}
|
||||
if proc_.name().to_lowercase() == target && proc_.kill() {
|
||||
killed.push(pid_u32);
|
||||
}
|
||||
}
|
||||
} // гвард дропаем до await ниже
|
||||
|
||||
for pid in &killed {
|
||||
state.log(format!("убит {target} (PID {pid})")).await;
|
||||
}
|
||||
killed
|
||||
}
|
||||
|
||||
/// Раз в SCAN_INTERVAL применяет правила ко всем процессам.
|
||||
async fn watchdog(state: Shared) {
|
||||
loop {
|
||||
tokio::time::sleep(SCAN_INTERVAL).await;
|
||||
|
||||
let rules = state.rules.lock().await.clone();
|
||||
if !rules.enforce {
|
||||
continue;
|
||||
}
|
||||
// whitelist с пустым списком вынес бы всю систему
|
||||
if rules.mode == Mode::Whitelist && rules.allowed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let me = std::process::id();
|
||||
let mut killed: Vec<(String, u32)> = Vec::new();
|
||||
|
||||
{
|
||||
let mut sys = state.sys.lock().await;
|
||||
sys.refresh_processes();
|
||||
for (pid, proc_) in sys.processes() {
|
||||
let pid_u32 = pid.as_u32();
|
||||
let name = proc_.name().to_lowercase();
|
||||
if pid_u32 == me || is_protected(&name) {
|
||||
continue;
|
||||
}
|
||||
if rules.violates(&name) && proc_.kill() {
|
||||
killed.push((name, pid_u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (name, pid) in killed {
|
||||
state
|
||||
.log(format!("правило сработало: {name} (PID {pid}) остановлен"))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── статика ───────────────────────────────────────────────── */
|
||||
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(INDEX_HTML)
|
||||
}
|
||||
|
||||
async fn app_js() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/javascript; charset=utf-8")],
|
||||
APP_JS,
|
||||
)
|
||||
}
|
||||
|
||||
/* ── HTTP-обработчики ──────────────────────────────────────── */
|
||||
|
||||
async fn list_processes(State(state): State<Shared>) -> Json<Vec<ProcInfo>> {
|
||||
let rules = state.rules.lock().await.clone();
|
||||
let mut sys = state.sys.lock().await;
|
||||
sys.refresh_processes();
|
||||
|
||||
let mut list: Vec<ProcInfo> = sys
|
||||
.processes()
|
||||
.iter()
|
||||
.map(|(pid, p)| {
|
||||
let name = p.name().to_string();
|
||||
let lower = name.to_lowercase();
|
||||
ProcInfo {
|
||||
pid: pid.as_u32(),
|
||||
blocked: !is_protected(&lower) && rules.violates(&lower),
|
||||
name,
|
||||
exe: p.exe().map(|path| path.display().to_string()),
|
||||
memory_mb: p.memory() / 1024 / 1024,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
list.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then(a.pid.cmp(&b.pid))
|
||||
});
|
||||
Json(list)
|
||||
}
|
||||
|
||||
async fn get_rules(State(state): State<Shared>) -> Json<Rules> {
|
||||
Json(state.rules.lock().await.clone())
|
||||
}
|
||||
|
||||
async fn set_rules(
|
||||
State(state): State<Shared>,
|
||||
Json(mut new_rules): Json<Rules>,
|
||||
) -> Json<serde_json::Value> {
|
||||
new_rules.blocked = new_rules.blocked.iter().map(|s| s.to_lowercase()).collect();
|
||||
new_rules.allowed = new_rules.allowed.iter().map(|s| s.to_lowercase()).collect();
|
||||
{
|
||||
let mut rules = state.rules.lock().await;
|
||||
*rules = new_rules;
|
||||
rules.save();
|
||||
}
|
||||
state.log("правила обновлены").await;
|
||||
Json(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NameReq {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn rule_block(
|
||||
State(state): State<Shared>,
|
||||
Json(req): Json<NameReq>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let name = req.name.trim().to_lowercase();
|
||||
if is_protected(&name) {
|
||||
return Json(json!({ "ok": false, "error": "системный процесс" }));
|
||||
}
|
||||
{
|
||||
let mut rules = state.rules.lock().await;
|
||||
rules.blocked.insert(name.clone());
|
||||
rules.allowed.remove(&name);
|
||||
rules.save();
|
||||
}
|
||||
state.log(format!("в чёрный список: {name}")).await;
|
||||
Json(json!({ "ok": true, "name": name }))
|
||||
}
|
||||
|
||||
async fn rule_allow(
|
||||
State(state): State<Shared>,
|
||||
Json(req): Json<NameReq>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let name = req.name.trim().to_lowercase();
|
||||
{
|
||||
let mut rules = state.rules.lock().await;
|
||||
rules.allowed.insert(name.clone());
|
||||
rules.blocked.remove(&name);
|
||||
rules.save();
|
||||
}
|
||||
state.log(format!("в белый список: {name}")).await;
|
||||
Json(json!({ "ok": true, "name": name }))
|
||||
}
|
||||
|
||||
async fn rule_remove(
|
||||
State(state): State<Shared>,
|
||||
Json(req): Json<NameReq>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let name = req.name.trim().to_lowercase();
|
||||
{
|
||||
let mut rules = state.rules.lock().await;
|
||||
rules.blocked.remove(&name);
|
||||
rules.allowed.remove(&name);
|
||||
rules.save();
|
||||
}
|
||||
Json(json!({ "ok": true, "name": name }))
|
||||
}
|
||||
|
||||
async fn kill_now(
|
||||
State(state): State<Shared>,
|
||||
Json(req): Json<NameReq>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let killed = kill_by_name(&state, &req.name).await;
|
||||
Json(json!({ "ok": !killed.is_empty(), "killed": killed }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PidReq {
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
async fn kill_pid(State(state): State<Shared>, Json(req): Json<PidReq>) -> Json<serde_json::Value> {
|
||||
let mut sys = state.sys.lock().await;
|
||||
sys.refresh_processes();
|
||||
|
||||
let result = match sys.process(Pid::from_u32(req.pid)) {
|
||||
Some(p) if is_protected(&p.name().to_lowercase()) => {
|
||||
json!({ "ok": false, "error": "системный процесс" })
|
||||
}
|
||||
Some(p) => json!({ "ok": p.kill(), "name": p.name() }),
|
||||
None => json!({ "ok": false, "error": "процесс не найден" }),
|
||||
};
|
||||
drop(sys);
|
||||
|
||||
if result["ok"] == json!(true) {
|
||||
state.log(format!("ручное завершение PID {}", req.pid)).await;
|
||||
}
|
||||
Json(result)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RandomKillReq {
|
||||
name: String,
|
||||
#[serde(default = "default_min")]
|
||||
min_secs: u64,
|
||||
#[serde(default = "default_max")]
|
||||
max_secs: u64,
|
||||
}
|
||||
fn default_min() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_max() -> u64 {
|
||||
1800
|
||||
}
|
||||
|
||||
/// Завершение в случайный момент внутри окна [min, max].
|
||||
async fn kill_random(
|
||||
State(state): State<Shared>,
|
||||
Json(req): Json<RandomKillReq>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let min = req.min_secs;
|
||||
let max = req.max_secs.max(min + 1);
|
||||
|
||||
// ThreadRng не Send — держать его через .await компилятор не даст
|
||||
let delay = {
|
||||
let mut rng = rand::thread_rng();
|
||||
rng.gen_range(min..=max)
|
||||
};
|
||||
|
||||
let name = req.name.clone();
|
||||
let st = Arc::clone(&state);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||
let killed = kill_by_name(&st, &name).await;
|
||||
if killed.is_empty() {
|
||||
st.log(format!("случайное завершение: {name} уже не запущен"))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
state
|
||||
.log(format!(
|
||||
"запланировано случайное завершение {} через {delay} с",
|
||||
req.name
|
||||
))
|
||||
.await;
|
||||
Json(json!({ "ok": true, "name": req.name, "delay_secs": delay }))
|
||||
}
|
||||
|
||||
async fn get_log(State(state): State<Shared>) -> Json<Vec<String>> {
|
||||
Json(state.log.lock().await.clone())
|
||||
}
|
||||
|
||||
async fn update_status(State(state): State<Shared>) -> Json<update::UpdateStatus> {
|
||||
Json(state.update.lock().await.clone())
|
||||
}
|
||||
|
||||
async fn update_check(State(state): State<Shared>) -> Json<serde_json::Value> {
|
||||
// не блокируем ответ: проверка и возможная установка идут в фоне
|
||||
tokio::spawn(update::check_spawn(Arc::clone(&state)));
|
||||
Json(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/* ── точка входа ───────────────────────────────────────────── */
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let state: Shared = Arc::new(AppState {
|
||||
rules: Mutex::new(Rules::load()),
|
||||
sys: Mutex::new(System::new_all()),
|
||||
log: Mutex::new(Vec::new()),
|
||||
update: Mutex::new(update::UpdateStatus::new()),
|
||||
});
|
||||
|
||||
tokio::spawn(watchdog(Arc::clone(&state)));
|
||||
tokio::spawn(update::run(Arc::clone(&state)));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/app.js", get(app_js))
|
||||
.route("/processes", get(list_processes))
|
||||
.route("/rules", get(get_rules).post(set_rules))
|
||||
.route("/rules/block", post(rule_block))
|
||||
.route("/rules/allow", post(rule_allow))
|
||||
.route("/rules/remove", post(rule_remove))
|
||||
.route("/kill", post(kill_now))
|
||||
.route("/kill/pid", post(kill_pid))
|
||||
.route("/kill/random", post(kill_random))
|
||||
.route("/log", get(get_log))
|
||||
.route("/update", get(update_status))
|
||||
.route("/update/check", post(update_check))
|
||||
.with_state(Arc::clone(&state));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:8787")
|
||||
.await
|
||||
.expect("порт 8787 занят");
|
||||
|
||||
state.log("сервер запущен на http://127.0.0.1:8787").await;
|
||||
state
|
||||
.log(format!("правила: {}", rules_path().display()))
|
||||
.await;
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
//! Автообновление через 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user