4 Commits
Author SHA1 Message Date
maeneko 0842c4d4a5 Версия 0.5.2
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
Иконки: пересобраны из мастера 1024×1024. В .icns было одно представление
512×512 — macOS домасштабировала остальные сама; теперь все десять, от 16 до
1024. В .ico добавлен размер 24 и своя картинка вместо заглушки Electron.

Релиз: differentialPackage и publishAutoUpdate выключены — .blockmap и
latest.yml нужны electron-updater, которого в приложении нет, и лежали в
ассетах мёртвым грузом. Вместо них CI кладёт рядом с установщиком .md5.

Версия в интерфейсе больше не константа: renderer спрашивает её у main через
app.getVersion(). Держать константу «в синхроне с package.json» вручную не
вышло ни разу — она осталась на 0.4.0, и релизы 0.5.0 и 0.5.1 показали
пользователю чужую версию.
2026-09-04 08:31:52 +03:00
maeneko 88f8819570 Версия 0.5.1
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
2026-09-03 08:20:46 +03:00
maeneko 9288fc95cb Сравнивать бинарники службы по содержимому и ускорить опрос службы
fetch-zapret.mjs кладёт файлы через copyFile, а тот ставит текущее время:
после каждой сборки CI mtime новее при тех же байтах. Сравнение по времени
требовало UAC при каждом обновлении приложения и упиралось в занятый
WinDivert64.sys, ещё загруженный в ядро. Теперь сравниваем SHA-256, а если
файл всё же занят — уводим его в сторону переименованием.

Ожидание состояния службы переехало внутрь одного скрипта PowerShell: раньше
каждый опрос был отдельным процессом (~1 с на подъём .NET) с интервалом
300 мс, из чего и складывалась основная задержка включения.
2026-09-03 08:20:35 +03:00
maeneko beced42f2f Создавать релиз до сборки: чинит гонку двух publisher'ов
electron-builder заливает .exe и .blockmap параллельно, оба проверяют наличие
релиза, оба видят «нет» и оба его создают — второй падает с 422 already_exists
и уносит с собой свой файл. На v0.5.0 так потерялся сам .exe.
2026-09-03 08:06:31 +03:00
17 changed files with 218 additions and 71 deletions
+32 -4
View File
@@ -49,19 +49,47 @@ jobs:
# electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст. # electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст.
- run: node scripts/fetch-zapret.mjs - run: node scripts/fetch-zapret.mjs
# electron-builder заливает артефакты несколькими параллельными publisher'ами: оба
# проверяют, есть ли релиз с этим тегом, оба видят «нет» и оба лезут его создавать —
# второй падает с 422 already_exists и уносит с собой свой файл (на v0.5.0 так потерялся
# сам .exe). Создаём релиз заранее, тогда обоим остаётся только залить в готовый.
# Описание пустое — его заполняет шаг «Описание релиза» после сборки.
- name: Заготовка релиза
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 ||
gh release create "$GITHUB_REF_NAME" --title "${GITHUB_REF_NAME#v}" --notes ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Подпись не настроена: артефакты неподписанные. # Подпись не настроена: артефакты неподписанные.
# Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env. # Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env.
# #
# На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder публикует .exe в GitHub # На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder заливает .exe в релиз,
# Release с этим тегом (создаёт его, если нет), используя repository из package.json. # заготовленный шагом выше, используя repository из package.json.
# На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже. # На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже.
- name: Build - name: Build
run: npx --no-install electron-vite build && npx --no-install electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }} run: npx --no-install electron-vite build && npx --no-install electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# electron-builder создаёт релиз с пустым описанием, поэтому список изменений # differentialPackage: false и publishAutoUpdate: false в electron-builder.yml убирают
# дописываем сами: коммиты между предыдущим тегом vX.Y.Z и текущим. # .blockmap и latest.yml из ассетов (electron-updater в приложении нет — они были бы
# мёртвым грузом). В релизе должен остаться только .exe и контрольная сумма к нему.
- name: Контрольная сумма
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
set -euo pipefail
exe="$(ls release/*.exe)"
md5sum "$exe" | awk '{ print $1 }' > "$exe.md5"
gh release upload "$GITHUB_REF_NAME" "$exe.md5" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Релиз создан с пустым описанием, поэтому список изменений дописываем сами:
# коммиты между предыдущим тегом vX.Y.Z и текущим.
- name: Описание релиза - name: Описание релиза
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 147 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 633 KiB

