6 Commits
Author SHA1 Message Date
maeneko 88f8819570 Версия 0.5.1
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
2026-09-03 08:20:46 +03:00
maeneko 9288fc95cb Сравнивать бинарники службы по содержимому и ускорить опрос службы
fetch-zapret.mjs кладёт файлы через copyFile, а тот ставит текущее время:
после каждой сборки CI mtime новее при тех же байтах. Сравнение по времени
требовало UAC при каждом обновлении приложения и упиралось в занятый
WinDivert64.sys, ещё загруженный в ядро. Теперь сравниваем SHA-256, а если
файл всё же занят — уводим его в сторону переименованием.

Ожидание состояния службы переехало внутрь одного скрипта PowerShell: раньше
каждый опрос был отдельным процессом (~1 с на подъём .NET) с интервалом
300 мс, из чего и складывалась основная задержка включения.
2026-09-03 08:20:35 +03:00
maeneko beced42f2f Создавать релиз до сборки: чинит гонку двух publisher'ов
electron-builder заливает .exe и .blockmap параллельно, оба проверяют наличие
релиза, оба видят «нет» и оба его создают — второй падает с 422 already_exists
и уносит с собой свой файл. На v0.5.0 так потерялся сам .exe.
2026-09-03 08:06:31 +03:00
maeneko e8f03a2f86 Версия 0.5.0
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
2026-09-03 07:57:17 +03:00
maeneko cb499a09d1 Сохранять выбор стратегии и автозапуска между запусками
Настройки лежат в settings.json (userData) и восстанавливаются до открытия
окна. Списки сайтов уже хранятся в userData/zapret-lists и отдельного
сохранения не требуют.
2026-09-03 07:57:12 +03:00
maeneko 563d8b7292 update: обновления 0.5.0
- Исправлен баг при переподключении "Не удалось настроить службу: Процесс не может получить доступ к файлу "C:\ProgramData\rknboost\zapret\WinDivert64.sys", так как этот файл используется другим процессом."

- Теперь проверка дает результат сразу асихронно, а не ожидая ответа от всех.

