Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
release
|
||||
resources/zapret
|
||||
.idea
|
||||
.vscode
|
||||
@@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
@@ -0,0 +1,4 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
@@ -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`.
|
||||
@@ -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>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
@@ -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 — опора десктопа; на тач-устройствах обратная связь на нажатии/выборе.
|
||||
@@ -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
|
||||
@@ -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()]
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
Generated
+7078
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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)`)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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> {}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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».'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { RendererApi } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: RendererApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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)
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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, а не по имени сайта — на случай, ' +
|
||||
'если один хост прячет за собой сразу несколько доменов.'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Адрес репозитория — одно место на всё приложение. Отсюда его берут и кнопка «GitHub»
|
||||
* в настройках (`SettingsView.tsx`), и приветственное окно при первом запуске
|
||||
* (`src/main/first-run.ts`).
|
||||
*
|
||||
* TODO: заглушка. Пока ведёт на главную GitHub — заменить на настоящий адрес репозитория,
|
||||
* когда он появится. Менять только здесь, оба места подхватят автоматически.
|
||||
*/
|
||||
export const GITHUB_URL = 'https://github.com'
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user