+9
View File
@@ -7,9 +7,14 @@ directories:
# releaseType: release — публиковать релиз сразу, без черновика (draft — поведение по # releaseType: release — публиковать релиз сразу, без черновика (draft — поведение по
# умолчанию у electron-builder). С черновиком параллельная загрузка нескольких файлов иногда # умолчанию у electron-builder). С черновиком параллельная загрузка нескольких файлов иногда
# создаёт два черновика с одним и тем же тегом вместо одного релиза. # создаёт два черновика с одним и тем же тегом вместо одного релиза.
#
# publishAutoUpdate: false — не заливать latest.yml. Это метаданные для electron-updater,
# которого в приложении нет (обновляется пользователь вручную, скачивая новый инсталлятор
# со страницы релиза) — файл был бы мёртвым грузом в ассетах.
publish: publish:
provider: github provider: github
releaseType: release releaseType: release
publishAutoUpdate: false
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
@@ -54,6 +59,10 @@ nsis:
# в src/main/zapret/service.win32.ts) — сама установка приложения per-user безопасна. # в src/main/zapret/service.win32.ts) — сама установка приложения per-user безопасна.
oneClick: true oneClick: true
perMachine: false perMachine: false
# Без electron-updater (см. publishAutoUpdate выше) differential-обновления не нужны —
# differentialPackage: false отключает генерацию .blockmap, который иначе всегда
# заливался бы в ассеты релиза рядом с инсталлятором.
differentialPackage: false
# Удалять %APPDATA%\rknboost (списки сайтов, метка первого запуска) вместе с приложением. # Удалять %APPDATA%\rknboost (списки сайтов, метка первого запуска) вместе с приложением.
# По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после # По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после
# удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит # удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rknboost", "name": "rknboost",
"version": "0.5.0", "version": "0.5.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rknboost", "name": "rknboost",
"version": "0.5.0", "version": "0.5.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "rknboost", "name": "rknboost",
"version": "0.5.0", "version": "0.5.2",
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript", "description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "RKNboost", "author": "RKNboost",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 633 KiB

