feat: initial commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
/dist
|
||||
|
||||
# локальное состояние, а не исходники
|
||||
rules.json
|
||||
|
||||
.DS_Store
|
||||
Generated
+1747
File diff suppressed because it is too large
Load Diff
+22
@@ -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"
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
+276
@@ -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');
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user