Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8f03a2f86 | ||
|
|
cb499a09d1 | ||
|
|
563d8b7292 |
@@ -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
|
||||||
|
|||||||
@@ -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/*'
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rknboost",
|
"name": "rknboost",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rknboost",
|
"name": "rknboost",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rknboost",
|
"name": "rknboost",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
|
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "RKNboost",
|
"author": "RKNboost",
|
||||||
|
|||||||
+8
-1
@@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
|
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
|
||||||
// сообщаем всем открытым окнам.
|
// сообщаем всем открытым окнам.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 падает. Повторяем, а если так и не вышло — не проваливаем
|
||||||
|
# удаление: служба уже снята, а оставшиеся файлы система освободит и без нас.
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user