6 Commits
Author SHA1 Message Date
maeneko 2126d2b3ef Windows: одна кнопка автозапуска и версия 0.6.2
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
CI / Сборка (Linux) (push) Canceled after 0s
CI / Сборка (macOS) (push) Canceled after 0s
На Windows в настройках было две похожие кнопки: автозапуск службы обхода
и автозапуск самого приложения (задача планировщика). Теперь это одна кнопка
«Запускать вместе с Windows»: при загрузке поднимается служба, а SenBoost
открывается свёрнутым в трей, без окна.

Задача планировщика больше не переключается отдельно — followZapretAutoStart
ставит и снимает её вслед за автозапуском службы (без лишнего UAC: приложение
и так работает с правами администратора). На macOS и Linux ничего не меняется.
2026-09-11 02:54:04 +03:00
maeneko 9b8a187073 README: добавлен раздел «Планы до 1.0»
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
CI / Сборка (Linux) (push) Canceled after 0s
CI / Сборка (macOS) (push) Canceled after 0s
2026-09-04 20:06:57 +03:00
maeneko 781d8a574f Добавлена работа в трее и версия 0.6.1
Приложение теперь может сворачиваться в трей вместо закрытия: обход
продолжает работать в фоне, иконка в трее показывает статус и
позволяет включить/выключить обход без открытия окна. Добавлен
автозапуск самого приложения вместе с системой (свёрнутым в трей).
2026-09-04 20:01:05 +03:00
maeneko 30248656a8 CI: сборка macOS
CI / Линт и типы (push) Canceled after 0s
CI / Сборка (Windows) (push) Canceled after 0s
CI / Сборка (Linux) (push) Canceled after 0s
CI / Сборка (macOS) (push) Canceled after 0s
Джоба build-mac на macos-latest по образцу Windows/Linux: та же заготовка релиза с
`|| true` (все три джобы стартуют параллельно и конкурируют за создание релиза),
electron-builder --mac с публикацией только на тег, артефакты в Artifacts на push/PR.

Отличия от соседних джоб, которые нельзя было скопировать как есть:
  • контрольная сумма считается через `md5 -q` — md5sum на macOS 14/15 нет вовсе;
  • шага «Описание релиза» здесь нет, он намеренно живёт только в Windows-джобе;
  • из релиза удаляются `*-mac.zip.blockmap`: у dmg блокмап отключён свежим
    writeUpdateInfo: false, а у mac-zip выключателя нет (зашит в macPackager), при
    этом electron-updater'а в приложении нет и блокмапы никому не нужны.
2026-09-04 15:00:38 +03:00
maeneko c71a27c9a3 Переименование проекта: RKNboost → SenBoost
Затрагивает не только видимые строки, но и системные идентификаторы: appId
(com.senboost.app), служба Windows (senboost-zapret) и её каталог в %ProgramData%,
LaunchDaemon и pf-якорь на macOS (com.senboost.zapret, com.apple/senboost),
каталог /run/senboost и таблица nftables на Linux, маркер стратегии в strategy.cfg
(--comment=senboost:<id>:<hash>), а также каталог userData, который Electron
выводит из имени приложения.
2026-09-04 14:41:56 +03:00
maeneko ebb9191d6f Версия 0.6.0
Заменяет tpws на utunws (форк nfqws под utun+BPF из Flowseal/zapret-mac-discord-youtube)
на macOS: движок уровня пакетов вместо socks5-прокси даёт macOS те же 22 стратегии
Flowseal, что у Windows и Linux, включая fake-пакеты и десинк UDP/QUIC — раньше tpws
этого не умел вовсе. Обход теперь ставится как LaunchDaemon (resources/darwin-helper/),
пароль администратора спрашивается при каждом включении/выключении, тот же принцип, что
pkexec на Linux. Системный прокси и авто-список hostlist-auto больше не нужны и убраны.