- Также программа сохраняет что было выбрано при последнем запуске
2026-09-03 07:52:41 +03:00
17 changed files with 460 additions and 106 deletions
+50 -2
View File
@@ -34,6 +34,10 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# fetch-depth: 0 — шагу «Описание релиза» ниже нужны вся история и теги,
# чтобы найти предыдущий vX.Y.Z и собрать список коммитов после него.
fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
@@ -45,17 +49,61 @@ jobs:
# electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст. # electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст.
- run: node scripts/fetch-zapret.mjs - run: node scripts/fetch-zapret.mjs
# electron-builder заливает .exe и .blockmap двумя параллельными publisher'ами: оба
# проверяют, есть ли релиз с этим тегом, оба видят «нет» и оба лезут его создавать —
# второй падает с 422 already_exists и уносит с собой свой файл (на v0.5.0 так потерялся
# сам .exe). Создаём релиз заранее, тогда обоим остаётся только залить в готовый.
# Описание пустое — его заполняет шаг «Описание релиза» после сборки.
- name: Заготовка релиза
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 ||
gh release create "$GITHUB_REF_NAME" --title "${GITHUB_REF_NAME#v}" --notes ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Подпись не настроена: артефакты неподписанные. # Подпись не настроена: артефакты неподписанные.
# Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env. # Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env.
# #
# На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder публикует .exe в GitHub # На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder заливает .exe в релиз,
# Release с этим тегом (создаёт его, если нет), используя repository из package.json. # заготовленный шагом выше, используя repository из package.json.
# На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже. # На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже.
- name: Build - name: Build
run: npx --no-install electron-vite build && npx --no-install electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }} run: npx --no-install electron-vite build && npx --no-install electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Релиз создан с пустым описанием, поэтому список изменений дописываем сами:
# коммиты между предыдущим тегом vX.Y.Z и текущим.
- name: Описание релиза
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
# Предыдущий тег vX.Y.Z, начиная от родителя текущего. Пусто, если релиз первый.
PREV="$(git describe --tags --abbrev=0 --match 'v*' "$TAG^" 2>/dev/null || true)"
NOTES="$RUNNER_TEMP/release-notes.md"
{
if [ -n "$PREV" ]; then
echo "## Изменения с $PREV"
echo
git log --no-merges --pretty=format:'- %s (%h)' "$PREV..$TAG"
echo
echo
echo "**Полный diff:** https://github.com/$GITHUB_REPOSITORY/compare/$PREV...$TAG"
else
echo "## Изменения"
echo
git log --no-merges --pretty=format:'- %s (%h)' "$TAG"
echo
fi
} > "$NOTES"
gh release edit "$TAG" --notes-file "$NOTES"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rknboost-windows name: rknboost-windows
+7
View File
@@ -4,6 +4,13 @@ directories:
buildResources: build buildResources: build
output: release output: release
# releaseType: release — публиковать релиз сразу, без черновика (draft — поведение по
# умолчанию у electron-builder). С черновиком параллельная загрузка нескольких файлов иногда
# создаёт два черновика с одним и тем же тегом вместо одного релиза.
publish:
provider: github
releaseType: release
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rknboost", "name": "rknboost",
"version": "0.4.0", "version": "0.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rknboost", "name": "rknboost",
"version": "0.4.0", "version": "0.5.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "rknboost", "name": "rknboost",
"version": "0.4.0", "version": "0.5.1",
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript", "description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "RKNboost", "author": "RKNboost",
+8 -1
View File
@@ -66,7 +66,14 @@ export function registerIpcHandlers(): void {
handle('zapret:list-reset', (_event, id) => resetZapretList(id)) handle('zapret:list-reset', (_event, id) => resetZapretList(id))
handle('zapret:auto-hostlist', () => zapretAutoHostlist()) handle('zapret:auto-hostlist', () => zapretAutoHostlist())
handle('zapret:auto-hostlist-clear', () => clearZapretAutoHostlist()) handle('zapret:auto-hostlist-clear', () => clearZapretAutoHostlist())
handle('zapret:diagnose', () => zapretDiagnostics()) // Результаты уходят не ответом на invoke, а событиями по мере готовности — и именно
// в то окно, которое просило проверку, а не всем подряд. Окно могло закрыться, пока
// сайт отвечал (или молчал все 10 с таймаута), — тогда показывать результат уже некому.
handle('zapret:diagnose', (event) =>
zapretDiagnostics((result) => {
if (!event.sender.isDestroyed()) event.sender.send('zapret:diagnostic-result', result)
})
)
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) — // Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
// сообщаем всем открытым окнам. // сообщаем всем открытым окнам.
+76
View File
@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto'
import { readFile, rename, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { app } from 'electron'
/**
* Выбор пользователя между запусками: `settings.json` в userData (на Windows —
* `%AppData%\RKNboost`), поэтому переживает обновление и переустановку.
*
* Списки сайтов сюда не входят: они лежат рядом, в `userData/zapret-lists/*.txt`, и
* читаются прямо оттуда самими tpws и winws (см. `zapret/lists.ts`).
*
* `null` — «пользователь не выбирал», решает движок.
*/
export interface AppSettings {
strategyId: string | null
/** Windows: запускать службу вместе с системой. На macOS не используется. */
autoStart: boolean | null
}
const DEFAULTS: AppSettings = { strategyId: null, autoStart: null }
let cache: AppSettings = { ...DEFAULTS }
/** До первой загрузки не пишем: иначе движок затёр бы файл умолчаниями. */
let loaded = false
/** Очередь, чтобы два быстрых переключения не переставили rename местами. */
let writeQueue: Promise<void> = Promise.resolve()
function settingsPath(): string {
return join(app.getPath('userData'), 'settings.json')
}
/** Файл могли испортить или он остался от версии с другими полями — непонятное отбрасываем. */
function sanitize(raw: unknown): AppSettings {
const value = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
return {
strategyId: typeof value.strategyId === 'string' && value.strategyId ? value.strategyId : null,
autoStart: typeof value.autoStart === 'boolean' ? value.autoStart : null
}
}
/** Читает настройки с диска. Вызывать один раз при старте, до создания окна. */
export async function loadSettings(): Promise<AppSettings> {
try {
cache = sanitize(JSON.parse(await readFile(settingsPath(), 'utf8')))
} catch {
cache = { ...DEFAULTS } // первый запуск или нечитаемый файл
}
loaded = true
return { ...cache }
}
/** Атомарная запись: временный файл рядом + rename, как в `zapret/lists.ts`. */
async function writeSettings(settings: AppSettings): Promise<void> {
const path = settingsPath()
const tmp = `${path}.${randomUUID()}.tmp`
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
await rename(tmp, path)
}
/**
* Вызывается из `ZapretEngine.patch()`, то есть на каждое изменение статуса — отсюда
* сравнение с прошлым значением. Записи не ждём: интерфейс не должен зависеть от диска,
* а не сохранившийся выбор не повод ломать работающий обход.
*/
export function rememberPreferences(settings: AppSettings): void {
if (!loaded) return
if (settings.strategyId === cache.strategyId && settings.autoStart === cache.autoStart) return
cache = settings
writeQueue = writeQueue
.then(() => writeSettings(settings))
.catch((error: unknown) => console.warn('[settings] не удалось сохранить:', error))
}
+2 -2
View File
@@ -29,8 +29,8 @@ export function createWindow(): BrowserWindow {
width: 440, width: 440,
height: 660, height: 660,
minWidth: 360, minWidth: 360,
// С запасом на самое высокое обычное состояние главного экрана (ошибка + три // С запасом на самое высокое обычное состояние главного экрана (ошибка + результаты
// результата проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте. // проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
minHeight: 560, minHeight: 560,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
+21 -15
View File
@@ -1,7 +1,11 @@
import { connect as netConnect, type Socket } from 'node:net' import { connect as netConnect, type Socket } from 'node:net'
import { connect as tlsConnect } from 'node:tls' import { connect as tlsConnect } from 'node:tls'
import { net } from 'electron' import { net } from 'electron'
import type { ZapretDiagnosticResult, ZapretStatus } from '../../shared/ipc-contract' import type {
ZapretDiagnosticResult,
ZapretDiagnosticTarget,
ZapretStatus
} from '../../shared/ipc-contract'
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения, // 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
// и медленный, но живой сайт успевал попасть в «Недоступно». // и медленный, но живой сайт успевал попасть в «Недоступно».
@@ -9,15 +13,8 @@ const TIMEOUT_MS = 10000
const TARGET_PORT = 443 const TARGET_PORT = 443
const RETRY_DELAY_MS = 300 const RETRY_DELAY_MS = 300
interface DiagnosticTarget {
id: string
label: string
host: string
}
/** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */ /** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */
const TARGETS: DiagnosticTarget[] = [ export const DIAGNOSTIC_TARGETS: ZapretDiagnosticTarget[] = [
{ id: 'instagram', label: 'Instagram', host: 'instagram.com' },
{ id: 'discord', label: 'Discord', host: 'discord.com' }, { id: 'discord', label: 'Discord', host: 'discord.com' },
{ id: 'youtube', label: 'YouTube', host: 'youtube.com' } { id: 'youtube', label: 'YouTube', host: 'youtube.com' }
] ]
@@ -178,12 +175,21 @@ async function withRetry(attempt: () => Promise<number>): Promise<number> {
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую: * остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
* на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает * на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает
* то, что есть сейчас. * то, что есть сейчас.
*
* Каждый сайт отдаёт свой результат в `onResult` сам по себе, не дожидаясь остальных:
* проверки идут параллельно и заканчиваются вразнобой (живой сайт отвечает за десятки
* миллисекунд, заблокированный — только по таймауту в 10 с), и общий ответ одним куском
* означал бы пустой экран всё это время. Промис резолвится, когда отработали все, —
* по нему вызывающий код понимает, что проверка закончена.
*/ */
export async function runDiagnostics(status: ZapretStatus): Promise<ZapretDiagnosticResult[]> { export async function runDiagnostics(
status: ZapretStatus,
onResult: (result: ZapretDiagnosticResult) => void
): Promise<void> {
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
return Promise.all( await Promise.all(
TARGETS.map(async (target) => { DIAGNOSTIC_TARGETS.map(async (target) => {
const attempt = (): Promise<number> => const attempt = (): Promise<number> =>
socksMatch socksMatch
? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2])) ? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2]))
@@ -191,14 +197,14 @@ export async function runDiagnostics(status: ZapretStatus): Promise<ZapretDiagno
try { try {
const ms = await withRetry(attempt) const ms = await withRetry(attempt)
return { ...target, ok: true, ms, error: null } onResult({ ...target, ok: true, ms, error: null })
} catch (error) { } catch (error) {
return { onResult({
...target, ...target,
ok: false, ok: false,
ms: null, ms: null,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} })
} }
}) })
) )
+21
View File
@@ -5,6 +5,8 @@ import type {
ZapretStatus, ZapretStatus,
ZapretState ZapretState
} from '../../shared/ipc-contract' } from '../../shared/ipc-contract'
import { rememberPreferences, type AppSettings } from '../settings'
/** Сколько строк вывода бинарника держим для UI. */ /** Сколько строк вывода бинарника держим для UI. */
const LOG_LIMIT = 200 const LOG_LIMIT = 200
@@ -31,6 +33,21 @@ export abstract class ZapretEngine {
return { ...this.status } return { ...this.status }
} }
/**
* Возвращает выбор пользователя из прошлого запуска (`settings.json` в userData) —
* вызывается из `recoverZapret()` до открытия окна. Ничего не запускает: восстанавливаем
* только то, что было выбрано в интерфейсе, а включает обход по-прежнему сам пользователь
* (или, на Windows, автозапуск службы).
*
* `null` в поле означает «не выбирали» — тогда остаётся то, что решил конструктор движка.
*/
restorePreferences(settings: AppSettings): void {
this.patch({
strategyId: settings.strategyId ?? this.status.strategyId,
autoStart: settings.autoStart ?? this.status.autoStart
})
}
getLog(): ZapretLogLine[] { getLog(): ZapretLogLine[] {
return [...this.log] return [...this.log]
} }
@@ -67,6 +84,10 @@ export abstract class ZapretEngine {
protected patch(changes: Partial<ZapretStatus>): ZapretStatus { protected patch(changes: Partial<ZapretStatus>): ZapretStatus {
this.status = { ...this.status, ...changes } this.status = { ...this.status, ...changes }
// Единая точка сохранения: любой путь смены стратегии или автозапуска (выбор в списке,
// start() с другой стратегией, подхваченное из системы состояние службы в sync())
// проходит через patch, так что забыть сохранить выбор здесь просто негде.
rememberPreferences({ strategyId: this.status.strategyId, autoStart: this.status.autoStart })
this.broadcast('zapret:status-changed', this.status) this.broadcast('zapret:status-changed', this.status)
return this.getStatus() return this.getStatus()
} }
+33 -10
View File
@@ -1,11 +1,18 @@
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { dirname, join } from 'node:path'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import type { ZapretStatus } from '../../shared/ipc-contract' import type { ZapretStatus } from '../../shared/ipc-contract'
import { ZapretEngine } from './engine' import { ZapretEngine } from './engine'
import { assertExecutable, protectedWinwsPath, serviceConfigPath, winwsPath } from './paths' import {
SERVICE_BINARY_NAMES,
assertExecutable,
serviceDataDir,
serviceConfigPath,
winwsPath
} from './paths'
import { import {
applyServiceConfig, applyServiceConfig,
queryService, queryService,
@@ -28,18 +35,34 @@ async function readInstalledMarkerLine(): Promise<string | null> {
} }
} }
async function sha256(path: string): Promise<string> {
return createHash('sha256')
.update(await readFile(path))
.digest('hex')
}
/** /**
* Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал * Служба запускает защищённую копию бинарников, а не оригинал из папки приложения — значит
* из папки установки приложения (winwsPath) — значит переустановку/обновление приложения * обновление по самому пути службы не отследить. Сравниваем содержимое: время изменения тут
* по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время * не годится совсем, `scripts/fetch-zapret.mjs` кладёт файлы через `copyFile`, а тот ставит
* изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло * текущее время — после каждой сборки CI mtime новее, хотя версия zapret запинована и байты
* позже последней копии, нужно копировать заново (а значит и UAC). * те же. По mtime мы на ровном месте требовали UAC при каждом обновлении приложения и лезли
* копировать поверх WinDivert64.sys, ещё загруженного в ядро.
*/ */
async function binariesNeedCopy(): Promise<boolean> { async function binariesNeedCopy(): Promise<boolean> {
const sourceDir = dirname(winwsPath())
const targetDir = serviceDataDir()
try { try {
const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())]) for (const name of SERVICE_BINARY_NAMES) {
return source.size !== copy.size || source.mtimeMs > copy.mtimeMs const [source, copy] = await Promise.all([
sha256(join(sourceDir, name)),
sha256(join(targetDir, name))
])
if (source !== copy) return true
}
return false
} catch { } catch {
// Какого-то файла ещё нет (первая установка) или он нечитаем — надёжнее скопировать.
return true return true
} }
} }
+40 -6
View File
@@ -1,13 +1,15 @@
import { readFile, rm } from 'node:fs/promises' import { readFile, rm } from 'node:fs/promises'
import type { import type {
ZapretDiagnosticResult, ZapretDiagnosticResult,
ZapretDiagnosticTarget,
ZapretList, ZapretList,
ZapretListId, ZapretListId,
ZapretLogLine, ZapretLogLine,
ZapretStatus, ZapretStatus,
ZapretStrategy ZapretStrategy
} from '../../shared/ipc-contract' } from '../../shared/ipc-contract'
import { runDiagnostics } from './diagnostics' import { loadSettings } from '../settings'
import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics'
import { DarwinEngine } from './engine.darwin' import { DarwinEngine } from './engine.darwin'
import { ZapretEngine, UnsupportedEngine } from './engine' import { ZapretEngine, UnsupportedEngine } from './engine'
import { Win32Engine } from './engine.win32' import { Win32Engine } from './engine.win32'
@@ -29,16 +31,35 @@ function createEngine(): ZapretEngine {
const engine = createEngine() const engine = createEngine()
/** /**
* Готовим списки сайтов и синхронизируем состояние с системой — на macOS чиним прокси * Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем
* от прошлого аварийного завершения, на Windows подхватываем службу, которая могла работать * состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows
* ещё до открытия окна (автозапуск с системой). Оба шага нужно сделать до открытия окна. * подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой).
* Все шаги нужно сделать до открытия окна.
*/ */
export async function recoverZapret(): Promise<void> { export async function recoverZapret(): Promise<void> {
await seedLists() await seedLists()
// Сохранённый выбор применяем ДО sync(): на Windows у уже установленной службы реальное
// состояние (стратегия в strategy.cfg, StartMode) важнее записанного нами и перекроет его.
const settings = await loadSettings()
engine.restorePreferences({
// Стратегия могла исчезнуть в обновлении (подборка Flowseal меняется) или остаться от
// запуска на другой платформе — тогда безопаснее вернуться к умолчанию движка, чем
// стартовать с id, которого больше нет.
strategyId: knownStrategyId(settings.strategyId),
autoStart: settings.autoStart
})
if (engine instanceof DarwinEngine) await engine.recoverFromCrash() if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
if (engine instanceof Win32Engine) await engine.sync() if (engine instanceof Win32Engine) await engine.sync()
} }
function knownStrategyId(strategyId: string | null): string | null {
if (!strategyId) return null
const known = listStrategies(process.platform).some((strategy) => strategy.id === strategyId)
return known ? strategyId : null
}
export function zapretStatus(): ZapretStatus { export function zapretStatus(): ZapretStatus {
return engine.getStatus() return engine.getStatus()
} }
@@ -105,6 +126,19 @@ export async function clearZapretAutoHostlist(): Promise<void> {
await rm(autoHostlistPath(), { force: true }) await rm(autoHostlistPath(), { force: true })
} }
export function zapretDiagnostics(): Promise<ZapretDiagnosticResult[]> { /**
return runDiagnostics(engine.getStatus()) * Запускает проверку и сразу возвращает список сайтов — результаты приходят в `onResult`
* по одному, по мере готовности (см. `runDiagnostics`). Промис проверки намеренно не ждём:
* ответ на IPC — это список сайтов, чтобы UI успел показать строки «Проверяю…» до того,
* как ответит первый сайт.
*/
export function zapretDiagnostics(
onResult: (result: ZapretDiagnosticResult) => void
): ZapretDiagnosticTarget[] {
// Каждая проверка ловит свою ошибку сама, наружу выбраться нечему — ловим здесь только
// чтобы неожиданное падение не осталось необработанным промисом.
runDiagnostics(engine.getStatus(), onResult).catch((error: unknown) => {
console.error('[zapret] проверка соединения завершилась ошибкой:', error)
})
return DIAGNOSTIC_TARGETS
} }
+13
View File
@@ -56,6 +56,19 @@ export function serviceConfigPath(): string {
return join(serviceDataDir(), 'strategy.cfg') return join(serviceDataDir(), 'strategy.cfg')
} }
/**
* Файлы, которые служба запускает из своего защищённого каталога: сам winws и всё, от чего
* он зависит при старте. Список нужен и `engine.win32.ts` (решить, надо ли копировать),
* и повышенному скрипту в `service.win32.ts` (собственно копирование) — держим его здесь,
* чтобы он не разъехался на две копии.
*/
export const SERVICE_BINARY_NAMES = [
'winws.exe',
'cygwin1.dll',
'WinDivert.dll',
'WinDivert64.sys'
] as const
/** /**
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё, * Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь * а не на оригинал из папки установки приложения, указывает служба. Установка теперь
+100 -29
View File
@@ -10,7 +10,7 @@ import {
isElevated, isElevated,
runElevatedServiceTask runElevatedServiceTask
} from './elevated-task.win32' } from './elevated-task.win32'
import { serviceConfigPath, serviceDataDir, winwsPath } from './paths' import { SERVICE_BINARY_NAMES, serviceConfigPath, serviceDataDir, winwsPath } from './paths'
const run = promisify(execFile) const run = promisify(execFile)
@@ -31,6 +31,8 @@ const ERROR_SERVICE_ALREADY_RUNNING = 1056
const ERROR_SERVICE_NOT_ACTIVE = 1062 const ERROR_SERVICE_NOT_ACTIVE = 1062
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other' export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
/** То, чего можно дождаться от службы. `Absent` — службы в системе нет (снята). */
type WaitState = 'Running' | 'Stopped' | 'Absent'
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other' export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
export interface ServiceInfo { export interface ServiceInfo {
@@ -40,10 +42,6 @@ export interface ServiceInfo {
binPath: string binPath: string
} }
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function exitCodeOf(error: unknown): number { function exitCodeOf(error: unknown): number {
const code = (error as { code?: number | string } | undefined)?.code const code = (error as { code?: number | string } | undefined)?.code
return typeof code === 'number' ? code : 1 return typeof code === 'number' ? code : 1
@@ -81,12 +79,29 @@ function normalizeStartMode(raw: string | undefined): ServiceStartMode {
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM * Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает * `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
* локализованный текст, который парсить надёжно нельзя. * локализованный текст, который парсить надёжно нельзя.
*
* Ждём тоже здесь, внутри одного скрипта: запуск powershell.exe стоит около секунды (он
* поднимает .NET), а опросов до перехода службы в нужное состояние бывает с десяток. Раньше
* каждый опрос был отдельным процессом, и включение обхода складывалось в основном из этих
* запусков; теперь на всё ожидание приходится один. Сам опрос внутри CIM — десятки мс.
*/ */
export async function queryService(): Promise<ServiceInfo | null> { function queryScript(until: readonly WaitState[], timeoutMs: number): string {
const script = return `
`Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` + $targets = @(${until.map((state) => `'${state}'`).join(',')})
'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress' $deadline = [DateTime]::UtcNow.AddMilliseconds(${timeoutMs})
for (;;) {
$svc = Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" |
Select-Object State,StartMode,PathName
$state = if ($svc) { $svc.State } else { 'Absent' }
if ($targets.Count -eq 0 -or $targets -contains $state) { break }
if ([DateTime]::UtcNow -ge $deadline) { break }
Start-Sleep -Milliseconds ${POLL_INTERVAL_MS}
}
if ($svc) { $svc | ConvertTo-Json -Compress } else { 'null' }
`
}
async function runQuery(script: string): Promise<ServiceInfo | null> {
let stdout: string let stdout: string
try { try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script])) ;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
@@ -108,6 +123,19 @@ export async function queryService(): Promise<ServiceInfo | null> {
} }
} }
/** Текущее состояние службы; `null` — службы в системе нет. */
export function queryService(): Promise<ServiceInfo | null> {
return runQuery(queryScript([], 0))
}
/**
* Ждёт одного из состояний и возвращает последнее увиденное — в том числе по таймауту,
* поэтому результат у вызывающего кода обязателен к проверке.
*/
function waitForService(...states: WaitState[]): Promise<ServiceInfo | null> {
return runQuery(queryScript(states, POLL_TIMEOUT_MS))
}
function explainScError(error: unknown, verb: string): string { function explainScError(error: unknown, verb: string): string {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
if (/access is denied/i.test(message)) { if (/access is denied/i.test(message)) {
@@ -119,18 +147,6 @@ function explainScError(error: unknown, verb: string): string {
return `Не удалось ${verb} службу: ${message}` return `Не удалось ${verb} службу: ${message}`
} }
async function pollUntil(
predicate: (info: ServiceInfo | null) => boolean
): Promise<ServiceInfo | null> {
const deadline = Date.now() + POLL_TIMEOUT_MS
for (;;) {
const info = await queryService()
if (predicate(info)) return info
if (Date.now() >= deadline) return info
await sleep(POLL_INTERVAL_MS)
}
}
/** /**
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не * `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился) * дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
@@ -147,7 +163,7 @@ export async function startService(): Promise<ServiceInfo | null> {
} }
} }
const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped') const info = await waitForService('Running', 'Stopped')
if (info?.state !== 'Running') { if (info?.state !== 'Running') {
throw new Error( throw new Error(
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` + `Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
@@ -169,12 +185,11 @@ export async function stopService(): Promise<ServiceInfo | null> {
throw new Error(explainScError(error, 'остановить'), { cause: error }) throw new Error(explainScError(error, 'остановить'), { cause: error })
} }
} }
// `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката, // Результат обязательно проверяем: «остановка», не уложившаяся в POLL_TIMEOUT_MS, иначе
// поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS, // молча сошла бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
// молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой // пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
// остановка и защищает. // остановка и защищает.
const info = await pollUntil((i) => i === null || i.state === 'Stopped') const info = await waitForService('Stopped', 'Absent')
if (info && info.state !== 'Stopped') { if (info && info.state !== 'Stopped') {
throw new Error( throw new Error(
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).` `Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
@@ -270,6 +285,46 @@ function Invoke-ScRaw($rawArgs) {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() } return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
} }
# WinDivert64.sys — образ драйвера, загруженного в ядро: пока драйвер не выгружен, файл
# занят, и Copy-Item поверх него падает с «файл используется другим процессом». SCM
# рапортует Stopped, как только winws.exe отдал сигнал, а выгрузка идёт уже после — при
# быстром переподключении попадаем ровно в этот зазор.
#
# Отсюда три ступени. Сравнение по ХЕШУ, а не по времени изменения: fetch-zapret.mjs кладёт
# бинарники через copyFile, тот ставит текущее время, и после каждой сборки CI mtime новее
# при тех же байтах — по времени мы лезли копировать занятый .sys после каждого обновления
# приложения. Дальше короткие повторы — вдруг драйвер вот-вот выгрузится. Последняя ступень:
# уводим занятый файл в сторону переименованием (NTFS разрешает переименовать загруженный
# образ, хотя удалить его не даёт) и кладём новый на освободившееся имя.
function Copy-IfChanged($source, $destination) {
if (Test-Path -LiteralPath $destination) {
$sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash
$copyHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash
if ($sourceHash -eq $copyHash) { return }
}
for ($attempt = 1; $attempt -le 6; $attempt++) {
try {
Copy-Item -LiteralPath $source -Destination $destination -Force
return
}
catch {
Start-Sleep -Milliseconds 500
}
}
try {
$stale = "$destination.old"
Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue
Move-Item -LiteralPath $destination -Destination $stale -Force
Copy-Item -LiteralPath $source -Destination $destination -Force
}
catch {
throw "Не удалось обновить $destination : $($_.Exception.Message)"
}
}
try { try {
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json $payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
@@ -298,10 +353,15 @@ try {
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске. # cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
$sourceDir = Split-Path -Parent $payload.winwsPath $sourceDir = Split-Path -Parent $payload.winwsPath
$protectedWinws = Join-Path $dir 'winws.exe' $protectedWinws = Join-Path $dir 'winws.exe'
foreach ($name in @('winws.exe', 'cygwin1.dll', 'WinDivert.dll', 'WinDivert64.sys')) { foreach ($name in $payload.binaryNames) {
Copy-Item -Path (Join-Path $sourceDir $name) -Destination (Join-Path $dir $name) -Force Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name)
} }
# Хвосты от прошлой перенастройки: тогда файл был занят загруженным драйвером, сейчас
# он, скорее всего, уже свободен. Не вышло — не беда, попробуем в следующий раз.
Get-ChildItem -LiteralPath $dir -Filter '*.old' -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару # sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет # кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639). # значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
@@ -346,8 +406,18 @@ try {
& sc.exe stop $ServiceName 2>$null | Out-Null & sc.exe stop $ServiceName 2>$null | Out-Null
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
& sc.exe delete $ServiceName | Out-Null & sc.exe delete $ServiceName | Out-Null
if (Test-Path $payload.serviceDataDir) { # Тот же занятый WinDivert64.sys, что и при install: сразу после остановки драйвер ещё
# загружен, и Remove-Item падает. Повторяем, а если так и не вышло — не проваливаем
# удаление: служба уже снята, а оставшиеся файлы система освободит и без нас.
for ($attempt = 1; $attempt -le 10; $attempt++) {
if (-not (Test-Path $payload.serviceDataDir)) { break }
try {
Remove-Item -Path $payload.serviceDataDir -Recurse -Force Remove-Item -Path $payload.serviceDataDir -Recurse -Force
break
}
catch {
Start-Sleep -Milliseconds 500
}
} }
Write-Result $true $null Write-Result $true $null
} }
@@ -456,6 +526,7 @@ export async function applyServiceConfig(options: ApplyServiceConfigOptions): Pr
configPath: serviceConfigPath(), configPath: serviceConfigPath(),
configBody: options.configBody, configBody: options.configBody,
winwsPath: winwsPath(), winwsPath: winwsPath(),
binaryNames: SERVICE_BINARY_NAMES,
autoStart: options.autoStart autoStart: options.autoStart
}) })
} }
+5 -1
View File
@@ -6,6 +6,7 @@ import type {
IpcHandlers, IpcHandlers,
ThemeState, ThemeState,
ZapretDiagnosticResult, ZapretDiagnosticResult,
ZapretDiagnosticTarget,
ZapretList, ZapretList,
ZapretListId, ZapretListId,
ZapretLogLine, ZapretLogLine,
@@ -71,7 +72,10 @@ const api = {
getZapretAutoHostlist: (): Promise<string[]> => invoke('zapret:auto-hostlist'), getZapretAutoHostlist: (): Promise<string[]> => invoke('zapret:auto-hostlist'),
clearZapretAutoHostlist: (): Promise<void> => invoke('zapret:auto-hostlist-clear'), clearZapretAutoHostlist: (): Promise<void> => invoke('zapret:auto-hostlist-clear'),
runZapretDiagnostics: (): Promise<ZapretDiagnosticResult[]> => invoke('zapret:diagnose') /** Запускает проверку и отдаёт список сайтов; результаты — через `onZapretDiagnosticResult`. */
runZapretDiagnostics: (): Promise<ZapretDiagnosticTarget[]> => invoke('zapret:diagnose'),
onZapretDiagnosticResult: (listener: (result: ZapretDiagnosticResult) => void): (() => void) =>
subscribe('zapret:diagnostic-result', listener)
} }
export type RendererApi = typeof api export type RendererApi = typeof api
+16 -7
View File
@@ -4,7 +4,7 @@ import { useZapretDiagnostics } from '../hooks/useZapretDiagnostics'
/** Карточка обхода блокировок: статус и включение сверху, выбор стратегии и проверка соединения ниже. */ /** Карточка обхода блокировок: статус и включение сверху, выбор стратегии и проверка соединения ниже. */
export default function ZapretCard(): React.JSX.Element { export default function ZapretCard(): React.JSX.Element {
const { status, strategies, start, stop, setStrategy, busy } = useZapret() const { status, strategies, start, stop, setStrategy, busy } = useZapret()
const { results, running: testing, run: runTest } = useZapretDiagnostics() const { targets, results, running: testing, run: runTest } = useZapretDiagnostics()
const running = status?.state === 'running' || status?.state === 'starting' const running = status?.state === 'running' || status?.state === 'starting'
const strategyId = status?.strategyId ?? strategies[0]?.id const strategyId = status?.strategyId ?? strategies[0]?.id
@@ -79,23 +79,32 @@ export default function ZapretCard(): React.JSX.Element {
{testing ? 'Проверяю…' : 'Протестить'} {testing ? 'Проверяю…' : 'Протестить'}
</button> </button>
{results.length > 0 && ( {/* Строки появляются сразу все, ещё до первого ответа, и заполняются по мере
готовности — иначе список прыгал бы, дорисовываясь по одной строке. */}
{targets.length > 0 && (
<div className="settings__group"> <div className="settings__group">
{results.map((result) => ( {targets.map((target) => {
<div key={result.id} className="settings-row settings-row--static"> const result = results[target.id]
return (
<div key={target.id} className="settings-row settings-row--static">
{/* Причину показываем текстом, а не только подсказкой при наведении: {/* Причину показываем текстом, а не только подсказкой при наведении:
«Недоступно» само по себе не говорит, что чинить. */} «Недоступно» само по себе не говорит, что чинить. */}
<span className="diagnostic__label"> <span className="diagnostic__label">
{result.label} {target.label}
{!result.ok && result.error && ( {result && !result.ok && result.error && (
<small className="diagnostic__error">{result.error}</small> <small className="diagnostic__error">{result.error}</small>
)} )}
</span> </span>
{result ? (
<span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}> <span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}>
{result.ok ? `${result.ms} мс` : 'Недоступно'} {result.ok ? `${result.ms} мс` : 'Недоступно'}
</span> </span>
) : (
<span className="chip chip--neutral">Проверяю…</span>
)}
</div> </div>
))} )
})}
</div> </div>
)} )}
</section> </section>
+36 -13
View File
@@ -1,23 +1,46 @@
import { useCallback, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { ZapretDiagnosticResult } from '@shared/ipc-contract' import type { ZapretDiagnosticResult, ZapretDiagnosticTarget } from '@shared/ipc-contract'
/** Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists. */ /**
* Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists.
*
* `run()` только запускает проверку и получает список сайтов; результаты приходят по
* одному событием и складываются в `results` по мере готовности — живой сайт отвечает за
* десятки миллисекунд, заблокированный молчит до таймаута в 10 с, и ждать общего ответа
* значило бы держать пользователя перед пустым экраном ровно столько же.
*/
export function useZapretDiagnostics(): { export function useZapretDiagnostics(): {
results: ZapretDiagnosticResult[] targets: ZapretDiagnosticTarget[]
results: Record<string, ZapretDiagnosticResult>
running: boolean running: boolean
run: () => Promise<void> run: () => Promise<void>
} { } {
const [results, setResults] = useState<ZapretDiagnosticResult[]>([]) const [targets, setTargets] = useState<ZapretDiagnosticTarget[]>([])
const [running, setRunning] = useState(false) const [results, setResults] = useState<Record<string, ZapretDiagnosticResult>>({})
const [started, setStarted] = useState(false)
// Подписка живёт всё время жизни компонента, а не только на время проверки: результат
// приходит асинхронно, и подписываться в момент клика — значит гоняться с первым же
// быстрым ответом.
useEffect(
() =>
window.api.onZapretDiagnosticResult((result) => {
setResults((previous) => ({ ...previous, [result.id]: result }))
}),
[]
)
const run = useCallback(async () => { const run = useCallback(async () => {
setRunning(true) setStarted(true)
try { // Чистим до запроса: иначе прошлые результаты остались бы на экране рядом со свежими,
setResults(await window.api.runZapretDiagnostics()) // и было бы не понять, какие из них к какой проверке.
} finally { setResults({})
setRunning(false) setTargets(await window.api.runZapretDiagnostics())
}
}, []) }, [])
return { results, running, run } // Проверка идёт, пока пришли не все результаты. Каждый сайт отдаёт ровно один результат
// (см. runDiagnostics — там перехвачена и ошибка), так что счёт всегда сходится.
const finished = targets.length > 0 && targets.every((target) => results[target.id])
return { targets, results, running: started && !finished, run }
} }
+19 -7
View File
@@ -77,14 +77,18 @@ export interface ZapretList {
isDefault: boolean isDefault: boolean
} }
/** /** Сайт, который проверяет `src/main/zapret/diagnostics.ts`. */
* Результат проверки соединения (`src/main/zapret/diagnostics.ts`): успех — это успешный export interface ZapretDiagnosticTarget {
* TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос.
*/
export interface ZapretDiagnosticResult {
id: string id: string
label: string label: string
host: string host: string
}
/**
* Результат проверки одного сайта (`src/main/zapret/diagnostics.ts`): успех — это успешный
* TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос.
*/
export interface ZapretDiagnosticResult extends ZapretDiagnosticTarget {
ok: boolean ok: boolean
ms: number | null ms: number | null
error: string | null error: string | null
@@ -121,8 +125,14 @@ export interface IpcHandlers {
'zapret:auto-hostlist': () => string[] 'zapret:auto-hostlist': () => string[]
'zapret:auto-hostlist-clear': () => void 'zapret:auto-hostlist-clear': () => void
/** Проверка соединения: успешный TLS-хендшейк до нескольких сайтов через текущий бэкенд. */ /**
'zapret:diagnose': () => ZapretDiagnosticResult[] * Запустить проверку соединения и сразу вернуть список проверяемых сайтов — сами
* результаты приходят по одному событием `zapret:diagnostic-result`, как только будет
* готов каждый. Сайты проверяются параллельно и отвечают вразнобой: ждать самый
* медленный, чтобы показать все разом, значит держать пользователя перед пустым
* экраном до последнего таймаута.
*/
'zapret:diagnose': () => ZapretDiagnosticTarget[]
} }
export type IpcChannel = keyof IpcHandlers export type IpcChannel = keyof IpcHandlers
@@ -132,6 +142,8 @@ export interface IpcEvents {
'theme:changed': ThemeState 'theme:changed': ThemeState
'zapret:status-changed': ZapretStatus 'zapret:status-changed': ZapretStatus
'zapret:log': ZapretLogLine 'zapret:log': ZapretLogLine
/** Один готовый результат запущенной через `zapret:diagnose` проверки. */
'zapret:diagnostic-result': ZapretDiagnosticResult
} }
export type IpcEventChannel = keyof IpcEvents export type IpcEventChannel = keyof IpcEvents