Initial commit

This commit is contained in:
2026-09-03 03:48:00 +03:00
commit 635c920be7
57 changed files with 14375 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+64
View File
@@ -0,0 +1,64 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check:
name: Линт и типы
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run format:check
build:
name: Сборка (${{ matrix.os }})
needs: check
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
# Без этого шага Windows-сборка уезжает без winws.exe: extraResources в
# electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст.
- run: node scripts/fetch-zapret.mjs
# Подпись не настроена: артефакты неподписанные.
# Чтобы подписывать — добавьте секреты и передайте их в env:
# macOS: CSC_LINK, CSC_KEY_PASSWORD (+ APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID и notarize: true)
# Windows: CSC_LINK, CSC_KEY_PASSWORD
- name: Build
run: npx --no-install electron-vite build && npx --no-install electron-builder --publish never
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
with:
name: rknboost-${{ matrix.os }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.AppImage
release/*.deb
if-no-files-found: error
retention-days: 7
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
release
resources/zapret
.idea
.vscode
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+4
View File
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+173
View File
@@ -0,0 +1,173 @@
# RKNboost
## Требования
- Node.js 20+ (проверено на 24)
- npm 10+
## Команды
```bash
npm install # зависимости + скачивание бинарника Electron
npm run dev # dev-режим: HMR для UI, авто-рестарт main-процесса
npm run start # запуск собранной версии без упаковки
npm run build # typecheck + сборка main/preload/renderer в out/
npm run lint # ESLint
npm run format # Prettier
npm run typecheck # tsc для node- и web-частей отдельно
npm run build:unpack # быстрая проверка: приложение без установщика (release/*-unpacked)
npm run build:mac # .dmg + .zip (x64 + arm64)
npm run build:win # NSIS .exe (x64; arm64 не собираем — нет подписанного WinDivert)
npm run build:linux # AppImage + .deb
```
> Установщики под чужую ОС локально собрать нельзя — для этого есть CI
> (`.github/workflows/ci.yml` собирает все три платформы и кладёт артефакты в Actions).
> Если после `npm install` команда `npm run dev` падает с `Error: Electron uninstall` —
> бинарник Electron не докачался; исправляется повторным запуском
> `node node_modules/electron/install.js`.
## Структура
```
src/
main/ # Node-процесс: окна, меню, доступ к ОС
index.ts lifecycle приложения, single instance
window.ts createWindow / createPopupWindow, настройки безопасности
ipc.ts все ipcMain.handle
preload/ # мост между main и renderer (sandbox, contextBridge)
shared/ # ipc-contract.ts — типы каналов, общие для всех процессов
renderer/ # React-приложение (обычный браузерный код, без Node API)
build/ # иконки и entitlements для electron-builder
resources/ # файлы, попадающие в собранное приложение (иконка окна)
out/ # результат electron-vite build
release/ # готовые установщики
```
## Как добавить свой IPC-канал
1. Опишите канал в `src/shared/ipc-contract.ts` (аргументы и результат).
2. Зарегистрируйте обработчик в `src/main/ipc.ts` через хелпер `handle(...)` — типы проверятся.
3. Добавьте метод в объект `api` в `src/preload/index.ts`.
4. Вызывайте из React как `window.api.мойМетод()` — тип подтянется автоматически.
События из main в renderer описываются в `IpcEvents` и подписываются через `subscribe(...)`
(пример — `onThemeChanged`, возвращает функцию отписки для `useEffect`).
## Что показывают демо-карточки
| Карточка | Каналы | Что демонстрирует |
| ---------------- | ----------------------------------------- | ------------------------------------------------------------- |
| Система | `app:info` | версии Electron/Chromium/Node, платформа, путь к userData |
| IPC ping | `ping` | минимальный invoke/handle |
| Нативный диалог | `dialog:open-file` | системный выбор файла + `fs.stat` в main |
| Всплывающее окно | `dialog:message-box`, `window:open-popup` | модальный диалог ОС и второе `BrowserWindow` |
| Тема оформления | `theme:*` | `nativeTheme` как источник правды, реакция на смену темы в ОС |
Тайтлбар системный на всех платформах — окно создаётся без `frame`/`titleBarStyle`, рамку
и кнопки свернуть/развернуть/закрыть рисует сама ОС.
## Безопасность
- `sandbox: true`, `contextIsolation: true`, `nodeIntegration: false`;
- renderer не имеет доступа к `ipcRenderer` — только к методам из `window.api`;
- `setWindowOpenHandler` и `will-navigate` не дают уйти на внешний origin, ссылки открываются
в системном браузере, причём только с протоколами из `ALLOWED_EXTERNAL_PROTOCOLS`;
- в `index.html` задан CSP без внешних источников.
## Иконки
`build/icon.png` (1024×1024) — исходник, из него electron-builder делает иконки для всех платформ;
`build/icon.icns` и `build/icon.ico` лежат рядом как готовые варианты. `resources/icon.png` —
иконка окна для Linux. Замените эти файлы своими, имена оставьте прежними.
## Подпись и нотаризация
По умолчанию выключены (`notarize: false`), сборки неподписанные — их можно запускать локально,
но macOS покажет предупреждение Gatekeeper при переносе на другую машину.
Чтобы включить:
- **macOS**: переменные окружения `CSC_LINK` (base64 сертификата Developer ID) и `CSC_KEY_PASSWORD`,
плюс `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID` и `notarize: true`
в `electron-builder.yml`.
- **Windows**: `CSC_LINK` и `CSC_KEY_PASSWORD` с .pfx-сертификатом.
В CI эти значения передаются секретами GitHub — место уже отмечено комментарием в
`.github/workflows/ci.yml`.
## Обход блокировок (zapret)
Движки и данные — из двух сторонних проектов, оба MIT, оба скачиваются `npm run zapret:fetch`
(вызывается автоматически из `npm run build`) в `resources/zapret/` (в git не хранится):
- **[bol-van/zapret](https://github.com/bol-van/zapret)** — сами движки: `tpws` (macOS, SOCKS5-прокси
без root) и `winws.exe` + драйвер **WinDivert** (Windows, служба с правами администратора).
WinDivert — LGPLv3/GPLv2 на выбор; лицензии обоих проектов кладутся рядом с бинарниками
(`LICENSE.zapret.txt`, `LICENSE.WinDivert.txt`).
- **[Flowseal/zapret-discord-youtube](https://github.com/Flowseal/zapret-discord-youtube)** —
списки сайтов (`resources/zapret/lists/`) и fake-пакеты (`resources/zapret/fakes/`). Версия
зафиксирована по sha коммита в `scripts/fetch-zapret.mjs` (`FLOWSEAL_COMMIT`), лицензия —
`LICENSE.flowseal.txt`.
Свои сгенерированные стратегии для Windows (`src/main/zapret/strategies.win32.generated.ts`,
22 варианта) — не батники: `scripts/import-flowseal-strategies.mjs` разбирает `general*.bat`
из того же репозитория Flowseal и переносит аргументы `winws` в TypeScript. Файл коммитится;
перегенерировать и свериться через `git diff` нужно вручную при обновлении `FLOWSEAL_COMMIT`.
На macOS `tpws` не умеет ни fake-пакетов, ни UDP — там свои профили
(`src/main/zapret/strategies.ts`, раздел `DARWIN_DEFINITIONS`) поверх тех же списков.
Списки сайтов пользователь может редактировать полностью (включая встроенные) — рабочая копия
живёт в `userData/zapret-lists` и переживает обновление приложения; `tpws`/`winws` сами
перечитывают список при изменении файла, перезапуск обхода не нужен (`src/main/zapret/lists.ts`).
### Windows: служба и права
`winws` требует драйвер WinDivert и права администратора, поэтому на Windows он работает не как
обычный дочерний процесс, а как служба Windows `rknboost-zapret` (`src/main/zapret/service.win32.ts`).
Устанавливает и настраивает её само приложение — не инсталлятор, — потому что аргументы `winws`
зависят от выбранной пользователем стратегии.
- **Приложение запрашивает права администратора при каждом запуске**
(`win.requestedExecutionLevel: requireAdministrator` в `electron-builder.yml`). Поэтому все
действия со службой выполняются напрямую, без отдельных запросов UAC по ходу работы.
Если приложение почему-то запущено без прав (dev-режим — там манифеста нет), включается
запасной путь: `outer.ps1` через `Start-Process -Verb RunAs` поднимает само приложение
с флагом `--elevated-service-task`, и UAC запрашивается на него, а не на `powershell.exe` —
в окне UAC пользователь должен видеть имя приложения, а не постороннюю программу.
При установке службы обычным пользователям выдаётся право на `start`/`stop` именно этой
службы (`sc sdset`), так что и без прав включение-выключение обхода работает.
- **Автозапуск с системой** включён по умолчанию (`sc config start= auto`) — обход работает и
после перезагрузки, не дожидаясь запуска приложения. Отключается переключателем в настройках
(тоже через UAC). Выключение обхода кнопкой на главном экране действует только до следующей
перезагрузки, если автозапуск остался включён — служба поднимется вместе с системой заново.
- **arm64 не поддерживается**: у WinDivert нет подписанной сборки под arm64 (только x64 через
эмуляцию на Windows 11), поэтому `electron-builder.yml` собирает Windows-инсталлятор только
для x64.
- Установщик — **per-user** (ставится в `%LocalAppData%`, один клик, без UAC на саму установку).
> ⚠️ Известный компромисс. Каталог установки доступен на запись обычному пользователю, а exe
> при этом требует администратора — значит подмена `RKNboost.exe` даёт злоумышленнику права
> администратора с подтверждения самого пользователя. Это осознанный выбор владельца проекта
> в пользу быстрой установки без UAC. Безопасный вариант — `perMachine: true`, тогда exe лежит
> в защищённом от записи `Program Files`, но установка снова требует UAC и мастера.
Служба работает от `LocalSystem`, и её `binPath` не должен указывать в каталог, куда обычный
пользователь может писать без прав администратора — поэтому при первом включении обхода
приложение копирует `winws.exe` и WinDivert в защищённый ACL'ом
каталог в `%ProgramData%` (`protectedWinwsPath()` в `src/main/zapret/paths.ts`) и указывает
службе именно на эту копию, а не на оригинал в папке установки. При удалении приложения
деинсталлятор (`build/installer.nsh`) сам останавливает и снимает службу вместе с этой копией.
- На одну машину служба одна: её конфиг (`%ProgramData%\rknboost\zapret\strategy.cfg`) хранит
стратегию того пользователя, который последним её менял, — с несколькими пользователями на
одной машине это разделяется некорректно.
## Авто-обновления
Не подключены. Если понадобятся: `npm i electron-updater`, добавить секцию `publish`
в `electron-builder.yml` и вызвать `autoUpdater.checkForUpdatesAndNotify()` в `src/main/index.ts`.
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+16
View File
@@ -0,0 +1,16 @@
; Служба rknboost-zapret не ставится этим установщиком — её создаёт и настраивает само
; приложение при первом включении обхода (src/main/zapret/service.win32.ts), потому что
; аргументы winws зависят от выбранной пользователем стратегии. Но снять её при удалении
; приложения обязаны мы: иначе в системе остаётся служба, указывающая на удалённый winws.exe.
;
; Установка теперь per-user (perMachine: false в electron-builder.yml) — сам деинсталлятор
; запускается БЕЗ прав администратора, а sc delete и удаление %ProgramData% их требуют.
; Поэтому именно для этого шага отдельно запрашиваем UAC (тем же приёмом Start-Process
; -Verb RunAs, что и src/main/zapret/service.win32.ts), а не поднимаем весь деинсталлятор —
; удаление файлов приложения из %LocalAppData% в повышении не нуждается.
!macro customUnInstall
DetailPrint "Останавливаю службу обхода блокировок..."
nsExec::Exec `powershell.exe -NoProfile -Command "try { Start-Process powershell -Verb RunAs -Wait -ArgumentList @('-NoProfile','-Command','sc.exe stop rknboost-zapret; sc.exe delete rknboost-zapret; Remove-Item -Recurse -Force $$env:ProgramData\rknboost -ErrorAction SilentlyContinue') } catch {}"`
Pop $0
!macroend
+313
View File
@@ -0,0 +1,313 @@
# Дизайн-документ — Forgetting
Тёплая фиолетово-кремовая палитра в системе ролей Material 3.
Настроение: спокойный, премиальный, чуть «бутиковый» интерфейс — не типичный холодный «техно».
Документ единый и покрывает обе платформы. **Часть I (основы)** — палитра, роли, типографика, формы, состояния, доступность, тёмная тема — одинакова для десктопа и мобильного. **Часть II** описывает общий язык компонентов. **Части III и IV** — компоновку, которая на платформах различается. Язык дизайна один; меняется только раскладка.
---
# Часть I. Основы (общие для десктопа и мобильного)
## 1. Палитра
### Базовые цвета
| Назначение | HEX | Заметка |
|---|---|---|
| Фиолетовый (акцент) | `#a96bff` | HSL 275°, 100%, 71% — светлый, для тёмной темы и акцентов |
| Фиолетовый (основной) | `#6a45b8` | тон ~40 — заливка кнопок, активные элементы |
| Тёмно-фиолетовый (текст) | `#5a2da8` | текст на светло-фиолетовом контейнере |
| Крем (фон) | `#faf6f0` | основная поверхность |
| Крем (исходный) | `#f6f0ea` / `#ece0d1` | более тёплые/тёмные оттенки нейтрала |
| Оранжевый (исходный) | `#ffb76b` | стартовый цвет, ушёл в роль `tertiary` |
### Связь оттенков
Крем `#ece0d1` (тон ~33°) и оранжевый `#ffb76b` (тон ~31°) — родственники по тёплому углу спектра.
Фиолетовый — холодный акцент в противовес тёплому нейтралу. Отсюда баланс «тёплая база + прохладный акцент».
---
## 2. Роли Material 3
| Роль MD3 | Светлая тема | Текст на ней (`on-`) |
|---|---|---|
| `primary` | `#6a45b8` | `#ffffff` |
| `primary-container` | `#ebddff` | `#5a2da8` |
| `secondary-container` | `#ebddff` | `#5a2da8` |
| `tertiary-container` | `#ffdcad` | `#7a4a14` |
| `surface` | `#faf6f0` | `#3a2e22` |
| `surface-container` | `#f0e8dd` | `#3a2e22` |
| `surface-container-high` | `#ece2d3` | `#3a2e22` |
| `surface-bright` (рабочая зона) | `#fdfaf6` | `#3a2e22` |
| `outline` | `#d8cdbe` | — |
| `outline-variant` | `#ece2d3` | — |
| `error` | `#ba1a1a` | `#ffffff` |
| `error-container` | `#ffdad6` | `#410002` |
### Статусные цвета (тёплый подтон)
| Статус | Фон чипа | Текст | Точка |
|---|---|---|---|
| Онлайн / успех | `#c0dd97` | `#2c4d0a` | `#639922` |
| Офлайн / нейтраль | `#e4dac9` | `#7a6a55` | `#a99a85` |
| Ошибка | `#ffdad6` | `#410002` | `#ba1a1a` |
> Принцип: `primary` (тёмный `#6a45b8`) — для заливок и текста по контрасту.
> Светлый `#a96bff` держим как акцент/иконки/тёмную тему, не как фон под белый текст.
---
## 3. Типографика
- Один шрифт без засечек на весь интерфейс (system-ui / Inter / Roboto).
- Два веса: 400 (обычный) и 500 (акцент). Без 600–700.
- **Sentence case** везде, в том числе в заголовках таблиц. Без Title Case и КАПСА.
- Моноширинный шрифт для технических данных: IP-адреса, VPN-ключи, rx/tx.
Шкала:
| Элемент | Размер | Вес |
|---|---|---|
| Заголовок раздела | 20px | 500 |
| Подзаголовок / название карточки | 15–16px | 500 |
| Основной текст | 14px | 400 |
| Заголовки таблицы | 13px | 500 |
| Данные таблицы | 13px | 400 |
| Подписи / hint | 12px | 400 |
---
## 4. Скругления (MD3 shape scale)
| Элемент | Радиус |
|---|---|
| Кнопки, чипы, инпуты (pill) | `100px` |
| Карточки / контейнеры | `24px` |
| Внутренние блоки таблицы / метрики | `16px` |
| Иконка-логотип | `8–9px` |
| Аватар, точка статуса | `50%` |
> Скругления только полные (по всем сторонам). Односторонних бордюров с radius не делаем.
---
## 5. Состояния и взаимодействие (MD3 state layers)
Поверх элемента — полупрозрачный слой акцентного цвета:
- Hover: ~8% непрозрачности.
- Focus / pressed: ~12%.
- Для фиолетовых элементов слой фиолетовый, для нейтральных — тёмный нейтрал.
Элевация — через тональные поверхности (светлее/темнее крем), а не через тени. Иерархия строится тональностью: рабочая зона данных светлее крема, крем — для фона и хрома.
**Опора на платформу.**
- На десктопе (указатель) hover-слой работает как первичная обратная связь.
- На тач-устройствах hover не срабатывает — обратная связь строится на нажатии и выборе, не на наведении.
**Инертные vs действенные элементы.**
- Навигационные строки и статичные строки данных — **инертны** (без эффекта вдавливания); активное состояние показывается заливкой-таблеткой (`primary-container`).
- Реальные действия (кнопки, переключатель, QR, удаление) могут иметь лёгкий pressed-эффект через state layer акцентного цвета (~12%).
---
## 6. Доступность
- Контраст фиолетового `#a96bff` на креме ≈ 2,6:1 — годится только для крупных элементов; для мелкого текста по фиолетовому брать `#5a2da8` или темнее (≥ 4,5:1).
- Основной текст всегда тёмно-коричневый `#3a2e22`, не чисто чёрный и не фиолетовый. Светлый акцентный фиолетовый под мелкий текст не использовать.
- Минимальный размер шрифта — 12px; технические данные (IP, ключи, rx/tx) — моноширинным.
- Тач-таргеты ≥ 44px (кнопки действий, гамбургер, переключатель, пункты меню).
- Иконки-кнопки без подписи получают доступное имя (`aria-label`): меню, удаление и т.п.
- Один шрифт, два веса (400 / 500), sentence case везде.
---
## 7. Тёмная тема
Крем в тёмную тему не переносится — нужен отдельный набор. Направление — **тёплый уголь**: тёмные поверхности с тем же тёплым нейтральным подтоном, что и крем в светлой теме (тон ~30–40°), а не баклажановые. Так фирменный баланс «тёплая база + прохладный фиолетовый акцент» сохраняется и в темноте: акцент остаётся холодным фиолетовым на тёплой тёмной базе, как и в светлой.
Фиолетовый акцент — общий со светлой темой по смыслу, но осветлён под тёмный фон.
| Роль | Тёмная тема | Текст на ней (`on-`) |
|---|---|---|
| `surface` | `#1a1611` | `#ede2d4` |
| `surface-container` | `#251f18` | `#ede2d4` |
| `surface-container-high` | `#302820` | `#ede2d4` |
| `surface-bright` (рабочая зона) | `#2b251d` | `#ede2d4` |
| `primary` | `#cbb3ff` (светлый фиолетовый) | `#381b6c` |
| `primary-container` | `#52389e` | `#ebddff` |
| `secondary-container` | `#483a55` | `#ebddff` |
| `outline` | `#4d4334` | — |
| `outline-variant` | `#342c20` | — |
| `error` | `#ffb4ab` | `#5c1414` |
| `error-container` | `#5c1414` | `#ffb4ab` |
| текст / `on-surface` | `#ede2d4` | — |
| приглушённый текст | `#b6a994` | — |
### Статусные цвета (тёмная тема)
Фон и текст осветлены/переинвертированы под тёмный фон, подтон тот же тёплый.
| Статус | Фон чипа | Текст | Точка |
|---|---|---|---|
| Онлайн / успех | `#2d4413` | `#c8e3a0` | `#8fc24f` |
| Офлайн / нейтраль | `#34291c` | `#d0c0a4` | `#b0a085` |
| Ошибка | `#5c1414` | `#ffb4ab` | `#ff5449` |
> Значения — рабочая отправная точка. Контраст текста на поверхностях стоит прогнать через проверку (цель ≥ 4,5:1 для мелкого текста) перед фиксацией.
>
> Альтернатива на будущее: «баклажан» — поверхности тонируются фиолетовым (`surface` `#18121d`, `container` `#221a29`, `bright` `#2a2031`, текст `#ece0e9`, приглушённый `#b3a4bd`, `outline` `#4c4356`). Каноничный MD3-путь, звучит «премиальнее», но база уходит в холодный тон.
---
# Часть II. Компоненты (общий язык)
Эти компоненты используют одни и те же роли на обеих платформах; различается только их расположение в раскладке.
### Кнопки
- **Главное действие** (Создать пользователя): заливка `primary` `#6a45b8`, текст белый (`on-primary`), pill.
- **Вторичное** (Обновить): tonal — фон `secondary-container` `#ebddff`, текст `#5a2da8`, pill.
- **Опасное** (Выход, удаление): контур `#e6c4bc` / роль `error`, текст `#b34a3a`, без заливки, pill.
- Иконка слева от текста, gap 6–8px.
### Поля ввода и поиска
- Pill-инпут, фон `surface-bright` `#fdfaf6`, бордюр `outline` `#d8cdbe`, плейсхолдер приглушённым.
- Поле поиска — с иконкой поиска слева.
### Чипы статуса
- Pill, маленькая цветная точка + подпись. Цвета — из таблицы статусов (успех / нейтраль / ошибка), текст соответствующей роли `on-`.
---
# Часть III. Десктоп
### Компоновка
- Многоколоночная сетка.
- Заголовок раздела — в шапке контента.
### Боковое меню (постоянная панель)
- Фиксированная ширина ~240px, всегда на экране.
- Фон `#f0e8dd` (`surface-container`, на тон темнее основного — мягкое разделение без жёсткой границы).
- Логотип сверху, навигация ниже, «Выход» прижат к низу (`margin-top: auto`).
- Активный пункт: pill-контейнер `#ebddff` (`primary-container`) + текст `#5a2da8`.
- Неактивные: текст `#5a4a3a`, при наведении — лёгкая фиолетовая подсветка (state layer).
### Таблица пользователей
- Контейнер `surface-bright` `#fdfaf6`, скругление 16px, бордюр `#ece2d3` (`outline-variant`).
- Шапка на тоне `#f0e8dd`, текст среднего веса `#5a4a3a`.
- Разделители строк — `0.5px solid #ece2d3`.
- Колонки: `#`, Статус, Имя, IP, rx, tx, действия.
- IP и Key — моноширинным; длинный ключ обрезаем многоточием.
- Действия — иконки: копировать `#8a6bff`, удалить `#b34a3a`.
---
# Часть IV. Мобильный
## 1. Принципы адаптации
1. Одна колонка. Контент течёт вертикально на ширине телефона (рабочая ширина ~360px, поля по 16px).
2. Никаких таблиц с горизонтальной прокруткой — табличные данные разворачиваются в карточки.
3. Боковое меню десктопа превращается в модальный navigation drawer (паттерн Material 3).
4. Главное действие всегда в зоне большого пальца — кнопки полноширинные или парные, высота тача ≥ 44px.
5. Те же роли поверхностей и те же shape-токены, что на десктопе. Меняется компоновка, не язык дизайна.
## 2. Навигация и drawer
### Модель
Приложение имеет один основной раздел («Пользователи»). На десктопе это постоянная боковая панель ~240px; на мобильном — **модальный drawer**, скрытый по умолчанию.
### Открытие и закрытие (важно по MD3)
- Открытие — **только тапом по иконке-гамбургеру** в верхнем баре.
- Закрытие — тап по затемнению (scrim) или свайп панели к её левому краю (срабатывает при сдвиге ~40px+).
- Открытие edge-swipe'ом от края экрана **не используется**: жест конфликтует с системным «назад» (predictive back) на Android.
### Поведение
- Drawer выезжает слева, поверх контента, с затемнением под ним.
- Затемнение блокирует взаимодействие с экраном, пока панель открыта.
- Анимация — горизонтальный сдвиг панели + плавное появление scrim (быстрый, «решительный» easing, ~250–280 мс); затемнение и сдвиг анимируются вместе.
### Содержимое панели
Поверхность панели — `surface-container` (на тон темнее основного экрана), мягкое разделение без жёсткой границы. Скругление контейнера 24px, правые углы.
Сверху вниз:
1. **Шапка** — вордмарк приложения (роль заголовка) + версия (роль приглушённого текста), внутренний отступ.
2. **Тонкий разделитель** (`outline`, 0.5px).
3. **Навигация** — пункты-строки с ведущей иконкой 22px и подписью 15px, gap 16px.
- Активный пункт: pill-контейнер `primary-container` во всю ширину; текст, иконка и счётчик — `on-primary-container`, вес 500.
- Неактивный пункт: подпись и иконка — основной текст (тёмный нейтрал), без подсветки при наведении.
- Строки навигации **инертны при нажатии** — без эффекта вдавливания, выделяет только активное состояние.
4. **«Выход»** — прижат к низу (`margin-top: auto`), стиль опасного действия (контур, без заливки), pill, иконка слева.
### Замечание про объём меню
Длинный список разделов (как у Gmail-паттерна) оправдан только при 5+ назначениях. У приложения раздел один, поэтому drawer минимальный: шапка → активный пункт → «Выход» внизу. Доп. пункты добавляются только под реальные экраны.
## 3. Верхний бар (top app bar)
- Слева — иконка-гамбургер (кнопка-таргет ≥ 42px, круглая зона нажатия).
- Рядом — заголовок раздела (роль заголовка, 20px / 500).
- Бар без заливки и без тени; высота поверхности задаётся фоном экрана (`surface`).
## 4. Экран «Пользователи»
Порядок блоков сверху вниз: верхний бар → карточка сервера → «Добавить сервер» → форма создания → счётчик → список пользователей.
### 4.1. Карточка VPN-сервера
- Поверхность — `surface-bright`, скругление 24px.
- **Активная обводка** — рамка 2px роли `primary` (приглушённый светло-фиолетовый вариант): сигнал, что сервер выбран/активен.
- Слева — компактный **переключатель** (pill-трек, маленький круглый ползунок). Состояние «вкл» отражается цветом трека из роли статуса «успех» и положением ползунка.
- По центру — название сервера (роль подзаголовка, 16px / 500) и IP **моноширинным** (роль приглушённого текста).
- Справа — нейтральный чип счётчика пиров (роль статуса «нейтраль», pill).
### 4.2. «Добавить сервер»
- Полноширинная кнопка-«призрак»: пунктирный контур (`outline`), без заливки, иконка `+` слева, приглушённый текст. Pill (100px).
### 4.3. Форма создания пользователя
- Поле ввода: pill-инпут на `surface-bright`, бордюр `outline`, плейсхолдер приглушённым. Полная ширина.
- Под ним пара кнопок в ряд (50/50):
- **Создать** — главное действие: заливка `primary`, текст `on-primary`, иконка `+`, pill.
- **Обновить** — вторичное: tonal (`secondary-container` / `on-secondary-container`), иконка обновления, pill.
### 4.4. Список пользователей
- Над списком — счётчик («N пользователей») приглушённым текстом 12px.
- Десктопная таблица (`#`, Статус, Имя, IP, rx, tx, действия) на мобильном **разворачивается в карточки** — по одной на пользователя.
### 4.5. Карточка пользователя
Поверхность `surface-bright`, бордюр `outline-variant`, скругление 24px. Внутри:
1. **Верхняя строка** — слева порядковый `#N` (приглушённый) + имя (16px / 500); справа чип статуса «онлайн» (роль статуса «успех»: точка + подпись, pill).
2. **Строка IP** — иконка-глобус + адрес моноширинным.
3. **Два блока метрик** (rx / tx) в ряд 50/50: внутренние блоки на `surface-container`, скругление 16px. Сверху подпись со стрелкой (↓ rx / ↑ tx) приглушённым 12px, под ней значение моноширинным 18px / 500.
4. **Действия** — ряд: кнопка «QR» (контур `outline`, иконка QR акцентным фиолетовым) на всю доступную ширину + квадратная кнопка удаления (контур роли «ошибка», иконка корзины — `error`).
## 5. Сводка мобильных компонентов (через роли и токены)
| Компонент | Поверхность / контур | Форма | Текст |
|---|---|---|---|
| Карточка сервера | `surface-bright` + рамка `primary` 2px | 24px | подзаголовок + mono |
| Карточка пользователя | `surface-bright` + `outline-variant` | 24px | подзаголовок + mono |
| Блок метрики (rx/tx) | `surface-container` | 16px | подпись + mono |
| Кнопка «Создать» | заливка `primary` | pill 100px | `on-primary`, 500 |
| Кнопка «Обновить» | tonal `secondary-container` | pill 100px | `on-…`, 500 |
| «Добавить сервер» | пунктир `outline` | pill 100px | приглушённый |
| «Выход» | контур (опасное) | pill 100px | роль опасного |
| Поле ввода | `surface-bright` + `outline` | pill 100px | основной / плейсхолдер |
| Чип статуса | роль статуса (успех/нейтраль/ошибка) | pill 100px | соответствующий `on-` |
| Активный пункт меню | `primary-container` | pill 100px | `on-primary-container`, 500 |
---
# Отличия десктоп / мобильный (кратко)
| Аспект | Десктоп | Мобильный |
|---|---|---|
| Навигация | постоянная боковая панель ~240px | модальный drawer (гамбургер) |
| Список пользователей | таблица с колонками | карточки, по одной на пользователя |
| rx / tx | ячейки таблицы | два блока-метрики внутри карточки |
| Действия в строке | иконки в конце строки | ряд кнопок внизу карточки |
| Ширина | многоколоночная сетка | одна колонка, поля 16px |
| Заголовок раздела | в шапке контента | в верхнем баре рядом с гамбургером |
---
# Принципы (итог)
1. Тёплая база + прохладный фиолетовый акцент. Один язык дизайна на обеих платформах — меняется компоновка, не палитра.
2. Цвет кодирует смысл (статус, действие), а не порядок.
3. Иерархия — тональностью поверхности, не тенями. Рабочая зона данных светлее (почти белая), крем — для фона и хрома.
4. Sentence case, два веса шрифта, без капса. Технические данные — моноширинным.
5. Главное действие — залито фиолетовым; вторичное — tonal; опасное — контур.
6. Таблицы → карточки, rx/tx → блоки-метрики на мобильном; одна колонка, крупные тач-таргеты, действия в зоне большого пальца.
7. Drawer открывается гамбургером, закрывается scrim/свайпом; edge-swipe не задействуем. Hover — опора десктопа; на тач-устройствах обратная связь на нажатии/выборе.
+91
View File
@@ -0,0 +1,91 @@
appId: com.rknboost.app
productName: RKNboost
directories:
buildResources: build
output: release
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!.github/*'
# zapret идёт отдельно через extraResources ниже — не дублировать его в app.asar.
- '!resources/zapret/**'
asarUnpack:
- 'resources/**'
# Бинарники zapret (npm run zapret:fetch) кладём рядом с приложением, а не в asar —
# это исполняемые файлы, которые main-процесс запускает через spawn.
extraResources:
- from: resources/zapret
to: zapret
win:
executableName: RKNboost
# Приложение запрашивает права администратора при КАЖДОМ запуске (манифест exe).
# Внимание на компромисс: установка при этом остаётся per-user, то есть exe лежит в
# %LocalAppData%\Programs\RKNboost — папке, доступной на запись обычному пользователю.
# Значит подмена этого exe даёт злоумышленнику администратора с подтверждения самого
# пользователя. Это осознанный выбор владельца проекта; безопасный вариант — вернуть
# perMachine: true, чтобы exe лежал в защищённом от записи Program Files.
requestedExecutionLevel: requireAdministrator
target:
# arm64 не собираем: WinDivert (драйвер для winws — src/main/zapret/engine.win32.ts)
# не имеет подписанной сборки под arm64, только x64 через эмуляцию (docs/windows.md
# в поставке zapret) — arm64-инсталлятор был бы заведомо нерабочим.
- target: nsis
arch: [x64]
nsis:
# Быстрая установка per-user, без UAC и без мастера: ставится в %LocalAppData%, куда
# обычный пользователь и так пишет без повышения прав. Раньше здесь стоял perMachine —
# это была защита от того, что служба (LocalSystem) ссылалась бы на winws.exe прямо
# в папке установки. Теперь вместо этого при первом включении обхода приложение сама
# копирует winws.exe в защищённый ACL'ом каталог в %ProgramData% (см. applyServiceConfig
# в src/main/zapret/service.win32.ts) — сама установка приложения per-user безопасна.
oneClick: true
perMachine: false
# Удалять %APPDATA%\rknboost (списки сайтов, метка первого запуска) вместе с приложением.
# По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после
# удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит
# проверка `ifNot isUpdated`, так что отредактированные списки переживают обновление.
deleteAppDataOnUninstall: true
include: build/installer.nsh
artifactName: ${name}-${version}-setup-${arch}.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
category: public.app-category.productivity
target:
- target: dmg
arch: [x64, arm64]
- target: zip
arch: [x64, arm64]
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
hardenedRuntime: true
# tpws — сторонний Mach-O внутри .app; hardened runtime требует, чтобы electron-builder
# подписал и его тем же Developer ID, иначе Gatekeeper убьёт процесс при первом spawn.
binaries:
- Contents/Resources/zapret/darwin/tpws
# Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает.
# Как включить — см. раздел «Подпись» в README.md.
notarize: false
dmg:
artifactName: ${name}-${version}-${arch}.${ext}
linux:
target:
- AppImage
- deb
maintainer: RKNboost
category: Utility
synopsis: Кроссплатформенное приложение на Electron
appImage:
artifactName: ${name}-${version}-${arch}.${ext}
npmRebuild: false
+33
View File
@@ -0,0 +1,33 @@
import { resolve } from 'node:path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
// Три независимые сборки: main (Node), preload (sandbox) и renderer (браузер).
export default defineConfig({
main: {
build: {
rollupOptions: {
input: { index: resolve('src/main/index.ts') }
}
}
},
preload: {
build: {
rollupOptions: {
input: { index: resolve('src/preload/index.ts') },
// preload запускается в sandbox: всё, кроме самого electron, должно быть
// вкомпилировано в бандл — require() там недоступен.
external: ['electron']
}
}
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [react()]
}
})
+38
View File
@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import prettier from 'eslint-config-prettier'
export default tseslint.config(
{ ignores: ['node_modules', 'out', 'dist', 'release', '.vscode'] },
js.configs.recommended,
tseslint.configs.recommended,
{
files: [
'src/main/**/*.ts',
'src/preload/**/*.ts',
'electron.vite.config.ts',
'scripts/**/*.mjs'
],
languageOptions: {
globals: { ...globals.node }
}
},
{
files: ['src/renderer/**/*.{ts,tsx}'],
languageOptions: {
globals: { ...globals.browser }
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }]
}
},
prettier
)
+7078
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "rknboost",
"version": "0.4.0",
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
"main": "./out/main/index.js",
"author": "RKNboost",
"license": "MIT",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "electron-vite dev",
"start": "electron-vite preview",
"build": "npm run zapret:fetch && npm run typecheck && electron-vite build",
"zapret:fetch": "node scripts/fetch-zapret.mjs",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"build:unpack": "npm run build && electron-builder --dir",
"build:mac": "npm run build && electron-builder --mac",
"build:win": "npm run build && electron-builder --win",
"build:linux": "npm run build && electron-builder --linux",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"@electron-toolkit/utils": "^4.0.0"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@eslint/js": "^10.0.1",
"@types/node": "^24.9.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^44.1.1",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.9.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.69.0",
"vite": "^7.3.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env node
/**
* Кладёт бинарники и данные zapret в resources/zapret/ — оттуда electron-builder
* положит их в сборку через extraResources.
*
* Два источника:
* • bol-van/zapret — движки (tpws, winws.exe + WinDivert), версия пинована и
* сверяется по sha256sum.txt из релиза;
* • Flowseal/zapret-discord-youtube — подобранные сообществом списки доменов и
* стратегии winws (переносятся в код генератором `import-flowseal-strategies.mjs`,
* здесь просто данные: списки + fake-пакеты). У Flowseal нет sha256sum.txt —
* версия фиксируется по sha коммита, на который указывает тег.
*
* Каталог resources/zapret в git не хранится: запустите `npm run zapret:fetch`
* (или просто `npm run build` — он вызывает скрипт сам).
*/
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, copyFile, readFile, writeFile, rm, chmod } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ZAPRET_VERSION = 'v72.13'
const WINDIVERT_VERSION = 'v2.2.2'
/** Тег Flowseal, зафиксированный по sha коммита (проверено: `git/refs/tags/1.10.2`). */
const FLOWSEAL_VERSION = '1.10.2'
const FLOWSEAL_COMMIT = 'dfd8e613b099676cf2aa7b474ee5923801514dec'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DEST = join(ROOT, 'resources', 'zapret')
const RELEASE = `https://github.com/bol-van/zapret/releases/download/${ZAPRET_VERSION}`
const ARCHIVE = `zapret-${ZAPRET_VERSION}.tar.gz`
/** Корневой каталог внутри архива — с ним же начинаются пути в sha256sum.txt. */
const PREFIX = `zapret-${ZAPRET_VERSION}`
/** Что забираем из архива bol-van/zapret: путь внутри архива → путь внутри resources/zapret. */
const FILES = {
'binaries/mac64/tpws': 'darwin/tpws',
'binaries/windows-x86_64/winws.exe': 'win32/x64/winws.exe',
'binaries/windows-x86_64/cygwin1.dll': 'win32/x64/cygwin1.dll',
'binaries/windows-x86_64/WinDivert.dll': 'win32/x64/WinDivert.dll',
'binaries/windows-x86_64/WinDivert64.sys': 'win32/x64/WinDivert64.sys',
'docs/LICENSE.txt': 'LICENSE.zapret.txt'
}
/** Исполняемый бит нужен только там, где мы сами запускаем бинарник. */
const EXECUTABLE = new Set(['darwin/tpws'])
/**
* Списки доменов Flowseal → наши короткие имена (используются и в `lists.ts`, и
* в сгенерированных стратегиях). `*-user.txt` не берём — у нас все списки и так
* редактируемые, отдельного пользовательского слоя не нужно.
*/
const FLOWSEAL_LISTS = {
'lists/list-general.txt': 'lists/general.txt',
'lists/list-google.txt': 'lists/google.txt',
'lists/list-exclude.txt': 'lists/exclude.txt',
'lists/ipset-exclude.txt': 'lists/ipset-exclude.txt',
'lists/ipset-all.txt': 'lists/ipset-all.txt'
}
/** Fake-пакеты, реально используемые хотя бы одной стратегией general*.bat. */
const FLOWSEAL_FAKES = [
'ACTIVE_DISCORD_UDP.bin',
'ACTIVE_GAME_UDP.bin',
'quic_initial_4pda_to.bin',
'quic_initial_www_google_com.bin',
'stun.bin',
'stun2.bin',
'tls_clienthello_4pda_to.bin',
'tls_clienthello_max_ru.bin',
'tls_clienthello_sochi_park.bin',
'tls_clienthello_www_google_com.bin'
]
async function download(url) {
const response = await fetch(url, { redirect: 'follow' })
if (!response.ok) throw new Error(`${response.status} ${response.statusText} — ${url}`)
return Buffer.from(await response.arrayBuffer())
}
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex')
}
/** sha256sum.txt: строки вида "<hex> zapret-v72.13/binaries/mac64/tpws". */
function parseChecksums(text) {
const checksums = new Map()
for (const line of text.split('\n')) {
const match = /^([0-9a-f]{64})\s+(\S+)$/.exec(line.trim())
if (match) checksums.set(match[2], match[1])
}
return checksums
}
async function fetchZapretEngines(work) {
console.log(`zapret ${ZAPRET_VERSION}: скачиваю ${ARCHIVE}`)
const archivePath = join(work, ARCHIVE)
await writeFile(archivePath, await download(`${RELEASE}/${ARCHIVE}`))
const checksums = parseChecksums((await download(`${RELEASE}/sha256sum.txt`)).toString('utf8'))
// tar есть на macOS, Linux и в Windows 10 1803+; распаковываем только нужные пути.
execFileSync(
'tar',
['-xzf', archivePath, '-C', work, ...Object.keys(FILES).map((p) => `${PREFIX}/${p}`)],
{ stdio: ['ignore', 'ignore', 'inherit'] }
)
for (const [source, target] of Object.entries(FILES)) {
const extracted = join(work, PREFIX, source)
const content = await readFile(extracted)
// Лицензии в sha256sum.txt не попадают — там только binaries/**.
const expected = checksums.get(`${PREFIX}/${source}`)
if (expected) {
const actual = sha256(content)
if (actual !== expected) {
throw new Error(
`не совпала контрольная сумма ${source}\n ожидалось: ${expected}\n получено: ${actual}`
)
}
}
const destination = join(DEST, target)
await mkdir(dirname(destination), { recursive: true })
await copyFile(extracted, destination)
if (EXECUTABLE.has(target)) await chmod(destination, 0o755)
console.log(` ${target}${expected ? ' ✓ sha256' : ''}`)
}
// Лицензия WinDivert лежит в своём репозитории, в архив zapret не входит.
const windivertLicense = await download(
`https://raw.githubusercontent.com/basil00/WinDivert/${WINDIVERT_VERSION}/LICENSE`
)
await writeFile(join(DEST, 'LICENSE.WinDivert.txt'), windivertLicense)
console.log(' LICENSE.WinDivert.txt')
}
async function fetchFlowsealData(work) {
console.log(
`Flowseal ${FLOWSEAL_VERSION} (commit ${FLOWSEAL_COMMIT.slice(0, 12)}): скачиваю списки и fake-пакеты`
)
const archivePath = join(work, 'flowseal.tar.gz')
// Тарбол по sha коммита, а не по имени тега — тег можно переставить, sha неизменен.
await writeFile(
archivePath,
await download(
`https://codeload.github.com/Flowseal/zapret-discord-youtube/tar.gz/${FLOWSEAL_COMMIT}`
)
)
const paths = [
...Object.keys(FLOWSEAL_LISTS),
...FLOWSEAL_FAKES.map((name) => `bin/${name}`),
'LICENSE.txt'
]
// GitHub codeload архивирует репозиторий в каталог "<repo>-<sha>/".
const prefixOut = execFileSync('tar', ['-tzf', archivePath], { encoding: 'utf8' })
.split('\n')[0]
.split('/')[0]
execFileSync('tar', ['-xzf', archivePath, '-C', work, ...paths.map((p) => `${prefixOut}/${p}`)], {
stdio: ['ignore', 'ignore', 'inherit']
})
for (const [source, target] of Object.entries(FLOWSEAL_LISTS)) {
const destination = join(DEST, target)
await mkdir(dirname(destination), { recursive: true })
await copyFile(join(work, prefixOut, source), destination)
console.log(` ${target}`)
}
const fakesDir = join(DEST, 'fakes')
await mkdir(fakesDir, { recursive: true })
for (const name of FLOWSEAL_FAKES) {
await copyFile(join(work, prefixOut, 'bin', name), join(fakesDir, name))
console.log(` fakes/${name}`)
}
await copyFile(join(work, prefixOut, 'LICENSE.txt'), join(DEST, 'LICENSE.flowseal.txt'))
console.log(' LICENSE.flowseal.txt')
}
async function main() {
const work = await mkdtemp(join(tmpdir(), 'zapret-'))
try {
await fetchZapretEngines(work)
await fetchFlowsealData(work)
console.log(`Готово: ${DEST}`)
} finally {
await rm(work, { recursive: true, force: true })
}
}
// Повторная сборка не должна каждый раз ходить в сеть.
if (
process.argv.includes('--force') ||
!existsSync(join(DEST, 'darwin', 'tpws')) ||
!existsSync(join(DEST, 'win32', 'x64', 'winws.exe')) ||
!existsSync(join(DEST, 'lists', 'general.txt'))
) {
await main()
} else {
console.log(`zapret уже распакован в ${DEST} (перекачать: npm run zapret:fetch -- --force)`)
}
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
/**
* Разовый (перезапускаемый) генератор: превращает `general*.bat` из
* Flowseal/zapret-discord-youtube в `src/main/zapret/strategies.win32.generated.ts`.
*
* Зачем отдельным скриптом, а не частью `fetch-zapret.mjs`: вывод коммитится в
* репозиторий — в рантайме и в сборке .bat-файлов быть не должно, только сгенерированный
* TypeScript. Запускать вручную при обновлении `FLOWSEAL_COMMIT` в `fetch-zapret.mjs`,
* проверяя результат через `git diff`, а не доверяя вслепую (формат батников может
* измениться — тогда парсер должен упасть, а не тихо сгенерировать мусор).
*
* Формат batch-файлов Flowseal строго регулярный (проверено на всех 22 вариантах):
* start "zapret: %~n0" /min "%BIN%winws.exe" --arg1 --arg2 ^
* --arg3 ^
* ...
* --argN
* Это одна команда, продолжающаяся через `^` в конце строки — не программа, а
* длинная командная строка winws. Переносим только сами аргументы.
*/
import { execFileSync } from 'node:child_process'
import { mkdtemp, readFile, rm, readdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// Держим в синхроне с `fetch-zapret.mjs` — версия данных должна совпадать.
const FLOWSEAL_COMMIT = 'dfd8e613b099676cf2aa7b474ee5923801514dec'
const FLOWSEAL_VERSION = '1.10.2'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const OUTPUT = join(ROOT, 'src', 'main', 'zapret', 'strategies.win32.generated.ts')
/** Списки Flowseal → наши короткие имена (совпадает с `FLOWSEAL_LISTS` в fetch-zapret.mjs). */
const LIST_FILES = {
'list-general.txt': 'general',
'list-google.txt': 'google',
'list-exclude.txt': 'exclude',
'ipset-exclude.txt': 'ipset-exclude',
'ipset-all.txt': 'ipset-all'
}
/** Пользовательские дубликаты списков — у нас все списки и так редактируемые, лишние. */
const DROP_LIST_SUFFIXES = ['-user.txt']
/** Игровой фильтр Flowseal по умолчанию выключен: `GameFilterTCP=12`, `GameFilterUDP=12`. */
const GAME_FILTER_DISABLED = '12'
function idFromFilename(filename) {
// "general.bat" → "general" / "Стандартная"; "general (ALT2).bat" → "alt2" / "ALT2"
const match = /^general(?: \((.+)\))?\.bat$/.exec(filename)
if (!match) throw new Error(`Неожиданное имя файла стратегии: ${filename}`)
const variant = match[1]
if (!variant) return { id: 'general', name: 'Стандартная' }
const id = variant.toLowerCase().replace(/\s+/g, '-')
return { id, name: variant }
}
/** Разбивает командную строку на токены, учитывая кавычки, без внешней библиотеки. */
function tokenize(command) {
const tokens = command.match(/(?:[^\s"]|"[^"]*")+/g) ?? []
return tokens.map((token) => token.replace(/^"(.*)"$/, '$1'))
}
function resolvePlaceholders(token) {
for (const [flowsealName, ourName] of Object.entries(LIST_FILES)) {
if (token.includes(`%LISTS%${flowsealName}`)) {
return token.replace(`%LISTS%${flowsealName}`, `{LISTS}/${ourName}.txt`)
}
}
const binMatch = /%BIN%([A-Za-z0-9_.]+\.bin)/.exec(token)
if (binMatch) return token.replace(binMatch[0], `{FAKES}/${binMatch[1]}`)
return token
.replaceAll('%GameFilterTCP%', GAME_FILTER_DISABLED)
.replaceAll('%GameFilterUDP%', GAME_FILTER_DISABLED)
}
function isDroppedUserListArg(token) {
return DROP_LIST_SUFFIXES.some((suffix) => token.includes(`%LISTS%`) && token.includes(suffix))
}
function parseBatFile(filename, content) {
const marker = 'winws.exe"'
const start = content.indexOf(marker)
if (start === -1) throw new Error(`${filename}: не нашёл запуск winws.exe`)
// Дальше до конца файла — одна командная строка, продолжающаяся через "^\n".
const rest = content.slice(start + marker.length)
const joined = rest.replace(/\^\r?\n/g, ' ')
const tokens = tokenize(joined)
.map((t) => t.trim())
.filter(Boolean)
.filter((t) => !isDroppedUserListArg(t))
// Кавычки в .bat защищали путь от разбора cmd.exe при запуске через `start`.
// Мы передаём argv напрямую в spawn (без шелла) — кавычки внутри значения
// остались бы буквальными символами в имени файла, поэтому убираем везде.
.map((t) => t.replaceAll('"', ''))
.map(resolvePlaceholders)
if (tokens.length < 5)
throw new Error(`${filename}: подозрительно мало аргументов (${tokens.length})`)
const { id, name } = idFromFilename(filename)
return { id, name, args: tokens }
}
async function main() {
const work = await mkdtemp(join(tmpdir(), 'flowseal-strategies-'))
try {
console.log(
`Flowseal ${FLOWSEAL_VERSION} (commit ${FLOWSEAL_COMMIT.slice(0, 12)}): скачиваю batch-файлы`
)
const archivePath = join(work, 'flowseal.tar.gz')
const response = await fetch(
`https://codeload.github.com/Flowseal/zapret-discord-youtube/tar.gz/${FLOWSEAL_COMMIT}`
)
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
await writeFile(archivePath, Buffer.from(await response.arrayBuffer()))
const prefix = execFileSync('tar', ['-tzf', archivePath], { encoding: 'utf8' })
.split('\n')[0]
.split('/')[0]
execFileSync('tar', ['-xzf', archivePath, '-C', work], {
stdio: ['ignore', 'ignore', 'inherit']
})
const srcDir = join(work, prefix)
const batFiles = (await readdir(srcDir)).filter(
(name) => name.startsWith('general') && name.endsWith('.bat')
)
batFiles.sort()
if (batFiles.length === 0)
throw new Error('Не нашлось ни одного general*.bat — формат репозитория изменился')
const strategies = []
for (const filename of batFiles) {
const content = await readFile(join(srcDir, filename), 'utf8')
strategies.push(parseBatFile(filename, content))
console.log(
` ${filename} → ${strategies.at(-1).id} (${strategies.at(-1).args.length} аргументов)`
)
}
const ids = new Set(strategies.map((s) => s.id))
if (ids.size !== strategies.length)
throw new Error('Столкновение id — два файла дали одинаковый идентификатор')
const body = strategies
.map(
(s) =>
` {\n id: ${JSON.stringify(s.id)},\n name: ${JSON.stringify(s.name)},\n args: [\n${s.args
.map((a) => ` ${JSON.stringify(a)}`)
.join(',\n')}\n ]\n }`
)
.join(',\n')
const output = `/**
* СГЕНЕРИРОВАНО \`scripts/import-flowseal-strategies.mjs\` из
* Flowseal/zapret-discord-youtube ${FLOWSEAL_VERSION} (commit ${FLOWSEAL_COMMIT}).
* Не редактировать руками — перезапустить генератор и закоммитить результат.
*
* \`{LISTS}\` и \`{FAKES}\` — плейсхолдеры путей, резолвятся в \`strategies.ts\`
* через \`resolvePlaceholders()\` в фактические пути userData/resources.
*/
export interface GeneratedWin32Strategy {
id: string
name: string
args: string[]
}
export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
${body}
]
`
await writeFile(OUTPUT, output)
console.log(`Готово: ${OUTPUT} (${strategies.length} стратегий)`)
} finally {
await rm(work, { recursive: true, force: true })
}
}
await main()
+55
View File
@@ -0,0 +1,55 @@
import { access, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { app, dialog, type BrowserWindow } from 'electron'
import { GITHUB_URL } from '../shared/links'
import { openExternal } from './window'
/**
* Метка «приветствие уже показывали». Лежит в userData, поэтому переживает обновление
* приложения — окно показывается один раз за всё время, а не после каждой установки.
*/
function markerPath(): string {
return join(app.getPath('userData'), 'first-run.json')
}
async function alreadyShown(): Promise<boolean> {
try {
await access(markerPath())
return true
} catch {
return false
}
}
/**
* Приветственное окно при первом запуске: просьба поддержать проект звёздочкой.
*
* Рисует его сама ОС (`dialog.showMessageBox`), а не HTML-страница: так окно получается
* маленьким, модальным к главному и выглядит системным, а не как ещё один экран приложения.
*/
export async function showFirstRunSupportDialog(window: BrowserWindow): Promise<void> {
if (await alreadyShown()) return
// Метку ставим ДО показа, а не после ответа: если приложение закроют прямо на диалоге,
// лучше не показать его больше никогда, чем спрашивать при каждом запуске.
try {
await writeFile(markerPath(), JSON.stringify({ shownAt: new Date().toISOString() }), 'utf8')
} catch {
// Не смогли записать метку — покажем окно ещё раз в следующий запуск. Не повод падать.
}
const { response } = await dialog.showMessageBox(window, {
type: 'info',
buttons: ['Перейти', 'Закрыть'],
defaultId: 0,
cancelId: 1,
noLink: true,
title: 'Поддержите RKNboost',
message: 'Поддержите проект',
detail: 'Поддержите проект звездочкой на GitHub, и расскажите друзьям :З'
})
// openExternal (window.ts) проверяет протокол по белому списку — ссылка уходит
// в системный браузер, а не открывается внутри приложения.
if (response === 0) openExternal(GITHUB_URL)
}
+77
View File
@@ -0,0 +1,77 @@
import { app, BrowserWindow } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { createWindow } from './window'
import { showFirstRunSupportDialog } from './first-run'
import { registerIpcHandlers } from './ipc'
import { disposeZapret, recoverZapret } from './zapret'
import { elevatedTaskDir, runElevatedServiceTask } from './zapret/elevated-task.win32'
const APP_ID = 'com.rknboost.app'
// Приложение может быть запущено само собой через UAC как «повышенный помощник» — тогда
// оно не открывает окно и не занимает единственный экземпляр (иначе отдало бы фокус уже
// запущенной обычной копии и вышло, не сделав работу), а выполняет задачу службы и выходит.
const elevatedTask = process.platform === 'win32' ? elevatedTaskDir(process.argv) : null
if (elevatedTask) {
void app.whenReady().then(async () => {
app.exit(await runElevatedServiceTask(elevatedTask))
})
} else if (!app.requestSingleInstanceLock()) {
// Одна копия приложения на систему: второй запуск отдаёт фокус уже открытому окну.
app.quit()
} else {
app.on('second-instance', () => {
const [window] = BrowserWindow.getAllWindows()
if (!window) {
createWindow()
return
}
if (window.isMinimized()) window.restore()
window.focus()
})
app.whenReady().then(() => {
// Нужно Windows для группировки в панели задач и уведомлений.
electronApp.setAppUserModelId(APP_ID)
// F12 открывает DevTools в dev-режиме, Ctrl/Cmd+R не перезагружает прод-сборку.
app.on('browser-window-created', (_event, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
// На macOS чинит прокси, оставшийся от аварийно завершённого прошлого запуска,
// до того как окно вообще откроется.
void recoverZapret().then(() => {
const window = createWindow()
// Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого.
window.once('ready-to-show', () => void showFirstRunSupportDialog(window))
})
// macOS: клик по иконке в доке при закрытых окнах открывает окно заново.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Windows / Linux: закрыли последнее окно — вышли. macOS: приложение живёт до Cmd+Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// Гасим tpws/winws и откатываем системный прокси до фактического выхода —
// иначе процесс останется висеть, а на macOS ещё и интернет пропадёт.
let zapretCleaned = false
app.on('before-quit', (event) => {
if (zapretCleaned) return
event.preventDefault()
void disposeZapret().finally(() => {
zapretCleaned = true
app.quit()
})
})
}
+78
View File
@@ -0,0 +1,78 @@
import { BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract'
import { uninstallApp } from './uninstall.win32'
import {
clearZapretAutoHostlist,
resetZapretList,
saveZapretList,
setZapretAutoStart,
setZapretStrategy,
setZapretSystemProxy,
startZapret,
stopZapret,
zapretAutoHostlist,
zapretDiagnostics,
zapretLists,
zapretStatus,
zapretStrategies
} from './zapret'
/**
* Обёртка над `ipcMain.handle`, которая сверяет имя канала, аргументы и результат
* с контрактом из `src/shared/ipc-contract.ts`.
*/
function handle<K extends IpcChannel>(
channel: K,
handler: (
event: IpcMainInvokeEvent,
...args: Parameters<IpcHandlers[K]>
) => ReturnType<IpcHandlers[K]> | Promise<ReturnType<IpcHandlers[K]>>
): void {
ipcMain.handle(channel, handler as Parameters<typeof ipcMain.handle>[1])
}
/** Окно, из которого пришёл запрос (а не «первое попавшееся») — для диалога подтверждения. */
function windowOf(event: IpcMainInvokeEvent): BrowserWindow | null {
return BrowserWindow.fromWebContents(event.sender)
}
function themeState(): ThemeState {
return {
source: nativeTheme.themeSource,
shouldUseDarkColors: nativeTheme.shouldUseDarkColors
}
}
export function registerIpcHandlers(): void {
handle('theme:get', () => themeState())
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
// но канал регистрируем всегда — так и должно быть по контракту, просто на других
// платформах он никогда не вызывается.
handle('app:uninstall', (event) => {
if (process.platform === 'win32') void uninstallApp(windowOf(event))
})
handle('zapret:status', () => zapretStatus())
handle('zapret:strategies', () => zapretStrategies())
handle('zapret:start', (_event, strategyId) => startZapret(strategyId))
handle('zapret:stop', () => stopZapret())
handle('zapret:set-system-proxy', (_event, enabled) => setZapretSystemProxy(enabled))
handle('zapret:set-strategy', (_event, strategyId) => setZapretStrategy(strategyId))
handle('zapret:set-autostart', (_event, enabled) => setZapretAutoStart(enabled))
handle('zapret:lists', () => zapretLists())
handle('zapret:list-save', (_event, id, entries) => saveZapretList(id, entries))
handle('zapret:list-reset', (_event, id) => resetZapretList(id))
handle('zapret:auto-hostlist', () => zapretAutoHostlist())
handle('zapret:auto-hostlist-clear', () => clearZapretAutoHostlist())
handle('zapret:diagnose', () => zapretDiagnostics())
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
// сообщаем всем открытым окнам.
nativeTheme.on('updated', () => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('theme:changed', themeState())
}
})
}
+145
View File
@@ -0,0 +1,145 @@
import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import {
app,
dialog,
type BrowserWindow,
type MessageBoxOptions,
type MessageBoxReturnValue
} from 'electron'
const run = promisify(execFile)
/** dialog.showMessageBox без родительского окна — отдельная перегрузка, без окна её и зовём. */
function showDialog(
window: BrowserWindow | null,
options: MessageBoxOptions
): Promise<MessageBoxReturnValue> {
return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
interface UninstallCommand {
command: string
args: string[]
}
/**
* `UninstallString` записывает сам electron-builder (templates/nsis/include/installer.nsh:122)
* строкой `'"$2" $0'`, где `$2` — путь к деинсталлятору, а `$0` — `/currentuser` для
* per-user установки (или `/allusers`). То есть путь в кавычках И аргумент после него:
*
* "C:\Users\...\AppData\Local\Programs\RKNboost\Uninstall RKNboost.exe" /currentuser
*
* Аргумент обязателен: без него деинсталлятор не знает, из какой ветки реестра сниматься.
* Разбираем строку на команду и аргументы, а не пытаемся скормить её `spawn` целиком —
* иначе весь текст вместе с кавычками уходит в имя файла и получается ENOENT.
*/
function parseUninstallString(raw: string): UninstallCommand | null {
const trimmed = raw.trim()
if (!trimmed) return null
const quoted = /^"([^"]+)"\s*(.*)$/.exec(trimmed)
if (quoted) {
return { command: quoted[1], args: quoted[2].split(/\s+/).filter(Boolean) }
}
// Без кавычек путь бывает только если в нём нет пробелов — тогда первое слово и есть команда.
const [command, ...args] = trimmed.split(/\s+/)
return command ? { command, args } : null
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
async function findUninstallCommand(): Promise<UninstallCommand | null> {
const script =
"Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' " +
'| Get-ItemProperty ' +
"| Where-Object { $_.DisplayName -eq 'RKNboost' } " +
'| Select-Object -First 1 -ExpandProperty UninstallString'
let stdout: string
try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
} catch {
return null
}
return parseUninstallString(stdout)
}
/**
* `spawn` сообщает о ненайденном файле не исключением, а событием `error` на уже
* созданном объекте — без этого обработчика ENOENT всплывал бы необработанным
* отказом промиса в main-процессе. Выходим только после события `spawn`, то есть
* когда деинсталлятор точно стартовал: закрыться раньше значит не закрыться вовсе.
*/
function launchUninstaller({ command, args }: UninstallCommand): Promise<void> {
return new Promise((resolve, reject) => {
// detached: деинсталлятор должен пережить закрытие текущего процесса.
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
child.once('error', reject)
child.once('spawn', () => {
child.unref()
resolve()
})
})
}
/**
* Подтверждение нативным диалогом ОС (не веб-страницей — так надёжнее для
* действия, которое стирает и приложение, и списки сайтов пользователя),
* затем запуск деинсталлятора и выход — деинсталлятор не может удалить
* запущенное приложение.
*/
export async function uninstallApp(window: BrowserWindow | null): Promise<void> {
const { response } = await showDialog(window, {
type: 'warning',
buttons: ['Отмена', 'Удалить'],
defaultId: 0,
cancelId: 0,
noLink: true,
title: 'Удалить RKNboost',
message: 'Удалить RKNboost с этого компьютера?',
detail:
'Приложение закроется и запустится стандартный деинсталлятор Windows. ' +
'Вместе с ним будут удалены списки сайтов и служба обхода блокировок. ' +
'Windows может запросить права администратора.'
})
if (response !== 1) return
const uninstall = await findUninstallCommand()
if (!uninstall) {
await showDialog(window, {
type: 'error',
title: 'Не удалось найти деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».'
})
return
}
try {
await launchUninstaller(uninstall)
} catch (error) {
await showDialog(window, {
type: 'error',
title: 'Не удалось запустить деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».',
detail: error instanceof Error ? error.message : String(error)
})
return
}
app.quit()
}
+77
View File
@@ -0,0 +1,77 @@
import { BrowserWindow, shell } from 'electron'
import { join } from 'node:path'
import { is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { ALLOWED_EXTERNAL_PROTOCOLS } from '../shared/ipc-contract'
function protocolOf(url: string): string | null {
try {
return new URL(url).protocol
} catch {
return null
}
}
/** Открыть ссылку во внешнем браузере, но только если протокол в белом списке. */
export function openExternal(url: string): void {
const protocol = protocolOf(url)
if (protocol && (ALLOWED_EXTERNAL_PROTOCOLS as readonly string[]).includes(protocol)) {
void shell.openExternal(url)
} else {
console.warn(`[security] заблокирована внешняя ссылка: ${url}`)
}
}
/** Главное (и единственное) окно приложения. */
export function createWindow(): BrowserWindow {
const window = new BrowserWindow({
width: 440,
height: 660,
minWidth: 360,
// С запасом на самое высокое обычное состояние главного экрана (ошибка + три
// результата проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
minHeight: 560,
show: false,
autoHideMenuBar: true,
backgroundColor: '#1a1611',
// Иконка нужна только Linux — на macOS и Windows её берут из собранного бандла.
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// Значения по умолчанию у Electron уже безопасные, но фиксируем их явно,
// чтобы случайная правка была заметна в ревью.
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true
}
})
// Показываем окно только когда отрисован первый кадр — без «белой вспышки».
window.on('ready-to-show', () => window.show())
// window.open(...) и target="_blank" уходят в системный браузер, а не в новое окно Electron.
window.webContents.setWindowOpenHandler(({ url }) => {
openExternal(url)
return { action: 'deny' }
})
// Запрещаем renderer'у уходить с локальной страницы (защита от навигации на чужой origin).
window.webContents.on('will-navigate', (event, url) => {
const devServerUrl = process.env['ELECTRON_RENDERER_URL']
const isDevServer = Boolean(devServerUrl && url.startsWith(devServerUrl))
if (!isDevServer && !url.startsWith('file://')) {
event.preventDefault()
openExternal(url)
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
void window.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
void window.loadFile(join(__dirname, '../renderer/index.html'))
}
return window
}
+205
View File
@@ -0,0 +1,205 @@
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'
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
// и медленный, но живой сайт успевал попасть в «Недоступно».
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' },
{ id: 'discord', label: 'Discord', host: 'discord.com' },
{ id: 'youtube', label: 'YouTube', host: 'youtube.com' }
]
/**
* Расшифровка кодов Chromium: без неё в интерфейсе у всех трёх сайтов одинаковое
* «Недоступно», и по нему невозможно отличить «DPI режет» от «домен не резолвится»
* или «антивирус перехватывает HTTPS» — а лечатся они совершенно по-разному.
*/
const ERROR_HINTS: [pattern: RegExp, hint: string][] = [
[/ERR_NAME_NOT_RESOLVED|ERR_NAME_RESOLUTION_FAILED/, 'домен не резолвится — блокировка по DNS'],
[/ERR_INTERNET_DISCONNECTED|ERR_NETWORK_CHANGED/, 'нет подключения к интернету'],
[/ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED/, 'соединение сброшено — похоже на DPI'],
[/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/, 'таймаут — пакеты теряются молча'],
[/ERR_CONNECTION_REFUSED/, 'соединение отклонено'],
[/ERR_CERT_|ERR_SSL_/, 'сертификат не принят — вероятно, антивирус перехватывает HTTPS'],
[/ERR_PROXY_/, 'мешает системный прокси']
]
function describeNetError(message: string): string {
const code = /net::(ERR_[A-Z_]+)/.exec(message)?.[1] ?? message
const hint = ERROR_HINTS.find(([pattern]) => pattern.test(message))?.[1]
return hint ? `${hint} (${code})` : code
}
/**
* SOCKS5 CONNECT без авторизации — ровно то, что понимает tpws в режиме `--socks`.
* Три сообщения: приветствие (метод «без авторизации»), запрос на домен, ответ сервера.
*/
function socksConnect(socket: Socket, host: string, port: number): Promise<void> {
return new Promise((resolve, reject) => {
const hostBuf = Buffer.from(host, 'utf8')
socket.once('data', (greeting: Buffer) => {
if (greeting[0] !== 0x05 || greeting[1] !== 0x00) {
reject(new Error('SOCKS5: сервер отклонил метод авторизации'))
return
}
socket.once('data', (reply: Buffer) => {
if (reply[1] !== 0x00) {
reject(new Error(`SOCKS5: код ошибки 0x${reply[1].toString(16)}`))
return
}
resolve()
})
socket.write(
Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([port >> 8, port & 0xff])
])
)
})
socket.write(Buffer.from([0x05, 0x01, 0x00]))
})
}
/**
* Прямая проверка — путь для Windows (десинк общесистемный, через WinDivert) и для
* выключенного обхода.
*
* Намеренно через `net` Electron, то есть через сетевой стек Chromium, а НЕ через
* `node:tls`. Тест должен отвечать на вопрос «откроется ли этот сайт в браузере», а
* Node-стек отвечает на другой: у него собственный список корневых сертификатов (мимо
* хранилища Windows — и любой антивирус с проверкой HTTPS ломает проверку на ровном
* месте), собственный резолвер (мимо DoH) и заметно более простой ClientHello, чем у
* браузера, — а стратегии zapret настраивают именно под браузерный. Из-за этого проверка
* показывала «Недоступно» на всех трёх сайтах, которые в браузере при включённом обходе
* открывались нормально.
*/
function checkDirect(host: string): Promise<number> {
return new Promise((resolve, reject) => {
const start = Date.now()
// HEAD, а не GET: нас интересует только сам факт, что до сайта дошли и он ответил,
// тело страницы качать незачем. redirect: 'manual' — редирект (например,
// instagram.com → www.instagram.com) это тоже ответ, гнаться за ним не нужно.
const request = net.request({ method: 'HEAD', url: `https://${host}/`, redirect: 'manual' })
let settled = false
const finish = (error: Error | null): void => {
if (settled) return
settled = true
clearTimeout(timer)
request.abort()
if (error) reject(new Error(describeNetError(error.message)))
else resolve(Date.now() - start)
}
const timer = setTimeout(
() => finish(new Error(`net::ERR_TIMED_OUT ${TIMEOUT_MS} мс`)),
TIMEOUT_MS
)
request.on('response', () => finish(null))
request.on('redirect', () => finish(null))
request.on('error', finish)
request.end()
})
}
/** Тот же успех, но через локальный SOCKS5 tpws — путь для macOS, пока обход включён. */
function checkViaSocks(host: string, socksHost: string, socksPort: number): Promise<number> {
return new Promise((resolve, reject) => {
const start = Date.now()
const socket = netConnect({ host: socksHost, port: socksPort })
let settled = false
const finish = (error: Error | null): void => {
if (settled) return
settled = true
socket.destroy()
if (error) reject(error)
else resolve(Date.now() - start)
}
socket.setTimeout(TIMEOUT_MS, () => finish(new Error(`Таймаут ${TIMEOUT_MS} мс`)))
socket.once('error', finish)
socket.once('connect', () => {
socksConnect(socket, host, TARGET_PORT)
.then(() => {
const tlsSocket = tlsConnect({ socket, servername: host })
tlsSocket.once('secureConnect', () => finish(null))
tlsSocket.once('error', finish)
})
.catch(finish)
})
})
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Одна повторная попытка. Десинк срабатывает не всегда с первого соединения, и браузер это
* скрывает: он переустанавливает соединение сам, а пользователь видит только итог — сайт
* открылся. Проверка в одну попытку получалась строже браузера и писала «Недоступно» там,
* где сайт на самом деле открывается. Ошибку второй попытки отдаём наружу как есть — если
* не сработало дважды, показать причину важнее, чем спрятать её за повтором.
*/
async function withRetry(attempt: () => Promise<number>): Promise<number> {
try {
return await attempt()
} catch {
await delay(RETRY_DELAY_MS)
return attempt()
}
}
/**
* Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить
* «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем.
*
* `status.socksAddress` заполнен только на macOS и только пока обход запущен — во всех
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
* на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает
* то, что есть сейчас.
*/
export async function runDiagnostics(status: ZapretStatus): Promise<ZapretDiagnosticResult[]> {
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
return Promise.all(
TARGETS.map(async (target) => {
const attempt = (): Promise<number> =>
socksMatch
? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2]))
: checkDirect(target.host)
try {
const ms = await withRetry(attempt)
return { ...target, ok: true, ms, error: null }
} catch (error) {
return {
...target,
ok: false,
ms: null,
error: error instanceof Error ? error.message : String(error)
}
}
})
)
}
+90
View File
@@ -0,0 +1,90 @@
import { execFile } from 'node:child_process'
import { join } from 'node:path'
import { promisify } from 'node:util'
const run = promisify(execFile)
/**
* Аргумент, по которому приложение понимает, что его запустили не как приложение,
* а как «повышенного помощника»: отработать задачу службы и выйти, без окна.
*/
export const ELEVATED_TASK_FLAG = '--elevated-service-task'
/**
* Имена файлов внутри временного каталога задачи. Каталог готовит неповышенная сторона
* (`runElevated` в service.win32.ts), читает — повышенная, поэтому имена держим в одном
* месте, а не дублируем строками с обеих сторон.
*/
export const TASK_FILES = {
worker: 'worker.ps1',
payload: 'payload.json',
result: 'result.json'
} as const
let elevationCheck: Promise<boolean> | null = null
/**
* Запущены ли мы уже от администратора. В собранном приложении это всегда так
* (`requestedExecutionLevel: requireAdministrator` в electron-builder.yml), но проверяем
* фактически, а не по предположению: в dev-режиме манифеста нет, а от неверного ответа
* зависит, ждать пользователю запрос UAC или нет. Ответ не меняется в течение работы
* процесса, поэтому считаем его один раз.
*/
export function isElevated(): Promise<boolean> {
elevationCheck ??= run('powershell.exe', [
'-NoProfile',
'-Command',
'[Security.Principal.WindowsPrincipal]::new(' +
'[Security.Principal.WindowsIdentity]::GetCurrent()' +
').IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)'
])
.then(({ stdout }) => stdout.trim().toLowerCase() === 'true')
// Не смогли выяснить — считаем, что прав нет: лишний запрос UAC не страшен,
// а вот молча провалившаяся настройка службы — страшна.
.catch(() => false)
return elevationCheck
}
/** Каталог задачи из argv, если приложение запустили как помощника. Иначе null. */
export function elevatedTaskDir(argv: readonly string[]): string | null {
const index = argv.indexOf(ELEVATED_TASK_FLAG)
if (index === -1) return null
return argv[index + 1] ?? null
}
/**
* Выполняется уже с правами администратора: UAC запросили на нас самих, поэтому
* дочернему powershell повышаться не нужно — он наследует права.
*
* Смысл всей этой конструкции в том, ЧТО пользователь видит в окне UAC. Раньше приложение
* повышало powershell.exe, и Windows честно показывала «Windows PowerShell» с подписью
* Microsoft — запрос выглядел так, будто права просит не то, что запросил пользователь.
* Теперь повышается сам RKNboost.exe, и в запросе стоит имя приложения.
*
* Сам worker.ps1 при этом не изменился — вся логика sc/icacls осталась ровно та же,
* поменялось только то, кого повышают.
*/
export async function runElevatedServiceTask(taskDir: string): Promise<number> {
try {
await run(
'powershell.exe',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
join(taskDir, TASK_FILES.worker),
'-PayloadPath',
join(taskDir, TASK_FILES.payload),
'-ResultPath',
join(taskDir, TASK_FILES.result)
],
{ windowsHide: true }
)
return 0
} catch (error) {
const code = (error as { code?: unknown } | undefined)?.code
return typeof code === 'number' ? code : 1
}
}
+243
View File
@@ -0,0 +1,243 @@
import { spawn, type ChildProcessByStdio } from 'node:child_process'
import type { Readable } from 'node:stream'
import { createConnection, createServer } from 'node:net'
import type { ZapretStatus } from '../../shared/ipc-contract'
import { ZapretEngine } from './engine'
import { assertExecutable, tpwsPath } from './paths'
import { applySystemProxy, hasStaleSnapshot, restoreSystemProxy } from './proxy.darwin'
import { defaultStrategyId, tpwsArgs } from './strategies'
const HOST = '127.0.0.1'
const PREFERRED_PORT = 1080
const READY_TIMEOUT_MS = 3000
const STOP_TIMEOUT_MS = 3000
/** Свободен ли порт на localhost. */
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer()
server.once('error', () => resolve(false))
server.listen(port, HOST, () => server.close(() => resolve(true)))
})
}
async function pickPort(): Promise<number> {
for (let port = PREFERRED_PORT; port < PREFERRED_PORT + 20; port += 1) {
if (await isPortFree(port)) return port
}
throw new Error('Не нашлось свободного порта для socks-прокси.')
}
/** У tpws нет сигнала готовности — просто ждём, пока порт начнёт принимать соединения. */
function canConnect(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection({ host: HOST, port })
const finish = (ok: boolean): void => {
socket.destroy()
resolve(ok)
}
socket.once('connect', () => finish(true))
socket.once('error', () => finish(false))
})
}
async function waitUntilReady(port: number, isAlive: () => boolean): Promise<void> {
const deadline = Date.now() + READY_TIMEOUT_MS
while (Date.now() < deadline) {
if (!isAlive()) return // процесс уже умер, ошибку соберёт вызывающий код
if (await canConnect(port)) return
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`tpws не начал слушать ${HOST}:${port} за ${READY_TIMEOUT_MS} мс.`)
}
/**
* macOS: tpws поднимается обычным пользовательским процессом в режиме socks5
* (документация zapret: «Режим --socks не требует повышенных привилегий»),
* а трафик заворачивается системной настройкой прокси.
*
* Важное ограничение платформы: tpws не умеет UDP, поэтому QUIC не обходится.
* Практика это скрадывает — при системном socks-прокси браузеры сами уходят с QUIC на TCP.
*/
export class DarwinEngine extends ZapretEngine {
private child: ChildProcessByStdio<null, Readable, Readable> | null = null
private port = PREFERRED_PORT
/** Просили ли включить системный прокси. Переживает перезапуск tpws. */
private wantSystemProxy = true
constructor() {
super('tpws-socks', defaultStrategyId('darwin'))
}
/**
* Прошлый запуск мог не снять прокси (например, kill -9). Тогда система смотрит
* в мёртвый socks и интернета нет вовсе — чиним до того, как показать окно.
*/
async recoverFromCrash(): Promise<void> {
if (!(await hasStaleSnapshot())) return
this.appendLog(
'info',
'Найдены незакрытые настройки прокси от прошлого запуска — восстанавливаю.'
)
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
}
}
async start(strategyId: string): Promise<ZapretStatus> {
if (this.child) await this.stop()
const binary = tpwsPath()
await assertExecutable(binary)
this.patch({ state: 'starting', strategyId, error: null })
try {
this.port = await pickPort()
const child = spawn(binary, tpwsArgs(strategyId, this.port), {
stdio: ['ignore', 'pipe', 'pipe']
})
this.child = child
// Вывод tpws идёт в stderr целиком, включая обычные сообщения о старте.
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => this.appendLog('info', chunk))
let stderrTail = ''
child.stderr.on('data', (chunk: string) => {
stderrTail = `${stderrTail}${chunk}`.slice(-2000)
this.appendLog('info', chunk)
})
child.on('exit', (code, signal) => {
this.child = null
// Плановую остановку обрабатывает stop(), здесь ловим только падение.
if (this.status.state === 'stopping' || this.status.state === 'stopped') return
void this.handleCrash(code, signal, stderrTail)
})
await waitUntilReady(this.port, () => this.child !== null)
if (!this.child) {
throw new Error(stderrTail.trim() || 'tpws завершился сразу после запуска.')
}
if (this.wantSystemProxy) {
await applySystemProxy(HOST, this.port)
}
return this.patch({
state: 'running',
startedAt: new Date().toISOString(),
error: null,
socksAddress: `${HOST}:${this.port}`,
systemProxyApplied: this.wantSystemProxy
})
} catch (error) {
await this.cleanup()
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({
state: 'error',
error: message,
startedAt: null,
socksAddress: null,
systemProxyApplied: false
})
}
}
async stop(): Promise<ZapretStatus> {
if (!this.child && !this.status.systemProxyApplied) {
return this.patch({ state: 'stopped', startedAt: null, socksAddress: null })
}
this.patch({ state: 'stopping' })
await this.cleanup()
return this.patch({
state: 'stopped',
startedAt: null,
error: null,
socksAddress: null,
systemProxyApplied: false
})
}
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
this.wantSystemProxy = enabled
// Пока обход выключен, менять нечего — запомнили выбор и всё.
if (this.status.state !== 'running') return this.patch({ systemProxyApplied: false })
if (enabled) {
await applySystemProxy(HOST, this.port)
} else {
await restoreSystemProxy()
}
return this.patch({ systemProxyApplied: enabled })
}
async dispose(): Promise<void> {
await this.cleanup()
}
/**
* Порядок важен: сначала снимаем прокси, потом гасим tpws. Наоборот нельзя —
* между шагами система осталась бы с прокси, который уже никто не слушает.
*/
private async cleanup(): Promise<void> {
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
}
const child = this.child
if (!child) return
this.child = null
child.kill('SIGTERM')
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL')
resolve()
}, STOP_TIMEOUT_MS)
child.once('exit', () => {
clearTimeout(timer)
resolve()
})
})
}
private async handleCrash(
code: number | null,
signal: NodeJS.Signals | null,
stderrTail: string
): Promise<void> {
const reason = signal ? `сигнал ${signal}` : `код ${code}`
const details = stderrTail.trim().split('\n').slice(-3).join(' ')
// Прокси обязательно снять: иначе система указывает на мёртвый socks и сети не будет.
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
}
this.patch({
state: 'error',
error: `tpws неожиданно завершился (${reason}). ${details}`.trim(),
startedAt: null,
socksAddress: null,
systemProxyApplied: false
})
}
}
+114
View File
@@ -0,0 +1,114 @@
import { BrowserWindow } from 'electron'
import type {
ZapretBackend,
ZapretLogLine,
ZapretStatus,
ZapretState
} from '../../shared/ipc-contract'
/** Сколько строк вывода бинарника держим для UI. */
const LOG_LIMIT = 200
/** Общая часть обоих движков: хранение статуса и рассылка событий в окна. */
export abstract class ZapretEngine {
protected status: ZapretStatus
private readonly log: ZapretLogLine[] = []
constructor(backend: ZapretBackend, defaultStrategyId: string) {
this.status = {
state: 'stopped',
backend,
strategyId: defaultStrategyId,
startedAt: null,
error: null,
socksAddress: null,
systemProxyApplied: false,
serviceInstalled: false,
autoStart: false
}
}
getStatus(): ZapretStatus {
return { ...this.status }
}
getLog(): ZapretLogLine[] {
return [...this.log]
}
abstract start(strategyId: string): Promise<ZapretStatus>
abstract stop(): Promise<ZapretStatus>
/** Вызывается при выходе из приложения: вернуть систему в исходное состояние. */
abstract dispose(): Promise<void>
/** macOS-специфика; на других платформах переопределять не нужно. */
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
void enabled
throw new Error('Системный прокси на этой платформе не настраивается.')
}
/**
* Выключен — просто запоминаем выбор (никаких системных действий и никакого UAC).
* Работает — перезапускаем с новой стратегией через `start()`; на Windows тот сам
* остановит службу перед перенастройкой (см. `Win32Engine.start()`), потому что менять
* конфигурацию работающей службы нельзя.
*/
async setStrategy(strategyId: string): Promise<ZapretStatus> {
if (this.status.state !== 'running') {
return this.patch({ strategyId })
}
return this.start(strategyId)
}
/** Windows-специфика (автозапуск службы); на других платформах переопределять не нужно. */
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
void enabled
throw new Error('Автозапуск на этой платформе не настраивается.')
}
protected patch(changes: Partial<ZapretStatus>): ZapretStatus {
this.status = { ...this.status, ...changes }
this.broadcast('zapret:status-changed', this.status)
return this.getStatus()
}
protected setState(state: ZapretState, error: string | null = null): ZapretStatus {
return this.patch({ state, error })
}
/** Вывод бинарника: копим кольцевым буфером и сразу отдаём в UI. */
protected appendLog(level: ZapretLogLine['level'], chunk: string): void {
for (const raw of chunk.split('\n')) {
const line = raw.trimEnd()
if (!line) continue
const entry: ZapretLogLine = { level, line }
this.log.push(entry)
if (this.log.length > LOG_LIMIT) this.log.shift()
this.broadcast('zapret:log', entry)
}
}
private broadcast<T>(channel: string, payload: T): void {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(channel, payload)
}
}
}
/** Заглушка для платформ, под которые мы бинарники не собираем (Linux). */
export class UnsupportedEngine extends ZapretEngine {
constructor() {
super('unsupported', '')
this.status.error = 'Обход блокировок доступен только на macOS и Windows.'
}
async start(): Promise<ZapretStatus> {
throw new Error('Обход блокировок доступен только на macOS и Windows.')
}
async stop(): Promise<ZapretStatus> {
return this.getStatus()
}
async dispose(): Promise<void> {}
}
+197
View File
@@ -0,0 +1,197 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import type { ZapretStatus } from '../../shared/ipc-contract'
import { ZapretEngine } from './engine'
import { assertExecutable, protectedWinwsPath, serviceConfigPath, winwsPath } from './paths'
import {
applyServiceConfig,
queryService,
setServiceAutoStart,
startService,
stopService,
type ServiceInfo
} from './service.win32'
import { defaultStrategyId, winwsConfigFile, type WinwsConfig } from './strategies'
const run = promisify(execFile)
/** Первая строка `strategy.cfg` — из `winwsConfigFile()`, формат: `--comment=rknboost:<id>:<hash>`. */
async function readInstalledMarkerLine(): Promise<string | null> {
try {
const content = await readFile(serviceConfigPath(), 'utf8')
return content.split('\n')[0]?.trim() ?? null
} catch {
return null
}
}
/**
* Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал
* из папки установки приложения (winwsPath) — значит переустановку/обновление приложения
* по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время
* изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло
* позже последней копии, нужно копировать заново (а значит и UAC).
*/
async function binariesNeedCopy(): Promise<boolean> {
try {
const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())])
return source.size !== copy.size || source.mtimeMs > copy.mtimeMs
} catch {
return true
}
}
/**
* Перенастройка (а значит и UAC) нужна, если службы ещё нет, если защищённая копия
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
*/
async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null): Promise<boolean> {
if (!info) return true
if (await binariesNeedCopy()) return true
return (await readInstalledMarkerLine()) !== config.marker
}
/**
* Windows: winws требует драйвер WinDivert и права администратора, поэтому работает как служба
* Windows (`rknboost-zapret`), а не как обычный дочерний процесс приложения.
*
* UAC запрашивается только при первой установке службы и при смене аргументов (новая стратегия,
* переключение автозапуска) — этим занимается `service.win32.ts`. Обычные включение/выключение
* идут через `sc start`/`sc stop`, на которые интерактивным пользователям выдаётся право при
* установке (`sc sdset`), поэтому UAC для них не нужен.
*/
export class Win32Engine extends ZapretEngine {
constructor() {
super('winws-service', defaultStrategyId('win32'))
// Решение по умолчанию для первой установки: обход должен работать и после перезагрузки,
// не дожидаясь запуска приложения. Дальше пользователь может выключить переключателем.
this.status.autoStart = true
}
/**
* Вызывается из `recoverZapret()` до открытия окна: подхватывает состояние службы,
* которая могла работать ещё при загрузке системы (автозапуск), — иначе UI показал бы
* «Выключен» при уже работающем обходе.
*/
async sync(): Promise<ZapretStatus> {
const info = await queryService().catch(() => null)
if (!info) {
return this.patch({ serviceInstalled: false, state: 'stopped', startedAt: null })
}
const installedLine = await readInstalledMarkerLine()
const match = installedLine ? /^--comment=rknboost:([^:]+):/.exec(installedLine) : null
const strategyId = match?.[1] ?? this.status.strategyId
return this.patch({
serviceInstalled: true,
autoStart: info.startMode === 'Auto',
strategyId,
state: info.state === 'Running' ? 'running' : 'stopped',
// Точный момент запуска нам недоступен — служба могла подняться ещё при загрузке
// системы, до старта приложения. Берём момент обнаружения, лишь бы поле не пустовало.
startedAt: info.state === 'Running' ? new Date().toISOString() : null,
error: null
})
}
async start(strategyId: string): Promise<ZapretStatus> {
this.patch({ state: 'starting', strategyId, error: null })
let config: WinwsConfig
try {
config = winwsConfigFile(strategyId)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return this.patch({ state: 'error', error: message, startedAt: null })
}
try {
await assertExecutable(winwsPath())
const info = await queryService().catch(() => null)
if (await needsReconfigure(config, info)) {
// Перенастраивать работающую службу нельзя — её сначала надо остановить, иначе
// ломается всё сразу: работающий winws держит открытым свой же winws.exe, и
// Copy-Item в защищённый каталог падает на занятом файле; --dry-run упирается в
// тот же именованный mutex WinDivert («A copy of winws is already running with
// the same filter»); а sc config применяется только к СЛЕДУЮЩЕМУ запуску, так что
// служба молча продолжила бы работать со старой стратегией.
if (info?.state === 'Running') await stopService()
// --dry-run трогает mutex WinDivert, а не только парсит аргументы, поэтому зовём его
// здесь — после остановки и только когда действительно собираемся писать новый конфиг,
// а не когда просто подтверждаем уже работающую стратегию.
await this.dryRun(config.body)
await applyServiceConfig({ configBody: config.body, autoStart: this.status.autoStart })
}
const running = await startService()
return this.patch({
state: 'running',
startedAt: new Date().toISOString(),
error: null,
serviceInstalled: true,
autoStart: running?.startMode === 'Auto'
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({ state: 'error', error: message, startedAt: null })
}
}
async stop(): Promise<ZapretStatus> {
this.patch({ state: 'stopping' })
try {
await stopService()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({ state: 'error', error: message })
}
return this.patch({ state: 'stopped', startedAt: null, error: null })
}
/**
* Только тип автозапуска службы — если она ещё не установлена, лишь запоминаем выбор
* до первого включения (никакого UAC на пустом месте).
*/
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
if (!this.status.serviceInstalled) {
return this.patch({ autoStart: enabled })
}
await setServiceAutoStart(enabled)
return this.patch({ autoStart: enabled })
}
async dispose(): Promise<void> {
// Служба живёт своей жизнью в системе — выход приложения её не останавливает.
}
/**
* `winws.exe --dry-run @<файл>` только проверяет аргументы и завершается без открытия
* WinDivert — не требует прав администратора. Ошибку в стратегии видно до запроса UAC,
* а не после него.
*/
private async dryRun(configBody: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-dryrun-'))
const configPath = join(dir, 'dry-run.cfg')
try {
await writeFile(configPath, configBody, 'utf8')
await run(winwsPath(), ['--dry-run', `@${configPath}`])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Стратегия содержит недопустимые параметры winws: ${message}`, {
cause: error
})
} finally {
await rm(dir, { recursive: true, force: true })
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import { readFile, rm } from 'node:fs/promises'
import type {
ZapretDiagnosticResult,
ZapretList,
ZapretListId,
ZapretLogLine,
ZapretStatus,
ZapretStrategy
} from '../../shared/ipc-contract'
import { runDiagnostics } from './diagnostics'
import { DarwinEngine } from './engine.darwin'
import { ZapretEngine, UnsupportedEngine } from './engine'
import { Win32Engine } from './engine.win32'
import { readAllLists, resetList, seedLists, writeList } from './lists'
import { autoHostlistPath } from './paths'
import { listStrategies } from './strategies'
function createEngine(): ZapretEngine {
switch (process.platform) {
case 'darwin':
return new DarwinEngine()
case 'win32':
return new Win32Engine()
default:
return new UnsupportedEngine()
}
}
const engine = createEngine()
/**
* Готовим списки сайтов и синхронизируем состояние с системой — на macOS чиним прокси
* от прошлого аварийного завершения, на Windows подхватываем службу, которая могла работать
* ещё до открытия окна (автозапуск с системой). Оба шага нужно сделать до открытия окна.
*/
export async function recoverZapret(): Promise<void> {
await seedLists()
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
if (engine instanceof Win32Engine) await engine.sync()
}
export function zapretStatus(): ZapretStatus {
return engine.getStatus()
}
export function zapretStrategies(): ZapretStrategy[] {
return listStrategies(process.platform)
}
export function zapretLog(): ZapretLogLine[] {
return engine.getLog()
}
export function startZapret(strategyId: string): Promise<ZapretStatus> {
return engine.start(strategyId)
}
export function stopZapret(): Promise<ZapretStatus> {
return engine.stop()
}
export function setZapretSystemProxy(enabled: boolean): Promise<ZapretStatus> {
return engine.setSystemProxy(enabled)
}
export function setZapretStrategy(strategyId: string): Promise<ZapretStatus> {
return engine.setStrategy(strategyId)
}
export function setZapretAutoStart(enabled: boolean): Promise<ZapretStatus> {
return engine.setAutoStart(enabled)
}
/** Вызывать при выходе из приложения — гасит процесс и откатывает системные настройки. */
export function disposeZapret(): Promise<void> {
return engine.dispose()
}
export function zapretLists(): Promise<ZapretList[]> {
return readAllLists()
}
export function saveZapretList(id: ZapretListId, entries: string[]): Promise<ZapretList> {
return writeList(id, entries)
}
export function resetZapretList(id: ZapretListId): Promise<ZapretList> {
return resetList(id)
}
/** Домены, которые zapret нашёл сам через `--hostlist-auto` (только macOS-профили). */
export async function zapretAutoHostlist(): Promise<string[]> {
try {
const content = await readFile(autoHostlistPath(), 'utf8')
return content
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
export async function clearZapretAutoHostlist(): Promise<void> {
await rm(autoHostlistPath(), { force: true })
}
export function zapretDiagnostics(): Promise<ZapretDiagnosticResult[]> {
return runDiagnostics(engine.getStatus())
}
+151
View File
@@ -0,0 +1,151 @@
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ZapretList, ZapretListId } from '../../shared/ipc-contract'
import { bundledListsDir, userListsDir } from './paths'
/**
* Списки сайтов — данные, а не аргументы запуска. Пользователь правит их
* полностью свободно (это его решение), включая встроенные из Flowseal.
* Рабочая копия лежит в userData и переживает обновление приложения; встроенная
* версия в `resources/zapret/lists` только читается — от неё берём исходное
* содержимое при первом запуске и при «Сбросить к встроенному».
*
* Важно: и tpws, и winws сами перечитывают список, если у файла изменились
* mtime или размер («automatically reload lists if their modification time or
* file size is changed», docs/readme zapret) — перезапускать обход после правки
* списка не нужно. Чтобы это гарантированно сработало (а не поймать читателя
* на файле, который ещё не дописан), пишем во временный файл и делаем `rename` —
* это меняет mtime одним атомарным шагом.
*/
const FILE_NAMES: Record<ZapretListId, string> = {
general: 'general.txt',
google: 'google.txt',
exclude: 'exclude.txt',
'ipset-exclude': 'ipset-exclude.txt',
'ipset-all': 'ipset-all.txt'
}
export const LIST_IDS = Object.keys(FILE_NAMES) as ZapretListId[]
/** Списки доменов (--hostlist=...) — в отличие от ipset-exclude/ipset-all, где строки это IP/CIDR. */
const HOSTLIST_IDS: ZapretListId[] = ['general', 'google', 'exclude']
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex')
}
function parseEntries(content: string): string[] {
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
}
function serializeEntries(entries: string[]): string {
return entries.join('\n') + (entries.length > 0 ? '\n' : '')
}
const DOMAIN_RE = /^\^?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/
const IP_CIDR_RE = /^([0-9a-f:]+|\d{1,3}(\.\d{1,3}){3})(\/\d{1,3})?$/i
/** Понятная ошибка вместо того, чтобы молча положить в список мусор, который tpws пропустит мимо ушей. */
function validateEntry(id: ZapretListId, raw: string): string {
const entry = raw.trim().toLowerCase()
if (!entry) throw new Error('Пустая строка в списке.')
const isHostlist = HOSTLIST_IDS.includes(id)
const pattern = isHostlist ? DOMAIN_RE : IP_CIDR_RE
if (!pattern.test(entry)) {
throw new Error(
isHostlist
? `«${raw}» не похоже на домен (пример: example.com или ^example.com для точного совпадения).`
: `«${raw}» не похоже на IP-адрес или подсеть (пример: 192.168.0.0/16).`
)
}
return entry
}
function bundledPath(id: ZapretListId): string {
return join(bundledListsDir(), FILE_NAMES[id])
}
function userPath(id: ZapretListId): string {
return join(userListsDir(), FILE_NAMES[id])
}
/** Атомарная запись: временный файл в том же каталоге (для atomic rename на одной ФС) + rename. */
async function writeAtomic(path: string, content: string): Promise<void> {
const tmp = `${path}.${randomUUID()}.tmp`
await writeFile(tmp, content, 'utf8')
await rename(tmp, path)
}
async function readBundled(id: ZapretListId): Promise<string> {
return readFile(bundledPath(id), 'utf8')
}
async function fileExists(path: string): Promise<boolean> {
try {
await readFile(path)
return true
} catch {
return false
}
}
/** Копирует встроенные списки в userData при первом запуске. Идемпотентно — уже существующие не трогает. */
export async function seedLists(): Promise<void> {
await mkdir(userListsDir(), { recursive: true })
for (const id of LIST_IDS) {
if (await fileExists(userPath(id))) continue
await writeAtomic(userPath(id), await readBundled(id))
}
}
async function toZapretList(id: ZapretListId): Promise<ZapretList> {
const [userContent, bundledContent] = await Promise.all([
readFile(userPath(id), 'utf8').catch(() => readBundled(id)),
readBundled(id)
])
return {
id,
entries: parseEntries(userContent),
// Список не тронут пользователем относительно текущей встроенной версии.
isDefault: sha256(userContent) === sha256(bundledContent)
}
}
export async function readList(id: ZapretListId): Promise<ZapretList> {
return toZapretList(id)
}
export async function readAllLists(): Promise<ZapretList[]> {
return Promise.all(LIST_IDS.map(readList))
}
export async function writeList(id: ZapretListId, rawEntries: string[]): Promise<ZapretList> {
const seen = new Set<string>()
const entries: string[] = []
for (const raw of rawEntries) {
const entry = validateEntry(id, raw)
if (seen.has(entry)) continue
seen.add(entry)
entries.push(entry)
}
await writeAtomic(userPath(id), serializeEntries(entries))
return toZapretList(id)
}
export async function resetList(id: ZapretListId): Promise<ZapretList> {
await writeAtomic(userPath(id), await readBundled(id))
return toZapretList(id)
}
/** Путь к рабочему списку — нужен `strategies.ts`, чтобы подставить его в аргументы движка. */
export function listFilePath(id: ZapretListId): string {
return userPath(id)
}
+94
View File
@@ -0,0 +1,94 @@
import { app } from 'electron'
import { access, constants } from 'node:fs/promises'
import { join, resolve } from 'node:path'
/**
* Бинарники zapret кладёт `scripts/fetch-zapret.mjs` в `resources/zapret`.
* В упакованном приложении electron-builder переносит их в `extraResources`,
* то есть в `process.resourcesPath/zapret` (вне asar — запускать код из архива нельзя).
*/
function zapretRoot(): string {
return app.isPackaged
? join(process.resourcesPath, 'zapret')
: resolve(app.getAppPath(), 'resources', 'zapret')
}
/** macOS: universal-бинарник tpws (arm64 + x86_64). */
export function tpwsPath(): string {
return join(zapretRoot(), 'darwin', 'tpws')
}
/** Windows: winws.exe рядом с cygwin1.dll и WinDivert. */
export function winwsPath(): string {
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
}
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
export function bundledListsDir(): string {
return join(zapretRoot(), 'lists')
}
/**
* Рабочая копия списков — здесь их правит пользователь. Живёт вне сборки
* (userData), чтобы пережить обновление приложения: встроенные списки в
* `bundledListsDir()` при апдейте перезатираются, а эта копия — нет.
*/
export function userListsDir(): string {
return join(app.getPath('userData'), 'zapret-lists')
}
/** Fake-пакеты (TLS ClientHello, QUIC initial, ...) для стратегий winws. */
export function fakesDir(): string {
return join(zapretRoot(), 'fakes')
}
/**
* Windows: служба `rknboost-zapret` работает от LocalSystem, поэтому её конфиг не может
* жить в userData обычного пользователя — нужен каталог машинного уровня. `%ProgramData%`
* почти всегда задан; `C:\ProgramData` — тот же путь, на который он указывает по умолчанию.
*/
export function serviceDataDir(): string {
return join(process.env.ProgramData ?? 'C:\\ProgramData', 'rknboost', 'zapret')
}
/** Файл аргументов `winws.exe @strategy.cfg` — пишется только повышенным скриптом службы. */
export function serviceConfigPath(): string {
return join(serviceDataDir(), 'strategy.cfg')
}
/**
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь
* per-user (`%LocalAppData%`), эта папка доступна на запись обычному пользователю —
* если бы SYSTEM-служба запускала winws.exe прямо оттуда, кто угодно, работающий под
* тем же пользователем, мог бы подменить бинарник и получить код с правами SYSTEM.
* Копию (как и `strategy.cfg`) кладёт и защищает ACL только повышенный скрипт
* (`applyServiceConfig` в service.win32.ts), рядом, в том же serviceDataDir().
*/
export function protectedWinwsPath(): string {
return join(serviceDataDir(), 'winws.exe')
}
/**
* Список, который zapret пополняет сам (`--hostlist-auto`): при похожей на блокировку
* ошибке домен дописывается сюда и на лету попадает под десинк, без участия пользователя.
* Не входит в `ZapretListId` — это нередактируемый журнал находок, а не список на правку.
*/
export function autoHostlistPath(): string {
return join(userListsDir(), 'auto.txt')
}
/**
* Понятная ошибка вместо ENOENT из глубины spawn: без этой проверки пользователь
* увидит «spawn ... ENOENT» и не поймёт, что надо запустить `npm run zapret:fetch`.
*/
export async function assertExecutable(path: string): Promise<void> {
try {
await access(path, constants.X_OK)
} catch {
throw new Error(
`Не найден исполняемый файл zapret: ${path}\n` +
'Скачайте бинарники командой «npm run zapret:fetch».'
)
}
}
+167
View File
@@ -0,0 +1,167 @@
import { app } from 'electron'
import { execFile } from 'node:child_process'
import { readFile, writeFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
const run = promisify(execFile)
/**
* Домены и адреса, которые не должны идти через прокси. Без этого списка отвалится
* всё локальное: tpws по документации запрещает подключения к тому же хосту,
* на котором работает сам, включая localhost.
*/
const BYPASS = ['*.local', '169.254/16', 'localhost', '127.0.0.1', '::1']
interface ServiceProxyState {
service: string
enabled: boolean
server: string
port: string
bypass: string[]
}
/** Что было до нас — чтобы вернуть настройки ровно в исходное состояние. */
interface ProxySnapshot {
takenAt: string
services: ServiceProxyState[]
}
function snapshotPath(): string {
return join(app.getPath('userData'), 'proxy-snapshot.json')
}
/**
* `networksetup` требует членства в группе admin. На обычном Mac пользователь в ней
* состоит и пароль не спрашивается. Если же включена политика «требовать пароль
* администратора для системных настроек», команда падает — тогда повторяем её через
* системный диалог авторизации.
*/
async function networksetup(args: string[]): Promise<string> {
try {
const { stdout } = await run('/usr/sbin/networksetup', args)
return stdout
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!/root|denied|permission/i.test(message)) throw error
const command = ['/usr/sbin/networksetup', ...args]
.map((part) => `'${part.replaceAll("'", `'\\''`)}'`)
.join(' ')
try {
const { stdout } = await run('/usr/bin/osascript', [
'-e',
`do shell script "${command.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}" with administrator privileges`
])
return stdout
} catch (elevationError) {
const text = elevationError instanceof Error ? elevationError.message : String(elevationError)
if (text.includes('-128'))
throw new Error('Изменение системного прокси отменено.', { cause: elevationError })
throw new Error(`Не удалось изменить настройки прокси: ${text}`, { cause: elevationError })
}
}
}
/** Сетевые сервисы, кроме выключенных: у тех `networksetup` ставит «*» перед именем. */
async function activeServices(): Promise<string[]> {
const stdout = await networksetup(['-listallnetworkservices'])
return stdout
.split('\n')
.slice(1) // первая строка — пояснение про звёздочку
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('*'))
}
async function readServiceState(service: string): Promise<ServiceProxyState> {
const stdout = await networksetup(['-getsocksfirewallproxy', service])
const value = (key: string): string =>
new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(stdout)?.[1]?.trim() ?? ''
const bypassOutput = await networksetup(['-getproxybypassdomains', service])
// Пустой список macOS печатает фразой «There aren't any bypass domains…»,
// а в доменах пробелов не бывает — по этому и отличаем.
const bypass = bypassOutput
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.includes(' '))
return {
service,
enabled: value('Enabled').toLowerCase() === 'yes',
server: value('Server'),
port: value('Port'),
bypass
}
}
async function saveSnapshot(snapshot: ProxySnapshot): Promise<void> {
await writeFile(snapshotPath(), JSON.stringify(snapshot, null, 2), 'utf8')
}
async function loadSnapshot(): Promise<ProxySnapshot | null> {
try {
return JSON.parse(await readFile(snapshotPath(), 'utf8')) as ProxySnapshot
} catch {
return null
}
}
async function restoreService(state: ServiceProxyState): Promise<void> {
if (state.enabled && state.server) {
await networksetup([
'-setsocksfirewallproxy',
state.service,
state.server,
state.port || '1080'
])
await networksetup(['-setsocksfirewallproxystate', state.service, 'on'])
} else {
await networksetup(['-setsocksfirewallproxystate', state.service, 'off'])
}
// Пустой список сбрасывается специальным словом Empty — иначе останется наш.
const bypass = state.bypass.length > 0 ? state.bypass : ['Empty']
await networksetup(['-setproxybypassdomains', state.service, ...bypass])
}
/** Прописать socks-прокси во все активные сетевые сервисы, запомнив прежнее состояние. */
export async function applySystemProxy(host: string, port: number): Promise<void> {
const services = await activeServices()
if (services.length === 0) throw new Error('Не найдено ни одного активного сетевого сервиса.')
// Снимок делаем до первой правки и сразу пишем на диск: если приложение
// упадёт посреди применения, следующий запуск сможет всё вернуть.
const snapshot: ProxySnapshot = {
takenAt: new Date().toISOString(),
services: await Promise.all(services.map(readServiceState))
}
await saveSnapshot(snapshot)
for (const service of services) {
await networksetup(['-setsocksfirewallproxy', service, host, String(port)])
await networksetup(['-setproxybypassdomains', service, ...BYPASS])
await networksetup(['-setsocksfirewallproxystate', service, 'on'])
}
}
/** Вернуть настройки прокси в состояние из снимка. Без снимка ничего не трогаем. */
export async function restoreSystemProxy(): Promise<boolean> {
const snapshot = await loadSnapshot()
if (!snapshot) return false
for (const state of snapshot.services) {
await restoreService(state)
}
await rm(snapshotPath(), { force: true })
return true
}
/**
* Снимок остался с прошлого запуска — значит приложение завершилось аварийно,
* не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте.
*/
export async function hasStaleSnapshot(): Promise<boolean> {
return (await loadSnapshot()) !== null
}
+471
View File
@@ -0,0 +1,471 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { app } from 'electron'
import {
ELEVATED_TASK_FLAG,
TASK_FILES,
isElevated,
runElevatedServiceTask
} from './elevated-task.win32'
import { serviceConfigPath, serviceDataDir, winwsPath } from './paths'
const run = promisify(execFile)
export const SERVICE_NAME = 'rknboost-zapret'
const POLL_INTERVAL_MS = 300
const POLL_TIMEOUT_MS = 10000
/** Код возврата Windows для «пользователь отклонил запрос UAC» (ERROR_CANCELLED). */
const UAC_CANCELLED_CODE = 1223
/**
* `sc.exe` выходит с самим кодом ошибки Win32, и это единственный надёжный способ их
* различать: текст сообщения локализован, на русской Windows никакой `/not started/`
* не сматчится.
*/
const ERROR_SERVICE_ALREADY_RUNNING = 1056
const ERROR_SERVICE_NOT_ACTIVE = 1062
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
export interface ServiceInfo {
state: ServiceState
startMode: ServiceStartMode
/** Значение `PathName` из CIM — то, что реально прописано в `binPath` службы. */
binPath: string
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function exitCodeOf(error: unknown): number {
const code = (error as { code?: number | string } | undefined)?.code
return typeof code === 'number' ? code : 1
}
function normalizeState(raw: string | undefined): ServiceState {
switch (raw) {
case 'Running':
return 'Running'
case 'Stopped':
return 'Stopped'
case 'Start Pending':
return 'StartPending'
case 'Stop Pending':
return 'StopPending'
default:
return 'Other'
}
}
function normalizeStartMode(raw: string | undefined): ServiceStartMode {
switch (raw) {
case 'Auto':
return 'Auto'
case 'Manual':
return 'Manual'
case 'Disabled':
return 'Disabled'
default:
return 'Other'
}
}
/**
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
* локализованный текст, который парсить надёжно нельзя.
*/
export async function queryService(): Promise<ServiceInfo | null> {
const script =
`Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` +
'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress'
let stdout: string
try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
} catch (error) {
throw new Error(
`Не удалось опросить службу: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error }
)
}
const trimmed = stdout.trim()
if (!trimmed || trimmed === 'null') return null
const raw = JSON.parse(trimmed) as { State?: string; StartMode?: string; PathName?: string }
return {
state: normalizeState(raw.State),
startMode: normalizeStartMode(raw.StartMode),
binPath: raw.PathName ?? ''
}
}
function explainScError(error: unknown, verb: string): string {
const message = error instanceof Error ? error.message : String(error)
if (/access is denied/i.test(message)) {
return (
`Не удалось ${verb} службу: отказано в доступе. Похоже, права для обычных пользователей ` +
'не выданы — переустановите приложение.'
)
}
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 и возвращается сразу — если не
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
* выглядела бы как успех.
*/
export async function startService(): Promise<ServiceInfo | null> {
try {
await run('sc.exe', ['start', SERVICE_NAME])
} catch (error) {
// «Служба уже запущена» — цель достигнута, не ошибка: так бывает, когда пользователь
// выбрал в списке ту же стратегию, что уже работает, и перенастройка не понадобилась.
if (exitCodeOf(error) !== ERROR_SERVICE_ALREADY_RUNNING) {
throw new Error(explainScError(error, 'запустить'), { cause: error })
}
}
const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped')
if (info?.state !== 'Running') {
throw new Error(
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
`${info?.state ?? 'неизвестно'}). Возможная причина — не загрузился драйвер WinDivert ` +
'(включён Secure Boot, драйвер без действующей подписи, или Windows 7 без нужного обновления).'
)
}
return info
}
export async function stopService(): Promise<ServiceInfo | null> {
try {
await run('sc.exe', ['stop', SERVICE_NAME])
} catch (error) {
// «Служба не запущена» — не ошибка с точки зрения пользователя, тот же паттерн, что в
// engine.darwin.ts. Раньше это ловилось регуляркой по тексту `/not.*started/` — на русской
// Windows она не срабатывала никогда, и остановка уже остановленной службы падала ошибкой.
if (exitCodeOf(error) !== ERROR_SERVICE_NOT_ACTIVE) {
throw new Error(explainScError(error, 'остановить'), { cause: error })
}
}
// `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката,
// поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS,
// молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
// остановка и защищает.
const info = await pollUntil((i) => i === null || i.state === 'Stopped')
if (info && info.state !== 'Stopped') {
throw new Error(
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
)
}
return info
}
/**
* PowerShell 5.1 (то, что стоит на Windows по умолчанию) требует BOM, чтобы правильно
* прочитать кириллицу в UTF-8 файле скрипта — без BOM она превращается в кракозябры.
*/
const UTF8_BOM = '\uFEFF'
function withBom(script: string): string {
return UTF8_BOM + script
}
/**
* Выполняется без прав администратора: запрашивает повышение через `Start-Process -Verb RunAs`
* и ждёт завершения. Если пользователь отклонил UAC, `Start-Process` бросает исключение —
* ловим его и выходим с ERROR_CANCELLED, чтобы вызывающий код отличил отказ от реальной ошибки.
*
* Повышаем САМО приложение (`$AppPath` с флагом `--elevated-service-task`), а не powershell.exe:
* Windows пишет в окне UAC имя того файла, который повышают, и запрос от «Windows PowerShell»
* с подписью Microsoft выглядит так, будто права просит что-то постороннее. Повышенный
* экземпляр приложения уже сам, без второго UAC, запускает worker.ps1 из того же каталога
* (см. runElevatedServiceTask в elevated-task.win32.ts).
*
* `$AppDir` нужен только в dev-режиме: там `$AppPath` — это electron.exe, которому каталог
* приложения передаётся первым аргументом. В собранном приложении он пустой.
*/
const OUTER_SCRIPT = `
param(
[Parameter(Mandatory=$true)][string]$AppPath,
[Parameter(Mandatory=$true)][string]$TaskDir,
[string]$AppDir = ''
)
try {
$argList = @()
if ($AppDir) { $argList += $AppDir }
$argList += @('${ELEVATED_TASK_FLAG}', $TaskDir)
$p = Start-Process -FilePath $AppPath -Verb RunAs -Wait -PassThru -ArgumentList $argList
exit $p.ExitCode
}
catch {
exit ${UAC_CANCELLED_CODE}
}
`
/**
* Работает от администратора. Три действия: установить/перенастроить службу (`install`),
* поменять только тип автозапуска (`setAutoStart`), снять службу целиком (`remove`).
* Результат пишет в `$ResultPath` — код возврата процесса тут ненадёжен (PowerShell не всегда
* пробрасывает его наружу через несколько уровней Start-Process), а JSON-файл однозначен.
*/
const WORKER_SCRIPT = `
param(
[Parameter(Mandatory=$true)][string]$PayloadPath,
[Parameter(Mandatory=$true)][string]$ResultPath
)
$ErrorActionPreference = 'Stop'
$ServiceName = '${SERVICE_NAME}'
function Write-Result($ok, $errorMessage) {
$obj = @{ ok = $ok; error = $errorMessage }
$json = $obj | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($ResultPath, $json, (New-Object System.Text.UTF8Encoding($false)))
}
# Прямой вызов "& sc.exe create ... binPath= $binPath" ломает значения с embedded-кавычками:
# PowerShell (& / call operator, и точно так же Start-Process -ArgumentList) перепаковывает
# аргументы по-своему и режет уже готовую строку binPath на два токена по пробелу перед "@" —
# sc.exe получает лишний позиционный аргумент и падает с кодом 1639 (Invalid command line),
# хотя та же строка, набранная руками в cmd.exe, разбирается верно. ProcessStartInfo.Arguments —
# нижний уровень (.NET передаёт эту строку в CreateProcess как есть, без повторной токенизации
# самим PowerShell) — единственный способ гарантированно передать её той же строкой.
function Invoke-ScRaw($rawArgs) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'sc.exe'
$psi.Arguments = $rawArgs
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
}
try {
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
switch ($payload.action) {
'install' {
$dir = $payload.serviceDataDir
New-Item -ItemType Directory -Path $dir -Force | Out-Null
# Снимаем наследование прав каталога: конфиг читает служба от SYSTEM, писать в него
# должны только SYSTEM и администраторы — иначе обычный пользователь мог бы подменить
# аргументы winws, которые выполняются с правами SYSTEM.
& icacls $dir /inheritance:r | Out-Null
& icacls $dir /grant '*S-1-5-18:(OI)(CI)F' | Out-Null
& icacls $dir /grant '*S-1-5-32-544:(OI)(CI)F' | Out-Null
& icacls $dir /grant '*S-1-5-32-545:(OI)(CI)RX' | Out-Null
$configPath = $payload.configPath
# UTF-8 без BOM — этого требует сам winws при чтении @<config_file> (docs/windows.md zapret).
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($configPath, $payload.configBody, $utf8NoBom)
# Служба должна ссылаться на копию winws.exe в этом же защищённом каталоге, а не на
# оригинал в папке установки приложения: установка теперь per-user (%LocalAppData%),
# эта папка доступна на запись обычному пользователю — SYSTEM-служба, ссылающаяся туда,
# была бы локальным повышением привилегий. Копируем winws.exe и рядом лежащие
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
$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
}
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
# Тот же приём — в service_create.cmd из zapret-win-bundle.
$binPath = '"\\"' + $protectedWinws + '\\" @\\"' + $configPath + '\\""'
$startType = if ($payload.autoStart) { 'auto' } else { 'demand' }
& sc.exe query $ServiceName | Out-Null
$serviceExists = ($LASTEXITCODE -eq 0)
if ($serviceExists) {
$scArgs = 'config ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType
$result = Invoke-ScRaw $scArgs
if ($result.ExitCode -ne 0) { throw "sc config завершился с кодом $($result.ExitCode) : $($result.Output)" }
} else {
$scArgs = 'create ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType + ' DisplayName= "RKNboost: обход блокировок"'
$result = Invoke-ScRaw $scArgs
if ($result.ExitCode -ne 0) { throw "sc create завершился с кодом $($result.ExitCode) : $($result.Output)" }
}
& sc.exe description $ServiceName 'Служба RKNboost: обходит блокировки через winws/WinDivert.' | Out-Null
# Разрешаем интерактивным пользователям запуск/остановку/чтение статуса службы, но не
# смену конфигурации и не смену прав — иначе любой пользователь на машине мог бы подменить
# аргументы, с которыми служба запускает winws от имени SYSTEM.
$sddlLines = & sc.exe sdshow $ServiceName
$sddl = ($sddlLines | Where-Object { $_ -match '^D:' } | Select-Object -First 1)
if (-not $sddl) { throw 'sc sdshow не вернул дескриптор безопасности' }
$sddl = $sddl -replace '\\(A;;[^;]*;;;IU\\)', ''
$sddl = $sddl -replace '^D:', 'D:(A;;CCLCSWRPWPLORC;;;IU)'
$sdsetOut = & sc.exe sdset $ServiceName $sddl 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "sc sdset завершился с кодом $LASTEXITCODE : $($sdsetOut.Trim())" }
Write-Result $true $null
}
'setAutoStart' {
$startType = if ($payload.autoStart) { 'auto' } else { 'demand' }
$out = & sc.exe config $ServiceName start= $startType 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "sc config завершился с кодом $LASTEXITCODE : $($out.Trim())" }
Write-Result $true $null
}
'remove' {
& 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
}
Write-Result $true $null
}
default {
throw "Неизвестное действие: $($payload.action)"
}
}
}
catch {
Write-Result $false $_.Exception.Message
exit 1
}
`
async function readResult(path: string): Promise<{ ok: boolean; error: string | null } | null> {
try {
return JSON.parse(await readFile(path, 'utf8')) as { ok: boolean; error: string | null }
} catch {
return null
}
}
/**
* Пишет payload во временный каталог и выполняет `worker.ps1`.
*
* Если приложение уже запущено от администратора (в собранном виде — всегда), worker
* запускается напрямую: ни UAC, ни лишних процессов. Иначе — не более одного запроса UAC
* на всё действие: `outer.ps1` (без прав) через `Start-Process -Verb RunAs` поднимает
* приложение с флагом задачи, а уже оно запускает worker.
*
* Результат в обоих случаях читаем из `result.json`, а не из кода возврата процесса.
*/
async function runElevated(payload: Record<string, unknown>): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-svc-'))
const workerPath = join(dir, TASK_FILES.worker)
const outerPath = join(dir, 'outer.ps1')
const payloadPath = join(dir, TASK_FILES.payload)
const resultPath = join(dir, TASK_FILES.result)
try {
await writeFile(workerPath, withBom(WORKER_SCRIPT), 'utf8')
await writeFile(payloadPath, JSON.stringify(payload), 'utf8')
let exitCode = 0
if (await isElevated()) {
// Приложение и так запущено от администратора (в собранном виде — всегда, см.
// requestedExecutionLevel в electron-builder.yml). Повышать нечего: запускаем worker
// напрямую, без outer.ps1 и без второго экземпляра приложения на каждое действие.
exitCode = await runElevatedServiceTask(dir)
} else {
await writeFile(outerPath, withBom(OUTER_SCRIPT), 'utf8')
try {
await run('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
outerPath,
'-AppPath',
process.execPath,
'-TaskDir',
dir,
// В dev electron.exe нужен каталог приложения первым аргументом; в сборке — нет.
...(app.isPackaged ? [] : ['-AppDir', app.getAppPath()])
])
} catch (error) {
exitCode = exitCodeOf(error)
}
}
if (exitCode === UAC_CANCELLED_CODE) {
throw new Error('Установка отменена — запрос на права администратора был отклонён.')
}
const result = await readResult(resultPath)
if (!result?.ok) {
throw new Error(
result?.error
? `Не удалось настроить службу: ${result.error}`
: `Настройка службы завершилась с ошибкой (код ${exitCode}).`
)
}
} finally {
await rm(dir, { recursive: true, force: true })
}
}
export interface ApplyServiceConfigOptions {
/** Тело `strategy.cfg` — из `winwsConfigFile()` в `strategies.ts`. */
configBody: string
autoStart: boolean
}
/**
* Установить службу (если её ещё нет) или перенастроить существующую — один запрос UAC.
* Заодно копирует `winws.exe` + WinDivert из папки установки приложения (per-user,
* доступна на запись обычному пользователю) в защищённый каталог службы — см.
* `protectedWinwsPath()` в `paths.ts` — и указывает binPath именно туда.
* Саму службу не запускает — вызывающий код (`Win32Engine.start()`) делает это отдельным
* вызовом `startService()`, который уже не требует прав благодаря ACL, выставленному здесь.
*/
export async function applyServiceConfig(options: ApplyServiceConfigOptions): Promise<void> {
await runElevated({
action: 'install',
serviceDataDir: serviceDataDir(),
configPath: serviceConfigPath(),
configBody: options.configBody,
winwsPath: winwsPath(),
autoStart: options.autoStart
})
}
/** Только тип автозапуска — не трогает аргументы winws, отдельный (более лёгкий) UAC-запрос. */
export async function setServiceAutoStart(autoStart: boolean): Promise<void> {
await runElevated({ action: 'setAutoStart', autoStart })
}
/** Используется деинсталлятором (`build/installer.nsh`) и на случай ручной переустановки. */
export async function removeService(): Promise<void> {
await runElevated({ action: 'remove', serviceDataDir: serviceDataDir() })
}
+247
View File
@@ -0,0 +1,247 @@
import { createHash } from 'node:crypto'
import type { ZapretStrategy } from '../../shared/ipc-contract'
import { autoHostlistPath, fakesDir, userListsDir } from './paths'
import { WIN32_STRATEGIES } from './strategies.win32.generated'
/**
* Пресеты обхода.
*
* Универсального набора параметров не существует: что сработает, зависит от DPI
* конкретного провайдера.
*
* `tpws` (macOS) — прокси уровня TCP-соединений, а не пакетов: умеет только резать/
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
*
* `winws` (Windows) работает на уровне пакетов через WinDivert. Его пресеты — 22 варианта
* из Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
* генератором `scripts/import-flowseal-strategies.mjs` (не батниками — кодом).
*
* Оба движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
* `{LISTS}/<id>.txt` — путь резолвится в `resolvePlaceholders()` ниже.
*/
interface DarwinStrategyDefinition extends ZapretStrategy {
/** Аргументы tpws после общих `--socks --port=...`; плейсхолдеры ещё не резолвлены. */
tpws: string[]
}
const LISTS_PLACEHOLDER = '{LISTS}'
const FAKES_PLACEHOLDER = '{FAKES}'
const AUTO_PLACEHOLDER = '{AUTO}'
/** `--hostlist-auto` заставляет zapret самому копить сюда домены, похожие на заблокированные. */
const AUTO_HOSTLIST_ARGS = [`--hostlist-auto=${AUTO_PLACEHOLDER}`]
const DARWIN_DEFINITIONS: DarwinStrategyDefinition[] = [
{
id: 'split',
name: 'Разрез + переупорядочивание',
description: 'Базовый вариант из поставки zapret: разрез TLS ClientHello по середине SNI.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
'--split-pos=1,midsld',
'--disorder'
]
},
{
id: 'multisplit',
name: 'Многократный разрез',
description: 'Больше точек разреза. Помогает, когда DPI склеивает сегменты обратно.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
// --oob здесь не добавляем: tpws отказывается совмещать его с --disorder вне Linux.
'--split-pos=1,midsld,host+1',
'--disorder'
]
},
{
id: 'tlsrec',
name: 'Разрез TLS-записи',
description: 'Другой способ разреза (--tlsrec) — стоит попробовать, если «Разрез» не помогает.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
'--split-pos=1',
'--tlsrec=midsld'
]
},
{
id: 'oob',
name: 'Разрез с OOB-байтом',
description: 'Внеполосный байт вместо переупорядочивания — третий вариант на выбор.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
'--split-pos=1,midsld',
'--oob'
]
},
{
id: 'all-traffic',
name: 'Весь трафик, без списков',
description:
'Разрез применяется ко всем сайтам, а не только из списка «Обходить». Диагностика.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
'--methodeol',
'--new',
'--filter-tcp=443',
'--split-pos=1,midsld',
'--disorder'
]
},
{
id: 'passthrough',
name: 'Без изменения трафика',
description:
'Прокси работает, но пакеты не трогает. Нужен, чтобы отделить проблемы сети от стратегии.',
platforms: ['darwin'],
tpws: []
}
]
/** Стратегия по умолчанию — своя на каждой платформе, наборы id не пересекаются. */
export function defaultStrategyId(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'general' : 'split'
}
function resolvePlaceholders(arg: string): string {
return arg
.replaceAll(LISTS_PLACEHOLDER, userListsDir())
.replaceAll(FAKES_PLACEHOLDER, fakesDir())
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
}
/** Пресеты, применимые к текущей платформе. */
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
if (platform === 'darwin') {
return DARWIN_DEFINITIONS.map(({ id, name, description, platforms }) => ({
id,
name,
description,
platforms
}))
}
if (platform === 'win32') {
return WIN32_STRATEGIES.map(({ id, name }) => ({
id,
name,
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
platforms: ['win32']
}))
}
return []
}
function findDarwin(strategyId: string): DarwinStrategyDefinition {
const strategy = DARWIN_DEFINITIONS.find((item) => item.id === strategyId)
if (!strategy) throw new Error(`Неизвестная стратегия: ${strategyId}`)
return strategy
}
/** Полная командная строка tpws в режиме socks5 на localhost. */
export function tpwsArgs(strategyId: string, port: number): string[] {
return [
'--socks',
`--port=${port}`,
'--bind-addr=127.0.0.1',
'--maxconn=512',
...findDarwin(strategyId).tpws.map(resolvePlaceholders)
]
}
/** Аргументы winws — служба читает их из файла как `winws.exe @strategy.txt`. */
export function winwsArgs(strategyId: string): string[] {
const strategy = WIN32_STRATEGIES.find((item) => item.id === strategyId)
if (!strategy) throw new Error(`Неизвестная стратегия: ${strategyId}`)
return strategy.args.map(resolvePlaceholders)
}
/** Читает служба: `winws.exe @strategy.cfg`, ограничение размера у самого winws — 16 КБ. */
const MAX_CONFIG_FILE_BYTES = 16000
/**
* winws разбирает файл конфигурации через POSIX `wordexp()` (см. `nfq/nfqws.c` в поставке
* zapret) — без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
*/
function quoteConfigArg(arg: string): string {
return `'${arg.replaceAll("'", `'\\''`)}'`
}
/**
* Первая строка — маркер: по нему `Win32Engine.sync()` узнаёт, какая стратегия уже установлена
* в службу, не перечитывая все аргументы. Сам маркер — штатная опция `--comment` winws,
* в работу процесса не вмешивается.
*/
function configMarker(strategyId: string, body: string): string {
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
return `--comment=rknboost:${strategyId}:${hash}`
}
export interface WinwsConfig {
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws (docs/windows.md zapret). */
body: string
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
marker: string
}
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `WinwsConfig`. */
export function winwsConfigFile(strategyId: string): WinwsConfig {
const args = winwsArgs(strategyId).map(quoteConfigArg).join('\n')
const marker = configMarker(strategyId, args)
const body = `${marker}\n${args}\n`
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
throw new Error(
`Стратегия «${strategyId}» не помещается в лимит winws на файл конфигурации (16 КБ).`
)
}
return { body, marker }
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import type { RendererApi } from './index'
declare global {
interface Window {
api: RendererApi
}
}
export {}
+79
View File
@@ -0,0 +1,79 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import type {
IpcChannel,
IpcEventChannel,
IpcEvents,
IpcHandlers,
ThemeState,
ZapretDiagnosticResult,
ZapretList,
ZapretListId,
ZapretLogLine,
ZapretStatus,
ZapretStrategy
} from '../shared/ipc-contract'
/** Типизированный `invoke`: канал, аргументы и результат берутся из контракта. */
function invoke<K extends IpcChannel>(
channel: K,
...args: Parameters<IpcHandlers[K]>
): Promise<ReturnType<IpcHandlers[K]>> {
return ipcRenderer.invoke(channel, ...args) as Promise<ReturnType<IpcHandlers[K]>>
}
/** Подписка на событие main → renderer. Возвращает функцию отписки (для useEffect). */
function subscribe<K extends IpcEventChannel>(
channel: K,
listener: (payload: IpcEvents[K]) => void
): () => void {
const wrapped = (_event: IpcRendererEvent, payload: IpcEvents[K]): void => listener(payload)
ipcRenderer.on(channel, wrapped)
return () => {
ipcRenderer.off(channel, wrapped)
}
}
/**
* Единственное, что видит renderer. Никакого прямого доступа к `ipcRenderer`,
* к Node API или к файловой системе — только эти методы.
*/
const api = {
/** 'darwin' | 'win32' | 'linux' — renderer'у нужно, например, чтобы показывать автозапуск
* службы только на Windows (см. SettingsView.tsx). */
platform: process.platform,
getTheme: (): Promise<ThemeState> => invoke('theme:get'),
onThemeChanged: (listener: (state: ThemeState) => void): (() => void) =>
subscribe('theme:changed', listener),
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
uninstallApp: (): Promise<void> => invoke('app:uninstall'),
getZapretStatus: (): Promise<ZapretStatus> => invoke('zapret:status'),
getZapretStrategies: (): Promise<ZapretStrategy[]> => invoke('zapret:strategies'),
startZapret: (strategyId: string): Promise<ZapretStatus> => invoke('zapret:start', strategyId),
stopZapret: (): Promise<ZapretStatus> => invoke('zapret:stop'),
setZapretSystemProxy: (enabled: boolean): Promise<ZapretStatus> =>
invoke('zapret:set-system-proxy', enabled),
setZapretStrategy: (strategyId: string): Promise<ZapretStatus> =>
invoke('zapret:set-strategy', strategyId),
setZapretAutoStart: (enabled: boolean): Promise<ZapretStatus> =>
invoke('zapret:set-autostart', enabled),
onZapretStatusChanged: (listener: (status: ZapretStatus) => void): (() => void) =>
subscribe('zapret:status-changed', listener),
onZapretLog: (listener: (entry: ZapretLogLine) => void): (() => void) =>
subscribe('zapret:log', listener),
getZapretLists: (): Promise<ZapretList[]> => invoke('zapret:lists'),
saveZapretList: (id: ZapretListId, entries: string[]): Promise<ZapretList> =>
invoke('zapret:list-save', id, entries),
resetZapretList: (id: ZapretListId): Promise<ZapretList> => invoke('zapret:list-reset', id),
getZapretAutoHostlist: (): Promise<string[]> => invoke('zapret:auto-hostlist'),
clearZapretAutoHostlist: (): Promise<void> => invoke('zapret:auto-hostlist-clear'),
runZapretDiagnostics: (): Promise<ZapretDiagnosticResult[]> => invoke('zapret:diagnose')
}
export type RendererApi = typeof api
contextBridge.exposeInMainWorld('api', api)
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>RKNboost</title>
<!-- Жёсткий CSP: никаких внешних скриптов, стилей и запросов.
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react'
import SettingsView from './components/SettingsView'
import ZapretCard from './components/ZapretCard'
import { useTheme } from './hooks/useTheme'
import { useZapret } from './hooks/useZapret'
export default function App(): React.JSX.Element {
useTheme()
const { status, setAutoStart } = useZapret()
const [showSettings, setShowSettings] = useState(false)
return (
<main className="content">
{showSettings ? (
<SettingsView
onBack={() => setShowSettings(false)}
status={status}
onSetAutoStart={setAutoStart}
/>
) : (
<>
<div className="app-header">
<span className="app-header__name">RKNboost</span>
<button
type="button"
className="app-header__settings"
aria-label="Настройки"
onClick={() => setShowSettings(true)}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" strokeWidth="2" />
<circle cx="12" cy="12" r="2.5" fill="currentColor" />
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle) => (
<rect
key={angle}
x="10.5"
y="0.5"
width="3"
height="3.5"
rx="0.8"
fill="currentColor"
transform={`rotate(${angle} 12 12)`}
/>
))}
</svg>
</button>
</div>
<ZapretCard />
</>
)}
</main>
)
}
+641
View File
@@ -0,0 +1,641 @@
/**
* Дизайн-система из design.md: роли Material 3, тёплая кремовая база
* и прохладный фиолетовый акцент.
*
* Правила, которые тут зашиты:
* • иерархия — тональностью поверхностей, а не тенями (теней нет вовсе);
* • скругления только из shape-шкалы (pill / 24 / 16 / 9);
* • два веса шрифта (400 и 500), sentence case, технические данные — моноширинным;
* • состояния — полупрозрачный state layer поверх роли (hover 8%, focus/pressed 12%).
*/
:root {
/* --- Роли Material 3, светлая тема (design.md §2) --- */
--primary: #6a45b8;
--on-primary: #ffffff;
--primary-container: #ebddff;
--on-primary-container: #5a2da8;
--secondary-container: #ebddff;
--on-secondary-container: #5a2da8;
/* Светлый фиолетовый: только крупные элементы и иконки, под мелкий текст не годится. */
--accent: #a96bff;
--surface: #faf6f0;
--on-surface: #3a2e22;
--surface-container: #f0e8dd;
--surface-container-high: #ece2d3;
--surface-bright: #fdfaf6;
/* Текст: тёмно-коричневый, не чёрный и не фиолетовый (design.md §6). */
--on-surface-variant: #5a4a3a;
--text-muted: #7a6a55;
--outline: #d8cdbe;
--outline-variant: #ece2d3;
--error: #ba1a1a;
--on-error: #ffffff;
--error-outline: #e6c4bc;
--on-error-outline: #b34a3a;
/* --- Статусные цвета, тёплый подтон (design.md §2) --- */
--status-success-bg: #c0dd97;
--status-success-fg: #2c4d0a;
--status-success-dot: #639922;
--status-neutral-bg: #e4dac9;
--status-neutral-fg: #7a6a55;
--status-neutral-dot: #a99a85;
--status-error-bg: #ffdad6;
--status-error-fg: #410002;
--status-error-dot: #ba1a1a;
/* --- Форма (design.md §4) --- */
--shape-pill: 100px;
--shape-card: 24px;
--shape-inner: 16px;
--shape-icon: 9px;
--font-sans: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color-scheme: light;
}
/* Тёплый уголь, а не баклажан: тот же тёплый подтон, что у крема (design.md §7). */
:root.dark {
--primary: #cbb3ff;
--on-primary: #381b6c;
--primary-container: #52389e;
--on-primary-container: #ebddff;
--secondary-container: #483a55;
--on-secondary-container: #ebddff;
--accent: #cbb3ff;
--surface: #1a1611;
--on-surface: #ede2d4;
--surface-container: #251f18;
--surface-container-high: #302820;
--surface-bright: #2b251d;
--on-surface-variant: #cfc2ae;
--text-muted: #b6a994;
--outline: #4d4334;
--outline-variant: #342c20;
--error: #ffb4ab;
--on-error: #5c1414;
--error-outline: #6e2a24;
--on-error-outline: #ffb4ab;
/* --- Статусные цвета, тёмная тема (design.md §7) --- */
--status-success-bg: #2d4413;
--status-success-fg: #c8e3a0;
--status-success-dot: #8fc24f;
--status-neutral-bg: #34291c;
--status-neutral-fg: #d0c0a4;
--status-neutral-dot: #b0a085;
--status-error-bg: #5c1414;
--status-error-fg: #ffb4ab;
--status-error-dot: #ff5449;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
/* Контент занимает всю высоту окна — оно маленькое (400×300), пустой хвост
снизу иначе выглядит перекошенным. */
#root {
display: flex;
flex-direction: column;
}
body {
margin: 0;
background: var(--surface);
color: var(--on-surface);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 400;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--primary-container);
color: var(--on-primary-container);
}
/* Общее кольцо фокуса — тот же 12%-слой, что и у нажатия. */
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ---------- Контент ---------- */
/* Прокрутки на уровне оболочки нет: главный экран рассчитан на то, чтобы умещаться
в окно целиком. Прокручиваются только настройки и редактор списка — там содержимое
заведомо длиннее окна (правила ниже, .settings / .list-editor). */
.content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
/* Без этого flex-элемент не даёт себя сжать ниже размера содержимого,
и внутренняя прокрутка настроек не включается. */
min-height: 0;
}
/* Шапка приложения: вордмарк слева, версия справа (design.md, шапка drawer-панели). */
.app-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.app-header__name {
font-size: 20px;
font-weight: 500;
}
.app-header__settings {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--shape-icon);
background: transparent;
color: var(--text-muted);
font-size: 15px;
cursor: pointer;
}
.app-header__settings:hover {
background: color-mix(in srgb, var(--on-surface) 8%, transparent);
color: var(--on-surface);
}
.app-header__settings:active {
background: color-mix(in srgb, var(--on-surface) 12%, transparent);
}
/* Рабочая зона — светлее крема; разделение тональностью плюс тонкий контур. */
.card {
padding: 20px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-card);
}
.card h2 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 500;
}
/* ---------- Карточка обхода блокировок (design.md §4.1, «карточка VPN-сервера») ---------- */
.zapret-card {
display: flex;
flex-direction: column;
gap: 16px;
/* Предохранитель. В обычных состояниях карточка умещается целиком и никакой полосы
не видно — ради этого прокрутка и убрана с .content. Но текст ошибки приходит из
системы и по длине не ограничен, поэтому переполнение забирает на себя карточка,
а не оболочка: в патологическом случае пользователь увидит полосу внутри карточки,
а не обрезанное содержимое. */
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
}
/* Текст ошибки приходит из системы и по длине ничем не ограничен (например, вывод sc.exe).
Без потолка он растянул бы карточку и вытолкнул содержимое за пределы окна — а прокрутки
на главном экране нет, значит это было бы обрезано. Ограничиваем и прокручиваем сам блок
ошибки: главный экран при этом остаётся фиксированной высоты при любой длине ошибки. */
.zapret-card .list-editor__error {
max-height: 96px;
overflow-y: auto;
}
.zapret-card__head {
display: flex;
align-items: center;
gap: 14px;
}
.zapret-card__title {
flex: 1 1 auto;
min-width: 0;
margin: 0;
}
/* Кнопка включения — отдельно от названия и статуса, во всю ширину карточки
(design.md, часть II: главное действие — заливка primary, pill). */
.toggle-button {
width: 100%;
min-height: 44px;
padding: 0 20px;
border: 1px solid transparent;
border-radius: var(--shape-pill);
background: var(--primary);
color: var(--on-primary);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.toggle-button:hover {
background: color-mix(in srgb, var(--on-primary) 8%, var(--primary));
}
.toggle-button:active {
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
}
/* Обход уже активен — приглашаем выключить менее настойчивым, тональным цветом. */
.toggle-button--on {
background: var(--secondary-container);
color: var(--on-secondary-container);
}
.toggle-button--on:hover {
background: color-mix(in srgb, var(--on-secondary-container) 8%, var(--secondary-container));
}
.toggle-button--on:active {
background: color-mix(in srgb, var(--on-secondary-container) 12%, var(--secondary-container));
}
.toggle-button:disabled {
cursor: default;
opacity: 0.6;
}
/* Чип статуса: точка + подпись, роль зависит от состояния (design.md, статусные цвета). */
.chip {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: var(--shape-pill);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.chip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
}
.chip--success {
background: var(--status-success-bg);
color: var(--status-success-fg);
}
.chip--success::before {
background: var(--status-success-dot);
}
.chip--neutral {
background: var(--status-neutral-bg);
color: var(--status-neutral-fg);
}
.chip--neutral::before {
background: var(--status-neutral-dot);
}
.chip--error {
background: var(--status-error-bg);
color: var(--status-error-fg);
}
.chip--error::before {
background: var(--status-error-dot);
}
/* Убираемая версия чипа — список сайтов (design.md: состояние hover/active тем же слоем). */
.chip--removable {
gap: 8px;
background: var(--surface-container-high);
color: var(--on-surface-variant);
}
.chip--removable::before {
content: none;
}
.chip__remove {
display: grid;
place-items: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
font-size: 10px;
line-height: 1;
cursor: pointer;
}
.chip__remove:hover {
background: color-mix(in srgb, var(--on-surface) 12%, transparent);
}
/* ---------- Настройки: стратегия + списки сайтов ---------- */
/* Прокрутка живёт здесь, а не на .content: списки сайтов и настройки длиннее окна,
а главный экран прокручиваться не должен. */
.settings,
.list-editor {
display: flex;
flex-direction: column;
gap: 14px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
/* Полоса прокрутки не должна наезжать на карточки и кнопку удаления. */
padding-right: 4px;
margin-right: -4px;
}
.settings__back-row {
display: flex;
align-items: center;
gap: 10px;
}
.settings__back {
padding: 4px 10px 4px 4px;
border: none;
border-radius: var(--shape-pill);
background: transparent;
color: var(--text-muted);
font: inherit;
cursor: pointer;
}
.settings__back:hover {
color: var(--on-surface);
}
.settings__title {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.settings__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings__label {
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
}
.select {
height: 40px;
padding: 0 12px;
border: 1px solid var(--outline);
border-radius: var(--shape-inner);
background: var(--surface-bright);
color: var(--on-surface);
font: inherit;
}
.settings__group {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 4px;
border: none;
border-top: 1px solid var(--outline-variant);
background: transparent;
color: var(--on-surface);
font: inherit;
text-align: left;
cursor: pointer;
}
.settings-row:hover {
background: color-mix(in srgb, var(--on-surface) 4%, transparent);
}
.settings-row--static {
cursor: default;
}
.settings-row--static:hover {
background: transparent;
}
.diagnostic__label {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding-right: 12px;
}
.diagnostic__error {
color: var(--text-muted);
font-size: 12px;
line-height: 1.3;
}
.settings-row__count {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.settings-row__clear {
padding: 2px 10px;
border: 1px solid var(--outline);
border-radius: var(--shape-pill);
background: transparent;
color: var(--on-surface-variant);
font-size: 12px;
cursor: pointer;
}
.settings-row__clear:hover {
background: color-mix(in srgb, var(--on-surface) 8%, transparent);
}
.settings__support {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings__support-link {
width: 100%;
}
.settings__support-hint {
align-self: stretch;
margin: 0;
color: var(--text-muted);
font-size: 12px;
text-align: center;
}
.settings__version {
margin: 0;
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-mono);
text-align: left;
}
.list-editor__hint {
margin: 0;
color: var(--text-muted);
font-size: 12px;
}
.list-editor__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.list-editor__empty {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
.list-editor__add-row {
display: flex;
gap: 8px;
}
.text-input {
flex: 1 1 auto;
min-width: 0;
height: 36px;
padding: 0 12px;
border: 1px solid var(--outline);
border-radius: var(--shape-inner);
background: var(--surface-bright);
color: var(--on-surface);
font: inherit;
}
.action--outline {
flex: 0 0 auto;
height: 36px;
padding: 0 16px;
border: 1px solid var(--primary);
border-radius: var(--shape-pill);
background: transparent;
color: var(--primary);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.action--outline:hover {
background: color-mix(in srgb, var(--primary) 8%, transparent);
}
/* .action--outline на <a> вместо <button> — нужно вернуть центрирование текста
и убрать подчёркивание, которые браузер даёт ссылкам по умолчанию. */
a.action--outline {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.action--outline:disabled {
cursor: default;
opacity: 0.5;
background: transparent;
}
.list-editor__error {
margin: 0;
color: var(--on-error-outline);
font-size: 12px;
}
.list-editor__reset {
align-self: flex-start;
padding: 6px 4px;
border: none;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 12px;
text-decoration: underline;
cursor: pointer;
}
.list-editor__reset:hover:not(:disabled) {
color: var(--on-surface);
}
.list-editor__reset:disabled {
cursor: default;
opacity: 0.5;
text-decoration: none;
}
.action--danger {
width: 100%;
min-height: 44px;
padding: 0 20px;
border: 1px solid transparent;
border-radius: var(--shape-pill);
background: var(--error);
color: var(--on-error);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.action--danger:hover {
background: color-mix(in srgb, var(--error) 88%, black);
}
.action--danger:active {
background: color-mix(in srgb, var(--error) 78%, black);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import type { ZapretList } from '@shared/ipc-contract'
import { LIST_HINTS, LIST_TITLES } from '../lib/zapretListMeta'
/** Экран одного списка: чипы с удалением, поле добавления, сброс к встроенному. */
export default function ListEditor({
list,
onBack,
onSave,
onReset
}: {
list: ZapretList
onBack: () => void
onSave: (entries: string[]) => Promise<void>
onReset: () => Promise<void>
}): React.JSX.Element {
const [draft, setDraft] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const remove = (entry: string): void => {
void run(list.entries.filter((item) => item !== entry))
}
const add = (): void => {
const value = draft.trim()
if (!value) return
void run([...list.entries, value])
}
const run = async (entries: string[]): Promise<void> => {
setBusy(true)
setError(null)
try {
await onSave(entries)
setDraft('')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<section className="list-editor">
<div className="settings__back-row">
<button type="button" className="settings__back" onClick={onBack}>
‹ Назад
</button>
<h2 className="settings__title">{LIST_TITLES[list.id]}</h2>
</div>
<p className="list-editor__hint">{LIST_HINTS[list.id]}</p>
<div className="list-editor__chips">
{list.entries.length === 0 && <p className="list-editor__empty">Список пуст.</p>}
{list.entries.map((entry) => (
<span key={entry} className="chip chip--removable">
{entry}
<button
type="button"
className="chip__remove"
aria-label={`Убрать ${entry}`}
disabled={busy}
onClick={() => remove(entry)}
>
✕
</button>
</span>
))}
</div>
<div className="list-editor__add-row">
<input
type="text"
className="text-input"
placeholder={
list.id === 'ipset-exclude' || list.id === 'ipset-all'
? '192.168.0.0/16'
: 'example.com'
}
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') add()
}}
/>
<button
type="button"
className="action--outline"
disabled={busy || !draft.trim()}
onClick={add}
>
Добавить
</button>
</div>
{error && <p className="list-editor__error">{error}</p>}
<button
type="button"
className="list-editor__reset"
disabled={busy || list.isDefault}
onClick={() => void onReset()}
>
Сбросить к встроенному
</button>
</section>
)
}
@@ -0,0 +1,137 @@
import { useState } from 'react'
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
import { GITHUB_URL } from '@shared/links'
import { useZapretLists } from '../hooks/useZapretLists'
import { LIST_TITLES } from '../lib/zapretListMeta'
import ListEditor from './ListEditor'
/**
* Настройки: списки сайтов + автозапуск (Windows). Выбор стратегии и проверка
* соединения переехали на главный экран (ZapretCard.tsx) — рядом с кнопкой
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
*/
const isWindows = window.api.platform === 'win32'
const APP_CHANNEL = 'beta'
// Держать в синхроне с "version" в package.json — оттуда её берёт electron-builder
// для имени установщика и для «Установленные приложения» в Windows.
const APP_VERSION = '0.4.0'
export default function SettingsView({
onBack,
status,
onSetAutoStart
}: {
onBack: () => void
status: ZapretStatus | null
onSetAutoStart: (enabled: boolean) => Promise<void>
}): React.JSX.Element {
const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists()
const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
const openList = lists.find((list) => list.id === openListId)
if (openList) {
return (
<ListEditor
list={openList}
onBack={() => setOpenListId(null)}
onSave={async (entries) => {
await save(openList.id, entries)
}}
onReset={async () => {
await reset(openList.id)
}}
/>
)
}
return (
<section className="settings">
<div className="settings__back-row">
<button type="button" className="settings__back" onClick={onBack}>
‹ Назад
</button>
<h2 className="settings__title">Настройки</h2>
</div>
{isWindows && (
<div className="settings__group">
<span className="settings__label">Автозапуск</span>
<button
type="button"
className="settings-row"
onClick={() => void onSetAutoStart(!status?.autoStart)}
>
<span>Запускать вместе с Windows</span>
<span className={`chip ${status?.autoStart ? 'chip--success' : 'chip--neutral'}`}>
{status?.autoStart ? 'Включён' : 'Выключен'}
</span>
</button>
<p className="list-editor__hint">
Меняется через запрос прав администратора. Если выключить обход кнопкой на главном
экране, при включённом автозапуске он вернётся не раньше следующей перезагрузки.
</p>
</div>
)}
<div className="settings__group">
<span className="settings__label">Сайты</span>
{lists.map((list) => (
<button
key={list.id}
type="button"
className="settings-row"
onClick={() => setOpenListId(list.id)}
>
<span>{LIST_TITLES[list.id]}</span>
<span className="settings-row__count">{list.entries.length} ›</span>
</button>
))}
{/* На Windows ни одна из 22 стратегий Flowseal не использует --hostlist-auto — счётчик
всегда пуст, показывать его смысла нет. */}
{!isWindows && (
<div className="settings-row settings-row--static">
<span>Найдено автоматически</span>
<span className="settings-row__count">
{autoHostlist.length}
{autoHostlist.length > 0 && (
<button
type="button"
className="settings-row__clear"
onClick={() => void clearAuto()}
>
Очистить
</button>
)}
</span>
</div>
)}
</div>
<section className="card settings__support">
<h2>Поддержка</h2>
{/* Ссылка уходит в системный браузер: will-navigate в window.ts перехватывает
переход и отдаёт его openExternal с проверкой протокола. */}
<a href={GITHUB_URL} className="action--outline settings__support-link">
GitHub
</a>
<p className="settings__support-hint">Поддержите звёздочкой</p>
</section>
<p className="settings__version">
{APP_CHANNEL}-{APP_VERSION}-{window.api.platform}
</p>
{isWindows && (
<button
type="button"
className="action--danger"
onClick={() => void window.api.uninstallApp()}
>
Удалить приложение
</button>
)}
</section>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useZapret } from '../hooks/useZapret'
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 running = status?.state === 'running' || status?.state === 'starting'
const strategyId = status?.strategyId ?? strategies[0]?.id
const toggle = (): void => {
if (!strategyId) return
void (running ? stop() : start(strategyId))
}
const chip = ((): { className: string; label: string } => {
switch (status?.state) {
case 'running':
return { className: 'chip--success', label: 'Активен' }
case 'starting':
case 'stopping':
return { className: 'chip--neutral', label: 'Переключение…' }
case 'error':
return { className: 'chip--error', label: 'Ошибка' }
default:
return { className: 'chip--neutral', label: 'Выключен' }
}
})()
const toggleLabel =
status?.state === 'stopping' ? 'Выключение…' : running ? 'Выключить' : 'Включить'
return (
<section className="card zapret-card">
<div className="zapret-card__head">
<h2 className="zapret-card__title">Обход блокировок</h2>
<span className={`chip ${chip.className}`}>{chip.label}</span>
</div>
<button
type="button"
className={`toggle-button${running ? ' toggle-button--on' : ''}`}
disabled={busy || !strategyId}
onClick={toggle}
>
{toggleLabel}
</button>
{status?.state === 'error' && status.error && (
<p className="list-editor__error">{status.error}</p>
)}
<div className="settings__field">
<span className="settings__label">Стратегия</span>
{/* На время переключения список заблокирован: смена стратегии на работающем
обходе останавливает и перенастраивает службу, и второй выбор посреди этого
наложился бы на первый. */}
<select
className="select"
disabled={busy}
value={status?.strategyId ?? ''}
onChange={(event) => void setStrategy(event.target.value)}
>
{strategies.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{strategy.name}
</option>
))}
</select>
</div>
<button
type="button"
className="action--outline zapret-card__test-button"
disabled={testing}
onClick={() => void runTest()}
>
{testing ? 'Проверяю…' : 'Протестить'}
</button>
{results.length > 0 && (
<div className="settings__group">
{results.map((result) => (
<div key={result.id} className="settings-row settings-row--static">
{/* Причину показываем текстом, а не только подсказкой при наведении:
«Недоступно» само по себе не говорит, что чинить. */}
<span className="diagnostic__label">
{result.label}
{!result.ok && result.error && (
<small className="diagnostic__error">{result.error}</small>
)}
</span>
<span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}>
{result.ok ? `${result.ms} мс` : 'Недоступно'}
</span>
</div>
))}
</div>
)}
</section>
)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from 'react'
/**
* Синхронизирует окно с системной темой: следит за `nativeTheme` main-процесса
* и вешает класс `.dark` на <html>. Ручного переключателя нет — приложение
* всегда следует настройке ОС.
*/
export function useTheme(): void {
useEffect(() => {
void window.api.getTheme().then((theme) => {
document.documentElement.classList.toggle('dark', theme.shouldUseDarkColors)
})
return window.api.onThemeChanged((theme) => {
document.documentElement.classList.toggle('dark', theme.shouldUseDarkColors)
})
}, [])
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from 'react'
import type { ZapretLogLine, ZapretStatus, ZapretStrategy } from '@shared/ipc-contract'
const LOG_LIMIT = 50
/**
* Статус обхода блокировок + список пресетов, в синхроне с main-процессом.
* Тот же паттерн, что и `useTheme`: получить текущее значение, подписаться
* на изменения, вернуть функцию отписки из useEffect.
*/
export function useZapret(): {
status: ZapretStatus | null
strategies: ZapretStrategy[]
log: ZapretLogLine[]
start: (strategyId: string) => Promise<void>
stop: () => Promise<void>
setSystemProxy: (enabled: boolean) => Promise<void>
setStrategy: (strategyId: string) => Promise<void>
setAutoStart: (enabled: boolean) => Promise<void>
busy: boolean
} {
const [status, setStatus] = useState<ZapretStatus | null>(null)
const [strategies, setStrategies] = useState<ZapretStrategy[]>([])
const [log, setLog] = useState<ZapretLogLine[]>([])
const [busy, setBusy] = useState(false)
useEffect(() => {
void window.api.getZapretStatus().then(setStatus)
void window.api.getZapretStrategies().then(setStrategies)
return window.api.onZapretStatusChanged(setStatus)
}, [])
useEffect(() => {
return window.api.onZapretLog((entry) => {
setLog((previous) => [...previous, entry].slice(-LOG_LIMIT))
})
}, [])
const start = useCallback(async (strategyId: string) => {
setBusy(true)
try {
setStatus(await window.api.startZapret(strategyId))
} finally {
setBusy(false)
}
}, [])
const stop = useCallback(async () => {
setBusy(true)
try {
setStatus(await window.api.stopZapret())
} finally {
setBusy(false)
}
}, [])
const setSystemProxy = useCallback(async (enabled: boolean) => {
setStatus(await window.api.setZapretSystemProxy(enabled))
}, [])
const setStrategy = useCallback(async (strategyId: string) => {
setBusy(true)
try {
setStatus(await window.api.setZapretStrategy(strategyId))
} finally {
setBusy(false)
}
}, [])
const setAutoStart = useCallback(async (enabled: boolean) => {
setBusy(true)
try {
setStatus(await window.api.setZapretAutoStart(enabled))
} finally {
setBusy(false)
}
}, [])
return { status, strategies, log, start, stop, setSystemProxy, setStrategy, setAutoStart, busy }
}
@@ -0,0 +1,23 @@
import { useCallback, useState } from 'react'
import type { ZapretDiagnosticResult } from '@shared/ipc-contract'
/** Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists. */
export function useZapretDiagnostics(): {
results: ZapretDiagnosticResult[]
running: boolean
run: () => Promise<void>
} {
const [results, setResults] = useState<ZapretDiagnosticResult[]>([])
const [running, setRunning] = useState(false)
const run = useCallback(async () => {
setRunning(true)
try {
setResults(await window.api.runZapretDiagnostics())
} finally {
setRunning(false)
}
}, [])
return { results, running, run }
}
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from 'react'
import type { ZapretList, ZapretListId } from '@shared/ipc-contract'
/**
* Списки сайтов + журнал автообнаружения, в синхроне с main-процессом.
* Тот же паттерн, что и `useZapret`/`useTheme`.
*/
export function useZapretLists(): {
lists: ZapretList[]
autoHostlist: string[]
save: (id: ZapretListId, entries: string[]) => Promise<ZapretList>
reset: (id: ZapretListId) => Promise<ZapretList>
clearAuto: () => Promise<void>
loading: boolean
} {
const [lists, setLists] = useState<ZapretList[]>([])
const [autoHostlist, setAutoHostlist] = useState<string[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
void Promise.all([window.api.getZapretLists(), window.api.getZapretAutoHostlist()]).then(
([nextLists, nextAuto]) => {
setLists(nextLists)
setAutoHostlist(nextAuto)
setLoading(false)
}
)
}, [])
const save = useCallback(async (id: ZapretListId, entries: string[]) => {
const updated = await window.api.saveZapretList(id, entries)
setLists((previous) => previous.map((list) => (list.id === id ? updated : list)))
return updated
}, [])
const reset = useCallback(async (id: ZapretListId) => {
const updated = await window.api.resetZapretList(id)
setLists((previous) => previous.map((list) => (list.id === id ? updated : list)))
return updated
}, [])
const clearAuto = useCallback(async () => {
await window.api.clearZapretAutoHostlist()
setAutoHostlist([])
}, [])
return { lists, autoHostlist, save, reset, clearAuto, loading }
}
+19
View File
@@ -0,0 +1,19 @@
import type { ZapretListId } from '@shared/ipc-contract'
export const LIST_TITLES: Record<ZapretListId, string> = {
general: 'Обходить',
google: 'YouTube / Google',
exclude: 'Не трогать',
'ipset-exclude': 'Исключённые подсети',
'ipset-all': 'Подсети (IP)'
}
export const LIST_HINTS: Record<ZapretListId, string> = {
general: 'Обход применяется только к этим сайтам.',
google: 'Отдельный список — YouTube и Google обрабатываются иначе, чем остальные сайты.',
exclude: 'Эти сайты обход не трогает никогда — банки и похожие сервисы стоит оставить здесь.',
'ipset-exclude': 'Подсети, которые обход тоже не трогает (локальная сеть и т. п.).',
'ipset-all':
'Windows: адреса, к которым обход применяется по IP, а не по имени сайта — на случай, ' +
'если один хост прячет за собой сразу несколько доменов.'
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './assets/main.css'
const container = document.getElementById('root')
if (!container) throw new Error('Не найден #root в index.html')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>
)
+140
View File
@@ -0,0 +1,140 @@
/**
* Единый контракт общения main ↔ renderer.
*
* Файл импортируется всеми тремя процессами (main, preload, renderer), поэтому
* здесь не должно быть импортов из `electron` или из DOM — только типы и константы.
*
* Чтобы добавить новый канал:
* 1. описать его в `IpcHandlers` (аргументы + результат);
* 2. зарегистрировать обработчик в `src/main/ipc.ts` (TS не даст ошибиться в типах);
* 3. пробросить метод наружу в `src/preload/index.ts`.
*/
export type ThemeSource = 'system' | 'light' | 'dark'
export interface ThemeState {
/** Что выбрал пользователь. */
source: ThemeSource
/** Что реально показывать сейчас (учитывает системную тему). */
shouldUseDarkColors: boolean
}
/** Состояние обхода блокировок. */
export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'
/**
* Чем именно обходим на этой платформе:
* • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны;
* • `winws-service` — Windows: служба с драйвером WinDivert;
* • `unsupported` — платформа без поддержки (Linux у нас не собран).
*/
export type ZapretBackend = 'tpws-socks' | 'winws-service' | 'unsupported'
export interface ZapretStatus {
state: ZapretState
backend: ZapretBackend
/** Выбранный пресет из `zapret:strategies`. */
strategyId: string
/** Когда обход включён, ISO-8601. */
startedAt: string | null
/** Текст последней ошибки — показываем пользователю как есть. */
error: string | null
/** macOS: адрес socks5-прокси, пока обход работает. */
socksAddress: string | null
/** macOS: прописан ли прокси в системных настройках сети. */
systemProxyApplied: boolean
/** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */
serviceInstalled: boolean
/** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). */
autoStart: boolean
}
/** Готовый набор параметров обхода. Подбирается под провайдера. */
export interface ZapretStrategy {
id: string
name: string
description: string
platforms: NodeJS.Platform[]
}
export interface ZapretLogLine {
level: 'info' | 'error'
line: string
}
/**
* Списки сайтов, применяемые обоими движками (см. `src/main/zapret/lists.ts`).
* Встроенное содержимое берётся из Flowseal/zapret-discord-youtube (MIT) — пользователь
* может редактировать любой список полностью, включая встроенные.
*/
export type ZapretListId = 'general' | 'google' | 'exclude' | 'ipset-exclude' | 'ipset-all'
export interface ZapretList {
id: ZapretListId
/** Домены (general/google/exclude) или IP/CIDR (ipset-exclude/ipset-all), уже без пустых строк и комментариев. */
entries: string[]
/** Совпадает ли содержимое с текущей встроенной версией — пользователь ничего не менял. */
isDefault: boolean
}
/**
* Результат проверки соединения (`src/main/zapret/diagnostics.ts`): успех — это успешный
* TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос.
*/
export interface ZapretDiagnosticResult {
id: string
label: string
host: string
ok: boolean
ms: number | null
error: string | null
}
/**
* Каналы вида renderer → main → ответ (`ipcRenderer.invoke` / `ipcMain.handle`).
* Ключ — имя канала, значение — сигнатура обработчика.
*/
export interface IpcHandlers {
'theme:get': () => ThemeState
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
'app:uninstall': () => void
'zapret:status': () => ZapretStatus
'zapret:strategies': () => ZapretStrategy[]
'zapret:start': (strategyId: string) => ZapretStatus
'zapret:stop': () => ZapretStatus
/** macOS: прописать/убрать socks-прокси в системных настройках сети. */
'zapret:set-system-proxy': (enabled: boolean) => ZapretStatus
/**
* Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS
* перезапускает tpws, на Windows перенастраивает службу (потребуется UAC).
*/
'zapret:set-strategy': (strategyId: string) => ZapretStatus
/** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */
'zapret:set-autostart': (enabled: boolean) => ZapretStatus
'zapret:lists': () => ZapretList[]
'zapret:list-save': (id: ZapretListId, entries: string[]) => ZapretList
'zapret:list-reset': (id: ZapretListId) => ZapretList
/** Список, который zapret пополняет сам через --hostlist-auto (только macOS-профили). */
'zapret:auto-hostlist': () => string[]
'zapret:auto-hostlist-clear': () => void
/** Проверка соединения: успешный TLS-хендшейк до нескольких сайтов через текущий бэкенд. */
'zapret:diagnose': () => ZapretDiagnosticResult[]
}
export type IpcChannel = keyof IpcHandlers
/** Каналы вида main → renderer (`webContents.send` / `ipcRenderer.on`). */
export interface IpcEvents {
'theme:changed': ThemeState
'zapret:status-changed': ZapretStatus
'zapret:log': ZapretLogLine
}
export type IpcEventChannel = keyof IpcEvents
/** Разрешённые протоколы для `shell.openExternal` — всё остальное main отклоняет. */
export const ALLOWED_EXTERNAL_PROTOCOLS = ['https:', 'mailto:'] as const
+9
View File
@@ -0,0 +1,9 @@
/**
* Адрес репозитория — одно место на всё приложение. Отсюда его берут и кнопка «GitHub»
* в настройках (`SettingsView.tsx`), и приветственное окно при первом запуске
* (`src/main/first-run.ts`).
*
* TODO: заглушка. Пока ведёт на главную GitHub — заменить на настоящий адрес репозитория,
* когда он появится. Менять только здесь, оба места подхватят автоматически.
*/
export const GITHUB_URL = 'https://github.com'
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*",
"src/shared/**/*"
],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/preload/*.d.ts",
"src/shared/**/*"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/renderer/src/*"],
"@shared/*": ["src/shared/*"]
}
}
}