diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d33918a..12595f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,10 @@ jobs: contents: write steps: - uses: actions/checkout@v4 + with: + # fetch-depth: 0 — шагу «Описание релиза» ниже нужны вся история и теги, + # чтобы найти предыдущий vX.Y.Z и собрать список коммитов после него. + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 24 @@ -56,6 +60,36 @@ jobs: env: 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 with: name: rknboost-windows diff --git a/electron-builder.yml b/electron-builder.yml index 9ac8e4a..7393a73 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -4,6 +4,13 @@ directories: buildResources: build output: release +# releaseType: release — публиковать релиз сразу, без черновика (draft — поведение по +# умолчанию у electron-builder). С черновиком параллельная загрузка нескольких файлов иногда +# создаёт два черновика с одним и тем же тегом вместо одного релиза. +publish: + provider: github + releaseType: release + files: - '!**/.vscode/*' - '!src/*' diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 40a612c..5b7af93 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -66,7 +66,14 @@ export function registerIpcHandlers(): void { handle('zapret:list-reset', (_event, id) => resetZapretList(id)) handle('zapret:auto-hostlist', () => zapretAutoHostlist()) 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) + }) + ) // Системная тема поменялась (например, пользователь включил тёмный режим в ОС) — // сообщаем всем открытым окнам. diff --git a/src/main/settings.ts b/src/main/settings.ts new file mode 100644 index 0000000..73e4a3e --- /dev/null +++ b/src/main/settings.ts @@ -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 = 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 + + return { + strategyId: typeof value.strategyId === 'string' && value.strategyId ? value.strategyId : null, + autoStart: typeof value.autoStart === 'boolean' ? value.autoStart : null + } +} + +/** Читает настройки с диска. Вызывать один раз при старте, до создания окна. */ +export async function loadSettings(): Promise { + 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 { + 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): 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)) +} diff --git a/src/main/window.ts b/src/main/window.ts index f165952..ceb0ae6 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -29,8 +29,8 @@ export function createWindow(): BrowserWindow { width: 440, height: 660, minWidth: 360, - // С запасом на самое высокое обычное состояние главного экрана (ошибка + три - // результата проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте. + // С запасом на самое высокое обычное состояние главного экрана (ошибка + результаты + // проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте. minHeight: 560, show: false, autoHideMenuBar: true, diff --git a/src/main/zapret/diagnostics.ts b/src/main/zapret/diagnostics.ts index 7b659ea..560c885 100644 --- a/src/main/zapret/diagnostics.ts +++ b/src/main/zapret/diagnostics.ts @@ -1,7 +1,11 @@ import { connect as netConnect, type Socket } from 'node:net' import { connect as tlsConnect } from 'node:tls' 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` заметно удлиняет установку соединения, // и медленный, но живой сайт успевал попасть в «Недоступно». @@ -9,15 +13,8 @@ const TIMEOUT_MS = 10000 const TARGET_PORT = 443 const RETRY_DELAY_MS = 300 -interface DiagnosticTarget { - id: string - label: string - host: string -} - /** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */ -const TARGETS: DiagnosticTarget[] = [ - { id: 'instagram', label: 'Instagram', host: 'instagram.com' }, +export const DIAGNOSTIC_TARGETS: ZapretDiagnosticTarget[] = [ { id: 'discord', label: 'Discord', host: 'discord.com' }, { id: 'youtube', label: 'YouTube', host: 'youtube.com' } ] @@ -178,12 +175,21 @@ async function withRetry(attempt: () => Promise): Promise { * остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую: * на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает * то, что есть сейчас. + * + * Каждый сайт отдаёт свой результат в `onResult` сам по себе, не дожидаясь остальных: + * проверки идут параллельно и заканчиваются вразнобой (живой сайт отвечает за десятки + * миллисекунд, заблокированный — только по таймауту в 10 с), и общий ответ одним куском + * означал бы пустой экран всё это время. Промис резолвится, когда отработали все, — + * по нему вызывающий код понимает, что проверка закончена. */ -export async function runDiagnostics(status: ZapretStatus): Promise { +export async function runDiagnostics( + status: ZapretStatus, + onResult: (result: ZapretDiagnosticResult) => void +): Promise { const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null - return Promise.all( - TARGETS.map(async (target) => { + await Promise.all( + DIAGNOSTIC_TARGETS.map(async (target) => { const attempt = (): Promise => socksMatch ? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2])) @@ -191,14 +197,14 @@ export async function runDiagnostics(status: ZapretStatus): Promise): ZapretStatus { this.status = { ...this.status, ...changes } + // Единая точка сохранения: любой путь смены стратегии или автозапуска (выбор в списке, + // start() с другой стратегией, подхваченное из системы состояние службы в sync()) + // проходит через patch, так что забыть сохранить выбор здесь просто негде. + rememberPreferences({ strategyId: this.status.strategyId, autoStart: this.status.autoStart }) this.broadcast('zapret:status-changed', this.status) return this.getStatus() } diff --git a/src/main/zapret/index.ts b/src/main/zapret/index.ts index f3655e8..f8e265d 100644 --- a/src/main/zapret/index.ts +++ b/src/main/zapret/index.ts @@ -1,13 +1,15 @@ import { readFile, rm } from 'node:fs/promises' import type { ZapretDiagnosticResult, + ZapretDiagnosticTarget, ZapretList, ZapretListId, ZapretLogLine, ZapretStatus, ZapretStrategy } from '../../shared/ipc-contract' -import { runDiagnostics } from './diagnostics' +import { loadSettings } from '../settings' +import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics' import { DarwinEngine } from './engine.darwin' import { ZapretEngine, UnsupportedEngine } from './engine' import { Win32Engine } from './engine.win32' @@ -29,16 +31,35 @@ function createEngine(): ZapretEngine { const engine = createEngine() /** - * Готовим списки сайтов и синхронизируем состояние с системой — на macOS чиним прокси - * от прошлого аварийного завершения, на Windows подхватываем службу, которая могла работать - * ещё до открытия окна (автозапуск с системой). Оба шага нужно сделать до открытия окна. + * Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем + * состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows + * подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой). + * Все шаги нужно сделать до открытия окна. */ export async function recoverZapret(): Promise { 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 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 { return engine.getStatus() } @@ -105,6 +126,19 @@ export async function clearZapretAutoHostlist(): Promise { await rm(autoHostlistPath(), { force: true }) } -export function zapretDiagnostics(): Promise { - 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 } diff --git a/src/main/zapret/service.win32.ts b/src/main/zapret/service.win32.ts index 7792eed..0b77005 100644 --- a/src/main/zapret/service.win32.ts +++ b/src/main/zapret/service.win32.ts @@ -270,6 +270,40 @@ function Invoke-ScRaw($rawArgs) { 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 { $payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json @@ -299,7 +333,7 @@ try { $sourceDir = Split-Path -Parent $payload.winwsPath $protectedWinws = Join-Path $dir 'winws.exe' 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 как ОДИН аргумент — значит всю строку нужно взять в одну пару @@ -346,8 +380,18 @@ try { & sc.exe stop $ServiceName 2>$null | Out-Null Start-Sleep -Milliseconds 500 & sc.exe delete $ServiceName | Out-Null - if (Test-Path $payload.serviceDataDir) { - Remove-Item -Path $payload.serviceDataDir -Recurse -Force + # Тот же занятый 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 + break + } + catch { + Start-Sleep -Milliseconds 500 + } } Write-Result $true $null } diff --git a/src/preload/index.ts b/src/preload/index.ts index 68e6bea..e472f0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import type { IpcHandlers, ThemeState, ZapretDiagnosticResult, + ZapretDiagnosticTarget, ZapretList, ZapretListId, ZapretLogLine, @@ -71,7 +72,10 @@ const api = { getZapretAutoHostlist: (): Promise => invoke('zapret:auto-hostlist'), clearZapretAutoHostlist: (): Promise => invoke('zapret:auto-hostlist-clear'), - runZapretDiagnostics: (): Promise => invoke('zapret:diagnose') + /** Запускает проверку и отдаёт список сайтов; результаты — через `onZapretDiagnosticResult`. */ + runZapretDiagnostics: (): Promise => invoke('zapret:diagnose'), + onZapretDiagnosticResult: (listener: (result: ZapretDiagnosticResult) => void): (() => void) => + subscribe('zapret:diagnostic-result', listener) } export type RendererApi = typeof api diff --git a/src/renderer/src/components/ZapretCard.tsx b/src/renderer/src/components/ZapretCard.tsx index 1200954..347359e 100644 --- a/src/renderer/src/components/ZapretCard.tsx +++ b/src/renderer/src/components/ZapretCard.tsx @@ -4,7 +4,7 @@ import { useZapretDiagnostics } from '../hooks/useZapretDiagnostics' /** Карточка обхода блокировок: статус и включение сверху, выбор стратегии и проверка соединения ниже. */ export default function ZapretCard(): React.JSX.Element { 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 strategyId = status?.strategyId ?? strategies[0]?.id @@ -79,23 +79,32 @@ export default function ZapretCard(): React.JSX.Element { {testing ? 'Проверяю…' : 'Протестить'} - {results.length > 0 && ( + {/* Строки появляются сразу все, ещё до первого ответа, и заполняются по мере + готовности — иначе список прыгал бы, дорисовываясь по одной строке. */} + {targets.length > 0 && (
- {results.map((result) => ( -
- {/* Причину показываем текстом, а не только подсказкой при наведении: - «Недоступно» само по себе не говорит, что чинить. */} - - {result.label} - {!result.ok && result.error && ( - {result.error} + {targets.map((target) => { + const result = results[target.id] + return ( +
+ {/* Причину показываем текстом, а не только подсказкой при наведении: + «Недоступно» само по себе не говорит, что чинить. */} + + {target.label} + {result && !result.ok && result.error && ( + {result.error} + )} + + {result ? ( + + {result.ok ? `${result.ms} мс` : 'Недоступно'} + + ) : ( + Проверяю… )} - - - {result.ok ? `${result.ms} мс` : 'Недоступно'} - -
- ))} +
+ ) + })}
)} diff --git a/src/renderer/src/hooks/useZapretDiagnostics.ts b/src/renderer/src/hooks/useZapretDiagnostics.ts index 84fc089..48ace2d 100644 --- a/src/renderer/src/hooks/useZapretDiagnostics.ts +++ b/src/renderer/src/hooks/useZapretDiagnostics.ts @@ -1,23 +1,46 @@ -import { useCallback, useState } from 'react' -import type { ZapretDiagnosticResult } from '@shared/ipc-contract' +import { useCallback, useEffect, useState } from 'react' +import type { ZapretDiagnosticResult, ZapretDiagnosticTarget } from '@shared/ipc-contract' -/** Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists. */ +/** + * Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists. + * + * `run()` только запускает проверку и получает список сайтов; результаты приходят по + * одному событием и складываются в `results` по мере готовности — живой сайт отвечает за + * десятки миллисекунд, заблокированный молчит до таймаута в 10 с, и ждать общего ответа + * значило бы держать пользователя перед пустым экраном ровно столько же. + */ export function useZapretDiagnostics(): { - results: ZapretDiagnosticResult[] + targets: ZapretDiagnosticTarget[] + results: Record running: boolean run: () => Promise } { - const [results, setResults] = useState([]) - const [running, setRunning] = useState(false) + const [targets, setTargets] = useState([]) + const [results, setResults] = useState>({}) + const [started, setStarted] = useState(false) + + // Подписка живёт всё время жизни компонента, а не только на время проверки: результат + // приходит асинхронно, и подписываться в момент клика — значит гоняться с первым же + // быстрым ответом. + useEffect( + () => + window.api.onZapretDiagnosticResult((result) => { + setResults((previous) => ({ ...previous, [result.id]: result })) + }), + [] + ) const run = useCallback(async () => { - setRunning(true) - try { - setResults(await window.api.runZapretDiagnostics()) - } finally { - setRunning(false) - } + setStarted(true) + // Чистим до запроса: иначе прошлые результаты остались бы на экране рядом со свежими, + // и было бы не понять, какие из них к какой проверке. + setResults({}) + 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 } } diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index 947e868..85c6e09 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -77,14 +77,18 @@ export interface ZapretList { isDefault: boolean } -/** - * Результат проверки соединения (`src/main/zapret/diagnostics.ts`): успех — это успешный - * TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос. - */ -export interface ZapretDiagnosticResult { +/** Сайт, который проверяет `src/main/zapret/diagnostics.ts`. */ +export interface ZapretDiagnosticTarget { id: string label: string host: string +} + +/** + * Результат проверки одного сайта (`src/main/zapret/diagnostics.ts`): успех — это успешный + * TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос. + */ +export interface ZapretDiagnosticResult extends ZapretDiagnosticTarget { ok: boolean ms: number | null error: string | null @@ -121,8 +125,14 @@ export interface IpcHandlers { 'zapret:auto-hostlist': () => string[] 'zapret:auto-hostlist-clear': () => void - /** Проверка соединения: успешный TLS-хендшейк до нескольких сайтов через текущий бэкенд. */ - 'zapret:diagnose': () => ZapretDiagnosticResult[] + /** + * Запустить проверку соединения и сразу вернуть список проверяемых сайтов — сами + * результаты приходят по одному событием `zapret:diagnostic-result`, как только будет + * готов каждый. Сайты проверяются параллельно и отвечают вразнобой: ждать самый + * медленный, чтобы показать все разом, значит держать пользователя перед пустым + * экраном до последнего таймаута. + */ + 'zapret:diagnose': () => ZapretDiagnosticTarget[] } export type IpcChannel = keyof IpcHandlers @@ -132,6 +142,8 @@ export interface IpcEvents { 'theme:changed': ThemeState 'zapret:status-changed': ZapretStatus 'zapret:log': ZapretLogLine + /** Один готовый результат запущенной через `zapret:diagnose` проверки. */ + 'zapret:diagnostic-result': ZapretDiagnosticResult } export type IpcEventChannel = keyof IpcEvents