README: Windows отмечена как гарантированно рабочая, macOS и Linux — как частично.
2026-09-04 14:36:52 +03:00
55 changed files with 1990 additions and 776 deletions
+88 -2
View File
@@ -123,7 +123,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: rknboost-windows
name: senboost-windows
path: release/*.exe
if-no-files-found: error
retention-days: 7
@@ -189,9 +189,95 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: rknboost-linux
name: senboost-linux
path: |
release/*.AppImage
release/*.deb
if-no-files-found: error
retention-days: 7
build-mac:
name: Сборка (macOS)
needs: check
# macos-latest — arm64-раннер, но electron-builder собирает обе цели из
# electron-builder.yml (dmg + zip, x64 и arm64): нужные бинарники Electron он качает
# сам, а utunws в resources/zapret/darwin и так universal (x86_64 + arm64).
runs-on: macos-latest
# На тег vX.Y.Z нужен contents: write — electron-builder публикует релиз через GH_TOKEN.
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
# Без этого шага сборка уезжает без utunws: extraResources в electron-builder.yml
# просто копирует resources/zapret, а он у чистого чекаута пуст.
- run: node scripts/fetch-zapret.mjs
# Та же гонка «кто первый создаст релиз», что и в джобах Windows/Linux выше — эта
# джоба конкурирует с ними наравне (все стартуют параллельно после check), отсюда и
# тот же `|| true`. Комментарий с полным объяснением — в джобе Windows.
- name: Заготовка релиза
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 ||
gh release create "$GITHUB_REF_NAME" --title "${GITHUB_REF_NAME#v}" --notes '' || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Подпись и нотаризация не настроены (notarize: false в electron-builder.yml, секретов
# CSC_LINK/CSC_KEY_PASSWORD нет) — .dmg и .zip уезжают неподписанными, и Gatekeeper
# будет ругаться на них у пользователя. Как включить — раздел «Подпись» в README.md.
# На push в main/PR публикации нет — только упаковка и Artifacts ниже.
- name: Build
run: npx --no-install electron-vite build && npx --no-install electron-builder --mac --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Тот же мотив, что у Windows/Linux-джоб: в релизе должны остаться только сами
# артефакты и контрольная сумма к каждому. `md5 -q`, а не `md5sum` из тех джоб: md5sum
# на macOS появился лишь в свежих версиях (в 26 это хардлинк на тот же бинарник, что и
# md5), а на 14/15 его нет вовсе — `md5 -q` есть везде и печатает голый хеш, ровно как
# `md5sum | awk '{print $1}'` у соседних джоб.
#
# Заодно убираем из релиза `*-mac.zip.blockmap`: для dmg блокмап выключен
# (writeUpdateInfo: false в electron-builder.yml), а для mac-zip выключателя нет —
# electron-builder публикует его вместе с архивом, хотя electron-updater'а в
# приложении нет и differential-обновления никому не нужны.
- name: Контрольная сумма
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
for blockmap in release/*.blockmap; do
gh release delete-asset "$GITHUB_REF_NAME" "$(basename "$blockmap")" --yes 2>/dev/null || true
done
files=(release/*.dmg release/*.zip)
if [ "${#files[@]}" -eq 0 ]; then
echo "не нашлось ни .dmg, ни .zip в release/" >&2
exit 1
fi
for file in "${files[@]}"; do
md5 -q "$file" > "$file.md5"
gh release upload "$GITHUB_REF_NAME" "$file.md5" --clobber
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Шага «Описание релиза» здесь намеренно нет — он живёт только в джобе Windows,
# иначе джобы перезаписывали бы описание релиза друг другу.
- uses: actions/upload-artifact@v4
with:
name: senboost-macos
path: |
release/*.dmg
release/*.zip
if-no-files-found: error
retention-days: 7
+37 -8
View File
@@ -1,4 +1,4 @@
# RKNboost
# SenBoost
очередная обертка под zapret
@@ -26,22 +26,51 @@ npm run build:linux # AppImage + .deb (x64)
## Обход блокировок по платформам
| Платформа | Движок | Права |
| --------- | ----------------------------------------------------- | ----------------------------------------------------- |
| macOS | `tpws` — локальный socks5-прокси | не нужны |
| Windows | `winws` + драйвер WinDivert, служба `rknboost-zapret` | UAC при первой настройке |
| Linux | `nfqws` + правила nftables (NFQUEUE) | пароль через `pkexec` при каждом включении/выключении |
| Платформа | Движок | Права | Статус |
| --------- | ------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------ |
| Windows | `winws` + драйвер WinDivert, служба `senboost-zapret` | UAC при первой настройке | стабильно, гарантированно работает |
| macOS | `utunws` (форк nfqws под utun+BPF) + правила pf, LaunchDaemon | пароль администратора при каждом включении/выключении | частично — движок портирован недавно |
| Linux | `nfqws` + правила nftables (NFQUEUE) | пароль через `pkexec` при каждом включении/выключении | частично |
На всех трёх платформах — один и тот же движок zapret уровня пакетов (winws/nfqws/utunws —
одна и та же сборка `nfq/nfqws.c` под разный бэкенд), поэтому используется один набор из
22 стратегий Flowseal/zapret-discord-youtube.
На macOS для работы нужна версия 14 (Sonoma) или новее — движок собран под соответствующий
API ядра. Переотправка пакетов идёт через BPF на MAC-адрес шлюза, поэтому активный
VPN/туннель (маршрут по умолчанию уходит в свой utun) ломает обход — выключите другие VPN
перед включением. Как и на других платформах, полезно сменить DNS на сторонний
(1.1.1.1/8.8.8.8 или DNS-over-HTTPS) — блокировку по DNS сам zapret не лечит.
Принудительная остановка (если приложение недоступно):
```bash
sudo "/Library/Application Support/SenBoost/zapret/stop.sh"
```
На Linux дополнительно нужны пакеты `nftables` и `polkit` (в .deb — заявлены как зависимости
и ставятся сами; для AppImage — своими силами, `sudo apt install nftables policykit-1`).
Ни службы, ни автозапуска с системой на Linux нет: демон и правила nftables переживают выход
из приложения (правила закрыты флагом `bypass` — без работающего nfqws трафик просто проходит
как есть, а не блокируется), но после перезагрузки заново их никто не поднимает.
как есть, а не блокируется), но после перезагрузки заново их никто не поднимает. Автозапуск
самого приложения (см. ниже) частично это закрывает — обход поднимется при входе в систему,
но потребует `pkexec` на каждый вход.
## Планы до 1.0
- [ ] Очистка и ревью нейросгенерированного кода
- [ ] Полная поддержка MacOS и Linux
- [x] Фоновая работа в трей
- [ ] Поддержка Telegram-прокси
- [ ] Обновление из приложения
## Ссылки
- **[bol-van/zapret](https://github.com/bol-van/zapret)** — сам движок zapret, спасибо за столь хороший инструмент
- **[Flowseal/zapret-discord-youtube](https://github.com/Flowseal/zapret-discord-youtube)** —
списки сайтов (`resources/zapret/lists/`) и fake-пакеты (`resources/zapret/fakes/`).
списки сайтов (`resources/zapret/lists/`), fake-пакеты (`resources/zapret/fakes/`) и все
22 стратегии (`src/main/zapret/strategies.win32.generated.ts`).
- **[Flowseal/zapret-mac-discord-youtube](https://github.com/Flowseal/zapret-mac-discord-youtube)** —
macOS-порт nfqws (`utunws`, `resources/zapret/darwin/`) и референс для привилегированных
скриптов LaunchDaemon (`resources/darwin-helper/`).
- **[Sergeydigl3/zapret-discord-youtube-linux](https://github.com/Sergeydigl3/zapret-discord-youtube-linux)** —
референс для Linux-порта: те же стратегии Flowseal через `nfqws`, nftables вместо WinDivert.
+7 -3
View File
@@ -1,4 +1,4 @@
; Служба rknboost-zapret не ставится этим установщиком — её создаёт и настраивает само
; Служба senboost-zapret не ставится этим установщиком — её создаёт и настраивает само
; приложение при первом включении обхода (src/main/zapret/service.win32.ts), потому что
; аргументы winws зависят от выбранной пользователем стратегии. Но снять её при удалении
; приложения обязаны мы: иначе в системе остаётся служба, указывающая на удалённый winws.exe.
@@ -8,9 +8,13 @@
; Поэтому именно для этого шага отдельно запрашиваем UAC (тем же приёмом Start-Process
; -Verb RunAs, что и src/main/zapret/service.win32.ts), а не поднимаем весь деинсталлятор —
; удаление файлов приложения из %LocalAppData% в повышении не нуждается.
;
; Тем же повышенным вызовом снимаем задачу автозапуска приложения («SenBoost Autostart»,
; src/main/app-autostart.win32.ts): она создана с правами администратора и после удаления
; приложения указывала бы на несуществующий exe.
!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 {}"`
DetailPrint "Останавливаю службу обхода блокировок и снимаю автозапуск..."
nsExec::Exec `powershell.exe -NoProfile -Command "try { Start-Process powershell -Verb RunAs -Wait -ArgumentList @('-NoProfile','-Command','sc.exe stop senboost-zapret; sc.exe delete senboost-zapret; schtasks.exe /Delete /TN ''SenBoost Autostart'' /F; Remove-Item -Recurse -Force $$env:ProgramData\senboost -ErrorAction SilentlyContinue') } catch {}"`
Pop $0
!macroend
+28 -11
View File
@@ -1,5 +1,5 @@
appId: com.rknboost.app
productName: RKNboost
appId: com.senboost.app
productName: SenBoost
directories:
buildResources: build
output: release
@@ -24,9 +24,11 @@ files:
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!.github/*'
# zapret и linux-helper идут отдельно через extraResources ниже — не дублировать их в app.asar.
# zapret и linux-helper/darwin-helper идут отдельно через extraResources ниже — не
# дублировать их в app.asar.
- '!resources/zapret/**'
- '!resources/linux-helper/**'
- '!resources/darwin-helper/**'
asarUnpack:
- 'resources/**'
@@ -50,10 +52,10 @@ extraResources:
- 'LICENSE.flowseal.txt'
win:
executableName: RKNboost
executableName: SenBoost
# Приложение запрашивает права администратора при КАЖДОМ запуске (манифест exe).
# Внимание на компромисс: установка при этом остаётся per-user, то есть exe лежит в
# %LocalAppData%\Programs\RKNboost — папке, доступной на запись обычному пользователю.
# %LocalAppData%\Programs\SenBoost — папке, доступной на запись обычному пользователю.
# Значит подмена этого exe даёт злоумышленнику администратора с подтверждения самого
# пользователя. Это осознанный выбор владельца проекта; безопасный вариант — вернуть
# perMachine: true, чтобы exe лежал в защищённом от записи Program Files.
@@ -84,7 +86,7 @@ nsis:
# differentialPackage: false отключает генерацию .blockmap, который иначе всегда
# заливался бы в ассеты релиза рядом с инсталлятором.
differentialPackage: false
# Удалять %APPDATA%\rknboost (списки сайтов, метка первого запуска) вместе с приложением.
# Удалять %APPDATA%\senboost (списки сайтов, метка первого запуска) вместе с приложением.
# По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после
# удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит
# проверка `ifNot isUpdated`, так что отредактированные списки переживают обновление.
@@ -97,6 +99,10 @@ nsis:
mac:
category: public.app-category.productivity
# utunws (форк nfqws под utun+BPF, см. src/main/zapret/engine.darwin.ts) собран под
# -target …-macos14.0 в самом релизе Flowseal/zapret-mac-discord-youtube — на более
# старой системе он не запустится, минимум объявляем и на уровне инсталлятора.
minimumSystemVersion: '14.0'
target:
- target: dmg
arch: [x64, arm64]
@@ -105,21 +111,32 @@ mac:
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
hardenedRuntime: true
# tpws — сторонний Mach-O внутри .app; hardened runtime требует, чтобы electron-builder
# utunws — сторонний Mach-O внутри .app; hardened runtime требует, чтобы electron-builder
# подписал и его тем же Developer ID, иначе Gatekeeper убьёт процесс при первом spawn.
binaries:
- Contents/Resources/zapret/darwin/tpws
- Contents/Resources/zapret/darwin/utunws
# Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает.
# Как включить — см. раздел «Подпись» в README.md.
notarize: false
extraResources:
- from: resources/zapret/darwin
to: zapret/darwin
# install.sh/stop.sh/daemon.sh/watchdog.sh/autostart.sh — привилегированные скрипты,
# запускаются через osascript "with administrator privileges" (elevate.darwin.ts).
# Не из resources/zapret: тот качает fetch-zapret.mjs, а этот каталог — свой, лежит
# в репозитории (см. src/main/zapret/paths.ts:bundledDarwinHelperDir()).
- from: resources/darwin-helper
to: darwin-helper
dmg:
artifactName: ${name}-${version}-${arch}.${ext}
# Тот же мотив, что differentialPackage: false у nsis — electron-updater'а в приложении
# нет, и .blockmap рядом с .dmg был бы мёртвым грузом в ассетах релиза. У mac-таргета zip
# такого выключателя нет (isWriteUpdateInfo зашит в macPackager), поэтому
# `*-mac.zip.blockmap` всё равно соберётся — см. комментарий в джобе «Сборка (macOS)».
writeUpdateInfo: false
linux:
executableName: rknboost
executableName: senboost
# desktopName в package.json + syncDesktopName — иначе Electron не свяжет открытые окна
# с .desktop-записью (app_id/WM_CLASS), и часть окружений не покажет иконку в панели задач.
syncDesktopName: true
@@ -128,13 +145,13 @@ linux:
arch: [x64]
- target: deb
arch: [x64]
maintainer: RKNboost
maintainer: SenBoost
category: Network
synopsis: Обход блокировок через zapret
extraResources:
- from: resources/zapret/linux
to: zapret/linux
# rknboost-helper.sh — привилегированный помощник (nfqws.linux.ts зовёт его через
# senboost-helper.sh — привилегированный помощник (nfqws.linux.ts зовёт его через
# pkexec). Не из resources/zapret: тот качает fetch-zapret.mjs, а этот файл — свой,
# лежит в репозитории (см. src/main/zapret/paths.ts:bundledHelperPath()).
- from: resources/linux-helper
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "rknboost",
"version": "0.5.4",
"name": "senboost",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rknboost",
"version": "0.5.4",
"name": "senboost",
"version": "0.6.2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+5 -5
View File
@@ -1,14 +1,14 @@
{
"name": "rknboost",
"version": "0.5.4",
"name": "senboost",
"version": "0.6.2",
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
"main": "./out/main/index.js",
"desktopName": "rknboost.desktop",
"author": "RKNboost",
"desktopName": "senboost.desktop",
"author": "SenBoost",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/maeneko/rknboost"
"url": "https://github.com/maeneko/senboost"
},
"private": true,
"engines": {
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Меняет RunAtLoad уже установленного LaunchDaemon без перезапуска демона и без разрыва
# pf-правил — root нужен только чтобы переписать защищённый plist в /Library/LaunchDaemons.
# Отдельный скрипт вместо повторного install.sh: переустановка перечитала бы и
# перекопировала utunws/fakes/strategy.cfg и пересобрала pf-правила заново — ради смены
# одного булева поля это лишний риск моргнуть сетью, которого нет у setServiceAutoStart()
# на Windows (`sc config start=`, тоже не трогает работающую службу).
set -eu
PLIST=/Library/LaunchDaemons/com.senboost.zapret.plist
if [ "$#" -ne 1 ]; then
echo 'autostart.sh: ожидается 1 аргумент (0 или 1)' >&2
exit 1
fi
case "$1" in
0) RUNATLOAD=false ;;
1) RUNATLOAD=true ;;
*) echo 'autostart.sh: ожидается 0 или 1' >&2; exit 1 ;;
esac
[ -f "$PLIST" ] || { echo 'autostart.sh: демон не установлен' >&2; exit 1; }
/usr/bin/plutil -replace RunAtLoad -bool "$RUNATLOAD" "$PLIST"
@@ -0,0 +1,25 @@
<?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>Label</key>
<string>@LABEL@</string>
<key>ProgramArguments</key>
<array>
<string>@DEST@/daemon.sh</string>
</array>
<key>RunAtLoad</key>
<@RUNATLOAD@/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>2</integer>
<key>StandardOutPath</key>
<string>@DEST@/zapret.log</string>
<key>StandardErrorPath</key>
<string>@DEST@/zapret.log</string>
</dict>
</plist>
+119
View File
@@ -0,0 +1,119 @@
#!/bin/sh
# Супервизор обхода — то, что реально запускает launchd (com.senboost.zapret.plist.in,
# ProgramArguments). Живёт под root, пока LaunchDaemon загружен; KeepAlive/SuccessfulExit
# в plist перезапускает этот скрипт заново при любом его завершении — поэтому определение
# интерфейса/MAC шлюза выполняется здесь при каждом старте (в том числе после смены сети),
# а не один раз в install.sh. Портировано из run.sh форка Flowseal/zapret-mac-discord-youtube
# (MIT) — та же логика с utunws через utun+BPF, свои идентификаторы (см. install.sh).
set -eu
DEST='/Library/Application Support/SenBoost/zapret'
ANCHOR=com.apple/senboost
UTUN_UNIT=61
UTUN_IFACE=utun60
UTUN_LOCAL=10.78.0.1
UTUN_REMOTE=10.78.0.2
PIDFILE=/var/run/senboost-zapret.pid
TOKEN_FILE=/var/run/senboost-zapret.pf-token
ROUTE6_FILE=/var/run/senboost-zapret.route6
ENGINE_PID=
WATCHDOG_PID=
if [ -L "$DEST" ] || [ ! -f "$DEST/strategy.cfg" ] || [ ! -f "$DEST/ports.conf" ]; then
exit 1
fi
TCP_PORTS=$(/usr/bin/sed -n '1p' "$DEST/ports.conf")
UDP_PORTS=$(/usr/bin/sed -n '2p' "$DEST/ports.conf")
# ':' — диапазоны в синтаксисе pf (19294:19344), не дефис — см. install.sh.
case ",$TCP_PORTS," in *[!0-9,:]*) exit 1 ;; esac
case ",$UDP_PORTS," in *[!0-9,:]*) exit 1 ;; esac
clear_intercept() {
/sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true
}
release_pf_token() {
if [ -s "$TOKEN_FILE" ]; then
TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true)
if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi
/bin/rm -f "$TOKEN_FILE"
fi
}
cleanup() {
trap - EXIT INT TERM HUP
clear_intercept
if [ -n "$WATCHDOG_PID" ]; then kill "$WATCHDOG_PID" >/dev/null 2>&1 || true; fi
if [ -n "$ENGINE_PID" ]; then kill "$ENGINE_PID" >/dev/null 2>&1 || true; fi
if [ -n "$WATCHDOG_PID" ]; then wait "$WATCHDOG_PID" >/dev/null 2>&1 || true; fi
if [ -n "$ENGINE_PID" ]; then wait "$ENGINE_PID" >/dev/null 2>&1 || true; fi
release_pf_token
/bin/rm -f "$PIDFILE"
}
trap cleanup EXIT INT TERM HUP
clear_intercept
/usr/bin/pkill -9 -f "$DEST/bin/utunws" >/dev/null 2>&1 || true
PHYSICAL_IFACE=$(/sbin/route -n get default | /usr/bin/awk '/interface:/{print $2; exit}')
GATEWAY=$(/sbin/route -n get default | /usr/bin/awk '/gateway:/{print $2; exit}')
# Нет маршрута по умолчанию или он смотрит не в физический интерфейс (например, весь
# трафик уже уходит в VPN/другой utun) — переотправка через BPF работать не будет, честная
# ошибка вместо демона, который поднялся, но ничего не десинхронизирует.
if [ -z "$PHYSICAL_IFACE" ] || [ -z "$GATEWAY" ]; then exit 1; fi
/sbin/ping -c 1 -t 1 "$GATEWAY" >/dev/null 2>&1 || true
GATEWAY_MAC=$(/usr/sbin/arp -n "$GATEWAY" | /usr/bin/awk '/ at /{print $4; exit}')
case "$GATEWAY_MAC" in
*:*:*:*:*:*) ;;
*) exit 1 ;;
esac
GATEWAY6_MAC=
if /sbin/route -n get -inet6 default >"$ROUTE6_FILE" 2>/dev/null; then
GATEWAY6=$(/usr/bin/awk '/gateway:/{print $2; exit}' "$ROUTE6_FILE")
if [ -n "$GATEWAY6" ]; then
GATEWAY6_MAC=$(/usr/sbin/ndp -n "$GATEWAY6" 2>/dev/null | /usr/bin/awk '/ at |%/{for(i=1;i<=NF;i++) if($i ~ /^([0-9a-f]{1,2}:){5}[0-9a-f]{1,2}$/){print $i; exit}}')
fi
fi
/bin/rm -f "$ROUTE6_FILE"
export ZAPRET_IFACE="$PHYSICAL_IFACE"
export ZAPRET_GATEWAY_MAC="$GATEWAY_MAC"
export ZAPRET_GATEWAY6_MAC="$GATEWAY6_MAC"
export ZAPRET_UTUN_UNIT=$UTUN_UNIT
"$DEST/bin/utunws" --pidfile="$PIDFILE" "@$DEST/strategy.cfg" >>"$DEST/engine.log" 2>&1 &
ENGINE_PID=$!
I=0
while ! /sbin/ifconfig "$UTUN_IFACE" >/dev/null 2>&1; do
I=$((I + 1))
if ! kill -0 "$ENGINE_PID" 2>/dev/null || [ "$I" -gt 100 ]; then exit 1; fi
sleep 0.1
done
/sbin/ifconfig "$UTUN_IFACE" "$UTUN_LOCAL" "$UTUN_REMOTE" netmask 255.255.255.255 up
# utunws сам пишет pidfile до начала работы (fopen до цикла чтения utun) — chmod здесь
# только на случай нестандартного umask, чтобы sync()/наблюдатель читали его без root.
/bin/chmod 0644 "$PIDFILE" 2>/dev/null || true
"$DEST/watchdog.sh" $$ "$ENGINE_PID" &
WATCHDOG_PID=$!
if /sbin/pfctl -s info 2>/dev/null | /usr/bin/grep -q '^Status: Disabled'; then
TOKEN=$(/sbin/pfctl -E 2>&1 | /usr/bin/awk '/Token :/ { print $3 }')
if [ -n "$TOKEN" ]; then /bin/echo "$TOKEN" >"$TOKEN_FILE"; fi
fi
printf '%s\n' \
"pass out quick route-to ($UTUN_IFACE $UTUN_REMOTE) inet proto tcp from any to any port {$TCP_PORTS} user { >root } no state" \
"pass out quick route-to ($UTUN_IFACE $UTUN_REMOTE) inet proto udp from any to any port {$UDP_PORTS} user { >root } no state" \
| /sbin/pfctl -a "$ANCHOR" -f -
# Смена сети (Wi-Fi ↔ Ethernet, новый шлюз при DHCP-обновлении) делает пойманный при старте
# gateway MAC неверным — переотправка на старый MAC уходит в никуда. Выход отсюда с ошибкой
# перезапускает весь скрипт заново через KeepAlive плиста, включая переопределение шлюза.
while kill -0 "$ENGINE_PID" 2>/dev/null; do
sleep 2
CURRENT_IFACE=$(/sbin/route -n get default 2>/dev/null | /usr/bin/awk '/interface:/{print $2; exit}')
CURRENT_GATEWAY=$(/sbin/route -n get default 2>/dev/null | /usr/bin/awk '/gateway:/{print $2; exit}')
if [ "$CURRENT_IFACE" != "$PHYSICAL_IFACE" ] || [ "$CURRENT_GATEWAY" != "$GATEWAY" ]; then exit 1; fi
done
exit 1
+133
View File
@@ -0,0 +1,133 @@
#!/bin/sh
# Привилегированная установка обхода блокировок на macOS — единственное, что SenBoost
# запускает через osascript "with administrator privileges" (см. elevate.darwin.ts) для
# включения обхода. Не редактировать под bash-специфику: этот файл исполняется как
# /bin/sh — на macOS это тот же путь, что и на Linux, но лучше не полагаться на bashisms.
#
# Зовётся из src/main/zapret/utunws.darwin.ts ровно так:
# osascript … 'do shell script "'\''<этот файл>'\'' '\''<utunws>'\'' …" with administrator privileges'
#
# Аргументы (все уже подготовлены и провалидированы на стороне приложения — сюда попадают
# только готовые пути и провалидированные строки портов, тот же принцип, что у Linux-
# помощника resources/linux-helper/senboost-helper.sh):
# 1: utunws_src — путь к бинарнику utunws в resources/zapret/darwin (ещё не установлен)
# 2: fakes_src — каталог с fake-пакетами (resources/zapret/fakes)
# 3: config_src — уже отрендеренный strategy.cfg (маркер --comment=senboost:<id>:<hash>
# первой строкой, пути {LISTS}/{FAKES} уже резолвлены — см. strategies.ts)
# 4: tcp_ports — список портов TCP для pf, формат "80,443,8443" (уже проверен регуляркой
# в packetEngineConfig())
# 5: udp_ports — то же для UDP
# 6: run_at_load — 0 или 1: должен ли демон переживать перезагрузку системы без
# запущенного приложения (настройка «Автозапуск» в интерфейсе)
set -eu
HELPER_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
DEST='/Library/Application Support/SenBoost/zapret'
LABEL=com.senboost.zapret
PLIST=/Library/LaunchDaemons/$LABEL.plist
ANCHOR=com.apple/senboost
PIDFILE=/var/run/senboost-zapret.pid
TOKEN_FILE=/var/run/senboost-zapret.pf-token
KEEPINIT_FILE=/var/db/senboost-zapret.keepinit
if [ "$#" -ne 6 ]; then
echo 'install.sh: ожидается 6 аргументов' >&2
exit 1
fi
UTUNWS_SRC=$1
FAKES_SRC=$2
CONFIG_SRC=$3
TCP_PORTS=$4
UDP_PORTS=$5
RUN_AT_LOAD=$6
[ -x "$UTUNWS_SRC" ] || { echo 'install.sh: не найден бинарник utunws' >&2; exit 1; }
[ -d "$FAKES_SRC" ] || { echo 'install.sh: не найден каталог fake-пакетов' >&2; exit 1; }
[ -f "$CONFIG_SRC" ] || { echo 'install.sh: не найден strategy.cfg' >&2; exit 1; }
# Формат уже проверен регуляркой в packetEngineConfig()/utunwsConfig() — здесь просто не
# доверяем слепо тому, что дошло до root, тот же приём, что в senboost-helper.sh на Linux.
# ':' — не опечатка: utunwsConfig() переводит диапазоны в синтаксис pf (19294-19344 →
# 19294:19344, см. toPfPortRanges() в strategies.ts), дефисов в этих строках уже нет.
case ",$TCP_PORTS," in *[!0-9,:]*) echo 'install.sh: странный формат tcp-портов' >&2; exit 1 ;; esac
case ",$UDP_PORTS," in *[!0-9,:]*) echo 'install.sh: странный формат udp-портов' >&2; exit 1 ;; esac
case "$RUN_AT_LOAD" in
0) RUNATLOAD=false ;;
1) RUNATLOAD=true ;;
*) echo 'install.sh: run_at_load должен быть 0 или 1' >&2; exit 1 ;;
esac
# Сносим прошлую установку — самолечение на случай аварийно прерванной прошлой сессии,
# тот же приём, что cmd_stop() в senboost-helper.sh на Linux делает первым шагом start.
/bin/launchctl bootout system/"$LABEL" >/dev/null 2>&1 || true
/sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true
# Матчим процесс по полному пути НАШЕЙ установки, а не по имени: "utunws" называется так
# же и у независимо установленного Flowseal/zapret-mac-discord-youtube — pkill -x убил бы
# чужой процесс, если пользователь пользуется обоими приложениями одновременно.
/usr/bin/pkill -9 -f "$DEST/bin/utunws" >/dev/null 2>&1 || true
if [ -s "$TOKEN_FILE" ]; then
TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true)
if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi
/bin/rm -f "$TOKEN_FILE"
fi
/bin/rm -f "$PIDFILE"
# Копия, а не запуск прямо из resources/zapret приложения: root не должен исполнять файл
# из каталога, куда обычный пользователь может писать (то же рассуждение, что у
# protectedWinwsPath() в paths.ts на Windows). daemon.sh/watchdog.sh лежат рядом с этим
# скриптом в bundledDarwinHelperDir() — тоже часть сборки, тоже копируем в защищённое место.
/bin/rm -rf "$DEST/bin" "$DEST/fakes"
/bin/mkdir -p "$DEST/bin" "$DEST/fakes"
/usr/bin/ditto "$UTUNWS_SRC" "$DEST/bin/utunws"
/usr/bin/ditto "$FAKES_SRC" "$DEST/fakes"
/usr/bin/ditto "$HELPER_DIR/daemon.sh" "$DEST/daemon.sh"
/usr/bin/ditto "$HELPER_DIR/watchdog.sh" "$DEST/watchdog.sh"
# Скачанный релиз-архив помечен com.apple.quarantine — карантин на исполняемый файл внутри
# защищённого каталога, который запускает launchd, только мешал бы.
/usr/bin/xattr -dr com.apple.quarantine "$DEST" >/dev/null 2>&1 || true
/usr/bin/install -m 0644 "$CONFIG_SRC" "$DEST/strategy.cfg"
printf '%s\n%s\n' "$TCP_PORTS" "$UDP_PORTS" > "$DEST/ports.conf"
/usr/sbin/chown -R root:wheel "$DEST"
/bin/chmod 755 "$DEST" "$DEST/bin" "$DEST/bin/utunws" "$DEST/daemon.sh" "$DEST/watchdog.sh" "$DEST/fakes"
/bin/chmod 644 "$DEST/strategy.cfg" "$DEST/ports.conf"
/usr/bin/sed \
-e "s|@LABEL@|$LABEL|g" \
-e "s|@DEST@|$DEST|g" \
-e "s|@RUNATLOAD@|$RUNATLOAD|g" \
"$HELPER_DIR/com.senboost.zapret.plist.in" > "$PLIST"
/usr/sbin/chown root:wheel "$PLIST"
/bin/chmod 644 "$PLIST"
# net.inet.tcp.keepinit=7000 удлиняет время до TCP RST на этапе установления соединения —
# desync нужно время сработать. Сохраняем исходное значение один раз, откатываем в stop.sh.
if [ ! -s "$KEEPINIT_FILE" ]; then
/usr/sbin/sysctl -n net.inet.tcp.keepinit > "$KEEPINIT_FILE"
/bin/chmod 600 "$KEEPINIT_FILE"
fi
/usr/sbin/sysctl -w net.inet.tcp.keepinit=7000 >/dev/null
/bin/launchctl enable system/"$LABEL"
I=0
while ! /bin/launchctl bootstrap system "$PLIST"; do
I=$((I + 1))
if [ "$I" -gt 10 ]; then
/bin/sh "$HELPER_DIR/stop.sh"
exit 1
fi
sleep 0.5
done
/bin/launchctl kickstart -k system/"$LABEL"
I=0
while ! /sbin/ifconfig utun60 >/dev/null 2>&1; do
I=$((I + 1))
if [ "$I" -gt 100 ]; then
/bin/sh "$HELPER_DIR/stop.sh"
echo 'install.sh: utunws не поднялся' >&2
/usr/bin/tail -n 40 "$DEST/engine.log" >&2 2>/dev/null || true
exit 1
fi
sleep 0.1
done
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# Привилегированная остановка обхода блокировок на macOS. Без аргументов — вызывается и как
# osascript-точка входа из utunws.darwin.ts (выключение обхода), и напрямую (уже под root,
# без повторной элевации) из install.sh, когда новая установка не поднялась.
#
# Полный снос, а не пауза: LaunchDaemon, strategy.cfg, pf-правила — всё убирается. Обратное
# решение (оставить демона установленным и просто «выключенным», как служба на Windows) не
# подходит здесь — на macOS нет отдельного «выключенного» состояния для LaunchDaemon кроме
# unload/bootout, так что «выключить» и «удалить установку» здесь одно и то же действие.
# Настройка автозапуска (RunAtLoad) значения не теряет — она хранится в приложении и
# применяется заново при следующем включении (см. engine.darwin.ts).
set -eu
LABEL=com.senboost.zapret
ANCHOR=com.apple/senboost
DEST='/Library/Application Support/SenBoost/zapret'
PLIST=/Library/LaunchDaemons/$LABEL.plist
PIDFILE=/var/run/senboost-zapret.pid
TOKEN_FILE=/var/run/senboost-zapret.pf-token
KEEPINIT_FILE=/var/db/senboost-zapret.keepinit
/bin/launchctl disable system/"$LABEL" >/dev/null 2>&1 || true
/bin/launchctl bootout system/"$LABEL" >/dev/null 2>&1 || true
/sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true
# См. install.sh про то, почему по полному пути, а не по имени процесса.
/usr/bin/pkill -9 -f "$DEST/bin/utunws" >/dev/null 2>&1 || true
/bin/rm -f "$PIDFILE" "$PLIST"
if [ -s "$TOKEN_FILE" ]; then
TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true)
if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi
/bin/rm -f "$TOKEN_FILE"
fi
if [ -s "$KEEPINIT_FILE" ]; then
KEEPINIT=$(/bin/cat "$KEEPINIT_FILE" 2>/dev/null || true)
case "$KEEPINIT" in
*[!0-9]*|'') ;;
*) /usr/sbin/sysctl -w "net.inet.tcp.keepinit=$KEEPINIT" >/dev/null 2>&1 || true ;;
esac
/bin/rm -f "$KEEPINIT_FILE"
fi
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Подчищает pf, если daemon.sh (супервизор) умер, не успев выполнить свой cleanup() —
# например, получил SIGKILL. Без этого правило route-to пережило бы демона, и трафик на
# заблокированные порты уходил бы в мёртвый utun вместо прохождения как обычно (pf-правило
# не имеет bypass-семантики, в отличие от очереди NFQUEUE на Linux). Портировано из
# watchdog.sh форка Flowseal/zapret-mac-discord-youtube (MIT) без изменений логики.
SUPERVISOR_PID=$1
ENGINE_PID=$2
ANCHOR=com.apple/senboost
TOKEN_FILE=/var/run/senboost-zapret.pf-token
while kill -0 "$SUPERVISOR_PID" 2>/dev/null && kill -0 "$ENGINE_PID" 2>/dev/null; do
sleep 0.2
done
/sbin/pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true
kill "$ENGINE_PID" >/dev/null 2>&1 || true
if [ -s "$TOKEN_FILE" ]; then
TOKEN=$(/bin/cat "$TOKEN_FILE" 2>/dev/null || true)
if [ -n "$TOKEN" ]; then /sbin/pfctl -X "$TOKEN" >/dev/null 2>&1 || true; fi
/bin/rm -f "$TOKEN_FILE"
fi
@@ -1,5 +1,5 @@
#!/bin/sh
# Привилегированный помощник обхода блокировок на Linux — единственное, что RKNboost
# Привилегированный помощник обхода блокировок на Linux — единственное, что SenBoost
# запускает через `pkexec`. Не редактировать под bash-специфику: /bin/sh на Debian/Ubuntu —
# это dash, а не bash.
#
@@ -17,18 +17,18 @@
# команда не найдена) при разборе результата на стороне приложения.
set -eu
RUN_DIR=/run/rknboost
RUN_DIR=/run/senboost
PIDFILE="$RUN_DIR/nfqws.pid"
CONFIG="$RUN_DIR/strategy.cfg"
ERRLOG="$RUN_DIR/nfqws.err"
TABLE_FAMILY=inet
TABLE_NAME=rknboost
TABLE_NAME=senboost
# Держать в синхроне с NFQUEUE_NUM в nfqws.linux.ts (используется там же для --dry-run).
QUEUE_NUM=220
FW_MARK=0x40000000
log() {
echo "rknboost-helper: $*" >&2
echo "senboost-helper: $*" >&2
}
# pkexec запускает с урезанным PATH (санирует окружение) — sbin, где обычно лежит nft,
@@ -107,7 +107,7 @@ cmd_start() {
chmod 0755 "$RUN_DIR"
# strategy.cfg в /run — источник маркера стратегии для LinuxEngine.sync(): читается без
# root (0644), первая строка — тот же --comment=rknboost:<id>:<hash>, что и на Windows.
# root (0644), первая строка — тот же --comment=senboost:<id>:<hash>, что и на Windows.
cp "$config_src" "$CONFIG"
chmod 0644 "$CONFIG"
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

+57 -6
View File
@@ -3,13 +3,16 @@
* Кладёт бинарники и данные zapret в resources/zapret/ — оттуда electron-builder
* положит их в сборку через extraResources.
*
* Два источника:
* • bol-van/zapret — движки (tpws, winws.exe + WinDivert), версия пинована и
* Три источника:
* • bol-van/zapret — движки (winws.exe + WinDivert, nfqws), версия пинована и
* сверяется по sha256sum.txt из релиза;
* • Flowseal/zapret-discord-youtube — подобранные сообществом списки доменов и
* стратегии winws (переносятся в код генератором `import-flowseal-strategies.mjs`,
* стратегии winws (переносятся в код генератором `parser-strategies.mjs`,
* здесь просто данные: списки + fake-пакеты). У Flowseal нет sha256sum.txt —
* версия фиксируется по sha коммита, на который указывает тег.
* • Flowseal/zapret-mac-discord-youtube — macOS-порт nfqws (`utunws`, работает через
* utun+BPF вместо netfilter/WinDivert). Тоже без sha256sum.txt — сумму архива
* релиза фиксируем сами (см. `FLOWSEAL_MAC_ZIP_SHA256`).
*
* Каталог resources/zapret в git не хранится: запустите `npm run zapret:fetch`
* (или просто `npm run build` — он вызывает скрипт сам).
@@ -30,6 +33,16 @@ const WINDIVERT_VERSION = 'v2.2.2'
const FLOWSEAL_VERSION = '1.10.2'
const FLOWSEAL_COMMIT = 'dfd8e613b099676cf2aa7b474ee5923801514dec'
/**
* Релиз Flowseal/zapret-mac-discord-youtube, откуда берём `utunws` — форк nfqws под
* macOS (utun+BPF вместо netfilter). У репозитория нет sha256sum.txt, поэтому вместо
* суммы отдельного бинарника фиксируем сумму всего архива релиза — она получена один раз
* командой `shasum -a 256` по скачанному `ZapretMac-macOS-universal.zip` и проверяется
* при каждой закачке, чтобы подмена ассета в чужом форке или на CDN не прошла незамеченной.
*/
const FLOWSEAL_MAC_VERSION = '1.1.2'
const FLOWSEAL_MAC_ZIP_SHA256 = '9895b0ec5d13ec05676f755cde71107e72d2661538440c3cc4f06e0e6d47844f'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DEST = join(ROOT, 'resources', 'zapret')
@@ -40,7 +53,6 @@ const PREFIX = `zapret-${ZAPRET_VERSION}`
/** Что забираем из архива bol-van/zapret: путь внутри архива → путь внутри resources/zapret. */
const FILES = {
'binaries/mac64/tpws': 'darwin/tpws',
'binaries/linux-x86_64/nfqws': 'linux/x64/nfqws',
'binaries/windows-x86_64/winws.exe': 'win32/x64/winws.exe',
'binaries/windows-x86_64/cygwin1.dll': 'win32/x64/cygwin1.dll',
@@ -50,7 +62,7 @@ const FILES = {
}
/** Исполняемый бит нужен только там, где мы сами запускаем бинарник. */
const EXECUTABLE = new Set(['darwin/tpws', 'linux/x64/nfqws'])
const EXECUTABLE = new Set(['darwin/utunws', 'linux/x64/nfqws'])
/**
* Списки доменов Flowseal → наши короткие имена (используются и в `lists.ts`, и
@@ -191,12 +203,51 @@ async function fetchFlowsealData(work) {
console.log(' LICENSE.flowseal.txt')
}
/** Путь внутри ZapretMac-macOS-universal.zip до самого бинарника. */
const UTUNWS_ZIP_ENTRY = 'ZapretMac.app/Contents/Resources/Payload/bin/utunws'
async function fetchUtunwsEngine(work) {
console.log(`Flowseal-mac ${FLOWSEAL_MAC_VERSION}: скачиваю ZapretMac-macOS-universal.zip`)
const zipPath = join(work, 'zapretmac.zip')
const zipBuffer = await download(
`https://github.com/Flowseal/zapret-mac-discord-youtube/releases/download/${FLOWSEAL_MAC_VERSION}/ZapretMac-macOS-universal.zip`
)
const actual = sha256(zipBuffer)
if (actual !== FLOWSEAL_MAC_ZIP_SHA256) {
throw new Error(
`не совпала контрольная сумма ZapretMac-macOS-universal.zip\n` +
` ожидалось: ${FLOWSEAL_MAC_ZIP_SHA256}\n получено: ${actual}`
)
}
await writeFile(zipPath, zipBuffer)
// unzip есть из коробки на macOS и в Git for Windows/WSL; в архиве Flowseal нет sha256sum.txt
// на отдельные файлы — сумму всего zip уже сверили выше, здесь просто достаём бинарник.
const utunwsDestination = join(DEST, 'darwin', 'utunws')
await mkdir(dirname(utunwsDestination), { recursive: true })
const extracted = execFileSync('unzip', ['-p', zipPath, UTUNWS_ZIP_ENTRY])
await writeFile(utunwsDestination, extracted)
await chmod(utunwsDestination, 0o755)
console.log(` darwin/utunws (${extracted.length} байт, universal x86_64+arm64)`)
// LICENSE в архив релиза не входит (только ZapretMac.app) — берём из репозитория по тому
// же тегу, что и версия релиза, тем же приёмом, что LICENSE.WinDivert.txt выше.
const license = await download(
`https://raw.githubusercontent.com/Flowseal/zapret-mac-discord-youtube/${FLOWSEAL_MAC_VERSION}/LICENSE`
)
await writeFile(join(DEST, 'LICENSE.flowseal-mac.txt'), license)
console.log(' LICENSE.flowseal-mac.txt')
}
async function main() {
const work = await mkdtemp(join(tmpdir(), 'zapret-'))
try {
await fetchZapretEngines(work)
await fetchFlowsealData(work)
await fetchUtunwsEngine(work)
console.log(`Готово: ${DEST}`)
} finally {
await rm(work, { recursive: true, force: true })
@@ -206,7 +257,7 @@ async function main() {
// Повторная сборка не должна каждый раз ходить в сеть.
if (
process.argv.includes('--force') ||
!existsSync(join(DEST, 'darwin', 'tpws')) ||
!existsSync(join(DEST, 'darwin', 'utunws')) ||
!existsSync(join(DEST, 'linux', 'x64', 'nfqws')) ||
!existsSync(join(DEST, 'win32', 'x64', 'winws.exe')) ||
!existsSync(join(DEST, 'lists', 'general.txt'))
+140
View File
@@ -0,0 +1,140 @@
// Рисует иконки для трея: щит с заливкой («обход включён») и щит контуром («выключен»),
// по паре размеров на каждую (16 и 32 px — 1x и 2x для меню-бара macOS).
//
// Чёрный цвет + альфа — это ровно то, что macOS ждёт от template-картинки: систему интересует
// только альфа-канал, цвет она подставляет сама под светлое или тёмное меню-бар.
//
// Скрипт запускается руками (`node scripts/make-tray-icons.mjs`), результат лежит в
// resources/tray и коммитится: в сборку его не включаем, чтобы не тащить генерацию картинок
// в каждый `npm run build`.
import { deflateSync } from 'node:zlib'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'tray')
/** Сглаживание: считаем SUPERSAMPLE² проб на пиксель и усредняем — иначе на 16 px рвань. */
const SUPERSAMPLE = 4
/** Толщина контура у «выключенного» щита, в долях размера. */
const STROKE = 0.11
/** Центр, относительно которого сжимаем щит, чтобы получить внутренний край контура. */
const CENTER = { x: 0.5, y: 0.54 }
const TOP = 0.1
const BOTTOM = 0.94
/** Ниже этой линии бока щита сходятся к острию. */
const SHOULDER = 0.56
const HALF_WIDTH = 0.34
/** Скругление верхних углов. */
const CORNER = 0.1
/**
* Точка внутри щита? Щит — прямоугольник со скруглённым верхом, ниже «плеч» сужающийся
* по четверти эллипса к острию внизу.
*/
function insideShield(x, y) {
if (y < TOP || y > BOTTOM) return false
let halfWidth = HALF_WIDTH
if (y > SHOULDER) {
const t = (y - SHOULDER) / (BOTTOM - SHOULDER)
halfWidth = HALF_WIDTH * Math.sqrt(Math.max(0, 1 - t * t))
}
const dx = Math.abs(x - 0.5)
if (dx > halfWidth) return false
// Скругление верхних углов: за пределами прямых участков считаем расстояние до центра дуги.
if (y < TOP + CORNER && dx > halfWidth - CORNER) {
const cx = halfWidth - CORNER
const cy = TOP + CORNER
return (dx - cx) ** 2 + (y - cy) ** 2 <= CORNER * CORNER
}
return true
}
/** Тот же щит, сжатый к центру на толщину контура, — внутренняя граница обводки. */
function insideInnerShield(x, y) {
const scale = 1 / (1 - 2 * STROKE)
return insideShield((x - CENTER.x) * scale + CENTER.x, (y - CENTER.y) * scale + CENTER.y)
}
/** Альфа пикселя: доля проб, попавших в фигуру. */
function coverage(px, py, size, filled) {
let hits = 0
for (let sy = 0; sy < SUPERSAMPLE; sy++) {
for (let sx = 0; sx < SUPERSAMPLE; sx++) {
const x = (px + (sx + 0.5) / SUPERSAMPLE) / size
const y = (py + (sy + 0.5) / SUPERSAMPLE) / size
if (!insideShield(x, y)) continue
if (!filled && insideInnerShield(x, y)) continue
hits++
}
}
return Math.round((hits / (SUPERSAMPLE * SUPERSAMPLE)) * 255)
}
function rgba(size, filled) {
// По байту на канал, плюс ведущий байт фильтра в начале каждой строки (формат PNG).
const rows = Buffer.alloc(size * (size * 4 + 1))
let offset = 0
for (let y = 0; y < size; y++) {
rows[offset++] = 0 // filter type 0 (None)
for (let x = 0; x < size; x++) {
offset += 3 // R,G,B остаются нулями — чёрный
rows[offset++] = coverage(x, y, size, filled)
}
}
return rows
}
function chunk(type, data) {
const length = Buffer.alloc(4)
length.writeUInt32BE(data.length)
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(body))
return Buffer.concat([length, body, crc])
}
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
return c >>> 0
})
function crc32(buffer) {
let c = 0xffffffff
for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
function png(size, filled) {
const header = Buffer.alloc(13)
header.writeUInt32BE(size, 0)
header.writeUInt32BE(size, 4)
header[8] = 8 // бит на канал
header[9] = 6 // цветовой тип: RGBA
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(rgba(size, filled), { level: 9 })),
chunk('IEND', Buffer.alloc(0))
])
}
await mkdir(OUT_DIR, { recursive: true })
for (const [name, filled] of [
['on', true],
['off', false]
]) {
for (const size of [16, 32]) {
const path = join(OUT_DIR, `${name}-${size}.png`)
await writeFile(path, png(size, filled))
console.log(`создано: ${path}`)
}
}
+58
View File
@@ -0,0 +1,58 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { app } from 'electron'
/**
* На Linux `app.setLoginItemSettings` не работает (Electron поддерживает его только на macOS
* и Windows), поэтому пишем .desktop-файл в каталог автозапуска — общее для GNOME, KDE, XFCE
* соглашение freedesktop.
*/
function autostartFilePath(): string {
const configHome = process.env['XDG_CONFIG_HOME'] || join(homedir(), '.config')
return join(configHome, 'autostart', 'senboost.desktop')
}
/**
* Внутри AppImage `process.execPath` указывает во временную точку монтирования, которая
* исчезает после выхода, — запускать надо сам файл образа.
*/
function execCommand(): string {
const binary = process.env['APPIMAGE'] || process.execPath
const args = app.isPackaged ? [] : [app.getAppPath()]
return [binary, ...args, '--hidden'].map((part) => `"${part}"`).join(' ')
}
export async function isAppAutoStartEnabled(): Promise<boolean> {
try {
const content = await readFile(autostartFilePath(), 'utf8')
// Файл могли оставить включённым, но отключить галочкой в «Автозапуске» окружения —
// тогда в нём стоит X-GNOME-Autostart-enabled=false.
return !/^X-GNOME-Autostart-enabled\s*=\s*false\s*$/im.test(content)
} catch {
return false
}
}
export async function setAppAutoStart(enabled: boolean): Promise<void> {
const path = autostartFilePath()
if (!enabled) {
await rm(path, { force: true })
return
}
const entry = [
'[Desktop Entry]',
'Type=Application',
'Name=SenBoost',
'Comment=Обход блокировок через zapret',
`Exec=${execCommand()}`,
'Terminal=false',
'X-GNOME-Autostart-enabled=true',
''
].join('\n')
await mkdir(join(path, '..'), { recursive: true })
await writeFile(path, entry, 'utf8')
}
+99
View File
@@ -0,0 +1,99 @@
import { app } from 'electron'
import type { ZapretStatus } from '../shared/ipc-contract'
import * as linux from './app-autostart.linux'
import * as win32 from './app-autostart.win32'
import { isTrayActive } from './tray'
import { onZapretStatusChanged, zapretStatus } from './zapret'
/**
* Автозапуск САМОГО приложения вместе с системой — не путать с автозапуском обхода
* (`zapret:set-autostart`): тот поднимает службу Windows или LaunchDaemon macOS вообще без
* приложения, а этот открывает приложение свёрнутым в трей, чтобы обход можно было включить
* и выключить одним кликом.
*
* На Windows отдельного переключателя у него нет — он следует за автозапуском службы
* (см. `followZapretAutoStart`), в настройках одна кнопка «Запускать вместе с Windows».
*
* Источник истины — сама система (реестр/планировщик, SMAppService, каталог автозапуска),
* в settings.json ничего не дублируем: после переустановки системы или приложения
* сохранённое значение всё равно разошлось бы с реальностью.
*/
export function isAppAutoStartEnabled(): Promise<boolean> {
switch (process.platform) {
case 'darwin':
return Promise.resolve(app.getLoginItemSettings().openAtLogin)
case 'win32':
return win32.isAppAutoStartEnabled()
case 'linux':
return linux.isAppAutoStartEnabled()
default:
return Promise.resolve(false)
}
}
export async function setAppAutoStart(enabled: boolean): Promise<void> {
switch (process.platform) {
case 'darwin':
// Electron 44 регистрирует приложение через SMAppService: пароль администратора не
// нужен, но система может попросить подтвердить запись в «Объектах входа».
app.setLoginItemSettings({ openAtLogin: enabled })
return
case 'win32':
await win32.setAppAutoStart(enabled)
return
case 'linux':
await linux.setAppAutoStart(enabled)
return
default:
throw new Error('Автозапуск приложения на этой платформе не поддерживается.')
}
}
/**
* Windows: служба обхода стартует при загрузке — значит, и приложение должно подняться
* при входе, свёрнутым в трей, чтобы обход было чем выключить. Две кнопки для этого путали:
* обе назывались «запускать вместе с Windows», а по отдельности смысла почти не имели.
*
* Задача планировщика существует ровно тогда, когда служба установлена и стоит на `Auto`.
* Приложение и так работает с правами администратора, так что лишнего UAC это не добавляет —
* пользователь видит один запрос, на смену типа запуска службы. Подписка на статус, а не
* вызов из IPC: обход включают и из меню трея, и первое включение ставит службу уже с `Auto`.
*
* Вызывать после `recoverZapret()` — до него статус ещё не знает о службе.
*/
export function followZapretAutoStart(): void {
if (process.platform !== 'win32') return
let applied: boolean | null = null
let queue = Promise.resolve()
const sync = (status: ZapretStatus): void => {
const wanted = status.serviceInstalled && status.autoStart && isTrayActive()
if (wanted === applied) return
applied = wanted
queue = queue
.then(async () => {
if ((await win32.isAppAutoStartEnabled()) !== wanted) await win32.setAppAutoStart(wanted)
})
.catch((error) => {
// Главное — служба, она уже настроена. Без задачи обход всё равно поднимется при
// загрузке, просто без значка в трее; в dev-режиме сюда попадаем всегда (нет прав).
console.warn('[autostart] не удалось синхронизировать задачу планировщика:', error)
})
}
onZapretStatusChanged(sync)
sync(zapretStatus())
}
/**
* Запуск пришёл из автозапуска — окно показывать не нужно, приложение садится в трей.
*
* На Windows и Linux мы сами передаём `--hidden` в команде автозапуска; на macOS аргументы
* задать нельзя (поле `args` у `setLoginItemSettings` только для Windows), поэтому спрашиваем
* систему, она ли нас открыла.
*/
export function shouldStartHidden(): boolean {
if (process.argv.includes('--hidden')) return true
return process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAtLogin
}
+62
View File
@@ -0,0 +1,62 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { app } from 'electron'
import { isElevated } from './zapret/elevated-task.win32'
const run = promisify(execFile)
/** Имя задачи в планировщике. Оно же снимается деинсталлятором (`build/installer.nsh`). */
const TASK_NAME = 'SenBoost Autostart'
/**
* Автозапуск приложения на Windows делаем задачей планировщика, а НЕ ключом реестра
* (`app.setLoginItemSettings`): exe собран с `requestedExecutionLevel: requireAdministrator`
* (см. electron-builder.yml), а UAC при входе в систему не спрашивают — приложение из ключа
* реестра просто не запустилось бы. Задача с `/RL HIGHEST` поднимает его с правами сразу.
*
* Сама по себе из интерфейса не переключается: её ставит и снимает `followZapretAutoStart`
* (`app-autostart.ts`) вслед за автозапуском службы обхода.
*/
function taskCommand(): string {
// В dev первым аргументом electron.exe нужен каталог приложения, в сборке — нет.
const args = app.isPackaged ? [] : [app.getAppPath()]
return [`"${process.execPath}"`, ...args.map((arg) => `"${arg}"`), '--hidden'].join(' ')
}
export async function isAppAutoStartEnabled(): Promise<boolean> {
try {
await run('schtasks.exe', ['/Query', '/TN', TASK_NAME], { windowsHide: true })
return true
} catch {
// Единственный ожидаемый случай — задачи нет; отличать его от сломанного schtasks нечем
// (текст сообщения локализован), да и результат для пользователя один и тот же.
return false
}
}
export async function setAppAutoStart(enabled: boolean): Promise<void> {
// Создание и удаление задачи требуют прав администратора. В собранном приложении они уже
// есть всегда, лишнего UAC не будет; в dev-режиме — нет, и молчать об этом нельзя.
if (!(await isElevated())) {
throw new Error(
'Автозапуск настраивается только в установленном приложении: задача планировщика ' +
'создаётся с правами администратора, а в режиме разработки их нет.'
)
}
const args = enabled
? ['/Create', '/TN', TASK_NAME, '/TR', taskCommand(), '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F']
: ['/Delete', '/TN', TASK_NAME, '/F']
try {
await run('schtasks.exe', args, { windowsHide: true })
} catch (error) {
// Снятие несуществующей задачи — цель уже достигнута, не ошибка.
if (!enabled && !(await isAppAutoStartEnabled())) return
throw new Error(
`Не удалось ${enabled ? 'включить' : 'выключить'} автозапуск приложения: ` +
`${error instanceof Error ? error.message : String(error)}`,
{ cause: error }
)
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ export async function showFirstRunSupportDialog(window: BrowserWindow): Promise<
defaultId: 0,
cancelId: 1,
noLink: true,
title: 'Поддержите RKNboost',
title: 'Поддержите SenBoost',
message: 'Поддержите проект',
detail: 'Поддержите проект звездочкой на GitHub, и расскажите друзьям :З'
})
+46 -14
View File
@@ -1,12 +1,15 @@
import { app, BrowserWindow } from 'electron'
import { app } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { createWindow } from './window'
import { followZapretAutoStart, shouldStartHidden } from './app-autostart'
import { beginQuit, createWindow, showMainWindow } from './window'
import { showFirstRunSupportDialog } from './first-run'
import { registerIpcHandlers } from './ipc'
import { loadSettings } from './settings'
import { createTray } from './tray'
import { disposeZapret, recoverZapret } from './zapret'
import { elevatedTaskDir, runElevatedServiceTask } from './zapret/elevated-task.win32'
const APP_ID = 'com.rknboost.app'
const APP_ID = 'com.senboost.app'
// Приложение может быть запущено само собой через UAC как «повышенный помощник» — тогда
// оно не открывает окно и не занимает единственный экземпляр (иначе отдало бы фокус уже
@@ -22,16 +25,11 @@ if (elevatedTask) {
app.quit()
} else {
app.on('second-instance', () => {
const [window] = BrowserWindow.getAllWindows()
if (!window) {
createWindow()
return
}
if (window.isMinimized()) window.restore()
window.focus()
// Окна может не быть вовсе: приложение живёт в трее, в том числе стартовав свёрнутым.
showMainWindow()
})
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Нужно Windows для группировки в панели задач и уведомлений.
electronApp.setAppUserModelId(APP_ID)
@@ -40,23 +38,55 @@ if (elevatedTask) {
optimizer.watchWindowShortcuts(window)
})
// Настройки читаем один раз здесь: их ждут и recoverZapret (выбранная стратегия),
// и окно (прятать ли его в трей по закрытию).
await loadSettings()
registerIpcHandlers()
// Трей создаём до окна: окно спрашивает у него, есть ли куда прятаться по закрытию.
createTray({
showWindow: () => {
showMainWindow()
},
quit: () => {
beginQuit()
app.quit()
}
})
// Запуск из автозапуска системы — приложение садится в трей, окно не показываем.
const startHidden = shouldStartHidden()
// На macOS чинит прокси, оставшийся от аварийно завершённого прошлого запуска,
// до того как окно вообще откроется.
void recoverZapret().then(() => {
const window = createWindow()
followZapretAutoStart()
const window = createWindow({ show: !startHidden })
// Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого.
// Свёрнутому в трей приложению показывать модальное окно некуда — пользователь его
// не увидит и не закроет, а метку «уже показывали» диалог поставит.
if (!startHidden) {
window.once('ready-to-show', () => void showFirstRunSupportDialog(window))
}
})
// macOS: клик по иконке в доке при закрытых окнах открывает окно заново.
// macOS: клик по иконке в доке (в том числе при закрытых окнах) открывает окно.
// Система шлёт это событие и при самом запуске приложения — на первое из них при
// свёрнутом старте не реагируем, иначе автозапуск сразу же показал бы окно.
let ignoreActivate = startHidden
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
if (ignoreActivate) {
ignoreActivate = false
return
}
showMainWindow()
})
})
// Windows / Linux: закрыли последнее окно — вышли. macOS: приложение живёт до Cmd+Q.
// При включённом «сворачивать в трей» окно не закрывается, а прячется, и сюда мы не попадаем
// (см. обработчик `close` в window.ts).
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
@@ -67,6 +97,8 @@ if (elevatedTask) {
// иначе процесс останется висеть, а на macOS ещё и интернет пропадёт.
let zapretCleaned = false
app.on('before-quit', (event) => {
// С этого момента закрытие окна означает выход, а не сворачивание в трей.
beginQuit()
if (zapretCleaned) return
event.preventDefault()
void disposeZapret().finally(() => {
+27 -7
View File
@@ -1,16 +1,21 @@
import { app, BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract'
import type {
BackgroundSettings,
IpcChannel,
IpcHandlers,
ThemeState
} from '../shared/ipc-contract'
import { isAppAutoStartEnabled, setAppAutoStart } from './app-autostart'
import { rememberPreferences, getSettings } from './settings'
import { isTrayActive } from './tray'
import { uninstallApp } from './uninstall.win32'
import {
clearZapretAutoHostlist,
resetZapretList,
saveZapretList,
setZapretAutoStart,
setZapretStrategy,
setZapretSystemProxy,
startZapret,
stopZapret,
zapretAutoHostlist,
zapretDiagnostics,
zapretLists,
zapretStatus,
@@ -43,6 +48,15 @@ function themeState(): ThemeState {
}
}
async function backgroundSettings(): Promise<BackgroundSettings> {
return {
// `null` в настройках — «не выбирали»: по умолчанию прячем в трей.
closeToTray: getSettings().closeToTray !== false,
launchAtLogin: await isAppAutoStartEnabled(),
trayAvailable: isTrayActive()
}
}
export function registerIpcHandlers(): void {
handle('theme:get', () => themeState())
@@ -57,19 +71,25 @@ export function registerIpcHandlers(): void {
if (process.platform === 'win32') void uninstallApp(windowOf(event))
})
handle('app:background-get', () => backgroundSettings())
handle('app:background-set', async (_event, patch) => {
if (patch.closeToTray !== undefined) rememberPreferences({ closeToTray: patch.closeToTray })
// Автозапуск меняем последним и не глотаем ошибку: пользователь должен узнать, что
// задача в планировщике (или объект входа) не создалась.
if (patch.launchAtLogin !== undefined) await setAppAutoStart(patch.launchAtLogin)
return backgroundSettings()
})
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())
// Результаты уходят не ответом на invoke, а событиями по мере готовности — и именно
// в то окно, которое просило проверку, а не всем подряд. Окно могло закрыться, пока
// сайт отвечал (или молчал все 10 с таймаута), — тогда показывать результат уже некому.
+27 -8
View File
@@ -5,7 +5,7 @@ import { app } from 'electron'
/**
* Выбор пользователя между запусками: `settings.json` в userData (на Windows —
* `%AppData%\RKNboost`), поэтому переживает обновление и переустановку.
* `%AppData%\SenBoost`), поэтому переживает обновление и переустановку.
*
* Списки сайтов сюда не входят: они лежат рядом, в `userData/zapret-lists/*.txt`, и
* читаются прямо оттуда самими tpws и winws (см. `zapret/lists.ts`).
@@ -16,9 +16,18 @@ export interface AppSettings {
strategyId: string | null
/** Windows: запускать службу вместе с системой. На macOS не используется. */
autoStart: boolean | null
/** Закрытие окна прячет его в трей вместо выхода. `null` — не выбирали, считаем включённым. */
closeToTray: boolean | null
/** Подсказку «свернулся в трей и продолжаю работать» уже показывали. */
trayHintShown: boolean | null
}
const DEFAULTS: AppSettings = { strategyId: null, autoStart: null }
const DEFAULTS: AppSettings = {
strategyId: null,
autoStart: null,
closeToTray: null,
trayHintShown: null
}
let cache: AppSettings = { ...DEFAULTS }
@@ -37,7 +46,9 @@ function sanitize(raw: unknown): AppSettings {
const value = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
return {
strategyId: typeof value.strategyId === 'string' && value.strategyId ? value.strategyId : null,
autoStart: typeof value.autoStart === 'boolean' ? value.autoStart : null
autoStart: typeof value.autoStart === 'boolean' ? value.autoStart : null,
closeToTray: typeof value.closeToTray === 'boolean' ? value.closeToTray : null,
trayHintShown: typeof value.trayHintShown === 'boolean' ? value.trayHintShown : null
}
}
@@ -60,14 +71,22 @@ async function writeSettings(settings: AppSettings): Promise<void> {
await rename(tmp, path)
}
/** Настройки, прочитанные при старте. До `loadSettings()` — умолчания. */
export function getSettings(): AppSettings {
return { ...cache }
}
/**
* Вызывается из `ZapretEngine.patch()`, то есть на каждое изменение статуса — отсюда
* сравнение с прошлым значением. Записи не ждём: интерфейс не должен зависеть от диска,
* а не сохранившийся выбор не повод ломать работающий обход.
* Сохранить изменившиеся поля. Вызывается в том числе из `ZapretEngine.patch()`, то есть на
* каждое изменение статуса — отсюда сравнение с прошлым значением. Записи не ждём: интерфейс
* не должен зависеть от диска, а не сохранившийся выбор не повод ломать работающий обход.
*/
export function rememberPreferences(settings: AppSettings): void {
export function rememberPreferences(patch: Partial<AppSettings>): void {
if (!loaded) return
if (settings.strategyId === cache.strategyId && settings.autoStart === cache.autoStart) return
const settings: AppSettings = { ...cache, ...patch }
const keys = Object.keys(settings) as (keyof AppSettings)[]
if (keys.every((key) => settings[key] === cache[key])) return
cache = settings
writeQueue = writeQueue
+156
View File
@@ -0,0 +1,156 @@
import { readFileSync } from 'node:fs'
import { app, dialog, Menu, nativeImage, Notification, Tray, type NativeImage } from 'electron'
import type { ZapretStatus } from '../shared/ipc-contract'
import appIcon from '../../resources/icon.png?asset'
import trayOn1x from '../../resources/tray/on-16.png?asset'
import trayOn2x from '../../resources/tray/on-32.png?asset'
import trayOff1x from '../../resources/tray/off-16.png?asset'
import trayOff2x from '../../resources/tray/off-32.png?asset'
import { onZapretStatusChanged, startZapret, stopZapret, zapretStatus } from './zapret'
/**
* Ссылку держим на уровне модуля: `Tray` живёт ровно столько, сколько живёт объект, и
* локальная переменная означала бы исчезающую при сборке мусора иконку.
*/
let tray: Tray | null = null
/** Действия, которые трею делает не сам — их владелец `src/main/index.ts`. */
interface TrayHandlers {
showWindow: () => void
quit: () => void
}
let handlers: TrayHandlers | null = null
/** Идёт включение или выключение — пункт меню на это время недоступен. */
function isBusy(status: ZapretStatus): boolean {
return status.state === 'starting' || status.state === 'stopping'
}
function statusLabel(status: ZapretStatus): string {
switch (status.state) {
case 'running':
return 'Обход включён'
case 'starting':
return 'Обход включается…'
case 'stopping':
return 'Обход выключается…'
case 'error':
return 'Ошибка обхода'
default:
return 'Обход выключен'
}
}
/**
* Картинка в трее.
*
* macOS: монохромный щит с прозрачным фоном (`resources/tray`, генерируется скриптом
* `scripts/make-tray-icons.mjs`), помеченный как template — систему интересует только альфа,
* цвет она подставляет сама под светлый и тёмный меню-бар. Вариант 2x добавляем через
* `addRepresentation`, а не соседним файлом `@2x`: electron-vite при импорте `?asset`
* переносит файлы поштучно, и соглашение об именах через сборку не переживает.
*
* Windows и Linux: уменьшенная иконка приложения — она непрозрачный квадрат, поэтому
* состояние обхода показываем не картинкой, а подсказкой и текстом меню.
*/
function trayImage(status: ZapretStatus): NativeImage {
if (process.platform !== 'darwin') {
return nativeImage
.createFromPath(appIcon)
.resize(process.platform === 'linux' ? { width: 22, height: 22 } : { width: 16, height: 16 })
}
const running = status.state === 'running'
const image = nativeImage.createFromPath(running ? trayOn1x : trayOff1x)
image.addRepresentation({ scaleFactor: 2, buffer: readFileSync(running ? trayOn2x : trayOff2x) })
image.setTemplateImage(true)
return image
}
/** Ошибку показываем системным окном: главного окна на экране может и не быть. */
function reportError(error: unknown): void {
dialog.showErrorBox('SenBoost', error instanceof Error ? error.message : String(error))
}
function toggle(status: ZapretStatus): void {
const action = status.state === 'running' ? stopZapret() : startZapret(status.strategyId)
action.catch(reportError)
}
function render(status: ZapretStatus): void {
if (!tray || !handlers) return
const supported = status.backend !== 'unsupported'
const running = status.state === 'running'
tray.setImage(trayImage(status))
tray.setToolTip(`SenBoost — ${statusLabel(status).toLowerCase()}`)
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: statusLabel(status), enabled: false },
{ type: 'separator' },
{
label: running ? 'Выключить обход' : 'Включить обход',
enabled: supported && !isBusy(status),
click: () => toggle(status)
},
{ label: 'Показать окно', click: () => handlers?.showWindow() },
{ type: 'separator' },
{ label: 'Выход', click: () => handlers?.quit() }
])
)
}
/**
* Создаёт иконку в трее. Может не получиться: на Linux иконка держится на AppIndicator
* (в GNOME — расширение), и без него `Tray` бросает исключение. Тогда трея просто нет,
* `isTrayActive()` вернёт false, и окно ведёт себя по-старому — закрытие закрывает
* приложение, а не прячет его туда, откуда пользователь не сможет достать.
*/
export function createTray(trayHandlers: TrayHandlers): void {
handlers = trayHandlers
try {
tray = new Tray(trayImage(zapretStatus()))
} catch (error) {
console.warn('[tray] не удалось создать иконку в трее:', error)
tray = null
return
}
// Левый клик открывает окно на Windows и Linux. На macOS клик по иконке в меню-баре
// открывает контекстное меню — это поведение системы, и трогать его не нужно.
if (process.platform !== 'darwin') tray.on('click', () => handlers?.showWindow())
render(zapretStatus())
onZapretStatusChanged(render)
// Иконка должна исчезнуть в момент выхода, а не когда до объекта дойдёт сборщик мусора.
app.on('will-quit', destroyTray)
}
export function isTrayActive(): boolean {
return tray !== null && !tray.isDestroyed()
}
/** Первое сворачивание в трей: объясняем, куда делось окно и что приложение ещё работает. */
export function notifyRunsInBackground(): void {
const title = 'SenBoost работает в фоне'
const body =
process.platform === 'darwin'
? 'Окно свёрнуто в меню-бар. Обход продолжает работать — выйти можно через меню значка.'
: 'Окно свёрнуто в трей. Обход продолжает работать — выйти можно через меню значка.'
if (process.platform === 'win32' && tray && !tray.isDestroyed()) {
tray.displayBalloon({ title, content: body, icon: nativeImage.createFromPath(appIcon) })
return
}
if (Notification.isSupported()) new Notification({ title, body }).show()
}
function destroyTray(): void {
tray?.destroy()
tray = null
}
+4 -4
View File
@@ -35,7 +35,7 @@ interface UninstallCommand {
* строкой `'"$2" $0'`, где `$2` — путь к деинсталлятору, а `$0` — `/currentuser` для
* per-user установки (или `/allusers`). То есть путь в кавычках И аргумент после него:
*
* "C:\Users\...\AppData\Local\Programs\RKNboost\Uninstall RKNboost.exe" /currentuser
* "C:\Users\...\AppData\Local\Programs\SenBoost\Uninstall SenBoost.exe" /currentuser
*
* Аргумент обязателен: без него деинсталлятор не знает, из какой ветки реестра сниматься.
* Разбираем строку на команду и аргументы, а не пытаемся скормить её `spawn` целиком —
@@ -66,7 +66,7 @@ async function findUninstallCommand(): Promise<UninstallCommand | null> {
const script =
"Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' " +
'| Get-ItemProperty ' +
"| Where-Object { $_.DisplayName -eq 'RKNboost' } " +
"| Where-Object { $_.DisplayName -eq 'SenBoost' } " +
'| Select-Object -First 1 -ExpandProperty UninstallString'
let stdout: string
@@ -110,8 +110,8 @@ export async function uninstallApp(window: BrowserWindow | null): Promise<void>
defaultId: 0,
cancelId: 0,
noLink: true,
title: 'Удалить RKNboost',
message: 'Удалить RKNboost с этого компьютера?',
title: 'Удалить SenBoost',
message: 'Удалить SenBoost с этого компьютера?',
detail:
'Приложение закроется и запустится стандартный деинсталлятор Windows. ' +
'Вместе с ним будут удалены списки сайтов и служба обхода блокировок. ' +
+68 -3
View File
@@ -3,6 +3,43 @@ 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'
import { getSettings, rememberPreferences } from './settings'
import { isTrayActive, notifyRunsInBackground } from './tray'
/** Единственное окно приложения — чтобы трей и второй запуск показывали именно его. */
let mainWindow: BrowserWindow | null = null
/**
* Пользователь действительно выходит («Выход» в трее, Cmd+Q, деинсталляция), а не просто
* закрывает окно. Пока флага нет, закрытие окна прячет его в трей — см. `createWindow()`.
*/
let quitting = false
export function beginQuit(): void {
quitting = true
}
export function isQuitting(): boolean {
return quitting
}
/** Прятать окно в трей вместо выхода: настройка пользователя (по умолчанию — да) + живой трей. */
function shouldHideToTray(): boolean {
return isTrayActive() && getSettings().closeToTray !== false
}
/**
* Показать окно: создать заново, если его нет (macOS оставляет приложение жить без окон),
* развернуть свёрнутое, вывести на передний план. Точка входа для трея, второго запуска
* приложения и клика по иконке в доке.
*/
export function showMainWindow(): BrowserWindow {
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow()
if (window.isMinimized()) window.restore()
window.show()
window.focus()
return window
}
function protocolOf(url: string): string | null {
try {
@@ -23,8 +60,13 @@ export function openExternal(url: string): void {
}
}
/** Главное (и единственное) окно приложения. */
export function createWindow(): BrowserWindow {
/**
* Главное (и единственное) окно приложения.
*
* `show: false` нужен запуску из автозапуска системы: приложение поднимается сразу свёрнутым
* в трей, окно готово, но на экран не выходит.
*/
export function createWindow(options: { show?: boolean } = {}): BrowserWindow {
const window = new BrowserWindow({
width: 440,
height: 660,
@@ -48,8 +90,31 @@ export function createWindow(): BrowserWindow {
}
})
mainWindow = window
window.on('closed', () => {
if (mainWindow === window) mainWindow = null
})
// Показываем окно только когда отрисован первый кадр — без «белой вспышки».
window.on('ready-to-show', () => window.show())
window.on('ready-to-show', () => {
if (options.show !== false) window.show()
})
// Крестик прячет окно в трей: обход продолжает работать, приложение остаётся в фоне.
// Реальный выход идёт через «Выход» в трее или Cmd+Q — там выставлен флаг `quitting`,
// и это условие уже не срабатывает.
window.on('close', (event) => {
if (quitting || !shouldHideToTray()) return
event.preventDefault()
window.hide()
// Иконка в трее маленькая, и первое исчезновение окна пугает — объясняем один раз.
if (!getSettings().trayHintShown) {
rememberPreferences({ trayHintShown: true })
notifyRunsInBackground()
}
})
// window.open(...) и target="_blank" уходят в системный браузер, а не в новое окно Electron.
window.webContents.setWindowOpenHandler(({ url }) => {
+1 -1
View File
@@ -2,7 +2,7 @@ import { access, constants } from 'node:fs/promises'
/**
* `pkexec` из состава polkit — им поднимаем root для nftables/nfqws. Проверяем по конкретным
* путям, а не через `PATH`: тот же мотив, что у `find_nft()` в `rknboost-helper.sh` — санитайз
* путям, а не через `PATH`: тот же мотив, что у `find_nft()` в `senboost-helper.sh` — санитайз
* окружения инструментами вроде pkexec/env-с-урезанным-PATH встречается достаточно часто,
* чтобы не полагаться на переменную окружения при поиске самого пути к элевации.
*/
+7 -88
View File
@@ -1,16 +1,9 @@
import { connect as netConnect, type Socket } from 'node:net'
import { connect as tlsConnect } from 'node:tls'
import { net } from 'electron'
import type {
ZapretDiagnosticResult,
ZapretDiagnosticTarget,
ZapretStatus
} from '../../shared/ipc-contract'
import type { ZapretDiagnosticResult, ZapretDiagnosticTarget } from '../../shared/ipc-contract'
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
// и медленный, но живой сайт успевал попасть в «Недоступно».
const TIMEOUT_MS = 10000
const TARGET_PORT = 443
const RETRY_DELAY_MS = 300
/** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */
@@ -41,43 +34,11 @@ function describeNetError(message: string): string {
}
/**
* 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) и для
* выключенного обхода.
* Единственный путь проверки на всех трёх платформах: десинк везде общесистемный —
* winws через WinDivert, nfqws через nftables/NFQUEUE, utunws через pf/utun (см.
* `engine.*.ts`) — прямой запрос видит его так же, как и обычный браузерный трафик.
* Раньше на macOS был отдельный путь через локальный SOCKS5 (`tpws --socks`), но новый
* движок `utunws` — не прокси, трафик десинхронизируется до того, как уйдёт в сеть.
*
* Намеренно через `net` Electron, то есть через сетевой стек Chromium, а НЕ через
* `node:tls`. Тест должен отвечать на вопрос «откроется ли этот сайт в браузере», а
@@ -118,35 +79,6 @@ function checkDirect(host: string): Promise<number> {
})
}
/** Тот же успех, но через локальный 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))
}
@@ -171,11 +103,6 @@ async function withRetry(attempt: () => Promise<number>): Promise<number> {
* Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить
* «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем.
*
* `status.socksAddress` заполнен только на macOS и только пока обход запущен — во всех
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
* на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает
* то, что есть сейчас.
*
* Каждый сайт отдаёт свой результат в `onResult` сам по себе, не дожидаясь остальных:
* проверки идут параллельно и заканчиваются вразнобой (живой сайт отвечает за десятки
* миллисекунд, заблокированный — только по таймауту в 10 с), и общий ответ одним куском
@@ -183,20 +110,12 @@ async function withRetry(attempt: () => Promise<number>): Promise<number> {
* по нему вызывающий код понимает, что проверка закончена.
*/
export async function runDiagnostics(
status: ZapretStatus,
onResult: (result: ZapretDiagnosticResult) => void
): Promise<void> {
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
await Promise.all(
DIAGNOSTIC_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)
const ms = await withRetry(() => checkDirect(target.host))
onResult({ ...target, ok: true, ms, error: null })
} catch (error) {
onResult({
+35
View File
@@ -0,0 +1,35 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const run = promisify(execFile)
/**
* Запускает привилегированный скрипт из `resources/darwin-helper/` через системный диалог
* авторизации — единственное место в приложении, где macOS просит пароль администратора.
* Логика перенесена из старого `proxy.darwin.ts` (`networksetup()`), где так же собиралась
* команда `osascript -e 'do shell script "..." with administrator privileges'`: одинарные
* кавычки вокруг каждого аргумента и `'` внутри значения экранируется как `'\''` — тот же
* приём, что `quoteConfigArg()` в `strategies.ts` использует для strategy.cfg.
*/
export async function runPrivileged(scriptPath: string, args: string[]): Promise<void> {
const command = [scriptPath, ...args]
.map((part) => `'${part.replaceAll("'", `'\\''`)}'`)
.join(' ')
try {
await run('/usr/bin/osascript', [
'-e',
`do shell script "${command.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}" with administrator privileges`
])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// -128 — «User canceled» из Authorization Services: пользователь закрыл диалог пароля
// или нажал «Отмена», это не ошибка выполнения скрипта, а отказ от неё.
if (message.includes('-128')) {
throw new Error('Операция отменена — не введён пароль администратора.', { cause: error })
}
throw new Error(`Не удалось выполнить привилегированную операцию: ${message}`, {
cause: error
})
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ export function elevatedTaskDir(argv: readonly string[]): string | null {
* Смысл всей этой конструкции в том, ЧТО пользователь видит в окне UAC. Раньше приложение
* повышало powershell.exe, и Windows честно показывала «Windows PowerShell» с подписью
* Microsoft — запрос выглядел так, будто права просит не то, что запросил пользователь.
* Теперь повышается сам RKNboost.exe, и в запросе стоит имя приложения.
* Теперь повышается сам SenBoost.exe, и в запросе стоит имя приложения.
*
* Сам worker.ps1 при этом не изменился — вся логика sc/icacls осталась ровно та же,
* поменялось только то, кого повышают.
+108 -196
View File
@@ -1,243 +1,155 @@
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'
import { assertExecutable, utunwsPath } from './paths'
import { defaultStrategyId, utunwsConfig } from './strategies'
import {
assertMacosVersion,
dryRun,
readUtunwsState,
setUtunwsAutoStart,
startUtunws,
stopUtunws
} from './utunws.darwin'
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} мс.`)
}
/** Как часто опрашивать pidfile — демон нам не дочерний процесс, экзит-событие поймать некому. */
const WATCH_INTERVAL_MS = 3000
/**
* macOS: tpws поднимается обычным пользовательским процессом в режиме socks5
* (документация zapret: «Режим --socks не требует повышенных привилегий»),
* а трафик заворачивается системной настройкой прокси.
*
* Важное ограничение платформы: tpws не умеет UDP, поэтому QUIC не обходится.
* Практика это скрадывает — при системном socks-прокси браузеры сами уходят с QUIC на TCP.
* macOS: `utunws` (форк `nfqws` из Flowseal/zapret-mac-discord-youtube — тот же движок
* уровня пакетов, что winws и nfqws, но через utun+BPF вместо WinDivert/netfilter) под
* LaunchDaemon. Пароль администратора спрашивается ровно при двух действиях — включении и
* выключении обхода (`resources/darwin-helper/install.sh`/`stop.sh` через `osascript`),
* тот же принцип, что `pkexec` на Linux. Выключение полностью сносит установку (плист,
* strategy.cfg, pf-правила) — «выключено» и «не установлено» здесь одно состояние, поэтому
* `dispose()` не требуется останавливать демон: он живёт своей жизнью в системе, как и на
* Windows/Linux (если пользователь не тронул переключатель, обход продолжает работать
* после закрытия приложения).
*/
export class DarwinEngine extends ZapretEngine {
private child: ChildProcessByStdio<null, Readable, Readable> | null = null
private port = PREFERRED_PORT
/** Просили ли включить системный прокси. Переживает перезапуск tpws. */
private wantSystemProxy = true
private watchTimer: ReturnType<typeof setInterval> | null = null
constructor() {
super('tpws-socks', defaultStrategyId('darwin'))
super('utunws-pf', defaultStrategyId('darwin'))
// Решение по умолчанию для первой установки — то же, что у Win32Engine: обход должен
// работать и после перезагрузки, не дожидаясь запуска приложения.
this.status.autoStart = true
}
/**
* Прошлый запуск мог не снять прокси (например, kill -9). Тогда система смотрит
* в мёртвый socks и интернета нет вовсе — чиним до того, как показать окно.
* Вызывается из `recoverZapret()` до открытия окна — подхватывает LaunchDaemon,
* переживший закрытие приложения (RunAtLoad+KeepAlive держит его живым и через
* перезагрузку системы), тот же принцип, что `Win32Engine.sync()`/`LinuxEngine.sync()`.
*/
async recoverFromCrash(): Promise<void> {
if (!(await hasStaleSnapshot())) return
async sync(): Promise<ZapretStatus> {
const { pid, strategyId, autoStart } = await readUtunwsState()
this.appendLog(
'info',
'Найдены незакрытые настройки прокси от прошлого запуска — восстанавливаю.'
)
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
if (pid === null) {
this.stopWatch()
return this.patch({ state: 'stopped', startedAt: null, error: null })
}
this.startWatch()
return this.patch({
state: 'running',
strategyId: strategyId ?? this.status.strategyId,
// Реальное состояние демона важнее сохранённого приложением выбора — тот же принцип,
// что у Win32Engine.sync() с info.startMode.
autoStart: autoStart ?? this.status.autoStart,
// Момент реального запуска нам не известен — демон мог подняться ещё при загрузке
// системы, до старта приложения. Тот же компромисс, что и в остальных движках.
startedAt: new Date().toISOString(),
error: null
})
}
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
// Всё, что можно проверить без пароля, проверяем в этом порядке: сначала формат
// самой стратегии, потом версию системы, потом сами аргументы через --dry-run — тот
// же принцип, что и у LinuxEngine.start(), единственный шанс не побеспокоить
// пользователя диалогом авторизации ради заведомо неверных данных.
const config = utunwsConfig(strategyId)
assertMacosVersion()
await assertExecutable(utunwsPath())
await dryRun(config.body)
// Вывод 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)
await startUtunws({
configBody: config.body,
tcpPorts: config.tcpPorts,
udpPorts: config.udpPorts,
autoStart: this.status.autoStart
})
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)
})
const { pid } = await readUtunwsState()
if (pid !== null) this.startWatch()
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
})
return this.patch({ state: 'running', startedAt: new Date().toISOString(), error: null })
} 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
})
return this.patch({ state: 'error', error: message, startedAt: null })
}
}
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
})
this.stopWatch()
try {
await stopUtunws()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({ state: 'error', error: message })
}
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()
return this.patch({ state: 'stopped', startedAt: null, error: null })
}
/**
* Порядок важен: сначала снимаем прокси, потом гасим tpws. Наоборот нельзя —
* между шагами система осталась бы с прокси, который уже никто не слушает.
* Только RunAtLoad уже установленного LaunchDaemon — если демона ещё нет, лишь запоминаем
* выбор до первого включения (никакого диалога авторизации на пустом месте), тот же
* принцип, что у `Win32Engine.setAutoStart()`.
*/
private async cleanup(): Promise<void> {
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
if (this.status.state !== 'running') {
return this.patch({ autoStart: enabled })
}
await setUtunwsAutoStart(enabled)
return this.patch({ autoStart: enabled })
}
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))
async dispose(): Promise<void> {
// LaunchDaemon живёт своей жизнью в системе — выход приложения его не останавливает
// (см. комментарий класса). Опрос pidfile всё равно больше не нужен.
this.stopWatch()
}
/**
* Демон нам не дочерний процесс (владеет им launchd, а не Electron) — событие завершения
* поймать некому, поэтому опрашиваем pidfile по таймеру, как и `LinuxEngine`. В отличие
* от Linux pid здесь может смениться без нашего участия: `daemon.sh` перезапускает
* utunws заново при смене сети (новый шлюз), поэтому pidfile перечитываем на каждом тике,
* а не держим захваченный при старте pid.
*/
private startWatch(): void {
this.stopWatch()
this.watchTimer = setInterval(() => {
void readUtunwsState().then(({ pid: currentPid }) => {
if (currentPid !== null || this.status.state !== 'running') return
this.stopWatch()
this.appendLog('error', 'utunws больше не запущен')
this.patch({
state: 'error',
error: `tpws неожиданно завершился (${reason}). ${details}`.trim(),
startedAt: null,
socksAddress: null,
systemProxyApplied: false
error: 'Процесс utunws неожиданно завершился.',
startedAt: null
})
})
}, WATCH_INTERVAL_MS)
}
private stopWatch(): void {
if (this.watchTimer) clearInterval(this.watchTimer)
this.watchTimer = null
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ const WATCH_INTERVAL_MS = 3000
* NFQUEUE. Ни служба, ни автозапуск не заведены (см. README.md) — каждое включение просит
* пароль через `pkexec` (см. `nfqws.linux.ts`), а сам демон и его правила переживают выход
* из приложения: `bypass` на очереди означает, что их отсутствие не рвёт пользователю сеть
* (см. `rknboost-helper.sh`), поэтому `dispose()` намеренно пуст — как и на Windows.
* (см. `senboost-helper.sh`), поэтому `dispose()` намеренно пуст — как и на Windows.
*/
export class LinuxEngine extends ZapretEngine {
private watchTimer: ReturnType<typeof setInterval> | null = null
+17 -10
View File
@@ -10,6 +10,20 @@ import { rememberPreferences, type AppSettings } from '../settings'
/** Сколько строк вывода бинарника держим для UI. */
const LOG_LIMIT = 200
const statusListeners = new Set<(status: ZapretStatus) => void>()
/**
* Подписка на изменение статуса внутри main-процесса. Окнам статус уходит событием
* `zapret:status-changed`, но трей — не окно (`src/main/tray.ts`), а знать о включении
* и выключении ему нужно ровно так же. Возвращает функцию отписки.
*/
export function onZapretStatusChanged(listener: (status: ZapretStatus) => void): () => void {
statusListeners.add(listener)
return () => {
statusListeners.delete(listener)
}
}
/** Общая часть обоих движков: хранение статуса и рассылка событий в окна. */
export abstract class ZapretEngine {
protected status: ZapretStatus
@@ -22,8 +36,6 @@ export abstract class ZapretEngine {
strategyId: defaultStrategyId,
startedAt: null,
error: null,
socksAddress: null,
systemProxyApplied: false,
serviceInstalled: false,
autoStart: false
}
@@ -41,7 +53,7 @@ export abstract class ZapretEngine {
*
* `null` в поле означает «не выбирали» — тогда остаётся то, что решил конструктор движка.
*/
restorePreferences(settings: AppSettings): void {
restorePreferences(settings: Pick<AppSettings, 'strategyId' | 'autoStart'>): void {
this.patch({
strategyId: settings.strategyId ?? this.status.strategyId,
autoStart: settings.autoStart ?? this.status.autoStart
@@ -57,12 +69,6 @@ export abstract class ZapretEngine {
/** Вызывается при выходе из приложения: вернуть систему в исходное состояние. */
abstract dispose(): Promise<void>
/** macOS-специфика; на других платформах переопределять не нужно. */
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
void enabled
throw new Error('Системный прокси на этой платформе не настраивается.')
}
/**
* Выключен — просто запоминаем выбор (никаких системных действий и никакого UAC).
* Работает — перезапускаем с новой стратегией через `start()`; на Windows тот сам
@@ -76,7 +82,7 @@ export abstract class ZapretEngine {
return this.start(strategyId)
}
/** Windows-специфика (автозапуск службы); на других платформах переопределять не нужно. */
/** macOS/Windows-специфика (автозапуск демона/службы); на Linux переопределять не нужно. */
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
void enabled
throw new Error('Автозапуск на этой платформе не настраивается.')
@@ -89,6 +95,7 @@ export abstract class ZapretEngine {
// проходит через patch, так что забыть сохранить выбор здесь просто негде.
rememberPreferences({ strategyId: this.status.strategyId, autoStart: this.status.autoStart })
this.broadcast('zapret:status-changed', this.status)
for (const listener of statusListeners) listener(this.getStatus())
return this.getStatus()
}
+3 -3
View File
@@ -30,7 +30,7 @@ import {
const run = promisify(execFile)
/** Первая строка `strategy.cfg` — из `winwsConfigFile()`, формат: `--comment=rknboost:<id>:<hash>`. */
/** Первая строка `strategy.cfg` — из `winwsConfigFile()`, формат: `--comment=senboost:<id>:<hash>`. */
async function readInstalledMarkerLine(): Promise<string | null> {
try {
const content = await readFile(serviceConfigPath(), 'utf8')
@@ -85,7 +85,7 @@ async function needsReconfigure(config: EngineConfig, info: ServiceInfo | null):
/**
* Windows: winws требует драйвер WinDivert и права администратора, поэтому работает как служба
* Windows (`rknboost-zapret`), а не как обычный дочерний процесс приложения.
* Windows (`senboost-zapret`), а не как обычный дочерний процесс приложения.
*
* UAC запрашивается только при первой установке службы и при смене аргументов (новая стратегия,
* переключение автозапуска) — этим занимается `service.win32.ts`. Обычные включение/выключение
@@ -207,7 +207,7 @@ export class Win32Engine extends ZapretEngine {
* а не после него.
*/
private async dryRun(configBody: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-dryrun-'))
const dir = await mkdtemp(join(tmpdir(), 'senboost-dryrun-'))
const configPath = join(dir, 'dry-run.cfg')
try {
await writeFile(configPath, configBody, 'utf8')
+22 -34
View File
@@ -1,4 +1,3 @@
import { readFile, rm } from 'node:fs/promises'
import type {
ZapretDiagnosticResult,
ZapretDiagnosticTarget,
@@ -8,14 +7,14 @@ import type {
ZapretStatus,
ZapretStrategy
} from '../../shared/ipc-contract'
import { loadSettings } from '../settings'
import { getSettings } from '../settings'
import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics'
import { DarwinEngine } from './engine.darwin'
import { ZapretEngine, UnsupportedEngine } from './engine'
import { ZapretEngine, UnsupportedEngine, onZapretStatusChanged } from './engine'
import { LinuxEngine } from './engine.linux'
import { Win32Engine } from './engine.win32'
import { readAllLists, resetList, seedLists, writeList } from './lists'
import { autoHostlistPath } from './paths'
import { restoreSystemProxy } from './proxy.darwin'
import { listStrategies } from './strategies'
function createEngine(): ZapretEngine {
@@ -35,17 +34,20 @@ const engine = createEngine()
/**
* Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем
* состояние с системой — на macOS чиним прокси от прошлого аварийного завершения, на Windows
* подхватываем службу, которая могла работать ещё до открытия окна (автозапуск с системой),
* на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него нет, но сам
* процесс и правила nftables продолжают работать). Все шаги нужно сделать до открытия окна.
* состояние с системой — на macOS подхватываем LaunchDaemon, который мог работать ещё до
* открытия окна (автозапуск с системой) и один раз чиним системный прокси, оставшийся от
* версий на tpws (≤0.5.4, см. `proxy.darwin.ts`), на Windows подхватываем службу с тем же
* автозапуском, на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него
* нет, но сам процесс и правила nftables продолжают работать). Все шаги нужно сделать до
* открытия окна.
*/
export async function recoverZapret(): Promise<void> {
await seedLists()
// Сохранённый выбор применяем ДО sync(): на Windows у уже установленной службы реальное
// состояние (стратегия в strategy.cfg, StartMode) важнее записанного нами и перекроет его.
const settings = await loadSettings()
// Сохранённый выбор применяем ДО sync(): у уже установленной службы/демона реальное
// состояние (стратегия в strategy.cfg, автозапуск) важнее записанного нами и перекроет его.
// Файл к этому моменту уже прочитан в `app.whenReady()` (см. `src/main/index.ts`).
const settings = getSettings()
engine.restorePreferences({
// Стратегия могла исчезнуть в обновлении (подборка Flowseal меняется) или остаться от
// запуска на другой платформе — тогда безопаснее вернуться к умолчанию движка, чем
@@ -54,7 +56,12 @@ export async function recoverZapret(): Promise<void> {
autoStart: settings.autoStart
})
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
if (engine instanceof DarwinEngine) {
await restoreSystemProxy().catch(() => {
// Раз откатить не удалось — оставляем как есть, не мешаем окну открыться.
})
await engine.sync()
}
if (engine instanceof Win32Engine) await engine.sync()
if (engine instanceof LinuxEngine) await engine.sync()
}
@@ -65,6 +72,8 @@ function knownStrategyId(strategyId: string | null): string | null {
return known ? strategyId : null
}
export { onZapretStatusChanged }
export function zapretStatus(): ZapretStatus {
return engine.getStatus()
}
@@ -85,10 +94,6 @@ 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)
}
@@ -114,23 +119,6 @@ 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 })
}
/**
* Запускает проверку и сразу возвращает список сайтов — результаты приходят в `onResult`
* по одному, по мере готовности (см. `runDiagnostics`). Промис проверки намеренно не ждём:
@@ -142,7 +130,7 @@ export function zapretDiagnostics(
): ZapretDiagnosticTarget[] {
// Каждая проверка ловит свою ошибку сама, наружу выбраться нечему — ловим здесь только
// чтобы неожиданное падение не осталось необработанным промисом.
runDiagnostics(engine.getStatus(), onResult).catch((error: unknown) => {
runDiagnostics(onResult).catch((error: unknown) => {
console.error('[zapret] проверка соединения завершилась ошибкой:', error)
})
return DIAGNOSTIC_TARGETS
+4 -4
View File
@@ -78,7 +78,7 @@ export async function stageRuntime(): Promise<void> {
}
/**
* Номер очереди NFQUEUE — держать в синхроне с `QUEUE_NUM` в `rknboost-helper.sh`
* Номер очереди NFQUEUE — держать в синхроне с `QUEUE_NUM` в `senboost-helper.sh`
* (там же и объяснение выбора числа). В TS он нужен только для `--dry-run` ниже: сам
* помощник получает его не отсюда, а как захардкоженное значение в своём коде.
*/
@@ -97,7 +97,7 @@ const NFQUEUE_NUM = 220
* рассинхронизированы, но сам факт валидности остальных аргументов dry-run проверяет верно.
*/
export async function dryRun(configBody: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-dryrun-'))
const dir = await mkdtemp(join(tmpdir(), 'senboost-dryrun-'))
const configPath = join(dir, 'dry-run.cfg')
try {
await writeFile(configPath, configBody, 'utf8')
@@ -198,8 +198,8 @@ export async function isProcessAlive(pid: number): Promise<boolean> {
}
/**
* Не требует root: `nfqws.pid` и `strategy.cfg` в `/run/rknboost` помощник кладёт с правами
* 0644 (см. rknboost-helper.sh) именно затем, чтобы это чтение — и восстановление состояния
* Не требует root: `nfqws.pid` и `strategy.cfg` в `/run/senboost` помощник кладёт с правами
* 0644 (см. senboost-helper.sh) именно затем, чтобы это чтение — и восстановление состояния
* в `LinuxEngine.sync()` — обходилось без пароля.
*/
export async function readNfqwsState(): Promise<NfqwsState> {
+77 -22
View File
@@ -13,9 +13,9 @@ function zapretRoot(): string {
: resolve(app.getAppPath(), 'resources', 'zapret')
}
/** macOS: universal-бинарник tpws (arm64 + x86_64). */
export function tpwsPath(): string {
return join(zapretRoot(), 'darwin', 'tpws')
/** macOS: universal-бинарник utunws (arm64 + x86_64), форк nfqws под utun+BPF. */
export function utunwsPath(): string {
return join(zapretRoot(), 'darwin', 'utunws')
}
/** Windows: winws.exe рядом с cygwin1.dll и WinDivert. */
@@ -34,8 +34,8 @@ export function nfqwsPath(): string {
*/
export function bundledHelperPath(): string {
return app.isPackaged
? join(process.resourcesPath, 'linux-helper', 'rknboost-helper.sh')
: resolve(app.getAppPath(), 'resources', 'linux-helper', 'rknboost-helper.sh')
? join(process.resourcesPath, 'linux-helper', 'senboost-helper.sh')
: resolve(app.getAppPath(), 'resources', 'linux-helper', 'senboost-helper.sh')
}
/**
@@ -57,7 +57,7 @@ export function stagedNfqwsPath(): string {
}
export function stagedHelperPath(): string {
return join(linuxStagingDir(), 'rknboost-helper.sh')
return join(linuxStagingDir(), 'senboost-helper.sh')
}
/** Стейджинговая копия `fakesDir()` — то, что реально читает nfqws (см. её комментарий). */
@@ -73,11 +73,11 @@ export function bundledFakesDir(): string {
/**
* Рабочий каталог демона — pidfile и `strategy.cfg` (последний — источник маркера стратегии
* для `LinuxEngine.sync()`, читается без root: помощник кладёт его с правами 0644). Создаёт
* и чистит сам `rknboost-helper.sh`. `/run` — стандартный FHS-путь для данных, не переживающих
* и чистит сам `senboost-helper.sh`. `/run` — стандартный FHS-путь для данных, не переживающих
* перезагрузку: nfqws её тоже не переживает, автозапуска на Linux нет (см. README.md).
*/
export function linuxRuntimeDir(): string {
return '/run/rknboost'
return '/run/senboost'
}
export function linuxPidfilePath(): string {
@@ -88,6 +88,67 @@ export function linuxInstalledConfigPath(): string {
return join(linuxRuntimeDir(), 'strategy.cfg')
}
/**
* macOS: привилегированные скрипты в составе сборки (`resources/darwin-helper/`, отдельный
* `extraResources` в electron-builder.yml — не качается `fetch-zapret.mjs`, лежит в
* репозитории, тот же принцип, что и `bundledHelperPath()` для Linux).
*/
export function bundledDarwinHelperDir(): string {
return app.isPackaged
? join(process.resourcesPath, 'darwin-helper')
: resolve(app.getAppPath(), 'resources', 'darwin-helper')
}
export function darwinInstallScriptPath(): string {
return join(bundledDarwinHelperDir(), 'install.sh')
}
export function darwinStopScriptPath(): string {
return join(bundledDarwinHelperDir(), 'stop.sh')
}
export function darwinAutostartScriptPath(): string {
return join(bundledDarwinHelperDir(), 'autostart.sh')
}
/**
* macOS: каталог, куда `install.sh` копирует utunws, daemon.sh/watchdog.sh, fake-пакеты и
* strategy.cfg — свой пользовательский `resources/zapret` для этого не годится: root
* (`daemon.sh` под launchd) не должен исполнять файлы из каталога, доступного на запись
* обычному пользователю (то же рассуждение, что у `protectedWinwsPath()` на Windows).
*/
export function darwinInstallDir(): string {
return '/Library/Application Support/SenBoost/zapret'
}
/** Стейджинговая копия `fakesDir()` — то же назначение, что `stagedFakesDir()` на Linux. */
export function darwinInstalledFakesDir(): string {
return join(darwinInstallDir(), 'fakes')
}
/** Источник маркера стратегии для `DarwinEngine.sync()` — читается без root (0644). */
export function darwinInstalledConfigPath(): string {
return join(darwinInstallDir(), 'strategy.cfg')
}
/** Порты TCP/UDP (по строке на каждый) — их же читает `daemon.sh` при заряде pf-правил. */
export function darwinPortsPath(): string {
return join(darwinInstallDir(), 'ports.conf')
}
/**
* `utunws --pidfile=...` пишет сюда свой pid до начала работы (см. `daemon.sh`) — читается
* без root, тот же принцип, что у `linuxPidfilePath()`. `/var/run` — не `/run/senboost`
* (Linux-путь): на macOS это симлинк на `/private/var/run`, а не отдельная точка монтирования.
*/
export function darwinPidfilePath(): string {
return '/var/run/senboost-zapret.pid'
}
export function darwinPlistPath(): string {
return '/Library/LaunchDaemons/com.senboost.zapret.plist'
}
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
export function bundledListsDir(): string {
return join(zapretRoot(), 'lists')
@@ -105,20 +166,23 @@ export function userListsDir(): string {
/**
* Fake-пакеты (TLS ClientHello, QUIC initial, ...) — путь, который резолвится в
* `{FAKES}` (`strategies.ts`) и должен суметь прочитать сам движок при работе, не только
* при запуске. На Linux это стейджинговая копия (`stagedFakesDir()`) — см. её комментарий
* про FUSE; на остальных платформах движок и так работает под тем же uid, что видит сборку.
* при запуске. На Linux это стейджинговая копия (`stagedFakesDir()`, см. её комментарий про
* FUSE), на macOS — установленная копия под root (`darwinInstalledFakesDir()`, см. её
* комментарий); на Windows движок работает под тем же uid, что видит сборку приложения.
*/
export function fakesDir(): string {
return process.platform === 'linux' ? stagedFakesDir() : join(zapretRoot(), 'fakes')
if (process.platform === 'linux') return stagedFakesDir()
if (process.platform === 'darwin') return darwinInstalledFakesDir()
return join(zapretRoot(), 'fakes')
}
/**
* Windows: служба `rknboost-zapret` работает от LocalSystem, поэтому её конфиг не может
* Windows: служба `senboost-zapret` работает от LocalSystem, поэтому её конфиг не может
* жить в userData обычного пользователя — нужен каталог машинного уровня. `%ProgramData%`
* почти всегда задан; `C:\ProgramData` — тот же путь, на который он указывает по умолчанию.
*/
export function serviceDataDir(): string {
return join(process.env.ProgramData ?? 'C:\\ProgramData', 'rknboost', 'zapret')
return join(process.env.ProgramData ?? 'C:\\ProgramData', 'senboost', 'zapret')
}
/** Файл аргументов `winws.exe @strategy.cfg` — пишется только повышенным скриптом службы. */
@@ -152,15 +216,6 @@ 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`.
+20 -65
View File
@@ -1,17 +1,24 @@
import { app } from 'electron'
import { execFile } from 'node:child_process'
import { readFile, writeFile, rm } from 'node:fs/promises'
import { readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
const run = promisify(execFile)
/**
* Домены и адреса, которые не должны идти через прокси. Без этого списка отвалится
* всё локальное: tpws по документации запрещает подключения к тому же хосту,
* на котором работает сам, включая localhost.
* Разовая миграция с версий на `tpws` (≤0.5.4, socks5-прокси уровня TCP-соединений).
* Новый движок (`utunws`, см. `engine.darwin.ts`) обходит блокировки на уровне пакетов
* через pf/utun и системный прокси не использует вовсе — `applySystemProxy()` (когда-то
* прописывала socks в сетевые настройки) больше не нужна и удалена.
*
* Но у пользователей, обновляющихся со старой версии, снимок настроек мог остаться на
* диске: если tpws-приложение завершилось аварийно (`kill -9`, крах системы), оно не
* успевало снять socks-прокси из системных настроек сети — тогда система тычется в
* несуществующий сервер, и после обновления просто нет интернета, пока кто-то не откатит
* прокси руками. `restoreSystemProxy()`/`hasStaleSnapshot()` остаются ровно ради этого
* разового отката в `recoverZapret()` (`zapret/index.ts`), до открытия окна.
*/
const BYPASS = ['*.local', '169.254/16', 'localhost', '127.0.0.1', '::1']
interface ServiceProxyState {
service: string
@@ -35,7 +42,9 @@ function snapshotPath(): string {
* `networksetup` требует членства в группе admin. На обычном Mac пользователь в ней
* состоит и пароль не спрашивается. Если же включена политика «требовать пароль
* администратора для системных настроек», команда падает — тогда повторяем её через
* системный диалог авторизации.
* системный диалог авторизации (та же логика, что и `runPrivileged()` в `elevate.darwin.ts`,
* этот файл написан раньше и его исторически не трогаем — обе копии удаляются вместе с
* этой миграцией, когда версии ≤0.5.4 отойдут в прошлое).
*/
async function networksetup(args: string[]): Promise<string> {
try {
@@ -58,48 +67,14 @@ async function networksetup(args: string[]): Promise<string> {
} 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 })
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
@@ -126,26 +101,6 @@ async function restoreService(state: ServiceProxyState): Promise<void> {
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()
@@ -159,7 +114,7 @@ export async function restoreSystemProxy(): Promise<boolean> {
}
/**
* Снимок остался с прошлого запуска — значит приложение завершилось аварийно,
* Снимок остался с прошлого запуска старой версии — значит tpws завершился аварийно,
* не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте.
*/
export async function hasStaleSnapshot(): Promise<boolean> {
+4 -4
View File
@@ -14,7 +14,7 @@ import { SERVICE_BINARY_NAMES, serviceConfigPath, serviceDataDir, winwsPath } fr
const run = promisify(execFile)
export const SERVICE_NAME = 'rknboost-zapret'
export const SERVICE_NAME = 'senboost-zapret'
const POLL_INTERVAL_MS = 300
const POLL_TIMEOUT_MS = 10000
@@ -377,11 +377,11 @@ try {
$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: обход блокировок"'
$scArgs = 'create ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType + ' DisplayName= "SenBoost: обход блокировок"'
$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
& sc.exe description $ServiceName 'Служба SenBoost: обходит блокировки через winws/WinDivert.' | Out-Null
# Разрешаем интерактивным пользователям запуск/остановку/чтение статуса службы, но не
# смену конфигурации и не смену прав — иначе любой пользователь на машине мог бы подменить
@@ -451,7 +451,7 @@ async function readResult(path: string): Promise<{ ok: boolean; error: string |
* Результат в обоих случаях читаем из `result.json`, а не из кода возврата процесса.
*/
async function runElevated(payload: Record<string, unknown>): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-svc-'))
const dir = await mkdtemp(join(tmpdir(), 'senboost-svc-'))
const workerPath = join(dir, TASK_FILES.worker)
const outerPath = join(dir, 'outer.ps1')
const payloadPath = join(dir, TASK_FILES.payload)
+60 -186
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'
import type { ZapretStrategy } from '../../shared/ipc-contract'
import { autoHostlistPath, fakesDir, userListsDir } from './paths'
import { fakesDir, userListsDir } from './paths'
import { WIN32_STRATEGIES } from './strategies.win32.generated'
/**
@@ -9,166 +9,38 @@ import { WIN32_STRATEGIES } from './strategies.win32.generated'
* Универсального набора параметров не существует: что сработает, зависит от DPI
* конкретного провайдера.
*
* `tpws` (macOS) — прокси уровня TCP-соединений, а не пакетов: умеет только резать/
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
* `winws` (Windows), `nfqws` (Linux) и `utunws` (macOS) — один и тот же движок zapret на
* уровне пакетов с общим парсером аргументов (winws — сборка `nfq/nfqws.c` под WinDivert
* вместо netfilter, utunws — та же сборка под utun+BPF, см. Flowseal/zapret-mac-discord-
* youtube), поэтому все три платформы делят один набор пресетов — 22 варианта из
* Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
* генератором `scripts/parser-strategies.mjs` (не батниками — кодом).
*
* `winws` (Windows) и `nfqws` (Linux) — один и тот же движок zapret на уровне пакетов
* (winws — сборка nfq/nfqws.c под WinDivert вместо netfilter), поэтому делят один набор
* пресетов — 22 варианта из Flowseal/zapret-discord-youtube, перенесённые в
* `strategies.win32.generated.ts` генератором `scripts/import-flowseal-strategies.mjs`
* (не батниками — кодом). На Linux `--wf-tcp`/`--wf-udp` из тех же аргументов уходят не в
* nfqws, а в правила nftables — см. `nfqwsConfig()` ниже и `resources/linux-helper/`.
* `--wf-tcp`/`--wf-udp` из тех же аргументов сам движок понимает только на Windows
* (фильтр WinDivert); на Linux и macOS фильтрацию делает внешний слой — nftables и pf
* соответственно, — а сами порты вырезаются из аргументов и отдаются вызывающей стороне
* отдельно, см. `packetEngineConfig()` ниже, `resources/linux-helper/` и
* `resources/darwin-helper/`.
*
* Все три движка используют одни и те же списки сайтов (`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: []
}
]
/**
* Стратегия по умолчанию. win32 и linux делят один набор id (оба читают
* `WIN32_STRATEGIES`), поэтому и дефолт у них общий — 'general'; у darwin свой набор.
*/
/** Все три платформы делят один набор id (все читают `WIN32_STRATEGIES`), поэтому и дефолт общий. */
export function defaultStrategyId(platform: NodeJS.Platform): string {
switch (platform) {
case 'win32':
case 'linux':
void platform
return 'general'
default:
return 'split'
}
}
function resolvePlaceholders(arg: string): string {
return arg
.replaceAll(LISTS_PLACEHOLDER, userListsDir())
.replaceAll(FAKES_PLACEHOLDER, fakesDir())
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
return arg.replaceAll(LISTS_PLACEHOLDER, userListsDir()).replaceAll(FAKES_PLACEHOLDER, fakesDir())
}
/** win32 и linux показывают один и тот же список Flowseal-пресетов — читают его из общего массива. */
function flowsealStrategies(platform: 'win32' | 'linux'): ZapretStrategy[] {
/** win32, linux и darwin показывают один и тот же список Flowseal-пресетов. */
function flowsealStrategies(platform: 'win32' | 'linux' | 'darwin'): ZapretStrategy[] {
return WIN32_STRATEGIES.map(({ id, name }) => ({
id,
name,
@@ -180,36 +52,12 @@ function flowsealStrategies(platform: 'win32' | 'linux'): ZapretStrategy[] {
/** Пресеты, применимые к текущей платформе. */
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
if (platform === 'darwin') {
return DARWIN_DEFINITIONS.map(({ id, name, description, platforms }) => ({
id,
name,
description,
platforms
}))
}
if (platform === 'darwin') return flowsealStrategies('darwin')
if (platform === 'win32') return flowsealStrategies('win32')
if (platform === 'linux') return flowsealStrategies('linux')
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)
@@ -238,10 +86,10 @@ function quoteConfigArg(arg: string): string {
*/
function configMarker(strategyId: string, body: string): string {
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
return `--comment=rknboost:${strategyId}:${hash}`
return `--comment=senboost:${strategyId}:${hash}`
}
const MARKER_PATTERN = /^--comment=rknboost:([^:]+):/
const MARKER_PATTERN = /^--comment=senboost:([^:]+):/
/**
* Обратная операция к `configMarker()` — достаёт id стратегии из первой строки уже
@@ -283,23 +131,24 @@ export function winwsConfigFile(strategyId: string): EngineConfig {
const WF_TCP_PREFIX = '--wf-tcp='
const WF_UDP_PREFIX = '--wf-udp='
export interface NfqwsConfig extends EngineConfig {
/** Порты для правила nftables `post` (исходящий трафик) — см. `rknboost-helper.sh`. */
export interface PacketEngineConfig extends EngineConfig {
/** Порты TCP из `--wf-tcp`, формат `80,443,8443` — см. вызывающую сторону за смыслом. */
tcpPorts: string
/** Порты для правил nftables `post`/`pre` (исходящий и ответный UDP). */
/** Порты UDP из `--wf-udp`, тот же формат. */
udpPorts: string
}
/**
* На Windows `--wf-tcp`/`--wf-udp` — это фильтр самого WinDivert, зашитый в те же аргументы
* winws. На Linux фильтрацию делает nftables снаружи nfqws (см. `rknboost-helper.sh`), а
* сам nfqws эти два аргумента не понимает — вырезаем их и отдаём отдельно вызывающей стороне.
* winws. На Linux и macOS фильтрацию делает внешний слой — nftables (`senboost-helper.sh`)
* и pf (`resources/darwin-helper/daemon.sh`) соответственно, — а сам движок (nfqws/utunws)
* эти два аргумента не понимает: вырезаем их и отдаём отдельно вызывающей стороне.
*/
export function nfqwsConfig(strategyId: string): NfqwsConfig {
export function packetEngineConfig(strategyId: string): PacketEngineConfig {
const args = winwsArgs(strategyId)
let tcpPorts: string | null = null
let udpPorts: string | null = null
const nfqwsArgs: string[] = []
const engineArgs: string[] = []
for (const arg of args) {
if (arg.startsWith(WF_TCP_PREFIX)) {
@@ -307,21 +156,21 @@ export function nfqwsConfig(strategyId: string): NfqwsConfig {
} else if (arg.startsWith(WF_UDP_PREFIX)) {
udpPorts = arg.slice(WF_UDP_PREFIX.length)
} else {
nfqwsArgs.push(arg)
engineArgs.push(arg)
}
}
// На всех 22 стратегиях сейчас ровно по одному --wf-tcp/--wf-udp (проверено вручную при
// добавлении Linux), но если очередной апдейт Flowseal формат изменит — лучше явная ошибка
// здесь, чем nftables-правило без портов.
// здесь, чем правило фильтрации без портов.
if (tcpPorts === null || udpPorts === null) {
throw new Error(`Стратегия «${strategyId}»: не найден --wf-tcp/--wf-udp в аргументах.`)
}
// Значение уходит прямо в множество nft-правила (rknboost-helper.sh) без дополнительного
// экранирования — только цифры, запятые и дефисы диапазонов. Если Flowseal когда-нибудь
// передаст что-то ещё (например, `~` — отрицание порта), лучше явная ошибка тут, чем
// нераспознанный текст в командной строке nft, которую выполняет root.
// Значение уходит прямо в правило nftables (senboost-helper.sh) или pf (daemon.sh) без
// дополнительного экранирования — только цифры, запятые и дефисы диапазонов. Если Flowseal
// когда-нибудь передаст что-то ещё (например, `~` — отрицание порта), лучше явная ошибка
// тут, чем нераспознанный текст в командной строке nft/pfctl, которую выполняет root.
for (const [flag, ports] of [
['--wf-tcp', tcpPorts],
['--wf-udp', udpPorts]
@@ -331,5 +180,30 @@ export function nfqwsConfig(strategyId: string): NfqwsConfig {
}
}
return { ...renderConfigFile(strategyId, nfqwsArgs), tcpPorts, udpPorts }
return { ...renderConfigFile(strategyId, engineArgs), tcpPorts, udpPorts }
}
/** Тонкая обёртка для читаемости на стороне вызова — семантически то же самое. */
export function nfqwsConfig(strategyId: string): PacketEngineConfig {
return packetEngineConfig(strategyId)
}
export interface UtunwsConfig extends EngineConfig {
/** Порты TCP в синтаксисе диапазонов pf: `80,443,19294:19344` (везде `:` вместо `-`). */
tcpPorts: string
/** Порты UDP, тот же синтаксис. */
udpPorts: string
}
/** `19294-19344` (формат nftables/WinDivert) → `19294:19344` (формат диапазона в pf). */
function toPfPortRanges(ports: string): string {
return ports
.split(',')
.map((part) => part.replace('-', ':'))
.join(',')
}
export function utunwsConfig(strategyId: string): UtunwsConfig {
const { tcpPorts, udpPorts, ...rest } = packetEngineConfig(strategyId)
return { ...rest, tcpPorts: toPfPortRanges(tcpPorts), udpPorts: toPfPortRanges(udpPorts) }
}
@@ -1,5 +1,5 @@
/**
* СГЕНЕРИРОВАНО `scripts/import-flowseal-strategies.mjs` из
* СГЕНЕРИРОВАНО `scripts/parser-strategies.mjs` из
* Flowseal/zapret-discord-youtube 1.10.2 (commit dfd8e613b099676cf2aa7b474ee5923801514dec).
* Не редактировать руками — перезапустить генератор и закоммитить результат.
*
+172
View File
@@ -0,0 +1,172 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { release, tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { runPrivileged } from './elevate.darwin'
import {
bundledFakesDir,
darwinAutostartScriptPath,
darwinInstallScriptPath,
darwinInstalledConfigPath,
darwinPidfilePath,
darwinPlistPath,
darwinStopScriptPath,
utunwsPath
} from './paths'
import { parseStrategyMarker } from './strategies'
const run = promisify(execFile)
/**
* utunws собран под `-target …-macos14.0` (см. `scripts/fetch-zapret.mjs`) — на более
* старой системе он либо откажется запускаться, либо упадёт с малопонятной ошибкой
* загрузчика. Darwin major версии X соответствует macOS (X − 9): Darwin 23 = macOS 14
* (см. `EVERY_RELEASE_DARWIN_VERSION` в документации Apple — Sonoma это первая macOS 14).
*/
const MIN_DARWIN_MAJOR = 23
export function assertMacosVersion(): void {
const major = Number.parseInt(release().split('.')[0] ?? '', 10)
if (Number.isFinite(major) && major < MIN_DARWIN_MAJOR) {
throw new Error(
'Обход блокировок на macOS требует macOS 14 (Sonoma) или новее — движок utunws собран ' +
'под более новый API ядра и не запустится на этой версии системы.'
)
}
}
/**
* `utunws --dry-run @<файл>` — проверяет аргументы фильтра без root: privilege drop и
* открытие utun-интерфейса происходят позже в исполнении, чем разбор аргументов и выход
* (см. `bDry` в `nfq/nfqws.c` форка Flowseal/zapret-mac-discord-youtube). В отличие от
* Linux-`nfqws` здесь не нужен `--qnum` — этот путь специфичен для `#ifdef __linux__`.
*/
export async function dryRun(configBody: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'senboost-dryrun-'))
const configPath = join(dir, 'dry-run.cfg')
try {
await writeFile(configPath, configBody, 'utf8')
await run(utunwsPath(), ['--dry-run', `@${configPath}`])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Стратегия содержит недопустимые параметры utunws: ${message}`, {
cause: error
})
} finally {
await rm(dir, { recursive: true, force: true })
}
}
export interface StartUtunwsOptions {
configBody: string
tcpPorts: string
udpPorts: string
/** Должен ли LaunchDaemon переживать перезагрузку системы без запущенного приложения. */
autoStart: boolean
}
/**
* Единственные два места, где приложение просит пароль администратора — включение и
* выключение обхода (см. `runPrivileged()`/`resources/darwin-helper/install.sh`).
*/
export async function startUtunws(options: StartUtunwsOptions): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'senboost-strategy-'))
const configPath = join(dir, 'strategy.cfg')
try {
await writeFile(configPath, options.configBody, 'utf8')
await runPrivileged(darwinInstallScriptPath(), [
utunwsPath(),
bundledFakesDir(),
configPath,
options.tcpPorts,
options.udpPorts,
options.autoStart ? '1' : '0'
])
} finally {
await rm(dir, { recursive: true, force: true })
}
}
export async function stopUtunws(): Promise<void> {
await runPrivileged(darwinStopScriptPath(), [])
}
/**
* Переключает автозапуск LaunchDaemon с системой (RunAtLoad), не трогая уже работающий
* демон и pf-правила — та же безопасность для активного обхода, что у `setServiceAutoStart()`
* на Windows (`sc config start=`, тоже не останавливает работающую службу).
*/
export async function setUtunwsAutoStart(enabled: boolean): Promise<void> {
await runPrivileged(darwinAutostartScriptPath(), [enabled ? '1' : '0'])
}
export interface UtunwsState {
pid: number | null
strategyId: string | null
/** `RunAtLoad` установленного plist, `null` — если демон не установлен. */
autoStart: boolean | null
}
/** На macOS нет `/proc` — `kill(pid, 0)` без реальной отправки сигнала: EPERM тоже «жив». */
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM'
}
}
/**
* Не требует пароля: pidfile и `strategy.cfg` в защищённом каталоге установки помощник
* кладёт с правами, читаемыми без root (см. `install.sh`) — именно затем, чтобы это чтение
* и восстановление состояния в `DarwinEngine.sync()` обходились без диалога авторизации.
*/
export async function readUtunwsState(): Promise<UtunwsState> {
let pid: number | null = null
try {
const raw = (await readFile(darwinPidfilePath(), 'utf8')).trim()
const parsed = Number.parseInt(raw, 10)
if (Number.isInteger(parsed) && parsed > 0 && isProcessAlive(parsed)) {
pid = parsed
}
} catch {
// pidfile нет или процесс из него уже не живёт — считаем демон не запущенным.
}
let strategyId: string | null = null
try {
const content = await readFile(darwinInstalledConfigPath(), 'utf8')
strategyId = parseStrategyMarker(content.split('\n')[0]?.trim() ?? null)
} catch {
// strategy.cfg нет — не страшно, id останется null.
}
const autoStart = await readInstalledAutoStart()
return { pid, strategyId, autoStart }
}
/**
* `RunAtLoad` уже установленного LaunchDaemon — читается без root (plist в
* `/Library/LaunchDaemons` мировидим, 0644, как и любой другой системный plist). Источник
* истины для `DarwinEngine.sync()`: реальное состояние демона важнее сохранённого
* приложением выбора, тот же принцип, что `Win32Engine.sync()` берёт `info.startMode`
* у самой службы, а не у `settings.json`.
*/
async function readInstalledAutoStart(): Promise<boolean | null> {
try {
const { stdout } = await run('/usr/bin/plutil', [
'-extract',
'RunAtLoad',
'raw',
'-o',
'-',
darwinPlistPath()
])
return stdout.trim() === 'true'
} catch {
return null
}
}
+6 -4
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import type {
BackgroundSettings,
IpcChannel,
IpcEventChannel,
IpcEvents,
@@ -53,12 +54,15 @@ const api = {
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
uninstallApp: (): Promise<void> => invoke('app:uninstall'),
/** Трей и автозапуск приложения — см. `BackgroundSettings` в контракте. */
getBackgroundSettings: (): Promise<BackgroundSettings> => invoke('app:background-get'),
setBackgroundSettings: (patch: Partial<BackgroundSettings>): Promise<BackgroundSettings> =>
invoke('app:background-set', patch),
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> =>
@@ -72,8 +76,6 @@ const api = {
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'),
/** Запускает проверку и отдаёт список сайтов; результаты — через `onZapretDiagnosticResult`. */
runZapretDiagnostics: (): Promise<ZapretDiagnosticTarget[]> => invoke('zapret:diagnose'),
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>RKNboost</title>
<title>SenBoost</title>
<!-- Жёсткий CSP: никаких внешних скриптов, стилей и запросов.
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
+1 -1
View File
@@ -20,7 +20,7 @@ export default function App(): React.JSX.Element {
) : (
<>
<div className="app-header">
<span className="app-header__name">RKNboost</span>
<span className="app-header__name">SenBoost</span>
<button
type="button"
className="app-header__settings"
+64 -27
View File
@@ -2,17 +2,20 @@ import { useState } from 'react'
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
import { GITHUB_URL } from '@shared/links'
import { useAppVersion } from '../hooks/useAppVersion'
import { useBackgroundSettings } from '../hooks/useBackgroundSettings'
import { useZapretLists } from '../hooks/useZapretLists'
import { LIST_TITLES } from '../lib/zapretListMeta'
import ListEditor from './ListEditor'
/**
* Настройки: списки сайтов + автозапуск (Windows). Выбор стратегии и проверка
* Настройки: списки сайтов + автозапуск (macOS/Windows; на Windows одна кнопка на службу
* и приложение в трее). Выбор стратегии и проверка
* соединения переехали на главный экран (ZapretCard.tsx) — рядом с кнопкой
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
*/
const isWindows = window.api.platform === 'win32'
const isDarwin = window.api.platform === 'darwin'
const supportsAutoStart = isWindows || isDarwin
const APP_CHANNEL = 'beta'
export default function SettingsView({
@@ -24,7 +27,8 @@ export default function SettingsView({
status: ZapretStatus | null
onSetAutoStart: (enabled: boolean) => Promise<void>
}): React.JSX.Element {
const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists()
const { lists, save, reset } = useZapretLists()
const background = useBackgroundSettings()
const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
const appVersion = useAppVersion()
@@ -53,22 +57,75 @@ export default function SettingsView({
<h2 className="settings__title">Настройки</h2>
</div>
{isWindows && (
{background.settings?.trayAvailable && (
<div className="settings__group">
<span className="settings__label">Автозапуск</span>
<span className="settings__label">Фоновая работа</span>
<button
type="button"
className="settings-row"
disabled={background.busy}
onClick={() => void background.save({ closeToTray: !background.settings?.closeToTray })}
>
<span>Сворачивать в трей вместо выхода</span>
<span
className={`chip ${background.settings.closeToTray ? 'chip--success' : 'chip--neutral'}`}
>
{background.settings.closeToTray ? 'Включено' : 'Выключено'}
</span>
</button>
{/* На Windows автозапуск приложения идёт в комплекте с автозапуском службы —
одна кнопка ниже (см. followZapretAutoStart в main). */}
{!isWindows && (
<button
type="button"
className="settings-row"
disabled={background.busy}
onClick={() =>
void background.save({ launchAtLogin: !background.settings?.launchAtLogin })
}
>
<span>Запускать приложение вместе с системой</span>
<span
className={`chip ${background.settings.launchAtLogin ? 'chip--success' : 'chip--neutral'}`}
>
{background.settings.launchAtLogin ? 'Включён' : 'Выключен'}
</span>
</button>
)}
{background.error && <p className="list-editor__error">{background.error}</p>}
<p className="list-editor__hint">
{isWindows
? 'Закрытое окно прячется в трей, обход продолжает работать. Включить и ' +
'выключить его можно одним кликом по значку.'
: 'Приложение открывается свёрнутым в трей и обход можно включить одним кликом ' +
'по значку. Это не то же самое, что автозапуск обхода ниже: тот работает ' +
'вообще без запущенного приложения.'}
</p>
</div>
)}
{supportsAutoStart && (
<div className="settings__group">
<span className="settings__label">{isWindows ? 'Автозапуск' : 'Автозапуск обхода'}</span>
<button
type="button"
className="settings-row"
onClick={() => void onSetAutoStart(!status?.autoStart)}
>
<span>Запускать вместе с Windows</span>
<span>Запускать вместе с {isWindows ? 'Windows' : 'macOS'}</span>
<span className={`chip ${status?.autoStart ? 'chip--success' : 'chip--neutral'}`}>
{status?.autoStart ? 'Включён' : 'Выключен'}
</span>
</button>
<p className="list-editor__hint">
Меняется через запрос прав администратора. Если выключить обход кнопкой на главном
экране, при включённом автозапуске он вернётся не раньше следующей перезагрузки.
{isWindows
? 'Обход включается при загрузке Windows, а SenBoost открывается свёрнутым в ' +
'трей, без окна. Меняется через запрос прав администратора. Если выключить ' +
'обход кнопкой на главном экране, при включённом автозапуске он вернётся ' +
'не раньше следующей перезагрузки.'
: 'Меняется через пароль администратора. Действует, пока обход включён: ' +
'выключение кнопкой на главном экране полностью снимает установку — ' +
'автозапуск снова понадобится включить вместе с обходом.'}
</p>
</div>
)}
@@ -87,26 +144,6 @@ export default function SettingsView({
<span className="settings-row__count">{list.entries.length} ›</span>
</button>
))}
{/* --hostlist-auto есть только в darwin-профилях (см. strategies.ts) — ни одна из 22
стратегий Flowseal (Windows и Linux) его не использует, счётчик там всегда пуст. */}
{isDarwin && (
<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">
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from 'react'
import type { BackgroundSettings } from '@shared/ipc-contract'
/**
* `ipcRenderer.invoke` заворачивает сообщение из main в «Error invoking remote method
* 'app:background-set': Error: …» — пользователю нужен только текст, который написали мы.
*/
function readableError(cause: unknown): string {
const message = cause instanceof Error ? cause.message : String(cause)
return message.replace(/^Error invoking remote method '[^']*':\s*(Error:\s*)?/, '')
}
/**
* Трей и автозапуск приложения, в синхроне с main-процессом. Тот же паттерн, что и
* `useZapretLists`, с одним отличием: `launchAtLogin` меняет систему (планировщик задач,
* объекты входа, каталог автозапуска) и может не примениться — текст ошибки возвращаем,
* чтобы настройки показали его рядом с переключателем, а не проглотили.
*/
export function useBackgroundSettings(): {
settings: BackgroundSettings | null
save: (patch: Partial<BackgroundSettings>) => Promise<void>
error: string | null
busy: boolean
} {
const [settings, setSettings] = useState<BackgroundSettings | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
void window.api.getBackgroundSettings().then(setSettings)
}, [])
const save = useCallback(async (patch: Partial<BackgroundSettings>) => {
setBusy(true)
setError(null)
try {
setSettings(await window.api.setBackgroundSettings(patch))
} catch (cause) {
setError(readableError(cause))
// Переключатель мог уже показать новое значение — возвращаем к тому, что в системе.
setSettings(await window.api.getBackgroundSettings())
} finally {
setBusy(false)
}
}, [])
return { settings, save, error, busy }
}
+1 -6
View File
@@ -14,7 +14,6 @@ export function useZapret(): {
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
@@ -54,10 +53,6 @@ export function useZapret(): {
}
}, [])
const setSystemProxy = useCallback(async (enabled: boolean) => {
setStatus(await window.api.setZapretSystemProxy(enabled))
}, [])
const setStrategy = useCallback(async (strategyId: string) => {
setBusy(true)
try {
@@ -76,5 +71,5 @@ export function useZapret(): {
}
}, [])
return { status, strategies, log, start, stop, setSystemProxy, setStrategy, setAutoStart, busy }
return { status, strategies, log, start, stop, setStrategy, setAutoStart, busy }
}
+4 -16
View File
@@ -2,29 +2,22 @@ import { useCallback, useEffect, useState } from 'react'
import type { ZapretList, ZapretListId } from '@shared/ipc-contract'
/**
* Списки сайтов + журнал автообнаружения, в синхроне с main-процессом.
* Тот же паттерн, что и `useZapret`/`useTheme`.
* Списки сайтов, в синхроне с 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]) => {
void window.api.getZapretLists().then((nextLists) => {
setLists(nextLists)
setAutoHostlist(nextAuto)
setLoading(false)
}
)
})
}, [])
const save = useCallback(async (id: ZapretListId, entries: string[]) => {
@@ -39,10 +32,5 @@ export function useZapretLists(): {
return updated
}, [])
const clearAuto = useCallback(async () => {
await window.api.clearZapretAutoHostlist()
setAutoHostlist([])
}, [])
return { lists, autoHostlist, save, reset, clearAuto, loading }
return { lists, save, reset, loading }
}
+43 -17
View File
@@ -19,18 +19,41 @@ export interface ThemeState {
shouldUseDarkColors: boolean
}
/**
* Фоновая работа: трей + автозапуск самого приложения.
*
* Не путать с `ZapretStatus.autoStart` — тот про автозапуск обхода (служба Windows,
* LaunchDaemon macOS) вообще без приложения.
*/
export interface BackgroundSettings {
/** Закрытие окна прячет его в трей, а не завершает приложение. */
closeToTray: boolean
/**
* Приложение запускается вместе с системой, сразу свёрнутым в трей. На Windows только
* для чтения: следует за `ZapretStatus.autoStart` (см. `followZapretAutoStart`).
*/
launchAtLogin: boolean
/**
* Иконка в трее реально создалась. На Linux без AppIndicator трея нет — тогда прятать
* туда окно нельзя, и всю группу настроек показывать бессмысленно.
*/
trayAvailable: boolean
}
/** Состояние обхода блокировок. */
export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'
/**
* Чем именно обходим на этой платформе:
* • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны;
* Чем именно обходим на этой платформе — на всех трёх один и тот же движок уровня пакетов
* (форки zapret/nfqws), запросы прав отличаются:
* • `utunws-pf` — macOS: LaunchDaemon (`utunws` через utun+BPF), пароль администратора
* на каждое включение/выключение (`osascript`);
* • `winws-service` — Windows: служба с драйвером WinDivert;
* • `nfqws-nftables` — Linux: демон nfqws + правила nftables (NFQUEUE), root через pkexec
* на каждое включение — ни службы, ни автозапуска здесь нет;
* на каждое включение;
* • `unsupported` — платформа без поддержки (не macOS/Windows/Linux).
*/
export type ZapretBackend = 'tpws-socks' | 'winws-service' | 'nfqws-nftables' | 'unsupported'
export type ZapretBackend = 'utunws-pf' | 'winws-service' | 'nfqws-nftables' | 'unsupported'
export interface ZapretStatus {
state: ZapretState
@@ -41,13 +64,9 @@ export interface ZapretStatus {
startedAt: string | null
/** Текст последней ошибки — показываем пользователю как есть. */
error: string | null
/** macOS: адрес socks5-прокси, пока обход работает. На Linux/Windows всегда null. */
socksAddress: string | null
/** macOS: прописан ли прокси в системных настройках сети. На Linux/Windows не используется. */
systemProxyApplied: boolean
/** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */
serviceInstalled: boolean
/** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). На Linux автозапуска нет. */
/** macOS/Windows: автозапуск с системой без запущенного приложения. На Linux не используется. */
autoStart: boolean
}
@@ -114,27 +133,34 @@ export interface IpcHandlers {
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
'app:uninstall': () => void
'app:background-get': () => BackgroundSettings
/**
* Меняет переданные поля и возвращает состояние целиком. `launchAtLogin` идёт в систему
* (планировщик задач, SMAppService, каталог автозапуска) и может не примениться — тогда
* канал отвечает ошибкой, а не молча возвращает старое значение.
*/
'app:background-set': (patch: Partial<BackgroundSettings>) => BackgroundSettings
'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), на Linux
* перезапускает nfqws и правила nftables (потребуется пароль через pkexec).
* переустанавливает LaunchDaemon с utunws (потребуется пароль администратора), на Windows
* перенастраивает службу (потребуется UAC), на Linux перезапускает nfqws и правила
* nftables (потребуется пароль через pkexec).
*/
'zapret:set-strategy': (strategyId: string) => ZapretStatus
/** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */
/**
* macOS/Windows: автозапуск с системой; требует пароль администратора/UAC. На Windows
* вместе со службой при входе поднимается и приложение, свёрнутым в трей.
*/
'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
/**
* Запустить проверку соединения и сразу вернуть список проверяемых сайтов — сами
+1 -1
View File
@@ -3,4 +3,4 @@
* в настройках (`SettingsView.tsx`), и приветственное окно при первом запуске
* (`src/main/first-run.ts`).
*/
export const GITHUB_URL = 'https://github.com/maeneko/rknboost'
export const GITHUB_URL = 'https://github.com/maeneko/senboost'