update: обновления 0.5.0
- Исправлен баг при переподключении "Не удалось настроить службу: Процесс не может получить доступ к файлу "C:\ProgramData\rknboost\zapret\WinDivert64.sys", так как этот файл используется другим процессом." - Теперь проверка дает результат сразу асихронно, а не ожидая ответа от всех. - Также программа сохраняет что было выбрано при последнем запуске
This commit is contained in:
+8
-1
@@ -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)
|
||||
})
|
||||
)
|
||||
|
||||
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
|
||||
// сообщаем всем открытым окнам.
|
||||
|
||||
@@ -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
@@ -29,8 +29,8 @@ export function createWindow(): BrowserWindow {
|
||||
width: 440,
|
||||
height: 660,
|
||||
minWidth: 360,
|
||||
// С запасом на самое высокое обычное состояние главного экрана (ошибка + три
|
||||
// результата проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
|
||||
// С запасом на самое высокое обычное состояние главного экрана (ошибка + результаты
|
||||
// проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
|
||||
@@ -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<number>): Promise<number> {
|
||||
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
|
||||
* на 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
|
||||
|
||||
return Promise.all(
|
||||
TARGETS.map(async (target) => {
|
||||
await Promise.all(
|
||||
DIAGNOSTIC_TARGETS.map(async (target) => {
|
||||
const attempt = (): Promise<number> =>
|
||||
socksMatch
|
||||
? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2]))
|
||||
@@ -191,14 +197,14 @@ export async function runDiagnostics(status: ZapretStatus): Promise<ZapretDiagno
|
||||
|
||||
try {
|
||||
const ms = await withRetry(attempt)
|
||||
return { ...target, ok: true, ms, error: null }
|
||||
onResult({ ...target, ok: true, ms, error: null })
|
||||
} catch (error) {
|
||||
return {
|
||||
onResult({
|
||||
...target,
|
||||
ok: false,
|
||||
ms: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
ZapretStatus,
|
||||
ZapretState
|
||||
} from '../../shared/ipc-contract'
|
||||
import { rememberPreferences, type AppSettings } from '../settings'
|
||||
|
||||
/** Сколько строк вывода бинарника держим для UI. */
|
||||
const LOG_LIMIT = 200
|
||||
|
||||
@@ -31,6 +33,21 @@ export abstract class ZapretEngine {
|
||||
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[] {
|
||||
return [...this.log]
|
||||
}
|
||||
@@ -67,6 +84,10 @@ export abstract class ZapretEngine {
|
||||
|
||||
protected patch(changes: Partial<ZapretStatus>): 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()
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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() }
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user