update: обновления 0.5.0

- Исправлен баг при переподключении "Не удалось настроить службу: Процесс не может получить доступ к файлу "C:\ProgramData\rknboost\zapret\WinDivert64.sys", так как этот файл используется другим процессом."

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

- Также программа сохраняет что было выбрано при последнем запуске
This commit is contained in:
2026-09-03 07:52:41 +03:00
parent 54ad33c842
commit 563d8b7292
13 changed files with 370 additions and 64 deletions
+34
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
@@ -56,6 +60,36 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# electron-builder создаёт релиз с пустым описанием, поэтому список изменений
# дописываем сами: коммиты между предыдущим тегом 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/*'
+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)
})
)
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) — // Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
// сообщаем всем открытым окнам. // сообщаем всем открытым окнам.
+105
View File
@@ -0,0 +1,105 @@
import { randomUUID } from 'node:crypto'
import { readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { app } from 'electron'
/**
* Выбор пользователя, который должен пережить перезапуск приложения. Лежит в userData
* (`%AppData%\RKNboost\settings.json` на Windows — каталог по `productName` из
* electron-builder.yml) — то есть переживает и обновление, и переустановку приложения.
*
* Списки сайтов сюда намеренно не входят: они и так хранятся рядом, в
* `userData/zapret-lists/*.txt` (см. `zapret/lists.ts`), каждый в своём файле — их читают
* прямо оттуда сами tpws и winws, и дублировать те же домены ещё и здесь означало бы
* держать два источника правды.
*
* `null` в любом поле — «пользователь ничего не выбирал»: тогда решает движок (стратегия
* по умолчанию для платформы, автозапуск — как решил `Win32Engine`).
*/
export interface AppSettings {
/** Последняя выбранная стратегия обхода. */
strategyId: string | null
/** Windows: запускать службу вместе с системой. На macOS не используется. */
autoStart: boolean | null
}
const DEFAULTS: AppSettings = { strategyId: null, autoStart: null }
/**
* Кэш в памяти нужен не для скорости, а чтобы писать только при реальном изменении:
* `rememberPreferences` дёргается из `ZapretEngine.patch()`, то есть на каждое изменение
* статуса, включая промежуточные `starting`/`stopping`.
*/
let cache: AppSettings = { ...DEFAULTS }
/**
* До первой загрузки не пишем ничего: иначе движок, успевший обновить статус раньше
* `loadSettings()`, затёр бы файл значениями по умолчанию.
*/
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 {
if (typeof raw !== 'object' || raw === null) return { ...DEFAULTS }
const value = 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`
try {
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
await rename(tmp, path)
} catch (error) {
// Не смогли сохранить выбор — это неприятно, но не повод ломать работающий обход.
console.warn('[settings] не удалось сохранить настройки:', error)
await unlink(tmp).catch(() => {})
}
}
/**
* Запоминает выбор пользователя. Вызывается из `ZapretEngine.patch()` на каждое изменение
* статуса, поэтому пишет на диск только когда что-то из сохраняемого действительно
* поменялось, и не ждёт записи: статус в интерфейсе не должен зависеть от файловой системы.
*/
export function rememberPreferences(preferences: Partial<AppSettings>): void {
if (!loaded) return
const next: AppSettings = {
strategyId: preferences.strategyId ?? cache.strategyId,
autoStart: preferences.autoStart ?? cache.autoStart
}
if (next.strategyId === cache.strategyId && next.autoStart === cache.autoStart) return
cache = next
writeQueue = writeQueue.then(() => writeSettings(next))
}
+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()
} }
+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
} }
+47 -3
View File
@@ -270,6 +270,40 @@ 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 отдал сигнал
# об остановке, — выгрузка драйвера происходит уже после этого, поэтому при быстром
# переподключении (выключил и сразу включил, сменил стратегию) перенастройка попадала ровно
# в этот зазор.
#
# Отсюда две меры. Первая и главная: не копировать то, что не менялось, — при смене стратегии
# бинарники те же самые, и трогать занятый .sys незачем (Copy-Item сохраняет LastWriteTime
# источника, так что длина + время записи однозначно отличают уже скопированный файл).
# Вторая — на случай, когда копировать действительно надо (обновление приложения принесло
# новый zapret): ждём выгрузки драйвера повторами, а не падаем на первой же попытке.
function Copy-IfChanged($source, $destination) {
$src = Get-Item -LiteralPath $source
if (Test-Path -LiteralPath $destination) {
$dst = Get-Item -LiteralPath $destination
if ($dst.Length -eq $src.Length -and $dst.LastWriteTimeUtc -eq $src.LastWriteTimeUtc) { return }
}
$attempts = 20
for ($attempt = 1; $attempt -le $attempts; $attempt++) {
try {
Copy-Item -LiteralPath $source -Destination $destination -Force
return
}
catch {
if ($attempt -eq $attempts) {
throw "Не удалось обновить $destination за 10 с: $($_.Exception.Message) Если обход только что был выключен, драйвер WinDivert ещё выгружается — повторите через несколько секунд."
}
Start-Sleep -Milliseconds 500
}
}
}
try { try {
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json $payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
@@ -299,7 +333,7 @@ try {
$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 @('winws.exe', 'cygwin1.dll', 'WinDivert.dll', 'WinDivert64.sys')) {
Copy-Item -Path (Join-Path $sourceDir $name) -Destination (Join-Path $dir $name) -Force Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name)
} }
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару # sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
@@ -346,8 +380,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 -Path $payload.serviceDataDir -Recurse -Force # загружен, и Remove-Item падает. Повторяем, а если так и не вышло — не проваливаем
# удаление: служба уже снята, а оставшиеся файлы система освободит и без нас.
for ($attempt = 1; $attempt -le 10; $attempt++) {
if (-not (Test-Path $payload.serviceDataDir)) { break }
try {
Remove-Item -Path $payload.serviceDataDir -Recurse -Force
break
}
catch {
Start-Sleep -Milliseconds 500
}
} }
Write-Result $true $null Write-Result $true $null
} }
+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
+25 -16
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"> {/* Причину показываем текстом, а не только подсказкой при наведении:
{result.label} «Недоступно» само по себе не говорит, что чинить. */}
{!result.ok && result.error && ( <span className="diagnostic__label">
<small className="diagnostic__error">{result.error}</small> {target.label}
{result && !result.ok && result.error && (
<small className="diagnostic__error">{result.error}</small>
)}
</span>
{result ? (
<span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}>
{result.ok ? `${result.ms} мс` : 'Недоступно'}
</span>
) : (
<span className="chip chip--neutral">Проверяю…</span>
)} )}
</span> </div>
<span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}> )
{result.ok ? `${result.ms} мс` : 'Недоступно'} })}
</span>
</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