+5 -1
View File
@@ -1,4 +1,4 @@
import { BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron' import { app, BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract' import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract'
import { uninstallApp } from './uninstall.win32' import { uninstallApp } from './uninstall.win32'
import { import {
@@ -46,6 +46,10 @@ function themeState(): ThemeState {
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
handle('theme:get', () => themeState()) handle('theme:get', () => themeState())
// Ровно то же значение, что electron-builder кладёт в имя установщика и в
// «Установленные приложения» — оба берут его из "version" в package.json.
handle('app:version', () => app.getVersion())
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx), // Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
// но канал регистрируем всегда — так и должно быть по контракту, просто на других // но канал регистрируем всегда — так и должно быть по контракту, просто на других
// платформах он никогда не вызывается. // платформах он никогда не вызывается.
+33 -10
View File
@@ -1,11 +1,18 @@
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { dirname, join } from 'node:path'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import type { ZapretStatus } from '../../shared/ipc-contract' import type { ZapretStatus } from '../../shared/ipc-contract'
import { ZapretEngine } from './engine' import { ZapretEngine } from './engine'
import { assertExecutable, protectedWinwsPath, serviceConfigPath, winwsPath } from './paths' import {
SERVICE_BINARY_NAMES,
assertExecutable,
serviceDataDir,
serviceConfigPath,
winwsPath
} from './paths'
import { import {
applyServiceConfig, applyServiceConfig,
queryService, queryService,
@@ -28,18 +35,34 @@ async function readInstalledMarkerLine(): Promise<string | null> {
} }
} }
async function sha256(path: string): Promise<string> {
return createHash('sha256')
.update(await readFile(path))
.digest('hex')
}
/** /**
* Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал * Служба запускает защищённую копию бинарников, а не оригинал из папки приложения — значит
* из папки установки приложения (winwsPath) — значит переустановку/обновление приложения * обновление по самому пути службы не отследить. Сравниваем содержимое: время изменения тут
* по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время * не годится совсем, `scripts/fetch-zapret.mjs` кладёт файлы через `copyFile`, а тот ставит
* изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло * текущее время — после каждой сборки CI mtime новее, хотя версия zapret запинована и байты
* позже последней копии, нужно копировать заново (а значит и UAC). * те же. По mtime мы на ровном месте требовали UAC при каждом обновлении приложения и лезли
* копировать поверх WinDivert64.sys, ещё загруженного в ядро.
*/ */
async function binariesNeedCopy(): Promise<boolean> { async function binariesNeedCopy(): Promise<boolean> {
const sourceDir = dirname(winwsPath())
const targetDir = serviceDataDir()
try { try {
const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())]) for (const name of SERVICE_BINARY_NAMES) {
return source.size !== copy.size || source.mtimeMs > copy.mtimeMs const [source, copy] = await Promise.all([
sha256(join(sourceDir, name)),
sha256(join(targetDir, name))
])
if (source !== copy) return true
}
return false
} catch { } catch {
// Какого-то файла ещё нет (первая установка) или он нечитаем — надёжнее скопировать.
return true return true
} }
} }
+13
View File
@@ -56,6 +56,19 @@ export function serviceConfigPath(): string {
return join(serviceDataDir(), 'strategy.cfg') return join(serviceDataDir(), 'strategy.cfg')
} }
/**
* Файлы, которые служба запускает из своего защищённого каталога: сам winws и всё, от чего
* он зависит при старте. Список нужен и `engine.win32.ts` (решить, надо ли копировать),
* и повышенному скрипту в `service.win32.ts` (собственно копирование) — держим его здесь,
* чтобы он не разъехался на две копии.
*/
export const SERVICE_BINARY_NAMES = [
'winws.exe',
'cygwin1.dll',
'WinDivert.dll',
'WinDivert64.sys'
] as const
/** /**
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё, * Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь * а не на оригинал из папки установки приложения, указывает служба. Установка теперь
+73 -46
View File
@@ -10,7 +10,7 @@ import {
isElevated, isElevated,
runElevatedServiceTask runElevatedServiceTask
} from './elevated-task.win32' } from './elevated-task.win32'
import { serviceConfigPath, serviceDataDir, winwsPath } from './paths' import { SERVICE_BINARY_NAMES, serviceConfigPath, serviceDataDir, winwsPath } from './paths'
const run = promisify(execFile) const run = promisify(execFile)
@@ -31,6 +31,8 @@ const ERROR_SERVICE_ALREADY_RUNNING = 1056
const ERROR_SERVICE_NOT_ACTIVE = 1062 const ERROR_SERVICE_NOT_ACTIVE = 1062
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other' export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
/** То, чего можно дождаться от службы. `Absent` — службы в системе нет (снята). */
type WaitState = 'Running' | 'Stopped' | 'Absent'
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other' export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
export interface ServiceInfo { export interface ServiceInfo {
@@ -40,10 +42,6 @@ export interface ServiceInfo {
binPath: string binPath: string
} }
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function exitCodeOf(error: unknown): number { function exitCodeOf(error: unknown): number {
const code = (error as { code?: number | string } | undefined)?.code const code = (error as { code?: number | string } | undefined)?.code
return typeof code === 'number' ? code : 1 return typeof code === 'number' ? code : 1
@@ -81,12 +79,29 @@ function normalizeStartMode(raw: string | undefined): ServiceStartMode {
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM * Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает * `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
* локализованный текст, который парсить надёжно нельзя. * локализованный текст, который парсить надёжно нельзя.
*
* Ждём тоже здесь, внутри одного скрипта: запуск powershell.exe стоит около секунды (он
* поднимает .NET), а опросов до перехода службы в нужное состояние бывает с десяток. Раньше
* каждый опрос был отдельным процессом, и включение обхода складывалось в основном из этих
* запусков; теперь на всё ожидание приходится один. Сам опрос внутри CIM — десятки мс.
*/ */
export async function queryService(): Promise<ServiceInfo | null> { function queryScript(until: readonly WaitState[], timeoutMs: number): string {
const script = return `
`Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` + $targets = @(${until.map((state) => `'${state}'`).join(',')})
'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress' $deadline = [DateTime]::UtcNow.AddMilliseconds(${timeoutMs})
for (;;) {
$svc = Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" |
Select-Object State,StartMode,PathName
$state = if ($svc) { $svc.State } else { 'Absent' }
if ($targets.Count -eq 0 -or $targets -contains $state) { break }
if ([DateTime]::UtcNow -ge $deadline) { break }
Start-Sleep -Milliseconds ${POLL_INTERVAL_MS}
}
if ($svc) { $svc | ConvertTo-Json -Compress } else { 'null' }
`
}
async function runQuery(script: string): Promise<ServiceInfo | null> {
let stdout: string let stdout: string
try { try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script])) ;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
@@ -108,6 +123,19 @@ export async function queryService(): Promise<ServiceInfo | null> {
} }
} }
/** Текущее состояние службы; `null` — службы в системе нет. */
export function queryService(): Promise<ServiceInfo | null> {
return runQuery(queryScript([], 0))
}
/**
* Ждёт одного из состояний и возвращает последнее увиденное — в том числе по таймауту,
* поэтому результат у вызывающего кода обязателен к проверке.
*/
function waitForService(...states: WaitState[]): Promise<ServiceInfo | null> {
return runQuery(queryScript(states, POLL_TIMEOUT_MS))
}
function explainScError(error: unknown, verb: string): string { function explainScError(error: unknown, verb: string): string {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
if (/access is denied/i.test(message)) { if (/access is denied/i.test(message)) {
@@ -119,18 +147,6 @@ function explainScError(error: unknown, verb: string): string {
return `Не удалось ${verb} службу: ${message}` return `Не удалось ${verb} службу: ${message}`
} }
async function pollUntil(
predicate: (info: ServiceInfo | null) => boolean
): Promise<ServiceInfo | null> {
const deadline = Date.now() + POLL_TIMEOUT_MS
for (;;) {
const info = await queryService()
if (predicate(info)) return info
if (Date.now() >= deadline) return info
await sleep(POLL_INTERVAL_MS)
}
}
/** /**
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не * `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился) * дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
@@ -147,7 +163,7 @@ export async function startService(): Promise<ServiceInfo | null> {
} }
} }
const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped') const info = await waitForService('Running', 'Stopped')
if (info?.state !== 'Running') { if (info?.state !== 'Running') {
throw new Error( throw new Error(
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` + `Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
@@ -169,12 +185,11 @@ export async function stopService(): Promise<ServiceInfo | null> {
throw new Error(explainScError(error, 'остановить'), { cause: error }) throw new Error(explainScError(error, 'остановить'), { cause: error })
} }
} }
// `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката, // Результат обязательно проверяем: «остановка», не уложившаяся в POLL_TIMEOUT_MS, иначе
// поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS, // молча сошла бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
// молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой // пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
// остановка и защищает. // остановка и защищает.
const info = await pollUntil((i) => i === null || i.state === 'Stopped') const info = await waitForService('Stopped', 'Absent')
if (info && info.state !== 'Stopped') { if (info && info.state !== 'Stopped') {
throw new Error( throw new Error(
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).` `Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
@@ -270,40 +285,46 @@ function Invoke-ScRaw($rawArgs) {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() } return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
} }
# WinDivert64.sys — файл драйвера, загруженного в ядро: пока драйвер не выгружен, файл занят, # WinDivert64.sys — образ драйвера, загруженного в ядро: пока драйвер не выгружен, файл
# и Copy-Item поверх него падает с «Процесс не может получить доступ к файлу ... используется # занят, и Copy-Item поверх него падает с «файл используется другим процессом». SCM
# другим процессом». SCM рапортует службе состояние Stopped, как только winws.exe отдал сигнал # рапортует Stopped, как только winws.exe отдал сигнал, а выгрузка идёт уже после — при
# об остановке, — выгрузка драйвера происходит уже после этого, поэтому при быстром # быстром переподключении попадаем ровно в этот зазор.
# переподключении (выключил и сразу включил, сменил стратегию) перенастройка попадала ровно
# в этот зазор.
# #
# Отсюда две меры. Первая и главная: не копировать то, что не менялось, — при смене стратегии # Отсюда три ступени. Сравнение по ХЕШУ, а не по времени изменения: fetch-zapret.mjs кладёт
# бинарники те же самые, и трогать занятый .sys незачем (Copy-Item сохраняет LastWriteTime # бинарники через copyFile, тот ставит текущее время, и после каждой сборки CI mtime новее
# источника, так что длина + время записи однозначно отличают уже скопированный файл). # при тех же байтах — по времени мы лезли копировать занятый .sys после каждого обновления
# Вторая — на случай, когда копировать действительно надо (обновление приложения принесло # приложения. Дальше короткие повторы — вдруг драйвер вот-вот выгрузится. Последняя ступень:
# новый zapret): ждём выгрузки драйвера повторами, а не падаем на первой же попытке. # уводим занятый файл в сторону переименованием (NTFS разрешает переименовать загруженный
# образ, хотя удалить его не даёт) и кладём новый на освободившееся имя.
function Copy-IfChanged($source, $destination) { function Copy-IfChanged($source, $destination) {
$src = Get-Item -LiteralPath $source
if (Test-Path -LiteralPath $destination) { if (Test-Path -LiteralPath $destination) {
$dst = Get-Item -LiteralPath $destination $sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash
if ($dst.Length -eq $src.Length -and $dst.LastWriteTimeUtc -eq $src.LastWriteTimeUtc) { return } $copyHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash
if ($sourceHash -eq $copyHash) { return }
} }
$attempts = 20 for ($attempt = 1; $attempt -le 6; $attempt++) {
for ($attempt = 1; $attempt -le $attempts; $attempt++) {
try { try {
Copy-Item -LiteralPath $source -Destination $destination -Force Copy-Item -LiteralPath $source -Destination $destination -Force
return return
} }
catch { catch {
if ($attempt -eq $attempts) {
throw "Не удалось обновить $destination за 10 с: $($_.Exception.Message) Если обход только что был выключен, драйвер WinDivert ещё выгружается — повторите через несколько секунд."
}
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
} }
} }
try {
$stale = "$destination.old"
Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue
Move-Item -LiteralPath $destination -Destination $stale -Force
Copy-Item -LiteralPath $source -Destination $destination -Force
}
catch {
throw "Не удалось обновить $destination : $($_.Exception.Message)"
}
} }
try { try {
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json $payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
@@ -332,10 +353,15 @@ try {
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске. # cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
$sourceDir = Split-Path -Parent $payload.winwsPath $sourceDir = Split-Path -Parent $payload.winwsPath
$protectedWinws = Join-Path $dir 'winws.exe' $protectedWinws = Join-Path $dir 'winws.exe'
foreach ($name in @('winws.exe', 'cygwin1.dll', 'WinDivert.dll', 'WinDivert64.sys')) { foreach ($name in $payload.binaryNames) {
Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name) Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name)
} }
# Хвосты от прошлой перенастройки: тогда файл был занят загруженным драйвером, сейчас
# он, скорее всего, уже свободен. Не вышло — не беда, попробуем в следующий раз.
Get-ChildItem -LiteralPath $dir -Filter '*.old' -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару # sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет # кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639). # значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
@@ -500,6 +526,7 @@ export async function applyServiceConfig(options: ApplyServiceConfigOptions): Pr
configPath: serviceConfigPath(), configPath: serviceConfigPath(),
configBody: options.configBody, configBody: options.configBody,
winwsPath: winwsPath(), winwsPath: winwsPath(),
binaryNames: SERVICE_BINARY_NAMES,
autoStart: options.autoStart autoStart: options.autoStart
}) })
} }
+3
View File
@@ -43,6 +43,9 @@ const api = {
* службы только на Windows (см. SettingsView.tsx). */ * службы только на Windows (см. SettingsView.tsx). */
platform: process.platform, platform: process.platform,
/** Версия запущенной сборки — из package.json через `app.getVersion()` в main. */
getAppVersion: (): Promise<string> => invoke('app:version'),
getTheme: (): Promise<ThemeState> => invoke('theme:get'), getTheme: (): Promise<ThemeState> => invoke('theme:get'),
onThemeChanged: (listener: (state: ThemeState) => void): (() => void) => onThemeChanged: (listener: (state: ThemeState) => void): (() => void) =>
subscribe('theme:changed', listener), subscribe('theme:changed', listener),
+7 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract' import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
import { GITHUB_URL } from '@shared/links' import { GITHUB_URL } from '@shared/links'
import { useAppVersion } from '../hooks/useAppVersion'
import { useZapretLists } from '../hooks/useZapretLists' import { useZapretLists } from '../hooks/useZapretLists'
import { LIST_TITLES } from '../lib/zapretListMeta' import { LIST_TITLES } from '../lib/zapretListMeta'
import ListEditor from './ListEditor' import ListEditor from './ListEditor'
@@ -12,9 +13,6 @@ import ListEditor from './ListEditor'
*/ */
const isWindows = window.api.platform === 'win32' const isWindows = window.api.platform === 'win32'
const APP_CHANNEL = 'beta' const APP_CHANNEL = 'beta'
// Держать в синхроне с "version" в package.json — оттуда её берёт electron-builder
// для имени установщика и для «Установленные приложения» в Windows.
const APP_VERSION = '0.4.0'
export default function SettingsView({ export default function SettingsView({
onBack, onBack,
@@ -27,6 +25,7 @@ export default function SettingsView({
}): React.JSX.Element { }): React.JSX.Element {
const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists() const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists()
const [openListId, setOpenListId] = useState<ZapretListId | null>(null) const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
const appVersion = useAppVersion()
const openList = lists.find((list) => list.id === openListId) const openList = lists.find((list) => list.id === openListId)
if (openList) { if (openList) {
@@ -119,9 +118,13 @@ export default function SettingsView({
<p className="settings__support-hint">Поддержите звёздочкой</p> <p className="settings__support-hint">Поддержите звёздочкой</p>
</section> </section>
{/* Пока версия не пришла из main, строки нет вовсе: иначе на первом кадре
мелькнуло бы «beta--win32» с пустотой на месте номера. */}
{appVersion && (
<p className="settings__version"> <p className="settings__version">
{APP_CHANNEL}-{APP_VERSION}-{window.api.platform} {APP_CHANNEL}-{appVersion}-{window.api.platform}
</p> </p>
)}
{isWindows && ( {isWindows && (
<button <button
+1 -1
View File
@@ -52,7 +52,7 @@ export default function ZapretCard(): React.JSX.Element {
)} )}
<div className="settings__field"> <div className="settings__field">
<span className="settings__label">Стратегия</span> <span className="settings__label">Способ</span>
{/* На время переключения список заблокирован: смена стратегии на работающем {/* На время переключения список заблокирован: смена стратегии на работающем
обходе останавливает и перенастраивает службу, и второй выбор посреди этого обходе останавливает и перенастраивает службу, и второй выбор посреди этого
наложился бы на первый. */} наложился бы на первый. */}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
/**
* Версия запущенной сборки — та же, что в имени установщика и в «Установленных
* приложениях»: main отдаёт её через `app.getVersion()`, то есть прямо из package.json.
*
* Раньше renderer держал версию своей константой с пометкой «держать в синхроне с
* package.json». Синхронизировать вручную забыли: константа осталась на 0.4.0, и релизы
* 0.5.0 и 0.5.1 показывали пользователю чужую версию. Спрашивать у main дороже на один
* IPC-вызов, зато разъехаться уже нечему.
*
* `null` — версия ещё не пришла; показывать её в этот момент нечего.
*/
export function useAppVersion(): string | null {
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
// Окно могло закрыться, пока шёл ответ — тогда setState уже некому применять.
let cancelled = false
void window.api.getAppVersion().then((value) => {
if (!cancelled) setVersion(value)
})
return () => {
cancelled = true
}
}, [])
return version
}
+8
View File
@@ -101,6 +101,14 @@ export interface ZapretDiagnosticResult extends ZapretDiagnosticTarget {
export interface IpcHandlers { export interface IpcHandlers {
'theme:get': () => ThemeState 'theme:get': () => ThemeState
/**
* Версия приложения — `app.getVersion()`, то есть `"version"` из package.json той сборки,
* которая сейчас запущена. Раньше renderer держал её отдельной константой «в синхроне
* с package.json» вручную: константа осталась на 0.4.0 и релизы 0.5.0 и 0.5.1 показали
* пользователю чужую версию. Спрашивать у main — единственный способ не разъехаться.
*/
'app:version': () => string
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */ /** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
'app:uninstall': () => void 'app:uninstall': () => void