feat: initial commit

This commit is contained in:
2026-08-02 13:47:00 +07:00
commit 32d2553380
9 changed files with 3078 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
name: CI
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
name: Build
runs-on: windows-latest
permissions:
contents: write # publish release on tag
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Resolve version
id: meta
shell: pwsh
run: |
if ($env:GITHUB_REF -like 'refs/tags/v*') {
$version = $env:GITHUB_REF_NAME -replace '^v', ''
} else {
$version = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' |
Select-Object -First 1).Matches[0].Groups[1].Value
}
"version=$version" >> $env:GITHUB_OUTPUT
Write-Host "version: $version"
- name: Build
run: cargo build --release --locked
# Install unconditionally: the script uses ArchitecturesAllowed=x64compatible,
# which needs Inno Setup 6.3+. The version in the runner image is not guaranteed.
- name: Install Inno Setup
shell: pwsh
run: choco install innosetup --no-progress -y
- name: Build installer
shell: pwsh
run: |
$iscc = Get-ChildItem -ErrorAction SilentlyContinue -Path @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe"
) | Select-Object -First 1
if (-not $iscc) { throw "ISCC.exe not found" }
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item target\release\syshelper.exe dist\
& $iscc.FullName /DAppVersion=${{ steps.meta.outputs.version }} installer\setup.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC exited with $LASTEXITCODE" }
Get-ChildItem dist | Format-Table Name, Length
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: syshelper-${{ steps.meta.outputs.version }}-windows-x64
path: dist/*.exe
if-no-files-found: error
retention-days: 30
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$files = (Get-ChildItem dist\*.exe).FullName
gh release create $env:GITHUB_REF_NAME $files --generate-notes
+7
View File
@@ -0,0 +1,7 @@
/target
/dist
# локальное состояние, а не исходники
rules.json
.DS_Store
Generated
+1747
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "syshelper"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sysinfo = "0.30"
rand = "0.8"
chrono = "0.4"
semver = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
+99
View File
@@ -0,0 +1,99 @@
; Установщик «System Helper» для Windows x64.
; Сборка: ISCC.exe /DAppVersion=0.1.0 installer\setup.iss
; Результат кладётся в dist\syshelper-<версия>-setup.exe
#ifndef AppVersion
#define AppVersion "0.0.0-dev"
#endif
#ifndef ExeSource
#define ExeSource "..\target\release\syshelper.exe"
#endif
#define AppName "System Helper"
#define AppExeName "syshelper.exe"
#define AppUrl "http://127.0.0.1:8787"
; Имя задачи автообновления — должно совпадать с TASK_NAME в src/update.rs.
#define TaskName "SysHelperUpdate"
[Setup]
; AppId определяет программу при обновлении и удалении — не менять между версиями.
AppId={{B3F1C0A2-7E5D-4A19-9C3B-2D8E6F4A1B57}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
DefaultDirName={autopf}\SysHelper
DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
UninstallDisplayName={#AppName}
UninstallDisplayIcon={app}\{#AppExeName}
OutputDir=..\dist
OutputBaseFilename=syshelper-{#AppVersion}-setup
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
; Ставим в 64-битный Program Files; программа собрана только под x64.
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; Program Files и автозапуск через HKLM требуют администратора.
PrivilegesRequired=admin
CloseApplications=yes
RestartApplications=no
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "autostart"; \
Description: "Запускать при входе в систему"; \
GroupDescription: "Дополнительно:"
Name: "desktopicon"; \
Description: "{cm:CreateDesktopIcon}"; \
GroupDescription: "Дополнительно:"; \
Flags: unchecked
[Dirs]
; Сюда работающая от пользователя программа кладёт скачанный установщик, а
; задача Планировщика (от SYSTEM) забирает его. Нужен доступ на запись обоим.
Name: "{commonappdata}\SysHelper\update"; Permissions: users-modify
[Files]
Source: "{#ExeSource}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{group}\Веб-панель"; Filename: "{#AppUrl}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Registry]
; Автозапуск для всех пользователей. Запускается без повышения прав —
; этого хватает, чтобы завершать процессы того же пользователя.
Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
ValueType: string; ValueName: "SysHelper"; \
ValueData: """{app}\{#AppExeName}"""; \
Flags: uninsdeletevalue; Tasks: autostart
[Run]
; Задача автообновления: программа скачивает свежий установщик и просит
; Планировщик запустить его от SYSTEM с наивысшими правами — так обновление
; ставится тихо, без запроса UAC. Своего расписания у задачи нет (SC ONCE в
; прошлом), её лишь запускают по требованию из программы.
Filename: "{sys}\schtasks.exe"; \
Parameters: "/Create /F /RU SYSTEM /RL HIGHEST /SC ONCE /SD 01/01/2000 /ST 00:00 /TN ""{#TaskName}"" /TR ""{commonappdata}\SysHelper\update\setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCANCEL"""; \
Flags: runhidden; StatusMsg: "Настройка автообновления..."
Filename: "{app}\{#AppExeName}"; \
Description: "Запустить {#AppName}"; \
Flags: nowait postinstall skipifsilent
Filename: "{#AppUrl}"; \
Description: "Открыть веб-панель"; \
Flags: shellexec nowait postinstall skipifsilent
[UninstallRun]
Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /F /TN ""{#TaskName}"""; \
Flags: runhidden; RunOnceId: "DelUpdateTask"
Filename: "{sys}\taskkill.exe"; Parameters: "/f /im {#AppExeName}"; \
Flags: runhidden; RunOnceId: "StopApp"
+475
View File
@@ -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
View File
@@ -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;
}
}
+276
View File
@@ -0,0 +1,276 @@
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
async function api(path, body) {
const init = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {};
const res = await fetch(path, init);
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json();
}
/* ── composables ───────────────────────────────────────────── */
function useRules() {
const rules = ref({ mode: 'blacklist', blocked: [], allowed: [], enforce: true });
const load = async () => { rules.value = await api('/rules'); };
const block = (name) => api('/rules/block', { name }).then(load);
const allow = (name) => api('/rules/allow', { name }).then(load);
const remove = (name) => api('/rules/remove', { name }).then(load);
// patch мержим в текущее состояние: сервер принимает объект правил целиком
const save = (patch) => api('/rules', { ...rules.value, ...patch }).then(load);
return { rules, load, block, allow, remove, save };
}
function useProcesses() {
const raw = ref([]);
const load = async () => { raw.value = await api('/processes'); };
const kill = (name) => api('/kill', { name }).then(load);
// одноимённые процессы схлопываем — иначе двадцать строк chrome.exe
const groups = computed(() => {
const map = new Map();
for (const p of raw.value) {
const key = p.name.toLowerCase();
if (!map.has(key)) map.set(key, { key, name: p.name, count: 0, mem: 0, blocked: p.blocked });
const g = map.get(key);
g.count += 1;
g.mem += p.memory_mb;
}
return [...map.values()].sort((a, b) => b.mem - a.mem);
});
return { groups, load, kill };
}
function useLog() {
const lines = ref([]);
const load = async () => { lines.value = await api('/log'); };
return { lines, load };
}
function useUpdate() {
const info = ref({ current: '', latest: null, available: false, phase: 'idle', checked_at: null, error: null });
const load = async () => { info.value = await api('/update'); };
// после ручной проверки состояние меняется не мгновенно — перечитываем чуть позже
const check = () => api('/update/check', {}).then(() => setTimeout(load, 1500));
return { info, load, check };
}
function usePolling(fn, ms) {
let id = null;
onMounted(() => { fn(); id = setInterval(fn, ms); });
onUnmounted(() => clearInterval(id));
}
/* ── компоненты ────────────────────────────────────────────── */
const NameChips = {
props: {
title: { type: String, required: true },
names: { type: Array, default: () => [] },
},
emits: ['remove'],
setup(props) {
return { sorted: computed(() => [...props.names].sort()) };
},
template: `
<div class="chips">
<div class="muted">{{ title }}</div>
<span v-if="!sorted.length" class="muted">пусто</span>
<span v-for="name in sorted" :key="name" class="chip">
{{ name }}
<button title="убрать из списка" @click="$emit('remove', name)">×</button>
</span>
</div>`,
};
const UpdateBar = {
props: { info: { type: Object, required: true } },
emits: ['check'],
setup(props) {
const busy = computed(() => ['checking', 'downloading', 'installing'].includes(props.info.phase));
const status = computed(() => {
const i = props.info;
const phases = { checking: 'проверка…', downloading: 'загрузка…', installing: 'установка…' };
if (phases[i.phase]) return phases[i.phase];
if (i.error) return 'не удалось проверить обновления';
if (i.available && i.latest && i.latest !== i.current) return 'доступна версия ' + i.latest;
return 'установлена последняя версия';
});
return { busy, status };
},
template: `
<section class="card">
<div class="row" style="justify-content:space-between">
<div>
<h2 style="margin:0">Версия {{ info.current || '—' }}</h2>
<span :class="info.error ? 'warn' : 'muted'">{{ status }}</span>
</div>
<button :disabled="busy" @click="$emit('check')">Проверить обновления</button>
</div>
</section>`,
};
const RulesPanel = {
components: { NameChips },
props: { rules: { type: Object, required: true } },
emits: ['update', 'remove'],
template: `
<section class="card">
<h2>Правила</h2>
<div class="row">
<label>Режим:
<select :value="rules.mode" @change="$emit('update', { mode: $event.target.value })">
<option value="blacklist">чёрный список</option>
<option value="whitelist">белый список</option>
</select>
</label>
<label>
<input type="checkbox" :checked="rules.enforce"
@change="$emit('update', { enforce: $event.target.checked })">
применять правила
</label>
</div>
<p v-if="rules.mode === 'whitelist' && !rules.allowed.length" class="warn">
Белый список пуст — сервер намеренно ничего не завершает, иначе снесло бы систему.
</p>
<name-chips title="Заблокированы" :names="rules.blocked" @remove="$emit('remove', $event)" />
<name-chips title="Разрешены" :names="rules.allowed" @remove="$emit('remove', $event)" />
</section>`,
};
const RandomKill = {
emits: ['schedule'],
setup(_, { emit }) {
const name = ref('');
const min = ref(5);
const max = ref(30);
const submit = () => {
const value = name.value.trim();
if (!value) return;
emit('schedule', {
name: value,
min_secs: Math.max(0, min.value) * 60,
max_secs: Math.max(1, max.value) * 60,
});
name.value = '';
};
return { name, min, max, submit };
},
template: `
<section class="card">
<h2>Случайное завершение</h2>
<div class="row">
<input v-model="name" placeholder="game.exe" style="flex:1;min-width:140px"
@keyup.enter="submit">
<input v-model.number="min" type="number" min="0" style="width:70px"> —
<input v-model.number="max" type="number" min="1" style="width:70px">
<span class="muted">мин</span>
<button @click="submit">Запланировать</button>
</div>
</section>`,
};
const ProcessTable = {
props: {
groups: { type: Array, default: () => [] },
whitelist: { type: Boolean, default: false },
},
emits: ['toggle', 'kill'],
setup(props) {
const filter = ref('');
const visible = computed(() => {
const q = filter.value.trim().toLowerCase();
return q ? props.groups.filter((g) => g.key.includes(q)) : props.groups;
});
return { filter, visible };
},
template: `
<section class="card">
<h2>Процессы</h2>
<div class="row" style="margin-bottom:10px">
<input v-model="filter" placeholder="фильтр по имени" style="flex:1">
<span class="muted">{{ visible.length }} из {{ groups.length }}</span>
</div>
<div class="scroll">
<table>
<thead>
<tr><th>Имя</th><th>Экз.</th><th>Память</th><th></th></tr>
</thead>
<tbody>
<tr v-for="g in visible" :key="g.key" :class="{ blocked: g.blocked }">
<td>{{ g.name }}</td>
<td>{{ g.count }}</td>
<td>{{ g.mem }} МБ</td>
<td class="right">
<button @click="$emit('toggle', g)">
{{ g.blocked ? (whitelist ? 'Внести в белый список' : 'Разрешить') : 'Блокировать' }}
</button>
<button class="danger" style="margin-left:6px" @click="$emit('kill', g.name)">
Убить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>`,
};
const EventLog = {
props: { lines: { type: Array, default: () => [] } },
setup(props) {
return { recent: computed(() => props.lines.slice(-40).reverse().join('\n')) };
},
template: `
<section class="card">
<h2>Журнал</h2>
<pre>{{ recent || 'пусто' }}</pre>
</section>`,
};
/* ── корневой компонент ────────────────────────────────────── */
const App = {
components: { UpdateBar, RulesPanel, RandomKill, ProcessTable, EventLog },
setup() {
const { rules, load: loadRules, block, allow, remove, save } = useRules();
const { groups, load: loadProcesses, kill } = useProcesses();
const { lines, load: loadLog } = useLog();
const { info: update, load: loadUpdate, check: checkUpdate } = useUpdate();
onMounted(loadRules);
usePolling(() => { loadProcesses(); loadLog(); loadUpdate(); }, 3000);
// в whitelist «заблокирован» означает отсутствие в allowed — действия зеркальные
const toggle = async (g) => {
if (rules.value.mode === 'whitelist') {
await (g.blocked ? allow(g.name) : remove(g.name));
} else {
await (g.blocked ? remove(g.name) : block(g.name));
}
loadProcesses();
};
const scheduleRandom = async (payload) => {
await api('/kill/random', payload);
loadLog();
};
const whitelist = computed(() => rules.value.mode === 'whitelist');
return { rules, groups, lines, update, whitelist, save, remove, toggle, kill, scheduleRandom, checkUpdate };
},
template: `
<h1>Родительский контроль</h1>
<update-bar :info="update" @check="checkUpdate" />
<rules-panel :rules="rules" @update="save" @remove="remove" />
<random-kill @schedule="scheduleRandom" />
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
<event-log :lines="lines" />`,
};
createApp(App).mount('#app');
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Родительский контроль</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
[v-cloak] { display: none; }
body { margin:0; padding:16px; background:#14161a; color:#e6e8eb;
font:14px/1.5 system-ui, "Segoe UI", sans-serif; }
h1 { font-size:18px; margin:0 0 16px; }
h2 { font-size:14px; margin:0 0 10px; color:#9aa4b2; font-weight:600; }
.card { background:#1c1f26; border:1px solid #2a2f3a; border-radius:10px;
padding:14px; margin-bottom:14px; }
button { background:#2a2f3a; color:#e6e8eb; border:1px solid #3a4150;
border-radius:6px; padding:4px 10px; cursor:pointer; font-size:13px; }
button:hover { background:#343a47; }
button.danger { border-color:#6b2b2b; color:#ff9b9b; }
button.danger:hover { background:#3a1f1f; }
input, select { background:#14161a; color:#e6e8eb; border:1px solid #3a4150;
border-radius:6px; padding:5px 8px; font-size:13px; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; color:#9aa4b2; font-weight:500; padding:4px 6px;
border-bottom:1px solid #2a2f3a; font-size:12px; }
td { padding:4px 6px; border-bottom:1px solid #22262f; }
tr.blocked td:first-child { color:#ff9b9b; }
.scroll { max-height:340px; overflow:auto; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.chips { margin-top:12px; }
.chip { display:inline-flex; align-items:center; gap:4px; background:#232833;
border:1px solid #3a4150; border-radius:20px; padding:3px 6px 3px 12px;
margin:6px 6px 0 0; font-size:13px; }
.chip button { border:0; background:none; color:#9aa4b2; padding:0 4px; }
.right { text-align:right; white-space:nowrap; }
.muted { color:#6b7484; }
.warn { color:#e0b872; }
pre { margin:0; font:12px/1.6 ui-monospace, Consolas, monospace; color:#9aa4b2;
white-space:pre-wrap; max-height:200px; overflow:auto; }
</style>
</head>
<body>
<div id="app" v-cloak></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/app.js"></script>
</body>
</html>