Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8a187073 | ||
|
|
781d8a574f | ||
|
|
30248656a8 | ||
|
|
c71a27c9a3 | ||
|
|
ebb9191d6f | ||
|
|
6c0509e090 | ||
|
|
2df1c00516 | ||
|
|
0842c4d4a5 | ||
|
|
88f8819570 | ||
|
|
9288fc95cb | ||
|
|
beced42f2f | ||
|
|
e8f03a2f86 | ||
|
|
cb499a09d1 | ||
|
|
563d8b7292 |
@@ -34,6 +34,10 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# fetch-depth: 0 — шагу «Описание релиза» ниже нужны вся история и теги,
|
||||||
|
# чтобы найти предыдущий vX.Y.Z и собрать список коммитов после него.
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
@@ -45,20 +49,235 @@ jobs:
|
|||||||
# electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст.
|
# electron-builder.yml просто копирует resources/zapret, а он у чистого чекаута пуст.
|
||||||
- run: node scripts/fetch-zapret.mjs
|
- run: node scripts/fetch-zapret.mjs
|
||||||
|
|
||||||
|
# electron-builder заливает артефакты несколькими параллельными publisher'ами внутри
|
||||||
|
# этой же джобы, а с приходом Linux-сборки (отдельная джоба ниже) к этому добавилась
|
||||||
|
# ещё и гонка МЕЖДУ джобами: обе проверяют, есть ли релиз с этим тегом, обе видят «нет»
|
||||||
|
# и обе лезут его создавать — вторая падает с 422 already_exists (на v0.5.0 так
|
||||||
|
# потерялся сам .exe, когда конкурентами были два publisher'а внутри одной джобы).
|
||||||
|
# `|| true` — проигравшая гонку джоба не должна из-за этого падать целиком: релиз
|
||||||
|
# к моменту upload всё равно уже существует, просто создала его не она.
|
||||||
|
- 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 }}
|
||||||
|
|
||||||
# Подпись не настроена: артефакты неподписанные.
|
# Подпись не настроена: артефакты неподписанные.
|
||||||
# Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env.
|
# Чтобы подписывать — добавьте секреты CSC_LINK, CSC_KEY_PASSWORD и передайте их в env.
|
||||||
#
|
#
|
||||||
# На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder публикует .exe в GitHub
|
# На пуш тега vX.Y.Z (см. on.push.tags выше) electron-builder заливает .exe в релиз,
|
||||||
# Release с этим тегом (создаёт его, если нет), используя repository из package.json.
|
# заготовленный шагом выше, используя repository из package.json.
|
||||||
# На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже.
|
# На push в main/PR публикации нет — только упаковка и загрузка в Artifacts ниже.
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npx --no-install electron-vite build && npx --no-install electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
|
run: npx --no-install electron-vite build && npx --no-install electron-builder --win --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# differentialPackage: false и publishAutoUpdate: false в electron-builder.yml убирают
|
||||||
|
# .blockmap и latest.yml из ассетов (electron-updater в приложении нет — они были бы
|
||||||
|
# мёртвым грузом). В релизе должен остаться только .exe и контрольная сумма к нему.
|
||||||
|
- name: Контрольная сумма
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
exe="$(ls release/*.exe)"
|
||||||
|
md5sum "$exe" | awk '{ print $1 }' > "$exe.md5"
|
||||||
|
gh release upload "$GITHUB_REF_NAME" "$exe.md5" --clobber
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Релиз создан с пустым описанием, поэтому список изменений дописываем сами:
|
||||||
|
# коммиты между предыдущим тегом vX.Y.Z и текущим. Только в этой джобе — если то же
|
||||||
|
# самое сделает ещё и Linux-сборка, они просто перезапишут результат друг друга.
|
||||||
|
- name: Описание релиза
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
# Предыдущий тег vX.Y.Z, начиная от родителя текущего. Пусто, если релиз первый.
|
||||||
|
PREV="$(git describe --tags --abbrev=0 --match 'v*' "$TAG^" 2>/dev/null || true)"
|
||||||
|
NOTES="$RUNNER_TEMP/release-notes.md"
|
||||||
|
{
|
||||||
|
if [ -n "$PREV" ]; then
|
||||||
|
echo "## Изменения с $PREV"
|
||||||
|
echo
|
||||||
|
git log --no-merges --pretty=format:'- %s (%h)' "$PREV..$TAG"
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
echo "**Полный diff:** https://github.com/$GITHUB_REPOSITORY/compare/$PREV...$TAG"
|
||||||
|
else
|
||||||
|
echo "## Изменения"
|
||||||
|
echo
|
||||||
|
git log --no-merges --pretty=format:'- %s (%h)' "$TAG"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
} > "$NOTES"
|
||||||
|
gh release edit "$TAG" --notes-file "$NOTES"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: rknboost-windows
|
name: senboost-windows
|
||||||
path: release/*.exe
|
path: release/*.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
name: Сборка (Linux)
|
||||||
|
needs: check
|
||||||
|
runs-on: ubuntu-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
|
||||||
|
|
||||||
|
# Без этого шага сборка уезжает без nfqws: extraResources в electron-builder.yml
|
||||||
|
# просто копирует resources/zapret, а он у чистого чекаута пуст.
|
||||||
|
- run: node scripts/fetch-zapret.mjs
|
||||||
|
|
||||||
|
# Та же гонка «кто первый создаст релиз», что и в джобе Windows выше — эта джоба
|
||||||
|
# с ней конкурирует наравне (обе стартуют параллельно после 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 }}
|
||||||
|
|
||||||
|
# Подпись/нотаризация не при чём здесь — это Linux, .AppImage и .deb неподписанные
|
||||||
|
# в любом случае. На push в main/PR публикации нет — только упаковка и Artifacts ниже.
|
||||||
|
- name: Build
|
||||||
|
run: npx --no-install electron-vite build && npx --no-install electron-builder --linux --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Тот же мотив, что у Windows-джобы: в релизе должны остаться только сами артефакты
|
||||||
|
# и контрольная сумма к каждому — никаких .blockmap/latest-linux.yml.
|
||||||
|
- name: Контрольная сумма
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(release/*.AppImage release/*.deb)
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "не нашлось ни .AppImage, ни .deb в release/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
md5sum "$file" | awk '{ print $1 }' > "$file.md5"
|
||||||
|
gh release upload "$GITHUB_REF_NAME" "$file.md5" --clobber
|
||||||
|
done
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RKNboost
|
# SenBoost
|
||||||
|
|
||||||
очередная обертка под zapret
|
очередная обертка под zapret
|
||||||
|
|
||||||
@@ -21,11 +21,56 @@ npm run typecheck # tsc для node- и web-частей отдельно
|
|||||||
npm run build:unpack # быстрая проверка: приложение без установщика (release/*-unpacked)
|
npm run build:unpack # быстрая проверка: приложение без установщика (release/*-unpacked)
|
||||||
npm run build:mac # .dmg + .zip (x64 + arm64)
|
npm run build:mac # .dmg + .zip (x64 + arm64)
|
||||||
npm run build:win # NSIS .exe (x64; arm64 не собираем — нет подписанного WinDivert)
|
npm run build:win # NSIS .exe (x64; arm64 не собираем — нет подписанного WinDivert)
|
||||||
npm run build:linux # AppImage + .deb
|
npm run build:linux # AppImage + .deb (x64)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Обход блокировок по платформам
|
||||||
|
|
||||||
|
| Платформа | Движок | Права | Статус |
|
||||||
|
| --------- | ------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------ |
|
||||||
|
| 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, спасибо за столь хороший инструмент
|
- **[bol-van/zapret](https://github.com/bol-van/zapret)** — сам движок zapret, спасибо за столь хороший инструмент
|
||||||
- **[Flowseal/zapret-discord-youtube](https://github.com/Flowseal/zapret-discord-youtube)** —
|
- **[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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 633 KiB |
@@ -1,4 +1,4 @@
|
|||||||
; Служба rknboost-zapret не ставится этим установщиком — её создаёт и настраивает само
|
; Служба senboost-zapret не ставится этим установщиком — её создаёт и настраивает само
|
||||||
; приложение при первом включении обхода (src/main/zapret/service.win32.ts), потому что
|
; приложение при первом включении обхода (src/main/zapret/service.win32.ts), потому что
|
||||||
; аргументы winws зависят от выбранной пользователем стратегии. Но снять её при удалении
|
; аргументы winws зависят от выбранной пользователем стратегии. Но снять её при удалении
|
||||||
; приложения обязаны мы: иначе в системе остаётся служба, указывающая на удалённый winws.exe.
|
; приложения обязаны мы: иначе в системе остаётся служба, указывающая на удалённый winws.exe.
|
||||||
@@ -8,9 +8,13 @@
|
|||||||
; Поэтому именно для этого шага отдельно запрашиваем UAC (тем же приёмом Start-Process
|
; Поэтому именно для этого шага отдельно запрашиваем UAC (тем же приёмом Start-Process
|
||||||
; -Verb RunAs, что и src/main/zapret/service.win32.ts), а не поднимаем весь деинсталлятор —
|
; -Verb RunAs, что и src/main/zapret/service.win32.ts), а не поднимаем весь деинсталлятор —
|
||||||
; удаление файлов приложения из %LocalAppData% в повышении не нуждается.
|
; удаление файлов приложения из %LocalAppData% в повышении не нуждается.
|
||||||
|
;
|
||||||
|
; Тем же повышенным вызовом снимаем задачу автозапуска приложения («SenBoost Autostart»,
|
||||||
|
; src/main/app-autostart.win32.ts): она создана с правами администратора и после удаления
|
||||||
|
; приложения указывала бы на несуществующий exe.
|
||||||
|
|
||||||
!macro customUnInstall
|
!macro customUnInstall
|
||||||
DetailPrint "Останавливаю службу обхода блокировок..."
|
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 {}"`
|
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
|
Pop $0
|
||||||
!macroend
|
!macroend
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
appId: com.rknboost.app
|
appId: com.senboost.app
|
||||||
productName: RKNboost
|
productName: SenBoost
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
output: release
|
output: release
|
||||||
|
|
||||||
|
# releaseType: release — публиковать релиз сразу, без черновика (draft — поведение по
|
||||||
|
# умолчанию у electron-builder). С черновиком параллельная загрузка нескольких файлов иногда
|
||||||
|
# создаёт два черновика с одним и тем же тегом вместо одного релиза.
|
||||||
|
#
|
||||||
|
# publishAutoUpdate: false — не заливать latest.yml. Это метаданные для electron-updater,
|
||||||
|
# которого в приложении нет (обновляется пользователь вручную, скачивая новый инсталлятор
|
||||||
|
# со страницы релиза) — файл был бы мёртвым грузом в ассетах.
|
||||||
|
publish:
|
||||||
|
provider: github
|
||||||
|
releaseType: release
|
||||||
|
publishAutoUpdate: false
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- '!**/.vscode/*'
|
- '!**/.vscode/*'
|
||||||
- '!src/*'
|
- '!src/*'
|
||||||
@@ -12,22 +24,38 @@ files:
|
|||||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}'
|
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,yarn.lock}'
|
||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
- '!.github/*'
|
- '!.github/*'
|
||||||
# zapret идёт отдельно через extraResources ниже — не дублировать его в app.asar.
|
# zapret и linux-helper/darwin-helper идут отдельно через extraResources ниже — не
|
||||||
|
# дублировать их в app.asar.
|
||||||
- '!resources/zapret/**'
|
- '!resources/zapret/**'
|
||||||
|
- '!resources/linux-helper/**'
|
||||||
|
- '!resources/darwin-helper/**'
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- 'resources/**'
|
- 'resources/**'
|
||||||
|
|
||||||
# Бинарники zapret (npm run zapret:fetch) кладём рядом с приложением, а не в asar —
|
# Бинарники zapret (npm run zapret:fetch) кладём рядом с приложением, а не в asar —
|
||||||
# это исполняемые файлы, которые main-процесс запускает через spawn.
|
# это исполняемые файлы, которые main-процесс запускает через spawn.
|
||||||
|
#
|
||||||
|
# Общие для всех платформ куски (списки, fake-пакеты, лицензии zapret/Flowseal) — здесь;
|
||||||
|
# платформенные бинарники — в extraResources соответствующего раздела (win/mac/linux) ниже.
|
||||||
|
# extraResources платформы ДОБАВЛЯЕТСЯ к этому списку, а не заменяет его (проверено по
|
||||||
|
# исходникам electron-builder, app-builder-lib/fileMatcher.js) — так Windows-сборка не везёт
|
||||||
|
# nfqws и Mach-O tpws, а Linux-сборка не везёт WinDivert и winws.exe.
|
||||||
extraResources:
|
extraResources:
|
||||||
|
- from: resources/zapret/lists
|
||||||
|
to: zapret/lists
|
||||||
|
- from: resources/zapret/fakes
|
||||||
|
to: zapret/fakes
|
||||||
- from: resources/zapret
|
- from: resources/zapret
|
||||||
to: zapret
|
to: zapret
|
||||||
|
filter:
|
||||||
|
- 'LICENSE.zapret.txt'
|
||||||
|
- 'LICENSE.flowseal.txt'
|
||||||
|
|
||||||
win:
|
win:
|
||||||
executableName: RKNboost
|
executableName: SenBoost
|
||||||
# Приложение запрашивает права администратора при КАЖДОМ запуске (манифест exe).
|
# Приложение запрашивает права администратора при КАЖДОМ запуске (манифест exe).
|
||||||
# Внимание на компромисс: установка при этом остаётся per-user, то есть exe лежит в
|
# Внимание на компромисс: установка при этом остаётся per-user, то есть exe лежит в
|
||||||
# %LocalAppData%\Programs\RKNboost — папке, доступной на запись обычному пользователю.
|
# %LocalAppData%\Programs\SenBoost — папке, доступной на запись обычному пользователю.
|
||||||
# Значит подмена этого exe даёт злоумышленнику администратора с подтверждения самого
|
# Значит подмена этого exe даёт злоумышленнику администратора с подтверждения самого
|
||||||
# пользователя. Это осознанный выбор владельца проекта; безопасный вариант — вернуть
|
# пользователя. Это осознанный выбор владельца проекта; безопасный вариант — вернуть
|
||||||
# perMachine: true, чтобы exe лежал в защищённом от записи Program Files.
|
# perMachine: true, чтобы exe лежал в защищённом от записи Program Files.
|
||||||
@@ -38,6 +66,13 @@ win:
|
|||||||
# в поставке zapret) — arm64-инсталлятор был бы заведомо нерабочим.
|
# в поставке zapret) — arm64-инсталлятор был бы заведомо нерабочим.
|
||||||
- target: nsis
|
- target: nsis
|
||||||
arch: [x64]
|
arch: [x64]
|
||||||
|
extraResources:
|
||||||
|
- from: resources/zapret/win32
|
||||||
|
to: zapret/win32
|
||||||
|
- from: resources/zapret
|
||||||
|
to: zapret
|
||||||
|
filter:
|
||||||
|
- 'LICENSE.WinDivert.txt'
|
||||||
nsis:
|
nsis:
|
||||||
# Быстрая установка per-user, без UAC и без мастера: ставится в %LocalAppData%, куда
|
# Быстрая установка per-user, без UAC и без мастера: ставится в %LocalAppData%, куда
|
||||||
# обычный пользователь и так пишет без повышения прав. Раньше здесь стоял perMachine —
|
# обычный пользователь и так пишет без повышения прав. Раньше здесь стоял perMachine —
|
||||||
@@ -47,7 +82,11 @@ nsis:
|
|||||||
# в src/main/zapret/service.win32.ts) — сама установка приложения per-user безопасна.
|
# в src/main/zapret/service.win32.ts) — сама установка приложения per-user безопасна.
|
||||||
oneClick: true
|
oneClick: true
|
||||||
perMachine: false
|
perMachine: false
|
||||||
# Удалять %APPDATA%\rknboost (списки сайтов, метка первого запуска) вместе с приложением.
|
# Без electron-updater (см. publishAutoUpdate выше) differential-обновления не нужны —
|
||||||
|
# differentialPackage: false отключает генерацию .blockmap, который иначе всегда
|
||||||
|
# заливался бы в ассеты релиза рядом с инсталлятором.
|
||||||
|
differentialPackage: false
|
||||||
|
# Удалять %APPDATA%\senboost (списки сайтов, метка первого запуска) вместе с приложением.
|
||||||
# По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после
|
# По умолчанию electron-builder этого НЕ делает, и данные оставались бы в системе после
|
||||||
# удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит
|
# удаления. Обновление приложения под это не попадает — в шаблоне деинсталлятора стоит
|
||||||
# проверка `ifNot isUpdated`, так что отредактированные списки переживают обновление.
|
# проверка `ifNot isUpdated`, так что отредактированные списки переживают обновление.
|
||||||
@@ -60,6 +99,10 @@ nsis:
|
|||||||
|
|
||||||
mac:
|
mac:
|
||||||
category: public.app-category.productivity
|
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:
|
||||||
- target: dmg
|
- target: dmg
|
||||||
arch: [x64, arm64]
|
arch: [x64, arm64]
|
||||||
@@ -68,24 +111,70 @@ mac:
|
|||||||
entitlements: build/entitlements.mac.plist
|
entitlements: build/entitlements.mac.plist
|
||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
hardenedRuntime: true
|
hardenedRuntime: true
|
||||||
# tpws — сторонний Mach-O внутри .app; hardened runtime требует, чтобы electron-builder
|
# utunws — сторонний Mach-O внутри .app; hardened runtime требует, чтобы electron-builder
|
||||||
# подписал и его тем же Developer ID, иначе Gatekeeper убьёт процесс при первом spawn.
|
# подписал и его тем же Developer ID, иначе Gatekeeper убьёт процесс при первом spawn.
|
||||||
binaries:
|
binaries:
|
||||||
- Contents/Resources/zapret/darwin/tpws
|
- Contents/Resources/zapret/darwin/utunws
|
||||||
# Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает.
|
# Подпись и нотаризация выключены: без Apple Developer ID сборка иначе падает.
|
||||||
# Как включить — см. раздел «Подпись» в README.md.
|
# Как включить — см. раздел «Подпись» в README.md.
|
||||||
notarize: false
|
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:
|
dmg:
|
||||||
artifactName: ${name}-${version}-${arch}.${ext}
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
|
# Тот же мотив, что differentialPackage: false у nsis — electron-updater'а в приложении
|
||||||
|
# нет, и .blockmap рядом с .dmg был бы мёртвым грузом в ассетах релиза. У mac-таргета zip
|
||||||
|
# такого выключателя нет (isWriteUpdateInfo зашит в macPackager), поэтому
|
||||||
|
# `*-mac.zip.blockmap` всё равно соберётся — см. комментарий в джобе «Сборка (macOS)».
|
||||||
|
writeUpdateInfo: false
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
|
executableName: senboost
|
||||||
|
# desktopName в package.json + syncDesktopName — иначе Electron не свяжет открытые окна
|
||||||
|
# с .desktop-записью (app_id/WM_CLASS), и часть окружений не покажет иконку в панели задач.
|
||||||
|
syncDesktopName: true
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- target: AppImage
|
||||||
- deb
|
arch: [x64]
|
||||||
maintainer: RKNboost
|
- target: deb
|
||||||
category: Utility
|
arch: [x64]
|
||||||
synopsis: Кроссплатформенное приложение на Electron
|
maintainer: SenBoost
|
||||||
|
category: Network
|
||||||
|
synopsis: Обход блокировок через zapret
|
||||||
|
extraResources:
|
||||||
|
- from: resources/zapret/linux
|
||||||
|
to: zapret/linux
|
||||||
|
# senboost-helper.sh — привилегированный помощник (nfqws.linux.ts зовёт его через
|
||||||
|
# pkexec). Не из resources/zapret: тот качает fetch-zapret.mjs, а этот файл — свой,
|
||||||
|
# лежит в репозитории (см. src/main/zapret/paths.ts:bundledHelperPath()).
|
||||||
|
- from: resources/linux-helper
|
||||||
|
to: linux-helper
|
||||||
appImage:
|
appImage:
|
||||||
artifactName: ${name}-${version}-${arch}.${ext}
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
|
deb:
|
||||||
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
|
# electron-builder ЗАМЕНЯЕТ этим списком свои дефолтные зависимости Electron, а не
|
||||||
|
# дополняет их (проверено по исходникам, app-builder-lib/targets/FpmTarget.js) — потерять
|
||||||
|
# хотя бы одну из первых девяти значило бы получить .deb, который ставится и не запускается.
|
||||||
|
# nftables и polkit — уже наши, для nfqws/pkexec.
|
||||||
|
depends:
|
||||||
|
- libgtk-3-0
|
||||||
|
- libnotify4
|
||||||
|
- libnss3
|
||||||
|
- libxss1
|
||||||
|
- libxtst6
|
||||||
|
- xdg-utils
|
||||||
|
- libatspi2.0-0
|
||||||
|
- libuuid1
|
||||||
|
- libsecret-1-0
|
||||||
|
- nftables
|
||||||
|
- policykit-1
|
||||||
|
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rknboost",
|
"name": "senboost",
|
||||||
"version": "0.4.0",
|
"version": "0.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rknboost",
|
"name": "senboost",
|
||||||
"version": "0.4.0",
|
"version": "0.6.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "rknboost",
|
"name": "senboost",
|
||||||
"version": "0.4.0",
|
"version": "0.6.1",
|
||||||
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
|
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "RKNboost",
|
"desktopName": "senboost.desktop",
|
||||||
|
"author": "SenBoost",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/maeneko/rknboost"
|
"url": "https://github.com/maeneko/senboost"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 633 KiB |
@@ -0,0 +1,175 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Привилегированный помощник обхода блокировок на Linux — единственное, что SenBoost
|
||||||
|
# запускает через `pkexec`. Не редактировать под bash-специфику: /bin/sh на Debian/Ubuntu —
|
||||||
|
# это dash, а не bash.
|
||||||
|
#
|
||||||
|
# Зовётся из src/main/zapret/nfqws.linux.ts ровно двумя способами:
|
||||||
|
# pkexec <этот файл> start <nfqws> <strategy.cfg> <tcp-порты> <udp-порты>
|
||||||
|
# pkexec <этот файл> stop
|
||||||
|
#
|
||||||
|
# Всё, что можно решить без root (выбор стратегии, генерация strategy.cfg, проверка
|
||||||
|
# `nfqws --dry-run`), уже сделано на стороне приложения — сюда попадают только готовые пути
|
||||||
|
# и уже провалидированные строки портов (см. nfqwsConfig() в strategies.ts: там же формат
|
||||||
|
# портов проверен регуляркой, здесь ему просто доверяем). Раздельные пути/аргументы вместо
|
||||||
|
# одной строки — чтобы этот скрипт не собирал команду nft из чего-то похожего на shell-код.
|
||||||
|
#
|
||||||
|
# Коды выхода — намеренно 3..6, чтобы не путаться с 126/127 самого pkexec (отказ в правах /
|
||||||
|
# команда не найдена) при разборе результата на стороне приложения.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
RUN_DIR=/run/senboost
|
||||||
|
PIDFILE="$RUN_DIR/nfqws.pid"
|
||||||
|
CONFIG="$RUN_DIR/strategy.cfg"
|
||||||
|
ERRLOG="$RUN_DIR/nfqws.err"
|
||||||
|
TABLE_FAMILY=inet
|
||||||
|
TABLE_NAME=senboost
|
||||||
|
# Держать в синхроне с NFQUEUE_NUM в nfqws.linux.ts (используется там же для --dry-run).
|
||||||
|
QUEUE_NUM=220
|
||||||
|
FW_MARK=0x40000000
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "senboost-helper: $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# pkexec запускает с урезанным PATH (санирует окружение) — sbin, где обычно лежит nft,
|
||||||
|
# в нём может не быть. Ищем явно по стандартным местам, а не полагаемся на PATH.
|
||||||
|
find_nft() {
|
||||||
|
for candidate in /usr/sbin/nft /sbin/nft /usr/local/sbin/nft /usr/bin/nft; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if command -v nft >/dev/null 2>&1; then
|
||||||
|
command -v nft
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сносит и таблицу, и демона. Идёт первым шагом `start` (самолечение: следующий успешный
|
||||||
|
# запуск убирает любой мусор от аварийно прерванной прошлой сессии) и отдельной командой
|
||||||
|
# `stop`. Всегда завершается успешно — «уже остановлено» не ошибка, тот же принцип, что и
|
||||||
|
# у stopService() на Windows.
|
||||||
|
cmd_stop() {
|
||||||
|
nft="${1:-}"
|
||||||
|
|
||||||
|
if [ -f "$PIDFILE" ]; then
|
||||||
|
pid="$(cat "$PIDFILE" 2>/dev/null || true)"
|
||||||
|
# /proc/<pid>/comm — это ровно "nfqws\n" (обрезано до 15 символов ядром, но nfqws
|
||||||
|
# короче), сверяем, чтобы не убить чужой процесс, переиспользовавший тот же pid.
|
||||||
|
if [ -n "$pid" ] && [ -r "/proc/$pid/comm" ] && [ "$(cat "/proc/$pid/comm" 2>/dev/null)" = "nfqws" ]; then
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
i=0
|
||||||
|
while [ -d "/proc/$pid" ] && [ "$i" -lt 30 ]; do
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if [ -d "/proc/$pid" ]; then
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -f "$PIDFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$CONFIG" "$ERRLOG"
|
||||||
|
|
||||||
|
if [ -z "$nft" ]; then
|
||||||
|
nft="$(find_nft || true)"
|
||||||
|
fi
|
||||||
|
if [ -n "$nft" ] && "$nft" list table "$TABLE_FAMILY" "$TABLE_NAME" >/dev/null 2>&1; then
|
||||||
|
"$nft" delete table "$TABLE_FAMILY" "$TABLE_NAME" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_start() {
|
||||||
|
nfqws_bin="$1"
|
||||||
|
config_src="$2"
|
||||||
|
tcp_ports="$3"
|
||||||
|
udp_ports="$4"
|
||||||
|
|
||||||
|
nft="$(find_nft || true)"
|
||||||
|
if [ -z "$nft" ]; then
|
||||||
|
log "не найдена утилита nft (пакет nftables)"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# На всякий случай предупреждаем, если параллельно уже висит другой zapret со своей
|
||||||
|
# таблицей/очередью — двойной приём одного трафика в две очереди работать не будет,
|
||||||
|
# но это не наша таблица, сносить её самовольно нельзя.
|
||||||
|
if "$nft" list table inet zapret >/dev/null 2>&1 || "$nft" list table inet zapretunix >/dev/null 2>&1; then
|
||||||
|
log "внимание: обнаружена таблица nftables другого zapret-инструмента — возможен конфликт очередей"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmd_stop "$nft"
|
||||||
|
|
||||||
|
mkdir -p "$RUN_DIR"
|
||||||
|
chmod 0755 "$RUN_DIR"
|
||||||
|
|
||||||
|
# strategy.cfg в /run — источник маркера стратегии для LinuxEngine.sync(): читается без
|
||||||
|
# root (0644), первая строка — тот же --comment=senboost:<id>:<hash>, что и на Windows.
|
||||||
|
cp "$config_src" "$CONFIG"
|
||||||
|
chmod 0644 "$CONFIG"
|
||||||
|
|
||||||
|
if ! "$nft" add table "$TABLE_FAMILY" "$TABLE_NAME"; then
|
||||||
|
log "не удалось создать таблицу nftables"
|
||||||
|
cmd_stop "$nft"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
"$nft" add chain "$TABLE_FAMILY" "$TABLE_NAME" post '{ type filter hook postrouting priority mangle; }'
|
||||||
|
"$nft" add chain "$TABLE_FAMILY" "$TABLE_NAME" pre '{ type filter hook prerouting priority filter; }'
|
||||||
|
|
||||||
|
# bypass обязателен: без него при завершении/падении nfqws весь трафик на эти порты
|
||||||
|
# молча дропался бы. С bypass ядро принимает пакет, если очередь никто не слушает —
|
||||||
|
# то есть отключение обхода (или его крах) не рвёт пользователю сеть.
|
||||||
|
ok=1
|
||||||
|
"$nft" add rule "$TABLE_FAMILY" "$TABLE_NAME" post \
|
||||||
|
meta mark and $FW_MARK == 0 tcp dport "{$tcp_ports}" \
|
||||||
|
ct original packets 1-6 queue num $QUEUE_NUM bypass || ok=0
|
||||||
|
"$nft" add rule "$TABLE_FAMILY" "$TABLE_NAME" post \
|
||||||
|
meta mark and $FW_MARK == 0 udp dport "{$udp_ports}" \
|
||||||
|
ct original packets 1-6 queue num $QUEUE_NUM bypass || ok=0
|
||||||
|
"$nft" add rule "$TABLE_FAMILY" "$TABLE_NAME" pre \
|
||||||
|
tcp sport "{$tcp_ports}" \
|
||||||
|
ct reply packets 1-3 queue num $QUEUE_NUM bypass || ok=0
|
||||||
|
|
||||||
|
if [ "$ok" -ne 1 ]; then
|
||||||
|
log "не удалось создать правила nftables"
|
||||||
|
cmd_stop "$nft"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
# nfq_init() у nfqws происходит до daemonize() — если очередь занята, модуля нет в ядре
|
||||||
|
# или не хватает прав, процесс завершится с ненулевым кодом ДО демонизации, и мы это
|
||||||
|
# увидим здесь же, без опроса готовности.
|
||||||
|
if ! "$nfqws_bin" --daemon \
|
||||||
|
--pidfile="$PIDFILE" \
|
||||||
|
--qnum=$QUEUE_NUM \
|
||||||
|
--dpi-desync-fwmark=$FW_MARK \
|
||||||
|
"@$CONFIG" 2>"$ERRLOG"; then
|
||||||
|
log "nfqws не запустился: $(cat "$ERRLOG" 2>/dev/null || true)"
|
||||||
|
cmd_stop "$nft"
|
||||||
|
exit 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
action="${1:-}"
|
||||||
|
case "$action" in
|
||||||
|
start)
|
||||||
|
shift
|
||||||
|
if [ "$#" -ne 4 ]; then
|
||||||
|
log "start ожидает 4 аргумента: <nfqws> <config> <tcp-порты> <udp-порты>"
|
||||||
|
exit 6
|
||||||
|
fi
|
||||||
|
cmd_start "$1" "$2" "$3" "$4"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
cmd_stop ""
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "неизвестная команда: $action (ожидается start|stop)"
|
||||||
|
exit 6
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
After Width: | Height: | Size: 167 B |
|
After Width: | Height: | Size: 284 B |
|
After Width: | Height: | Size: 136 B |
|
After Width: | Height: | Size: 212 B |
@@ -3,13 +3,16 @@
|
|||||||
* Кладёт бинарники и данные zapret в resources/zapret/ — оттуда electron-builder
|
* Кладёт бинарники и данные zapret в resources/zapret/ — оттуда electron-builder
|
||||||
* положит их в сборку через extraResources.
|
* положит их в сборку через extraResources.
|
||||||
*
|
*
|
||||||
* Два источника:
|
* Три источника:
|
||||||
* • bol-van/zapret — движки (tpws, winws.exe + WinDivert), версия пинована и
|
* • bol-van/zapret — движки (winws.exe + WinDivert, nfqws), версия пинована и
|
||||||
* сверяется по sha256sum.txt из релиза;
|
* сверяется по sha256sum.txt из релиза;
|
||||||
* • Flowseal/zapret-discord-youtube — подобранные сообществом списки доменов и
|
* • Flowseal/zapret-discord-youtube — подобранные сообществом списки доменов и
|
||||||
* стратегии winws (переносятся в код генератором `import-flowseal-strategies.mjs`,
|
* стратегии winws (переносятся в код генератором `parser-strategies.mjs`,
|
||||||
* здесь просто данные: списки + fake-пакеты). У Flowseal нет sha256sum.txt —
|
* здесь просто данные: списки + fake-пакеты). У Flowseal нет sha256sum.txt —
|
||||||
* версия фиксируется по sha коммита, на который указывает тег.
|
* версия фиксируется по 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`
|
* Каталог resources/zapret в git не хранится: запустите `npm run zapret:fetch`
|
||||||
* (или просто `npm run build` — он вызывает скрипт сам).
|
* (или просто `npm run build` — он вызывает скрипт сам).
|
||||||
@@ -30,6 +33,16 @@ const WINDIVERT_VERSION = 'v2.2.2'
|
|||||||
const FLOWSEAL_VERSION = '1.10.2'
|
const FLOWSEAL_VERSION = '1.10.2'
|
||||||
const FLOWSEAL_COMMIT = 'dfd8e613b099676cf2aa7b474ee5923801514dec'
|
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 ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const DEST = join(ROOT, 'resources', 'zapret')
|
const DEST = join(ROOT, 'resources', 'zapret')
|
||||||
|
|
||||||
@@ -40,7 +53,7 @@ const PREFIX = `zapret-${ZAPRET_VERSION}`
|
|||||||
|
|
||||||
/** Что забираем из архива bol-van/zapret: путь внутри архива → путь внутри resources/zapret. */
|
/** Что забираем из архива bol-van/zapret: путь внутри архива → путь внутри resources/zapret. */
|
||||||
const FILES = {
|
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/winws.exe': 'win32/x64/winws.exe',
|
||||||
'binaries/windows-x86_64/cygwin1.dll': 'win32/x64/cygwin1.dll',
|
'binaries/windows-x86_64/cygwin1.dll': 'win32/x64/cygwin1.dll',
|
||||||
'binaries/windows-x86_64/WinDivert.dll': 'win32/x64/WinDivert.dll',
|
'binaries/windows-x86_64/WinDivert.dll': 'win32/x64/WinDivert.dll',
|
||||||
@@ -49,7 +62,7 @@ const FILES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Исполняемый бит нужен только там, где мы сами запускаем бинарник. */
|
/** Исполняемый бит нужен только там, где мы сами запускаем бинарник. */
|
||||||
const EXECUTABLE = new Set(['darwin/tpws'])
|
const EXECUTABLE = new Set(['darwin/utunws', 'linux/x64/nfqws'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Списки доменов Flowseal → наши короткие имена (используются и в `lists.ts`, и
|
* Списки доменов Flowseal → наши короткие имена (используются и в `lists.ts`, и
|
||||||
@@ -190,12 +203,51 @@ async function fetchFlowsealData(work) {
|
|||||||
console.log(' LICENSE.flowseal.txt')
|
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() {
|
async function main() {
|
||||||
const work = await mkdtemp(join(tmpdir(), 'zapret-'))
|
const work = await mkdtemp(join(tmpdir(), 'zapret-'))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchZapretEngines(work)
|
await fetchZapretEngines(work)
|
||||||
await fetchFlowsealData(work)
|
await fetchFlowsealData(work)
|
||||||
|
await fetchUtunwsEngine(work)
|
||||||
console.log(`Готово: ${DEST}`)
|
console.log(`Готово: ${DEST}`)
|
||||||
} finally {
|
} finally {
|
||||||
await rm(work, { recursive: true, force: true })
|
await rm(work, { recursive: true, force: true })
|
||||||
@@ -205,7 +257,8 @@ async function main() {
|
|||||||
// Повторная сборка не должна каждый раз ходить в сеть.
|
// Повторная сборка не должна каждый раз ходить в сеть.
|
||||||
if (
|
if (
|
||||||
process.argv.includes('--force') ||
|
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, 'win32', 'x64', 'winws.exe')) ||
|
||||||
!existsSync(join(DEST, 'lists', 'general.txt'))
|
!existsSync(join(DEST, 'lists', 'general.txt'))
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,11 @@ function parseBatFile(filename, content) {
|
|||||||
// Мы передаём argv напрямую в spawn (без шелла) — кавычки внутри значения
|
// Мы передаём argv напрямую в spawn (без шелла) — кавычки внутри значения
|
||||||
// остались бы буквальными символами в имени файла, поэтому убираем везде.
|
// остались бы буквальными символами в имени файла, поэтому убираем везде.
|
||||||
.map((t) => t.replaceAll('"', ''))
|
.map((t) => t.replaceAll('"', ''))
|
||||||
|
// `^!` — экранирование `!` в cmd.exe (в .bat включён enabledelayedexpansion, где `!`
|
||||||
|
// иначе разворачивался бы как переменная). cmd.exe снимает его сам перед запуском
|
||||||
|
// winws.exe; здесь командную строку разбираем руками, и `^` иначе остался бы буквально
|
||||||
|
// в значении. Реальное значение zapret ожидает — `!` («'!' = standard fake» в readme).
|
||||||
|
.map((t) => t.replaceAll('=^!', '=!'))
|
||||||
.map(resolvePlaceholders)
|
.map(resolvePlaceholders)
|
||||||
|
|
||||||
if (tokens.length < 5)
|
if (tokens.length < 5)
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import * as linux from './app-autostart.linux'
|
||||||
|
import * as win32 from './app-autostart.win32'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автозапуск САМОГО приложения вместе с системой — не путать с автозапуском обхода
|
||||||
|
* (`zapret:set-autostart`): тот поднимает службу Windows или LaunchDaemon macOS вообще без
|
||||||
|
* приложения, а этот открывает приложение свёрнутым в трей, чтобы обход можно было включить
|
||||||
|
* и выключить одним кликом.
|
||||||
|
*
|
||||||
|
* Источник истины — сама система (реестр/планировщик, 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 и Linux мы сами передаём `--hidden` в команде автозапуска; на macOS аргументы
|
||||||
|
* задать нельзя (поле `args` у `setLoginItemSettings` только для Windows), поэтому спрашиваем
|
||||||
|
* систему, она ли нас открыла.
|
||||||
|
*/
|
||||||
|
export function shouldStartHidden(): boolean {
|
||||||
|
if (process.argv.includes('--hidden')) return true
|
||||||
|
return process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAtLogin
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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` поднимает его с правами сразу.
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export async function showFirstRunSupportDialog(window: BrowserWindow): Promise<
|
|||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 1,
|
cancelId: 1,
|
||||||
noLink: true,
|
noLink: true,
|
||||||
title: 'Поддержите RKNboost',
|
title: 'Поддержите SenBoost',
|
||||||
message: 'Поддержите проект',
|
message: 'Поддержите проект',
|
||||||
detail: 'Поддержите проект звездочкой на GitHub, и расскажите друзьям :З'
|
detail: 'Поддержите проект звездочкой на GitHub, и расскажите друзьям :З'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app } from 'electron'
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { createWindow } from './window'
|
import { shouldStartHidden } from './app-autostart'
|
||||||
|
import { beginQuit, createWindow, showMainWindow } from './window'
|
||||||
import { showFirstRunSupportDialog } from './first-run'
|
import { showFirstRunSupportDialog } from './first-run'
|
||||||
import { registerIpcHandlers } from './ipc'
|
import { registerIpcHandlers } from './ipc'
|
||||||
|
import { loadSettings } from './settings'
|
||||||
|
import { createTray } from './tray'
|
||||||
import { disposeZapret, recoverZapret } from './zapret'
|
import { disposeZapret, recoverZapret } from './zapret'
|
||||||
import { elevatedTaskDir, runElevatedServiceTask } from './zapret/elevated-task.win32'
|
import { elevatedTaskDir, runElevatedServiceTask } from './zapret/elevated-task.win32'
|
||||||
|
|
||||||
const APP_ID = 'com.rknboost.app'
|
const APP_ID = 'com.senboost.app'
|
||||||
|
|
||||||
// Приложение может быть запущено само собой через UAC как «повышенный помощник» — тогда
|
// Приложение может быть запущено само собой через UAC как «повышенный помощник» — тогда
|
||||||
// оно не открывает окно и не занимает единственный экземпляр (иначе отдало бы фокус уже
|
// оно не открывает окно и не занимает единственный экземпляр (иначе отдало бы фокус уже
|
||||||
@@ -22,16 +25,11 @@ if (elevatedTask) {
|
|||||||
app.quit()
|
app.quit()
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
const [window] = BrowserWindow.getAllWindows()
|
// Окна может не быть вовсе: приложение живёт в трее, в том числе стартовав свёрнутым.
|
||||||
if (!window) {
|
showMainWindow()
|
||||||
createWindow()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (window.isMinimized()) window.restore()
|
|
||||||
window.focus()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
// Нужно Windows для группировки в панели задач и уведомлений.
|
// Нужно Windows для группировки в панели задач и уведомлений.
|
||||||
electronApp.setAppUserModelId(APP_ID)
|
electronApp.setAppUserModelId(APP_ID)
|
||||||
|
|
||||||
@@ -40,23 +38,54 @@ if (elevatedTask) {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Настройки читаем один раз здесь: их ждут и recoverZapret (выбранная стратегия),
|
||||||
|
// и окно (прятать ли его в трей по закрытию).
|
||||||
|
await loadSettings()
|
||||||
|
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
|
||||||
|
// Трей создаём до окна: окно спрашивает у него, есть ли куда прятаться по закрытию.
|
||||||
|
createTray({
|
||||||
|
showWindow: () => {
|
||||||
|
showMainWindow()
|
||||||
|
},
|
||||||
|
quit: () => {
|
||||||
|
beginQuit()
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Запуск из автозапуска системы — приложение садится в трей, окно не показываем.
|
||||||
|
const startHidden = shouldStartHidden()
|
||||||
|
|
||||||
// На macOS чинит прокси, оставшийся от аварийно завершённого прошлого запуска,
|
// На macOS чинит прокси, оставшийся от аварийно завершённого прошлого запуска,
|
||||||
// до того как окно вообще откроется.
|
// до того как окно вообще откроется.
|
||||||
void recoverZapret().then(() => {
|
void recoverZapret().then(() => {
|
||||||
const window = createWindow()
|
const window = createWindow({ show: !startHidden })
|
||||||
// Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого.
|
// Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого.
|
||||||
|
// Свёрнутому в трей приложению показывать модальное окно некуда — пользователь его
|
||||||
|
// не увидит и не закроет, а метку «уже показывали» диалог поставит.
|
||||||
|
if (!startHidden) {
|
||||||
window.once('ready-to-show', () => void showFirstRunSupportDialog(window))
|
window.once('ready-to-show', () => void showFirstRunSupportDialog(window))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// macOS: клик по иконке в доке при закрытых окнах открывает окно заново.
|
// macOS: клик по иконке в доке (в том числе при закрытых окнах) открывает окно.
|
||||||
|
// Система шлёт это событие и при самом запуске приложения — на первое из них при
|
||||||
|
// свёрнутом старте не реагируем, иначе автозапуск сразу же показал бы окно.
|
||||||
|
let ignoreActivate = startHidden
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (ignoreActivate) {
|
||||||
|
ignoreActivate = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showMainWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Windows / Linux: закрыли последнее окно — вышли. macOS: приложение живёт до Cmd+Q.
|
// Windows / Linux: закрыли последнее окно — вышли. macOS: приложение живёт до Cmd+Q.
|
||||||
|
// При включённом «сворачивать в трей» окно не закрывается, а прячется, и сюда мы не попадаем
|
||||||
|
// (см. обработчик `close` в window.ts).
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit()
|
app.quit()
|
||||||
@@ -67,6 +96,8 @@ if (elevatedTask) {
|
|||||||
// иначе процесс останется висеть, а на macOS ещё и интернет пропадёт.
|
// иначе процесс останется висеть, а на macOS ещё и интернет пропадёт.
|
||||||
let zapretCleaned = false
|
let zapretCleaned = false
|
||||||
app.on('before-quit', (event) => {
|
app.on('before-quit', (event) => {
|
||||||
|
// С этого момента закрытие окна означает выход, а не сворачивание в трей.
|
||||||
|
beginQuit()
|
||||||
if (zapretCleaned) return
|
if (zapretCleaned) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void disposeZapret().finally(() => {
|
void disposeZapret().finally(() => {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
|
import { app, BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
|
||||||
import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract'
|
import type {
|
||||||
|
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 { uninstallApp } from './uninstall.win32'
|
||||||
import {
|
import {
|
||||||
clearZapretAutoHostlist,
|
|
||||||
resetZapretList,
|
resetZapretList,
|
||||||
saveZapretList,
|
saveZapretList,
|
||||||
setZapretAutoStart,
|
setZapretAutoStart,
|
||||||
setZapretStrategy,
|
setZapretStrategy,
|
||||||
setZapretSystemProxy,
|
|
||||||
startZapret,
|
startZapret,
|
||||||
stopZapret,
|
stopZapret,
|
||||||
zapretAutoHostlist,
|
|
||||||
zapretDiagnostics,
|
zapretDiagnostics,
|
||||||
zapretLists,
|
zapretLists,
|
||||||
zapretStatus,
|
zapretStatus,
|
||||||
@@ -43,9 +48,22 @@ function themeState(): ThemeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function backgroundSettings(): Promise<BackgroundSettings> {
|
||||||
|
return {
|
||||||
|
// `null` в настройках — «не выбирали»: по умолчанию прячем в трей.
|
||||||
|
closeToTray: getSettings().closeToTray !== false,
|
||||||
|
launchAtLogin: await isAppAutoStartEnabled(),
|
||||||
|
trayAvailable: isTrayActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
handle('theme:get', () => themeState())
|
handle('theme:get', () => themeState())
|
||||||
|
|
||||||
|
// Ровно то же значение, что electron-builder кладёт в имя установщика и в
|
||||||
|
// «Установленные приложения» — оба берут его из "version" в package.json.
|
||||||
|
handle('app:version', () => app.getVersion())
|
||||||
|
|
||||||
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
|
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
|
||||||
// но канал регистрируем всегда — так и должно быть по контракту, просто на других
|
// но канал регистрируем всегда — так и должно быть по контракту, просто на других
|
||||||
// платформах он никогда не вызывается.
|
// платформах он никогда не вызывается.
|
||||||
@@ -53,20 +71,33 @@ export function registerIpcHandlers(): void {
|
|||||||
if (process.platform === 'win32') void uninstallApp(windowOf(event))
|
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:status', () => zapretStatus())
|
||||||
handle('zapret:strategies', () => zapretStrategies())
|
handle('zapret:strategies', () => zapretStrategies())
|
||||||
handle('zapret:start', (_event, strategyId) => startZapret(strategyId))
|
handle('zapret:start', (_event, strategyId) => startZapret(strategyId))
|
||||||
handle('zapret:stop', () => stopZapret())
|
handle('zapret:stop', () => stopZapret())
|
||||||
handle('zapret:set-system-proxy', (_event, enabled) => setZapretSystemProxy(enabled))
|
|
||||||
handle('zapret:set-strategy', (_event, strategyId) => setZapretStrategy(strategyId))
|
handle('zapret:set-strategy', (_event, strategyId) => setZapretStrategy(strategyId))
|
||||||
handle('zapret:set-autostart', (_event, enabled) => setZapretAutoStart(enabled))
|
handle('zapret:set-autostart', (_event, enabled) => setZapretAutoStart(enabled))
|
||||||
|
|
||||||
handle('zapret:lists', () => zapretLists())
|
handle('zapret:lists', () => zapretLists())
|
||||||
handle('zapret:list-save', (_event, id, entries) => saveZapretList(id, entries))
|
handle('zapret:list-save', (_event, id, entries) => saveZapretList(id, entries))
|
||||||
handle('zapret:list-reset', (_event, id) => resetZapretList(id))
|
handle('zapret:list-reset', (_event, id) => resetZapretList(id))
|
||||||
handle('zapret:auto-hostlist', () => zapretAutoHostlist())
|
// Результаты уходят не ответом на invoke, а событиями по мере готовности — и именно
|
||||||
handle('zapret:auto-hostlist-clear', () => clearZapretAutoHostlist())
|
// в то окно, которое просило проверку, а не всем подряд. Окно могло закрыться, пока
|
||||||
handle('zapret:diagnose', () => zapretDiagnostics())
|
// сайт отвечал (или молчал все 10 с таймаута), — тогда показывать результат уже некому.
|
||||||
|
handle('zapret:diagnose', (event) =>
|
||||||
|
zapretDiagnostics((result) => {
|
||||||
|
if (!event.sender.isDestroyed()) event.sender.send('zapret:diagnostic-result', result)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
|
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
|
||||||
// сообщаем всем открытым окнам.
|
// сообщаем всем открытым окнам.
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { readFile, rename, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор пользователя между запусками: `settings.json` в userData (на Windows —
|
||||||
|
* `%AppData%\SenBoost`), поэтому переживает обновление и переустановку.
|
||||||
|
*
|
||||||
|
* Списки сайтов сюда не входят: они лежат рядом, в `userData/zapret-lists/*.txt`, и
|
||||||
|
* читаются прямо оттуда самими tpws и winws (см. `zapret/lists.ts`).
|
||||||
|
*
|
||||||
|
* `null` — «пользователь не выбирал», решает движок.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
closeToTray: null,
|
||||||
|
trayHintShown: null
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: AppSettings = { ...DEFAULTS }
|
||||||
|
|
||||||
|
/** До первой загрузки не пишем: иначе движок затёр бы файл умолчаниями. */
|
||||||
|
let loaded = false
|
||||||
|
|
||||||
|
/** Очередь, чтобы два быстрых переключения не переставили rename местами. */
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
function settingsPath(): string {
|
||||||
|
return join(app.getPath('userData'), 'settings.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Файл могли испортить или он остался от версии с другими полями — непонятное отбрасываем. */
|
||||||
|
function sanitize(raw: unknown): AppSettings {
|
||||||
|
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,
|
||||||
|
closeToTray: typeof value.closeToTray === 'boolean' ? value.closeToTray : null,
|
||||||
|
trayHintShown: typeof value.trayHintShown === 'boolean' ? value.trayHintShown : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает настройки с диска. Вызывать один раз при старте, до создания окна. */
|
||||||
|
export async function loadSettings(): Promise<AppSettings> {
|
||||||
|
try {
|
||||||
|
cache = sanitize(JSON.parse(await readFile(settingsPath(), 'utf8')))
|
||||||
|
} catch {
|
||||||
|
cache = { ...DEFAULTS } // первый запуск или нечитаемый файл
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
return { ...cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Атомарная запись: временный файл рядом + rename, как в `zapret/lists.ts`. */
|
||||||
|
async function writeSettings(settings: AppSettings): Promise<void> {
|
||||||
|
const path = settingsPath()
|
||||||
|
const tmp = `${path}.${randomUUID()}.tmp`
|
||||||
|
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
|
||||||
|
await rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Настройки, прочитанные при старте. До `loadSettings()` — умолчания. */
|
||||||
|
export function getSettings(): AppSettings {
|
||||||
|
return { ...cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить изменившиеся поля. Вызывается в том числе из `ZapretEngine.patch()`, то есть на
|
||||||
|
* каждое изменение статуса — отсюда сравнение с прошлым значением. Записи не ждём: интерфейс
|
||||||
|
* не должен зависеть от диска, а не сохранившийся выбор не повод ломать работающий обход.
|
||||||
|
*/
|
||||||
|
export function rememberPreferences(patch: Partial<AppSettings>): void {
|
||||||
|
if (!loaded) 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
|
||||||
|
.then(() => writeSettings(settings))
|
||||||
|
.catch((error: unknown) => console.warn('[settings] не удалось сохранить:', error))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ interface UninstallCommand {
|
|||||||
* строкой `'"$2" $0'`, где `$2` — путь к деинсталлятору, а `$0` — `/currentuser` для
|
* строкой `'"$2" $0'`, где `$2` — путь к деинсталлятору, а `$0` — `/currentuser` для
|
||||||
* per-user установки (или `/allusers`). То есть путь в кавычках И аргумент после него:
|
* per-user установки (или `/allusers`). То есть путь в кавычках И аргумент после него:
|
||||||
*
|
*
|
||||||
* "C:\Users\...\AppData\Local\Programs\RKNboost\Uninstall RKNboost.exe" /currentuser
|
* "C:\Users\...\AppData\Local\Programs\SenBoost\Uninstall SenBoost.exe" /currentuser
|
||||||
*
|
*
|
||||||
* Аргумент обязателен: без него деинсталлятор не знает, из какой ветки реестра сниматься.
|
* Аргумент обязателен: без него деинсталлятор не знает, из какой ветки реестра сниматься.
|
||||||
* Разбираем строку на команду и аргументы, а не пытаемся скормить её `spawn` целиком —
|
* Разбираем строку на команду и аргументы, а не пытаемся скормить её `spawn` целиком —
|
||||||
@@ -66,7 +66,7 @@ async function findUninstallCommand(): Promise<UninstallCommand | null> {
|
|||||||
const script =
|
const script =
|
||||||
"Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' " +
|
"Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' " +
|
||||||
'| Get-ItemProperty ' +
|
'| Get-ItemProperty ' +
|
||||||
"| Where-Object { $_.DisplayName -eq 'RKNboost' } " +
|
"| Where-Object { $_.DisplayName -eq 'SenBoost' } " +
|
||||||
'| Select-Object -First 1 -ExpandProperty UninstallString'
|
'| Select-Object -First 1 -ExpandProperty UninstallString'
|
||||||
|
|
||||||
let stdout: string
|
let stdout: string
|
||||||
@@ -110,8 +110,8 @@ export async function uninstallApp(window: BrowserWindow | null): Promise<void>
|
|||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 0,
|
cancelId: 0,
|
||||||
noLink: true,
|
noLink: true,
|
||||||
title: 'Удалить RKNboost',
|
title: 'Удалить SenBoost',
|
||||||
message: 'Удалить RKNboost с этого компьютера?',
|
message: 'Удалить SenBoost с этого компьютера?',
|
||||||
detail:
|
detail:
|
||||||
'Приложение закроется и запустится стандартный деинсталлятор Windows. ' +
|
'Приложение закроется и запустится стандартный деинсталлятор Windows. ' +
|
||||||
'Вместе с ним будут удалены списки сайтов и служба обхода блокировок. ' +
|
'Вместе с ним будут удалены списки сайтов и служба обхода блокировок. ' +
|
||||||
|
|||||||
@@ -3,6 +3,43 @@ import { join } from 'node:path'
|
|||||||
import { is } from '@electron-toolkit/utils'
|
import { is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { ALLOWED_EXTERNAL_PROTOCOLS } from '../shared/ipc-contract'
|
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 {
|
function protocolOf(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -23,14 +60,19 @@ export function openExternal(url: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Главное (и единственное) окно приложения. */
|
/**
|
||||||
export function createWindow(): BrowserWindow {
|
* Главное (и единственное) окно приложения.
|
||||||
|
*
|
||||||
|
* `show: false` нужен запуску из автозапуска системы: приложение поднимается сразу свёрнутым
|
||||||
|
* в трей, окно готово, но на экран не выходит.
|
||||||
|
*/
|
||||||
|
export function createWindow(options: { show?: boolean } = {}): BrowserWindow {
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
width: 440,
|
width: 440,
|
||||||
height: 660,
|
height: 660,
|
||||||
minWidth: 360,
|
minWidth: 360,
|
||||||
// С запасом на самое высокое обычное состояние главного экрана (ошибка + три
|
// С запасом на самое высокое обычное состояние главного экрана (ошибка + результаты
|
||||||
// результата проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
|
// проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
|
||||||
minHeight: 560,
|
minHeight: 560,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
@@ -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.open(...) и target="_blank" уходят в системный браузер, а не в новое окно Electron.
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { access, constants } from 'node:fs/promises'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pkexec` из состава polkit — им поднимаем root для nftables/nfqws. Проверяем по конкретным
|
||||||
|
* путям, а не через `PATH`: тот же мотив, что у `find_nft()` в `senboost-helper.sh` — санитайз
|
||||||
|
* окружения инструментами вроде pkexec/env-с-урезанным-PATH встречается достаточно часто,
|
||||||
|
* чтобы не полагаться на переменную окружения при поиске самого пути к элевации.
|
||||||
|
*/
|
||||||
|
const PKEXEC_CANDIDATES = ['/usr/bin/pkexec', '/bin/pkexec']
|
||||||
|
|
||||||
|
/** `nft` — тот же список путей, что перебирает `find_nft()` в помощнике (держим в синхроне). */
|
||||||
|
const NFT_CANDIDATES = ['/usr/sbin/nft', '/sbin/nft', '/usr/local/sbin/nft', '/usr/bin/nft']
|
||||||
|
|
||||||
|
async function findExecutable(candidates: readonly string[]): Promise<string | null> {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
await access(candidate, constants.X_OK)
|
||||||
|
return candidate
|
||||||
|
} catch {
|
||||||
|
// пробуем следующий путь
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка окружения до первого запроса пароля — тот же мотив, что у `assertExecutable()`
|
||||||
|
* в `paths.ts`: без неё пользователь увидел бы «spawn pkexec ENOENT» или пустой отказ nft
|
||||||
|
* внутри чужого stderr и не понял бы, что установить.
|
||||||
|
*/
|
||||||
|
export async function assertLinuxDependencies(): Promise<void> {
|
||||||
|
if (!(await findExecutable(PKEXEC_CANDIDATES))) {
|
||||||
|
throw new Error(
|
||||||
|
'Не найден pkexec — установите пакет polkit ' +
|
||||||
|
'(Debian/Ubuntu: «sudo apt install policykit-1»).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!(await findExecutable(NFT_CANDIDATES))) {
|
||||||
|
throw new Error(
|
||||||
|
'Не найдена утилита nft — установите пакет nftables (Debian/Ubuntu: «sudo apt install nftables»).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import { connect as netConnect, type Socket } from 'node:net'
|
|
||||||
import { connect as tlsConnect } from 'node:tls'
|
|
||||||
import { net } from 'electron'
|
import { net } from 'electron'
|
||||||
import type { ZapretDiagnosticResult, ZapretStatus } from '../../shared/ipc-contract'
|
import type { ZapretDiagnosticResult, ZapretDiagnosticTarget } from '../../shared/ipc-contract'
|
||||||
|
|
||||||
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
|
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
|
||||||
// и медленный, но живой сайт успевал попасть в «Недоступно».
|
// и медленный, но живой сайт успевал попасть в «Недоступно».
|
||||||
const TIMEOUT_MS = 10000
|
const TIMEOUT_MS = 10000
|
||||||
const TARGET_PORT = 443
|
|
||||||
const RETRY_DELAY_MS = 300
|
const RETRY_DELAY_MS = 300
|
||||||
|
|
||||||
interface DiagnosticTarget {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
host: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */
|
/** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */
|
||||||
const TARGETS: DiagnosticTarget[] = [
|
export const DIAGNOSTIC_TARGETS: ZapretDiagnosticTarget[] = [
|
||||||
{ id: 'instagram', label: 'Instagram', host: 'instagram.com' },
|
|
||||||
{ id: 'discord', label: 'Discord', host: 'discord.com' },
|
{ id: 'discord', label: 'Discord', host: 'discord.com' },
|
||||||
{ id: 'youtube', label: 'YouTube', host: 'youtube.com' }
|
{ id: 'youtube', label: 'YouTube', host: 'youtube.com' }
|
||||||
]
|
]
|
||||||
@@ -44,43 +34,11 @@ function describeNetError(message: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SOCKS5 CONNECT без авторизации — ровно то, что понимает tpws в режиме `--socks`.
|
* Единственный путь проверки на всех трёх платформах: десинк везде общесистемный —
|
||||||
* Три сообщения: приветствие (метод «без авторизации»), запрос на домен, ответ сервера.
|
* winws через WinDivert, nfqws через nftables/NFQUEUE, utunws через pf/utun (см.
|
||||||
*/
|
* `engine.*.ts`) — прямой запрос видит его так же, как и обычный браузерный трафик.
|
||||||
function socksConnect(socket: Socket, host: string, port: number): Promise<void> {
|
* Раньше на macOS был отдельный путь через локальный SOCKS5 (`tpws --socks`), но новый
|
||||||
return new Promise((resolve, reject) => {
|
* движок `utunws` — не прокси, трафик десинхронизируется до того, как уйдёт в сеть.
|
||||||
const hostBuf = Buffer.from(host, 'utf8')
|
|
||||||
|
|
||||||
socket.once('data', (greeting: Buffer) => {
|
|
||||||
if (greeting[0] !== 0x05 || greeting[1] !== 0x00) {
|
|
||||||
reject(new Error('SOCKS5: сервер отклонил метод авторизации'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.once('data', (reply: Buffer) => {
|
|
||||||
if (reply[1] !== 0x00) {
|
|
||||||
reject(new Error(`SOCKS5: код ошибки 0x${reply[1].toString(16)}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.write(
|
|
||||||
Buffer.concat([
|
|
||||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
|
|
||||||
hostBuf,
|
|
||||||
Buffer.from([port >> 8, port & 0xff])
|
|
||||||
])
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.write(Buffer.from([0x05, 0x01, 0x00]))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Прямая проверка — путь для Windows (десинк общесистемный, через WinDivert) и для
|
|
||||||
* выключенного обхода.
|
|
||||||
*
|
*
|
||||||
* Намеренно через `net` Electron, то есть через сетевой стек Chromium, а НЕ через
|
* Намеренно через `net` Electron, то есть через сетевой стек Chromium, а НЕ через
|
||||||
* `node:tls`. Тест должен отвечать на вопрос «откроется ли этот сайт в браузере», а
|
* `node:tls`. Тест должен отвечать на вопрос «откроется ли этот сайт в браузере», а
|
||||||
@@ -121,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> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
@@ -174,31 +103,27 @@ async function withRetry(attempt: () => Promise<number>): Promise<number> {
|
|||||||
* Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить
|
* Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить
|
||||||
* «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем.
|
* «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем.
|
||||||
*
|
*
|
||||||
* `status.socksAddress` заполнен только на macOS и только пока обход запущен — во всех
|
* Каждый сайт отдаёт свой результат в `onResult` сам по себе, не дожидаясь остальных:
|
||||||
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
|
* проверки идут параллельно и заканчиваются вразнобой (живой сайт отвечает за десятки
|
||||||
* на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает
|
* миллисекунд, заблокированный — только по таймауту в 10 с), и общий ответ одним куском
|
||||||
* то, что есть сейчас.
|
* означал бы пустой экран всё это время. Промис резолвится, когда отработали все, —
|
||||||
|
* по нему вызывающий код понимает, что проверка закончена.
|
||||||
*/
|
*/
|
||||||
export async function runDiagnostics(status: ZapretStatus): Promise<ZapretDiagnosticResult[]> {
|
export async function runDiagnostics(
|
||||||
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
|
onResult: (result: ZapretDiagnosticResult) => void
|
||||||
|
): Promise<void> {
|
||||||
return Promise.all(
|
await Promise.all(
|
||||||
TARGETS.map(async (target) => {
|
DIAGNOSTIC_TARGETS.map(async (target) => {
|
||||||
const attempt = (): Promise<number> =>
|
|
||||||
socksMatch
|
|
||||||
? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2]))
|
|
||||||
: checkDirect(target.host)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ms = await withRetry(attempt)
|
const ms = await withRetry(() => checkDirect(target.host))
|
||||||
return { ...target, ok: true, ms, error: null }
|
onResult({ ...target, ok: true, ms, error: null })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
onResult({
|
||||||
...target,
|
...target,
|
||||||
ok: false,
|
ok: false,
|
||||||
ms: null,
|
ms: null,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export function elevatedTaskDir(argv: readonly string[]): string | null {
|
|||||||
* Смысл всей этой конструкции в том, ЧТО пользователь видит в окне UAC. Раньше приложение
|
* Смысл всей этой конструкции в том, ЧТО пользователь видит в окне UAC. Раньше приложение
|
||||||
* повышало powershell.exe, и Windows честно показывала «Windows PowerShell» с подписью
|
* повышало powershell.exe, и Windows честно показывала «Windows PowerShell» с подписью
|
||||||
* Microsoft — запрос выглядел так, будто права просит не то, что запросил пользователь.
|
* Microsoft — запрос выглядел так, будто права просит не то, что запросил пользователь.
|
||||||
* Теперь повышается сам RKNboost.exe, и в запросе стоит имя приложения.
|
* Теперь повышается сам SenBoost.exe, и в запросе стоит имя приложения.
|
||||||
*
|
*
|
||||||
* Сам worker.ps1 при этом не изменился — вся логика sc/icacls осталась ровно та же,
|
* Сам worker.ps1 при этом не изменился — вся логика sc/icacls осталась ровно та же,
|
||||||
* поменялось только то, кого повышают.
|
* поменялось только то, кого повышают.
|
||||||
|
|||||||
@@ -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 type { ZapretStatus } from '../../shared/ipc-contract'
|
||||||
import { ZapretEngine } from './engine'
|
import { ZapretEngine } from './engine'
|
||||||
import { assertExecutable, tpwsPath } from './paths'
|
import { assertExecutable, utunwsPath } from './paths'
|
||||||
import { applySystemProxy, hasStaleSnapshot, restoreSystemProxy } from './proxy.darwin'
|
import { defaultStrategyId, utunwsConfig } from './strategies'
|
||||||
import { defaultStrategyId, tpwsArgs } from './strategies'
|
import {
|
||||||
|
assertMacosVersion,
|
||||||
|
dryRun,
|
||||||
|
readUtunwsState,
|
||||||
|
setUtunwsAutoStart,
|
||||||
|
startUtunws,
|
||||||
|
stopUtunws
|
||||||
|
} from './utunws.darwin'
|
||||||
|
|
||||||
const HOST = '127.0.0.1'
|
/** Как часто опрашивать pidfile — демон нам не дочерний процесс, экзит-событие поймать некому. */
|
||||||
const PREFERRED_PORT = 1080
|
const WATCH_INTERVAL_MS = 3000
|
||||||
const READY_TIMEOUT_MS = 3000
|
|
||||||
const STOP_TIMEOUT_MS = 3000
|
|
||||||
|
|
||||||
/** Свободен ли порт на localhost. */
|
|
||||||
function isPortFree(port: number): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = createServer()
|
|
||||||
server.once('error', () => resolve(false))
|
|
||||||
server.listen(port, HOST, () => server.close(() => resolve(true)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickPort(): Promise<number> {
|
|
||||||
for (let port = PREFERRED_PORT; port < PREFERRED_PORT + 20; port += 1) {
|
|
||||||
if (await isPortFree(port)) return port
|
|
||||||
}
|
|
||||||
throw new Error('Не нашлось свободного порта для socks-прокси.')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** У tpws нет сигнала готовности — просто ждём, пока порт начнёт принимать соединения. */
|
|
||||||
function canConnect(port: number): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const socket = createConnection({ host: HOST, port })
|
|
||||||
const finish = (ok: boolean): void => {
|
|
||||||
socket.destroy()
|
|
||||||
resolve(ok)
|
|
||||||
}
|
|
||||||
socket.once('connect', () => finish(true))
|
|
||||||
socket.once('error', () => finish(false))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUntilReady(port: number, isAlive: () => boolean): Promise<void> {
|
|
||||||
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (!isAlive()) return // процесс уже умер, ошибку соберёт вызывающий код
|
|
||||||
if (await canConnect(port)) return
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`tpws не начал слушать ${HOST}:${port} за ${READY_TIMEOUT_MS} мс.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macOS: tpws поднимается обычным пользовательским процессом в режиме socks5
|
* macOS: `utunws` (форк `nfqws` из Flowseal/zapret-mac-discord-youtube — тот же движок
|
||||||
* (документация zapret: «Режим --socks не требует повышенных привилегий»),
|
* уровня пакетов, что winws и nfqws, но через utun+BPF вместо WinDivert/netfilter) под
|
||||||
* а трафик заворачивается системной настройкой прокси.
|
* LaunchDaemon. Пароль администратора спрашивается ровно при двух действиях — включении и
|
||||||
*
|
* выключении обхода (`resources/darwin-helper/install.sh`/`stop.sh` через `osascript`),
|
||||||
* Важное ограничение платформы: tpws не умеет UDP, поэтому QUIC не обходится.
|
* тот же принцип, что `pkexec` на Linux. Выключение полностью сносит установку (плист,
|
||||||
* Практика это скрадывает — при системном socks-прокси браузеры сами уходят с QUIC на TCP.
|
* strategy.cfg, pf-правила) — «выключено» и «не установлено» здесь одно состояние, поэтому
|
||||||
|
* `dispose()` не требуется останавливать демон: он живёт своей жизнью в системе, как и на
|
||||||
|
* Windows/Linux (если пользователь не тронул переключатель, обход продолжает работать
|
||||||
|
* после закрытия приложения).
|
||||||
*/
|
*/
|
||||||
export class DarwinEngine extends ZapretEngine {
|
export class DarwinEngine extends ZapretEngine {
|
||||||
private child: ChildProcessByStdio<null, Readable, Readable> | null = null
|
private watchTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private port = PREFERRED_PORT
|
|
||||||
/** Просили ли включить системный прокси. Переживает перезапуск tpws. */
|
|
||||||
private wantSystemProxy = true
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('tpws-socks', defaultStrategyId('darwin'))
|
super('utunws-pf', defaultStrategyId('darwin'))
|
||||||
|
// Решение по умолчанию для первой установки — то же, что у Win32Engine: обход должен
|
||||||
|
// работать и после перезагрузки, не дожидаясь запуска приложения.
|
||||||
|
this.status.autoStart = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Прошлый запуск мог не снять прокси (например, kill -9). Тогда система смотрит
|
* Вызывается из `recoverZapret()` до открытия окна — подхватывает LaunchDaemon,
|
||||||
* в мёртвый socks и интернета нет вовсе — чиним до того, как показать окно.
|
* переживший закрытие приложения (RunAtLoad+KeepAlive держит его живым и через
|
||||||
|
* перезагрузку системы), тот же принцип, что `Win32Engine.sync()`/`LinuxEngine.sync()`.
|
||||||
*/
|
*/
|
||||||
async recoverFromCrash(): Promise<void> {
|
async sync(): Promise<ZapretStatus> {
|
||||||
if (!(await hasStaleSnapshot())) return
|
const { pid, strategyId, autoStart } = await readUtunwsState()
|
||||||
|
|
||||||
this.appendLog(
|
if (pid === null) {
|
||||||
'info',
|
this.stopWatch()
|
||||||
'Найдены незакрытые настройки прокси от прошлого запуска — восстанавливаю.'
|
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||||
)
|
|
||||||
try {
|
|
||||||
await restoreSystemProxy()
|
|
||||||
} catch (error) {
|
|
||||||
this.appendLog('error', error instanceof Error ? error.message : String(error))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
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 })
|
this.patch({ state: 'starting', strategyId, error: null })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.port = await pickPort()
|
// Всё, что можно проверить без пароля, проверяем в этом порядке: сначала формат
|
||||||
const child = spawn(binary, tpwsArgs(strategyId, this.port), {
|
// самой стратегии, потом версию системы, потом сами аргументы через --dry-run — тот
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
// же принцип, что и у LinuxEngine.start(), единственный шанс не побеспокоить
|
||||||
})
|
// пользователя диалогом авторизации ради заведомо неверных данных.
|
||||||
this.child = child
|
const config = utunwsConfig(strategyId)
|
||||||
|
assertMacosVersion()
|
||||||
|
await assertExecutable(utunwsPath())
|
||||||
|
await dryRun(config.body)
|
||||||
|
|
||||||
// Вывод tpws идёт в stderr целиком, включая обычные сообщения о старте.
|
await startUtunws({
|
||||||
child.stdout.setEncoding('utf8')
|
configBody: config.body,
|
||||||
child.stderr.setEncoding('utf8')
|
tcpPorts: config.tcpPorts,
|
||||||
child.stdout.on('data', (chunk: string) => this.appendLog('info', chunk))
|
udpPorts: config.udpPorts,
|
||||||
|
autoStart: this.status.autoStart
|
||||||
let stderrTail = ''
|
|
||||||
child.stderr.on('data', (chunk: string) => {
|
|
||||||
stderrTail = `${stderrTail}${chunk}`.slice(-2000)
|
|
||||||
this.appendLog('info', chunk)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
const { pid } = await readUtunwsState()
|
||||||
this.child = null
|
if (pid !== null) this.startWatch()
|
||||||
// Плановую остановку обрабатывает stop(), здесь ловим только падение.
|
|
||||||
if (this.status.state === 'stopping' || this.status.state === 'stopped') return
|
|
||||||
void this.handleCrash(code, signal, stderrTail)
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitUntilReady(this.port, () => this.child !== null)
|
return this.patch({ state: 'running', startedAt: new Date().toISOString(), error: null })
|
||||||
|
|
||||||
if (!this.child) {
|
|
||||||
throw new Error(stderrTail.trim() || 'tpws завершился сразу после запуска.')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.wantSystemProxy) {
|
|
||||||
await applySystemProxy(HOST, this.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.patch({
|
|
||||||
state: 'running',
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
error: null,
|
|
||||||
socksAddress: `${HOST}:${this.port}`,
|
|
||||||
systemProxyApplied: this.wantSystemProxy
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.cleanup()
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
this.appendLog('error', message)
|
this.appendLog('error', message)
|
||||||
return this.patch({
|
return this.patch({ state: 'error', error: message, startedAt: null })
|
||||||
state: 'error',
|
|
||||||
error: message,
|
|
||||||
startedAt: null,
|
|
||||||
socksAddress: null,
|
|
||||||
systemProxyApplied: false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<ZapretStatus> {
|
async stop(): Promise<ZapretStatus> {
|
||||||
if (!this.child && !this.status.systemProxyApplied) {
|
|
||||||
return this.patch({ state: 'stopped', startedAt: null, socksAddress: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
this.patch({ state: 'stopping' })
|
this.patch({ state: 'stopping' })
|
||||||
await this.cleanup()
|
this.stopWatch()
|
||||||
|
try {
|
||||||
return this.patch({
|
await stopUtunws()
|
||||||
state: 'stopped',
|
} catch (error) {
|
||||||
startedAt: null,
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
error: null,
|
this.appendLog('error', message)
|
||||||
socksAddress: null,
|
return this.patch({ state: 'error', error: message })
|
||||||
systemProxyApplied: false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||||
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
|
|
||||||
this.wantSystemProxy = enabled
|
|
||||||
|
|
||||||
// Пока обход выключен, менять нечего — запомнили выбор и всё.
|
|
||||||
if (this.status.state !== 'running') return this.patch({ systemProxyApplied: false })
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
await applySystemProxy(HOST, this.port)
|
|
||||||
} else {
|
|
||||||
await restoreSystemProxy()
|
|
||||||
}
|
|
||||||
return this.patch({ systemProxyApplied: enabled })
|
|
||||||
}
|
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
|
||||||
await this.cleanup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Порядок важен: сначала снимаем прокси, потом гасим tpws. Наоборот нельзя —
|
* Только RunAtLoad уже установленного LaunchDaemon — если демона ещё нет, лишь запоминаем
|
||||||
* между шагами система осталась бы с прокси, который уже никто не слушает.
|
* выбор до первого включения (никакого диалога авторизации на пустом месте), тот же
|
||||||
|
* принцип, что у `Win32Engine.setAutoStart()`.
|
||||||
*/
|
*/
|
||||||
private async cleanup(): Promise<void> {
|
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
|
||||||
try {
|
if (this.status.state !== 'running') {
|
||||||
await restoreSystemProxy()
|
return this.patch({ autoStart: enabled })
|
||||||
} catch (error) {
|
}
|
||||||
this.appendLog('error', error instanceof Error ? error.message : String(error))
|
await setUtunwsAutoStart(enabled)
|
||||||
|
return this.patch({ autoStart: enabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = this.child
|
async dispose(): Promise<void> {
|
||||||
if (!child) return
|
// LaunchDaemon живёт своей жизнью в системе — выход приложения его не останавливает
|
||||||
this.child = null
|
// (см. комментарий класса). Опрос pidfile всё равно больше не нужен.
|
||||||
|
this.stopWatch()
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Демон нам не дочерний процесс (владеет им 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({
|
this.patch({
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: `tpws неожиданно завершился (${reason}). ${details}`.trim(),
|
error: 'Процесс utunws неожиданно завершился.',
|
||||||
startedAt: null,
|
startedAt: null
|
||||||
socksAddress: null,
|
|
||||||
systemProxyApplied: false
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}, WATCH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWatch(): void {
|
||||||
|
if (this.watchTimer) clearInterval(this.watchTimer)
|
||||||
|
this.watchTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ZapretStatus } from '../../shared/ipc-contract'
|
||||||
|
import { assertLinuxDependencies } from './deps.linux'
|
||||||
|
import { ZapretEngine } from './engine'
|
||||||
|
import { dryRun, isProcessAlive, readNfqwsState, startNfqws, stopNfqws } from './nfqws.linux'
|
||||||
|
import { defaultStrategyId, nfqwsConfig } from './strategies'
|
||||||
|
|
||||||
|
/** Как часто опрашивать /proc — демон нам не дочерний процесс, экзит-событие поймать некому. */
|
||||||
|
const WATCH_INTERVAL_MS = 3000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: nfqws (движок zapret на уровне пакетов, как и Windows-версия) + правила nftables
|
||||||
|
* NFQUEUE. Ни служба, ни автозапуск не заведены (см. README.md) — каждое включение просит
|
||||||
|
* пароль через `pkexec` (см. `nfqws.linux.ts`), а сам демон и его правила переживают выход
|
||||||
|
* из приложения: `bypass` на очереди означает, что их отсутствие не рвёт пользователю сеть
|
||||||
|
* (см. `senboost-helper.sh`), поэтому `dispose()` намеренно пуст — как и на Windows.
|
||||||
|
*/
|
||||||
|
export class LinuxEngine extends ZapretEngine {
|
||||||
|
private watchTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('nfqws-nftables', defaultStrategyId('linux'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается из `recoverZapret()` до открытия окна — как и `Win32Engine.sync()`, подхватывает
|
||||||
|
* состояние, оставшееся от предыдущего запуска приложения (демон root-процессом переживает
|
||||||
|
* закрытие приложения, автозапуска с системой при этом нет — состояние может быть только
|
||||||
|
* «работает с прошлой сессии» или «выключен»).
|
||||||
|
*/
|
||||||
|
async sync(): Promise<ZapretStatus> {
|
||||||
|
const { pid, strategyId } = await readNfqwsState()
|
||||||
|
|
||||||
|
if (pid === null) {
|
||||||
|
this.stopWatch()
|
||||||
|
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startWatch(pid)
|
||||||
|
return this.patch({
|
||||||
|
state: 'running',
|
||||||
|
strategyId: strategyId ?? this.status.strategyId,
|
||||||
|
// Момент реального запуска нам не известен — демон мог подняться в прошлой сессии
|
||||||
|
// приложения. Тот же компромисс, что и в Win32Engine.sync().
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(strategyId: string): Promise<ZapretStatus> {
|
||||||
|
this.patch({ state: 'starting', strategyId, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Всё, что можно проверить без пароля, проверяем в этом порядке: сначала формат
|
||||||
|
// самой стратегии, потом окружение, потом сами аргументы через --dry-run — тот же
|
||||||
|
// принцип, что и у Win32Engine.start(), только тут это не опционально, а единственный
|
||||||
|
// шанс не побеспокоить пользователя запросом root ради заведомо неверных данных.
|
||||||
|
const config = nfqwsConfig(strategyId)
|
||||||
|
await assertLinuxDependencies()
|
||||||
|
await dryRun(config.body)
|
||||||
|
|
||||||
|
await startNfqws({
|
||||||
|
configBody: config.body,
|
||||||
|
tcpPorts: config.tcpPorts,
|
||||||
|
udpPorts: config.udpPorts
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pid } = await readNfqwsState()
|
||||||
|
if (pid !== null) this.startWatch(pid)
|
||||||
|
|
||||||
|
return this.patch({ state: 'running', startedAt: new Date().toISOString(), error: null })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
this.appendLog('error', message)
|
||||||
|
return this.patch({ state: 'error', error: message, startedAt: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<ZapretStatus> {
|
||||||
|
this.patch({ state: 'stopping' })
|
||||||
|
this.stopWatch()
|
||||||
|
try {
|
||||||
|
await stopNfqws()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
this.appendLog('error', message)
|
||||||
|
return this.patch({ state: 'error', error: message })
|
||||||
|
}
|
||||||
|
return this.patch({ state: 'stopped', startedAt: null, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
// Демон и правила nftables живут своей жизнью в системе — выход приложения их не
|
||||||
|
// останавливает (см. комментарий класса). Опрос /proc всё равно больше не нужен.
|
||||||
|
this.stopWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Демон нам не дочерний процесс (владеет им root, а не Electron) — событие завершения
|
||||||
|
* поймать некому, поэтому опрашиваем `/proc/<pid>` по таймеру. `bypass` в правилах
|
||||||
|
* nftables делает мёртвый демон безопасным для сети, но не для интерфейса: без этого
|
||||||
|
* опроса карточка продолжала бы показывать «Активен» после падения nfqws.
|
||||||
|
*/
|
||||||
|
private startWatch(pid: number): void {
|
||||||
|
this.stopWatch()
|
||||||
|
this.watchTimer = setInterval(() => {
|
||||||
|
void isProcessAlive(pid).then((alive) => {
|
||||||
|
if (alive || this.status.state !== 'running') return
|
||||||
|
this.stopWatch()
|
||||||
|
this.appendLog('error', `nfqws (pid ${pid}) больше не запущен`)
|
||||||
|
this.patch({
|
||||||
|
state: 'error',
|
||||||
|
error: 'Процесс nfqws неожиданно завершился.',
|
||||||
|
startedAt: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, WATCH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWatch(): void {
|
||||||
|
if (this.watchTimer) clearInterval(this.watchTimer)
|
||||||
|
this.watchTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,25 @@ import type {
|
|||||||
ZapretStatus,
|
ZapretStatus,
|
||||||
ZapretState
|
ZapretState
|
||||||
} from '../../shared/ipc-contract'
|
} from '../../shared/ipc-contract'
|
||||||
|
import { rememberPreferences, type AppSettings } from '../settings'
|
||||||
|
|
||||||
/** Сколько строк вывода бинарника держим для UI. */
|
/** Сколько строк вывода бинарника держим для UI. */
|
||||||
const LOG_LIMIT = 200
|
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 {
|
export abstract class ZapretEngine {
|
||||||
protected status: ZapretStatus
|
protected status: ZapretStatus
|
||||||
@@ -20,8 +36,6 @@ export abstract class ZapretEngine {
|
|||||||
strategyId: defaultStrategyId,
|
strategyId: defaultStrategyId,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
error: null,
|
error: null,
|
||||||
socksAddress: null,
|
|
||||||
systemProxyApplied: false,
|
|
||||||
serviceInstalled: false,
|
serviceInstalled: false,
|
||||||
autoStart: false
|
autoStart: false
|
||||||
}
|
}
|
||||||
@@ -31,6 +45,21 @@ export abstract class ZapretEngine {
|
|||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает выбор пользователя из прошлого запуска (`settings.json` в userData) —
|
||||||
|
* вызывается из `recoverZapret()` до открытия окна. Ничего не запускает: восстанавливаем
|
||||||
|
* только то, что было выбрано в интерфейсе, а включает обход по-прежнему сам пользователь
|
||||||
|
* (или, на Windows, автозапуск службы).
|
||||||
|
*
|
||||||
|
* `null` в поле означает «не выбирали» — тогда остаётся то, что решил конструктор движка.
|
||||||
|
*/
|
||||||
|
restorePreferences(settings: Pick<AppSettings, 'strategyId' | 'autoStart'>): void {
|
||||||
|
this.patch({
|
||||||
|
strategyId: settings.strategyId ?? this.status.strategyId,
|
||||||
|
autoStart: settings.autoStart ?? this.status.autoStart
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getLog(): ZapretLogLine[] {
|
getLog(): ZapretLogLine[] {
|
||||||
return [...this.log]
|
return [...this.log]
|
||||||
}
|
}
|
||||||
@@ -40,12 +69,6 @@ export abstract class ZapretEngine {
|
|||||||
/** Вызывается при выходе из приложения: вернуть систему в исходное состояние. */
|
/** Вызывается при выходе из приложения: вернуть систему в исходное состояние. */
|
||||||
abstract dispose(): Promise<void>
|
abstract dispose(): Promise<void>
|
||||||
|
|
||||||
/** macOS-специфика; на других платформах переопределять не нужно. */
|
|
||||||
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
|
|
||||||
void enabled
|
|
||||||
throw new Error('Системный прокси на этой платформе не настраивается.')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выключен — просто запоминаем выбор (никаких системных действий и никакого UAC).
|
* Выключен — просто запоминаем выбор (никаких системных действий и никакого UAC).
|
||||||
* Работает — перезапускаем с новой стратегией через `start()`; на Windows тот сам
|
* Работает — перезапускаем с новой стратегией через `start()`; на Windows тот сам
|
||||||
@@ -59,7 +82,7 @@ export abstract class ZapretEngine {
|
|||||||
return this.start(strategyId)
|
return this.start(strategyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Windows-специфика (автозапуск службы); на других платформах переопределять не нужно. */
|
/** macOS/Windows-специфика (автозапуск демона/службы); на Linux переопределять не нужно. */
|
||||||
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
|
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
|
||||||
void enabled
|
void enabled
|
||||||
throw new Error('Автозапуск на этой платформе не настраивается.')
|
throw new Error('Автозапуск на этой платформе не настраивается.')
|
||||||
@@ -67,7 +90,12 @@ export abstract class ZapretEngine {
|
|||||||
|
|
||||||
protected patch(changes: Partial<ZapretStatus>): ZapretStatus {
|
protected patch(changes: Partial<ZapretStatus>): ZapretStatus {
|
||||||
this.status = { ...this.status, ...changes }
|
this.status = { ...this.status, ...changes }
|
||||||
|
// Единая точка сохранения: любой путь смены стратегии или автозапуска (выбор в списке,
|
||||||
|
// start() с другой стратегией, подхваченное из системы состояние службы в sync())
|
||||||
|
// проходит через patch, так что забыть сохранить выбор здесь просто негде.
|
||||||
|
rememberPreferences({ strategyId: this.status.strategyId, autoStart: this.status.autoStart })
|
||||||
this.broadcast('zapret:status-changed', this.status)
|
this.broadcast('zapret:status-changed', this.status)
|
||||||
|
for (const listener of statusListeners) listener(this.getStatus())
|
||||||
return this.getStatus()
|
return this.getStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,15 +123,15 @@ export abstract class ZapretEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заглушка для платформ, под которые мы бинарники не собираем (Linux). */
|
/** Заглушка для платформ, под которые мы бинарники не собираем (FreeBSD и прочее — не macOS/Windows/Linux). */
|
||||||
export class UnsupportedEngine extends ZapretEngine {
|
export class UnsupportedEngine extends ZapretEngine {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('unsupported', '')
|
super('unsupported', '')
|
||||||
this.status.error = 'Обход блокировок доступен только на macOS и Windows.'
|
this.status.error = 'Обход блокировок доступен только на macOS, Windows и Linux.'
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<ZapretStatus> {
|
async start(): Promise<ZapretStatus> {
|
||||||
throw new Error('Обход блокировок доступен только на macOS и Windows.')
|
throw new Error('Обход блокировок доступен только на macOS, Windows и Linux.')
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<ZapretStatus> {
|
async stop(): Promise<ZapretStatus> {
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
import { createHash } from 'node:crypto'
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import type { ZapretStatus } from '../../shared/ipc-contract'
|
import type { ZapretStatus } from '../../shared/ipc-contract'
|
||||||
import { ZapretEngine } from './engine'
|
import { ZapretEngine } from './engine'
|
||||||
import { assertExecutable, protectedWinwsPath, serviceConfigPath, winwsPath } from './paths'
|
import {
|
||||||
|
SERVICE_BINARY_NAMES,
|
||||||
|
assertExecutable,
|
||||||
|
serviceDataDir,
|
||||||
|
serviceConfigPath,
|
||||||
|
winwsPath
|
||||||
|
} from './paths'
|
||||||
import {
|
import {
|
||||||
applyServiceConfig,
|
applyServiceConfig,
|
||||||
queryService,
|
queryService,
|
||||||
@@ -14,11 +21,16 @@ import {
|
|||||||
stopService,
|
stopService,
|
||||||
type ServiceInfo
|
type ServiceInfo
|
||||||
} from './service.win32'
|
} from './service.win32'
|
||||||
import { defaultStrategyId, winwsConfigFile, type WinwsConfig } from './strategies'
|
import {
|
||||||
|
defaultStrategyId,
|
||||||
|
type EngineConfig,
|
||||||
|
parseStrategyMarker,
|
||||||
|
winwsConfigFile
|
||||||
|
} from './strategies'
|
||||||
|
|
||||||
const run = promisify(execFile)
|
const run = promisify(execFile)
|
||||||
|
|
||||||
/** Первая строка `strategy.cfg` — из `winwsConfigFile()`, формат: `--comment=rknboost:<id>:<hash>`. */
|
/** Первая строка `strategy.cfg` — из `winwsConfigFile()`, формат: `--comment=senboost:<id>:<hash>`. */
|
||||||
async function readInstalledMarkerLine(): Promise<string | null> {
|
async function readInstalledMarkerLine(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const content = await readFile(serviceConfigPath(), 'utf8')
|
const content = await readFile(serviceConfigPath(), 'utf8')
|
||||||
@@ -28,18 +40,34 @@ async function readInstalledMarkerLine(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sha256(path: string): Promise<string> {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(await readFile(path))
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал
|
* Служба запускает защищённую копию бинарников, а не оригинал из папки приложения — значит
|
||||||
* из папки установки приложения (winwsPath) — значит переустановку/обновление приложения
|
* обновление по самому пути службы не отследить. Сравниваем содержимое: время изменения тут
|
||||||
* по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время
|
* не годится совсем, `scripts/fetch-zapret.mjs` кладёт файлы через `copyFile`, а тот ставит
|
||||||
* изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло
|
* текущее время — после каждой сборки CI mtime новее, хотя версия zapret запинована и байты
|
||||||
* позже последней копии, нужно копировать заново (а значит и UAC).
|
* те же. По mtime мы на ровном месте требовали UAC при каждом обновлении приложения и лезли
|
||||||
|
* копировать поверх WinDivert64.sys, ещё загруженного в ядро.
|
||||||
*/
|
*/
|
||||||
async function binariesNeedCopy(): Promise<boolean> {
|
async function binariesNeedCopy(): Promise<boolean> {
|
||||||
|
const sourceDir = dirname(winwsPath())
|
||||||
|
const targetDir = serviceDataDir()
|
||||||
try {
|
try {
|
||||||
const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())])
|
for (const name of SERVICE_BINARY_NAMES) {
|
||||||
return source.size !== copy.size || source.mtimeMs > copy.mtimeMs
|
const [source, copy] = await Promise.all([
|
||||||
|
sha256(join(sourceDir, name)),
|
||||||
|
sha256(join(targetDir, name))
|
||||||
|
])
|
||||||
|
if (source !== copy) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
|
// Какого-то файла ещё нет (первая установка) или он нечитаем — надёжнее скопировать.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +77,7 @@ async function binariesNeedCopy(): Promise<boolean> {
|
|||||||
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
|
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
|
||||||
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
|
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
|
||||||
*/
|
*/
|
||||||
async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null): Promise<boolean> {
|
async function needsReconfigure(config: EngineConfig, info: ServiceInfo | null): Promise<boolean> {
|
||||||
if (!info) return true
|
if (!info) return true
|
||||||
if (await binariesNeedCopy()) return true
|
if (await binariesNeedCopy()) return true
|
||||||
return (await readInstalledMarkerLine()) !== config.marker
|
return (await readInstalledMarkerLine()) !== config.marker
|
||||||
@@ -57,7 +85,7 @@ async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null):
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Windows: winws требует драйвер WinDivert и права администратора, поэтому работает как служба
|
* Windows: winws требует драйвер WinDivert и права администратора, поэтому работает как служба
|
||||||
* Windows (`rknboost-zapret`), а не как обычный дочерний процесс приложения.
|
* Windows (`senboost-zapret`), а не как обычный дочерний процесс приложения.
|
||||||
*
|
*
|
||||||
* UAC запрашивается только при первой установке службы и при смене аргументов (новая стратегия,
|
* UAC запрашивается только при первой установке службы и при смене аргументов (новая стратегия,
|
||||||
* переключение автозапуска) — этим занимается `service.win32.ts`. Обычные включение/выключение
|
* переключение автозапуска) — этим занимается `service.win32.ts`. Обычные включение/выключение
|
||||||
@@ -84,8 +112,7 @@ export class Win32Engine extends ZapretEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const installedLine = await readInstalledMarkerLine()
|
const installedLine = await readInstalledMarkerLine()
|
||||||
const match = installedLine ? /^--comment=rknboost:([^:]+):/.exec(installedLine) : null
|
const strategyId = parseStrategyMarker(installedLine) ?? this.status.strategyId
|
||||||
const strategyId = match?.[1] ?? this.status.strategyId
|
|
||||||
|
|
||||||
return this.patch({
|
return this.patch({
|
||||||
serviceInstalled: true,
|
serviceInstalled: true,
|
||||||
@@ -102,7 +129,7 @@ export class Win32Engine extends ZapretEngine {
|
|||||||
async start(strategyId: string): Promise<ZapretStatus> {
|
async start(strategyId: string): Promise<ZapretStatus> {
|
||||||
this.patch({ state: 'starting', strategyId, error: null })
|
this.patch({ state: 'starting', strategyId, error: null })
|
||||||
|
|
||||||
let config: WinwsConfig
|
let config: EngineConfig
|
||||||
try {
|
try {
|
||||||
config = winwsConfigFile(strategyId)
|
config = winwsConfigFile(strategyId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -180,7 +207,7 @@ export class Win32Engine extends ZapretEngine {
|
|||||||
* а не после него.
|
* а не после него.
|
||||||
*/
|
*/
|
||||||
private async dryRun(configBody: string): Promise<void> {
|
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')
|
const configPath = join(dir, 'dry-run.cfg')
|
||||||
try {
|
try {
|
||||||
await writeFile(configPath, configBody, 'utf8')
|
await writeFile(configPath, configBody, 'utf8')
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { readFile, rm } from 'node:fs/promises'
|
|
||||||
import type {
|
import type {
|
||||||
ZapretDiagnosticResult,
|
ZapretDiagnosticResult,
|
||||||
|
ZapretDiagnosticTarget,
|
||||||
ZapretList,
|
ZapretList,
|
||||||
ZapretListId,
|
ZapretListId,
|
||||||
ZapretLogLine,
|
ZapretLogLine,
|
||||||
ZapretStatus,
|
ZapretStatus,
|
||||||
ZapretStrategy
|
ZapretStrategy
|
||||||
} from '../../shared/ipc-contract'
|
} from '../../shared/ipc-contract'
|
||||||
import { runDiagnostics } from './diagnostics'
|
import { getSettings } from '../settings'
|
||||||
|
import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics'
|
||||||
import { DarwinEngine } from './engine.darwin'
|
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 { Win32Engine } from './engine.win32'
|
||||||
import { readAllLists, resetList, seedLists, writeList } from './lists'
|
import { readAllLists, resetList, seedLists, writeList } from './lists'
|
||||||
import { autoHostlistPath } from './paths'
|
import { restoreSystemProxy } from './proxy.darwin'
|
||||||
import { listStrategies } from './strategies'
|
import { listStrategies } from './strategies'
|
||||||
|
|
||||||
function createEngine(): ZapretEngine {
|
function createEngine(): ZapretEngine {
|
||||||
@@ -21,6 +23,8 @@ function createEngine(): ZapretEngine {
|
|||||||
return new DarwinEngine()
|
return new DarwinEngine()
|
||||||
case 'win32':
|
case 'win32':
|
||||||
return new Win32Engine()
|
return new Win32Engine()
|
||||||
|
case 'linux':
|
||||||
|
return new LinuxEngine()
|
||||||
default:
|
default:
|
||||||
return new UnsupportedEngine()
|
return new UnsupportedEngine()
|
||||||
}
|
}
|
||||||
@@ -29,16 +33,47 @@ function createEngine(): ZapretEngine {
|
|||||||
const engine = createEngine()
|
const engine = createEngine()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Готовим списки сайтов и синхронизируем состояние с системой — на macOS чиним прокси
|
* Готовим списки сайтов, возвращаем выбор пользователя из прошлого запуска и синхронизируем
|
||||||
* от прошлого аварийного завершения, на Windows подхватываем службу, которая могла работать
|
* состояние с системой — на macOS подхватываем LaunchDaemon, который мог работать ещё до
|
||||||
* ещё до открытия окна (автозапуск с системой). Оба шага нужно сделать до открытия окна.
|
* открытия окна (автозапуск с системой) и один раз чиним системный прокси, оставшийся от
|
||||||
|
* версий на tpws (≤0.5.4, см. `proxy.darwin.ts`), на Windows подхватываем службу с тем же
|
||||||
|
* автозапуском, на Linux — демон nfqws, переживший закрытие приложения (автозапуска у него
|
||||||
|
* нет, но сам процесс и правила nftables продолжают работать). Все шаги нужно сделать до
|
||||||
|
* открытия окна.
|
||||||
*/
|
*/
|
||||||
export async function recoverZapret(): Promise<void> {
|
export async function recoverZapret(): Promise<void> {
|
||||||
await seedLists()
|
await seedLists()
|
||||||
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
|
|
||||||
|
// Сохранённый выбор применяем ДО sync(): у уже установленной службы/демона реальное
|
||||||
|
// состояние (стратегия в strategy.cfg, автозапуск) важнее записанного нами и перекроет его.
|
||||||
|
// Файл к этому моменту уже прочитан в `app.whenReady()` (см. `src/main/index.ts`).
|
||||||
|
const settings = getSettings()
|
||||||
|
engine.restorePreferences({
|
||||||
|
// Стратегия могла исчезнуть в обновлении (подборка Flowseal меняется) или остаться от
|
||||||
|
// запуска на другой платформе — тогда безопаснее вернуться к умолчанию движка, чем
|
||||||
|
// стартовать с id, которого больше нет.
|
||||||
|
strategyId: knownStrategyId(settings.strategyId),
|
||||||
|
autoStart: settings.autoStart
|
||||||
|
})
|
||||||
|
|
||||||
|
if (engine instanceof DarwinEngine) {
|
||||||
|
await restoreSystemProxy().catch(() => {
|
||||||
|
// Раз откатить не удалось — оставляем как есть, не мешаем окну открыться.
|
||||||
|
})
|
||||||
|
await engine.sync()
|
||||||
|
}
|
||||||
if (engine instanceof Win32Engine) await engine.sync()
|
if (engine instanceof Win32Engine) await engine.sync()
|
||||||
|
if (engine instanceof LinuxEngine) await engine.sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function knownStrategyId(strategyId: string | null): string | null {
|
||||||
|
if (!strategyId) return null
|
||||||
|
const known = listStrategies(process.platform).some((strategy) => strategy.id === strategyId)
|
||||||
|
return known ? strategyId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export { onZapretStatusChanged }
|
||||||
|
|
||||||
export function zapretStatus(): ZapretStatus {
|
export function zapretStatus(): ZapretStatus {
|
||||||
return engine.getStatus()
|
return engine.getStatus()
|
||||||
}
|
}
|
||||||
@@ -59,10 +94,6 @@ export function stopZapret(): Promise<ZapretStatus> {
|
|||||||
return engine.stop()
|
return engine.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setZapretSystemProxy(enabled: boolean): Promise<ZapretStatus> {
|
|
||||||
return engine.setSystemProxy(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setZapretStrategy(strategyId: string): Promise<ZapretStatus> {
|
export function setZapretStrategy(strategyId: string): Promise<ZapretStatus> {
|
||||||
return engine.setStrategy(strategyId)
|
return engine.setStrategy(strategyId)
|
||||||
}
|
}
|
||||||
@@ -88,23 +119,19 @@ export function resetZapretList(id: ZapretListId): Promise<ZapretList> {
|
|||||||
return resetList(id)
|
return resetList(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Домены, которые zapret нашёл сам через `--hostlist-auto` (только macOS-профили). */
|
/**
|
||||||
export async function zapretAutoHostlist(): Promise<string[]> {
|
* Запускает проверку и сразу возвращает список сайтов — результаты приходят в `onResult`
|
||||||
try {
|
* по одному, по мере готовности (см. `runDiagnostics`). Промис проверки намеренно не ждём:
|
||||||
const content = await readFile(autoHostlistPath(), 'utf8')
|
* ответ на IPC — это список сайтов, чтобы UI успел показать строки «Проверяю…» до того,
|
||||||
return content
|
* как ответит первый сайт.
|
||||||
.split('\n')
|
*/
|
||||||
.map((line) => line.trim())
|
export function zapretDiagnostics(
|
||||||
.filter(Boolean)
|
onResult: (result: ZapretDiagnosticResult) => void
|
||||||
} catch {
|
): ZapretDiagnosticTarget[] {
|
||||||
return []
|
// Каждая проверка ловит свою ошибку сама, наружу выбраться нечему — ловим здесь только
|
||||||
}
|
// чтобы неожиданное падение не осталось необработанным промисом.
|
||||||
}
|
runDiagnostics(onResult).catch((error: unknown) => {
|
||||||
|
console.error('[zapret] проверка соединения завершилась ошибкой:', error)
|
||||||
export async function clearZapretAutoHostlist(): Promise<void> {
|
})
|
||||||
await rm(autoHostlistPath(), { force: true })
|
return DIAGNOSTIC_TARGETS
|
||||||
}
|
|
||||||
|
|
||||||
export function zapretDiagnostics(): Promise<ZapretDiagnosticResult[]> {
|
|
||||||
return runDiagnostics(engine.getStatus())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { execFile, spawn } from 'node:child_process'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
chmod,
|
||||||
|
constants,
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import {
|
||||||
|
bundledFakesDir,
|
||||||
|
bundledHelperPath,
|
||||||
|
linuxInstalledConfigPath,
|
||||||
|
linuxPidfilePath,
|
||||||
|
linuxStagingDir,
|
||||||
|
nfqwsPath,
|
||||||
|
stagedFakesDir,
|
||||||
|
stagedHelperPath,
|
||||||
|
stagedNfqwsPath
|
||||||
|
} from './paths'
|
||||||
|
import { parseStrategyMarker } from './strategies'
|
||||||
|
|
||||||
|
const run = promisify(execFile)
|
||||||
|
|
||||||
|
async function sha256(path: string): Promise<string> {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(await readFile(path))
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesIdentical(a: string, b: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [hashA, hashB] = await Promise.all([sha256(a), sha256(b)])
|
||||||
|
return hashA === hashB
|
||||||
|
} catch {
|
||||||
|
// Копии ещё нет (первый запуск) или она нечитаема — надёжнее перекопировать.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Копирует nfqws, помощника и fake-пакеты из `resources/zapret`/`resources/linux-helper`
|
||||||
|
* в `linuxStagingDir()` — см. её комментарий в paths.ts про то, почему это не оптимизация,
|
||||||
|
* а необходимость под AppImage. Сравниваем содержимое, а не mtime — тот же мотив, что у
|
||||||
|
* `binariesNeedCopy()` в engine.win32.ts: `copyFile` всегда ставит текущее время, хотя
|
||||||
|
* зафиксированная версия zapret даёт одни и те же байты между сборками.
|
||||||
|
*/
|
||||||
|
export async function stageRuntime(): Promise<void> {
|
||||||
|
await mkdir(linuxStagingDir(), { recursive: true })
|
||||||
|
await mkdir(stagedFakesDir(), { recursive: true })
|
||||||
|
|
||||||
|
if (!(await filesIdentical(nfqwsPath(), stagedNfqwsPath()))) {
|
||||||
|
await copyFile(nfqwsPath(), stagedNfqwsPath())
|
||||||
|
}
|
||||||
|
await chmod(stagedNfqwsPath(), 0o755)
|
||||||
|
|
||||||
|
if (!(await filesIdentical(bundledHelperPath(), stagedHelperPath()))) {
|
||||||
|
await copyFile(bundledHelperPath(), stagedHelperPath())
|
||||||
|
}
|
||||||
|
await chmod(stagedHelperPath(), 0o755)
|
||||||
|
|
||||||
|
const fakesSrcDir = bundledFakesDir()
|
||||||
|
for (const name of await readdir(fakesSrcDir)) {
|
||||||
|
const src = join(fakesSrcDir, name)
|
||||||
|
const dst = join(stagedFakesDir(), name)
|
||||||
|
if (!(await filesIdentical(src, dst))) {
|
||||||
|
await copyFile(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Номер очереди NFQUEUE — держать в синхроне с `QUEUE_NUM` в `senboost-helper.sh`
|
||||||
|
* (там же и объяснение выбора числа). В TS он нужен только для `--dry-run` ниже: сам
|
||||||
|
* помощник получает его не отсюда, а как захардкоженное значение в своём коде.
|
||||||
|
*/
|
||||||
|
const NFQUEUE_NUM = 220
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `nfqws --dry-run @<файл>` — проверяет аргументы фильтра, root не нужен (в отличие от
|
||||||
|
* рабочего запуска демона). Запускаем бинарник из сборки напрямую: dry-run идёт от uid
|
||||||
|
* самого приложения, а не root, так что FUSE-примонтированный AppImage тут не помеха —
|
||||||
|
* ограничение касается только `pkexec` (см. linuxStagingDir()).
|
||||||
|
*
|
||||||
|
* В отличие от `Win32Engine['dryRun']` (там `winws.exe --dry-run @file` без доп. флагов
|
||||||
|
* достаточно) nfqws требует `--qnum` даже для проверки — «Need queue number (--qnum)»,
|
||||||
|
* даже когда реальная очередь не открывается. Значение то же, что использует помощник
|
||||||
|
* при настоящем запуске: раз номер не совпадёт — `nft`-правила и демон всё равно будут
|
||||||
|
* рассинхронизированы, но сам факт валидности остальных аргументов dry-run проверяет верно.
|
||||||
|
*/
|
||||||
|
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(nfqwsPath(), ['--dry-run', `--qnum=${NFQUEUE_NUM}`, `@${configPath}`])
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
throw new Error(`Стратегия содержит недопустимые параметры nfqws: ${message}`, {
|
||||||
|
cause: error
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отрендеренный конфиг лежит здесь до pkexec — сам помощник, уже под root, копирует его в /run. */
|
||||||
|
async function writeStagedConfig(body: string): Promise<string> {
|
||||||
|
const path = join(linuxStagingDir(), 'pending-strategy.cfg')
|
||||||
|
await writeFile(path, body, 'utf8')
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Хвост stderr помощника — тот же лимит и мотив, что у `stderrTail` в engine.darwin.ts. */
|
||||||
|
const STDERR_TAIL_LIMIT = 4000
|
||||||
|
|
||||||
|
function runHelper(args: string[]): Promise<{ code: number; stderr: string }> {
|
||||||
|
return new Promise((resolveHelper) => {
|
||||||
|
const child = spawn('pkexec', [stagedHelperPath(), ...args], {
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
let stderr = ''
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_TAIL_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
// pkexec не найден — spawn сообщает об этом событием 'error', а не кодом выхода
|
||||||
|
// (см. launchUninstaller() в uninstall.win32.ts, та же причина).
|
||||||
|
child.on('error', (error) => resolveHelper({ code: 127, stderr: error.message }))
|
||||||
|
child.on('close', (code) => resolveHelper({ code: code ?? 1, stderr }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartNfqwsOptions {
|
||||||
|
configBody: string
|
||||||
|
tcpPorts: string
|
||||||
|
udpPorts: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единственные два места, где приложение просит root — включение и выключение обхода. */
|
||||||
|
export async function startNfqws(options: StartNfqwsOptions): Promise<void> {
|
||||||
|
await stageRuntime()
|
||||||
|
const configPath = await writeStagedConfig(options.configBody)
|
||||||
|
|
||||||
|
const { code, stderr } = await runHelper([
|
||||||
|
'start',
|
||||||
|
stagedNfqwsPath(),
|
||||||
|
configPath,
|
||||||
|
options.tcpPorts,
|
||||||
|
options.udpPorts
|
||||||
|
])
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Не удалось запустить обход (pkexec, код ${code})` +
|
||||||
|
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopNfqws(): Promise<void> {
|
||||||
|
// Помощник может понадобиться, даже если start() в эту сессию ещё не звали — например,
|
||||||
|
// sync() увидел уже работающий с прошлого сеанса демон, и первое действие пользователя
|
||||||
|
// в новом запуске приложения — «Выключить».
|
||||||
|
await stageRuntime()
|
||||||
|
|
||||||
|
const { code, stderr } = await runHelper(['stop'])
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Не удалось остановить обход (pkexec, код ${code})` +
|
||||||
|
(stderr ? `: ${stderr}` : ' — запрос прав администратора отклонён или прерван.')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NfqwsState {
|
||||||
|
pid: number | null
|
||||||
|
strategyId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/proc/<pid>` — существование каталога о процессе видно без прав, сигнал слать не нужно. */
|
||||||
|
export async function isProcessAlive(pid: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(`/proc/${pid}`, constants.F_OK)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Не требует root: `nfqws.pid` и `strategy.cfg` в `/run/senboost` помощник кладёт с правами
|
||||||
|
* 0644 (см. senboost-helper.sh) именно затем, чтобы это чтение — и восстановление состояния
|
||||||
|
* в `LinuxEngine.sync()` — обходилось без пароля.
|
||||||
|
*/
|
||||||
|
export async function readNfqwsState(): Promise<NfqwsState> {
|
||||||
|
let pid: number | null = null
|
||||||
|
try {
|
||||||
|
const raw = (await readFile(linuxPidfilePath(), 'utf8')).trim()
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (Number.isInteger(parsed) && parsed > 0 && (await isProcessAlive(parsed))) {
|
||||||
|
pid = parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// pidfile нет или процесс из него уже не живёт — считаем демон не запущенным.
|
||||||
|
}
|
||||||
|
|
||||||
|
let strategyId: string | null = null
|
||||||
|
try {
|
||||||
|
const content = await readFile(linuxInstalledConfigPath(), 'utf8')
|
||||||
|
strategyId = parseStrategyMarker(content.split('\n')[0]?.trim() ?? null)
|
||||||
|
} catch {
|
||||||
|
// strategy.cfg нет — не страшно, id останется null.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pid, strategyId }
|
||||||
|
}
|
||||||
@@ -13,9 +13,9 @@ function zapretRoot(): string {
|
|||||||
: resolve(app.getAppPath(), 'resources', 'zapret')
|
: resolve(app.getAppPath(), 'resources', 'zapret')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** macOS: universal-бинарник tpws (arm64 + x86_64). */
|
/** macOS: universal-бинарник utunws (arm64 + x86_64), форк nfqws под utun+BPF. */
|
||||||
export function tpwsPath(): string {
|
export function utunwsPath(): string {
|
||||||
return join(zapretRoot(), 'darwin', 'tpws')
|
return join(zapretRoot(), 'darwin', 'utunws')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Windows: winws.exe рядом с cygwin1.dll и WinDivert. */
|
/** Windows: winws.exe рядом с cygwin1.dll и WinDivert. */
|
||||||
@@ -23,6 +23,132 @@ export function winwsPath(): string {
|
|||||||
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
|
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Linux: nfqws в составе сборки — для `--dry-run` (без root, читает свой же uid). */
|
||||||
|
export function nfqwsPath(): string {
|
||||||
|
return join(zapretRoot(), 'linux', 'x64', 'nfqws')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: привилегированный помощник в составе сборки (`resources/linux-helper/`, отдельный
|
||||||
|
* `extraResources` в electron-builder.yml — не качается `fetch-zapret.mjs`, лежит в репозитории).
|
||||||
|
*/
|
||||||
|
export function bundledHelperPath(): string {
|
||||||
|
return app.isPackaged
|
||||||
|
? join(process.resourcesPath, 'linux-helper', 'senboost-helper.sh')
|
||||||
|
: resolve(app.getAppPath(), 'resources', 'linux-helper', 'senboost-helper.sh')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: каталог, куда `nfqws`, помощник и fake-пакеты копируются перед КАЖДЫМ запуском
|
||||||
|
* (см. `nfqws.linux.ts`). Не оптимизация — необходимость: в AppImage `resources/zapret`
|
||||||
|
* примонтирован через FUSE без `allow_other`, и root (а `pkexec` поднимает именно root)
|
||||||
|
* туда просто не достучится — ни исполнить бинарник, ни прочитать fake-пакет во время
|
||||||
|
* работы. `userData` — обычная файловая система на диске; root видит любой файл на ней
|
||||||
|
* независимо от прав (DAC_OVERRIDE), так что копия здесь читается без проблем что для
|
||||||
|
* AppImage, что для .deb (там `resources/zapret` и так root-owned, копия просто лишняя,
|
||||||
|
* но не вредная — один код-путь на обе цели дешевле двух).
|
||||||
|
*/
|
||||||
|
export function linuxStagingDir(): string {
|
||||||
|
return join(app.getPath('userData'), 'linux-runtime')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stagedNfqwsPath(): string {
|
||||||
|
return join(linuxStagingDir(), 'nfqws')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stagedHelperPath(): string {
|
||||||
|
return join(linuxStagingDir(), 'senboost-helper.sh')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стейджинговая копия `fakesDir()` — то, что реально читает nfqws (см. её комментарий). */
|
||||||
|
export function stagedFakesDir(): string {
|
||||||
|
return join(linuxStagingDir(), 'fakes')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Встроенные fake-пакеты — источник для копирования в `stagedFakesDir()`. */
|
||||||
|
export function bundledFakesDir(): string {
|
||||||
|
return join(zapretRoot(), 'fakes')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рабочий каталог демона — pidfile и `strategy.cfg` (последний — источник маркера стратегии
|
||||||
|
* для `LinuxEngine.sync()`, читается без root: помощник кладёт его с правами 0644). Создаёт
|
||||||
|
* и чистит сам `senboost-helper.sh`. `/run` — стандартный FHS-путь для данных, не переживающих
|
||||||
|
* перезагрузку: nfqws её тоже не переживает, автозапуска на Linux нет (см. README.md).
|
||||||
|
*/
|
||||||
|
export function linuxRuntimeDir(): string {
|
||||||
|
return '/run/senboost'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linuxPidfilePath(): string {
|
||||||
|
return join(linuxRuntimeDir(), 'nfqws.pid')
|
||||||
|
}
|
||||||
|
|
||||||
|
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) — только для чтения. */
|
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
|
||||||
export function bundledListsDir(): string {
|
export function bundledListsDir(): string {
|
||||||
return join(zapretRoot(), 'lists')
|
return join(zapretRoot(), 'lists')
|
||||||
@@ -37,18 +163,26 @@ export function userListsDir(): string {
|
|||||||
return join(app.getPath('userData'), 'zapret-lists')
|
return join(app.getPath('userData'), 'zapret-lists')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fake-пакеты (TLS ClientHello, QUIC initial, ...) для стратегий winws. */
|
/**
|
||||||
|
* Fake-пакеты (TLS ClientHello, QUIC initial, ...) — путь, который резолвится в
|
||||||
|
* `{FAKES}` (`strategies.ts`) и должен суметь прочитать сам движок при работе, не только
|
||||||
|
* при запуске. На Linux это стейджинговая копия (`stagedFakesDir()`, см. её комментарий про
|
||||||
|
* FUSE), на macOS — установленная копия под root (`darwinInstalledFakesDir()`, см. её
|
||||||
|
* комментарий); на Windows движок работает под тем же uid, что видит сборку приложения.
|
||||||
|
*/
|
||||||
export function fakesDir(): string {
|
export function fakesDir(): string {
|
||||||
|
if (process.platform === 'linux') return stagedFakesDir()
|
||||||
|
if (process.platform === 'darwin') return darwinInstalledFakesDir()
|
||||||
return join(zapretRoot(), 'fakes')
|
return join(zapretRoot(), 'fakes')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Windows: служба `rknboost-zapret` работает от LocalSystem, поэтому её конфиг не может
|
* Windows: служба `senboost-zapret` работает от LocalSystem, поэтому её конфиг не может
|
||||||
* жить в userData обычного пользователя — нужен каталог машинного уровня. `%ProgramData%`
|
* жить в userData обычного пользователя — нужен каталог машинного уровня. `%ProgramData%`
|
||||||
* почти всегда задан; `C:\ProgramData` — тот же путь, на который он указывает по умолчанию.
|
* почти всегда задан; `C:\ProgramData` — тот же путь, на который он указывает по умолчанию.
|
||||||
*/
|
*/
|
||||||
export function serviceDataDir(): string {
|
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` — пишется только повышенным скриптом службы. */
|
/** Файл аргументов `winws.exe @strategy.cfg` — пишется только повышенным скриптом службы. */
|
||||||
@@ -56,6 +190,19 @@ export function serviceConfigPath(): string {
|
|||||||
return join(serviceDataDir(), 'strategy.cfg')
|
return join(serviceDataDir(), 'strategy.cfg')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файлы, которые служба запускает из своего защищённого каталога: сам winws и всё, от чего
|
||||||
|
* он зависит при старте. Список нужен и `engine.win32.ts` (решить, надо ли копировать),
|
||||||
|
* и повышенному скрипту в `service.win32.ts` (собственно копирование) — держим его здесь,
|
||||||
|
* чтобы он не разъехался на две копии.
|
||||||
|
*/
|
||||||
|
export const SERVICE_BINARY_NAMES = [
|
||||||
|
'winws.exe',
|
||||||
|
'cygwin1.dll',
|
||||||
|
'WinDivert.dll',
|
||||||
|
'WinDivert64.sys'
|
||||||
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
|
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
|
||||||
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь
|
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь
|
||||||
@@ -69,15 +216,6 @@ export function protectedWinwsPath(): string {
|
|||||||
return join(serviceDataDir(), 'winws.exe')
|
return join(serviceDataDir(), 'winws.exe')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Список, который zapret пополняет сам (`--hostlist-auto`): при похожей на блокировку
|
|
||||||
* ошибке домен дописывается сюда и на лету попадает под десинк, без участия пользователя.
|
|
||||||
* Не входит в `ZapretListId` — это нередактируемый журнал находок, а не список на правку.
|
|
||||||
*/
|
|
||||||
export function autoHostlistPath(): string {
|
|
||||||
return join(userListsDir(), 'auto.txt')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Понятная ошибка вместо ENOENT из глубины spawn: без этой проверки пользователь
|
* Понятная ошибка вместо ENOENT из глубины spawn: без этой проверки пользователь
|
||||||
* увидит «spawn ... ENOENT» и не поймёт, что надо запустить `npm run zapret:fetch`.
|
* увидит «spawn ... ENOENT» и не поймёт, что надо запустить `npm run zapret:fetch`.
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { execFile } from 'node:child_process'
|
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 { join } from 'node:path'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
|
|
||||||
const run = promisify(execFile)
|
const run = promisify(execFile)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Домены и адреса, которые не должны идти через прокси. Без этого списка отвалится
|
* Разовая миграция с версий на `tpws` (≤0.5.4, socks5-прокси уровня TCP-соединений).
|
||||||
* всё локальное: tpws по документации запрещает подключения к тому же хосту,
|
* Новый движок (`utunws`, см. `engine.darwin.ts`) обходит блокировки на уровне пакетов
|
||||||
* на котором работает сам, включая localhost.
|
* через 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 {
|
interface ServiceProxyState {
|
||||||
service: string
|
service: string
|
||||||
@@ -35,7 +42,9 @@ function snapshotPath(): string {
|
|||||||
* `networksetup` требует членства в группе admin. На обычном Mac пользователь в ней
|
* `networksetup` требует членства в группе admin. На обычном Mac пользователь в ней
|
||||||
* состоит и пароль не спрашивается. Если же включена политика «требовать пароль
|
* состоит и пароль не спрашивается. Если же включена политика «требовать пароль
|
||||||
* администратора для системных настроек», команда падает — тогда повторяем её через
|
* администратора для системных настроек», команда падает — тогда повторяем её через
|
||||||
* системный диалог авторизации.
|
* системный диалог авторизации (та же логика, что и `runPrivileged()` в `elevate.darwin.ts`,
|
||||||
|
* этот файл написан раньше и его исторически не трогаем — обе копии удаляются вместе с
|
||||||
|
* этой миграцией, когда версии ≤0.5.4 отойдут в прошлое).
|
||||||
*/
|
*/
|
||||||
async function networksetup(args: string[]): Promise<string> {
|
async function networksetup(args: string[]): Promise<string> {
|
||||||
try {
|
try {
|
||||||
@@ -58,48 +67,14 @@ async function networksetup(args: string[]): Promise<string> {
|
|||||||
} catch (elevationError) {
|
} catch (elevationError) {
|
||||||
const text = elevationError instanceof Error ? elevationError.message : String(elevationError)
|
const text = elevationError instanceof Error ? elevationError.message : String(elevationError)
|
||||||
if (text.includes('-128'))
|
if (text.includes('-128'))
|
||||||
throw new Error('Изменение системного прокси отменено.', { cause: elevationError })
|
throw new Error('Восстановление системного прокси отменено.', { cause: elevationError })
|
||||||
throw new Error(`Не удалось изменить настройки прокси: ${text}`, { 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> {
|
async function loadSnapshot(): Promise<ProxySnapshot | null> {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(snapshotPath(), 'utf8')) as ProxySnapshot
|
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])
|
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> {
|
export async function restoreSystemProxy(): Promise<boolean> {
|
||||||
const snapshot = await loadSnapshot()
|
const snapshot = await loadSnapshot()
|
||||||
@@ -159,7 +114,7 @@ export async function restoreSystemProxy(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Снимок остался с прошлого запуска — значит приложение завершилось аварийно,
|
* Снимок остался с прошлого запуска старой версии — значит tpws завершился аварийно,
|
||||||
* не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте.
|
* не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте.
|
||||||
*/
|
*/
|
||||||
export async function hasStaleSnapshot(): Promise<boolean> {
|
export async function hasStaleSnapshot(): Promise<boolean> {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
isElevated,
|
isElevated,
|
||||||
runElevatedServiceTask
|
runElevatedServiceTask
|
||||||
} from './elevated-task.win32'
|
} from './elevated-task.win32'
|
||||||
import { serviceConfigPath, serviceDataDir, winwsPath } from './paths'
|
import { SERVICE_BINARY_NAMES, serviceConfigPath, serviceDataDir, winwsPath } from './paths'
|
||||||
|
|
||||||
const run = promisify(execFile)
|
const run = promisify(execFile)
|
||||||
|
|
||||||
export const SERVICE_NAME = 'rknboost-zapret'
|
export const SERVICE_NAME = 'senboost-zapret'
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 300
|
const POLL_INTERVAL_MS = 300
|
||||||
const POLL_TIMEOUT_MS = 10000
|
const POLL_TIMEOUT_MS = 10000
|
||||||
@@ -31,6 +31,8 @@ const ERROR_SERVICE_ALREADY_RUNNING = 1056
|
|||||||
const ERROR_SERVICE_NOT_ACTIVE = 1062
|
const ERROR_SERVICE_NOT_ACTIVE = 1062
|
||||||
|
|
||||||
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
|
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
|
||||||
|
/** То, чего можно дождаться от службы. `Absent` — службы в системе нет (снята). */
|
||||||
|
type WaitState = 'Running' | 'Stopped' | 'Absent'
|
||||||
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
|
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
|
||||||
|
|
||||||
export interface ServiceInfo {
|
export interface ServiceInfo {
|
||||||
@@ -40,10 +42,6 @@ export interface ServiceInfo {
|
|||||||
binPath: string
|
binPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitCodeOf(error: unknown): number {
|
function exitCodeOf(error: unknown): number {
|
||||||
const code = (error as { code?: number | string } | undefined)?.code
|
const code = (error as { code?: number | string } | undefined)?.code
|
||||||
return typeof code === 'number' ? code : 1
|
return typeof code === 'number' ? code : 1
|
||||||
@@ -81,12 +79,29 @@ function normalizeStartMode(raw: string | undefined): ServiceStartMode {
|
|||||||
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
|
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
|
||||||
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
|
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
|
||||||
* локализованный текст, который парсить надёжно нельзя.
|
* локализованный текст, который парсить надёжно нельзя.
|
||||||
|
*
|
||||||
|
* Ждём тоже здесь, внутри одного скрипта: запуск powershell.exe стоит около секунды (он
|
||||||
|
* поднимает .NET), а опросов до перехода службы в нужное состояние бывает с десяток. Раньше
|
||||||
|
* каждый опрос был отдельным процессом, и включение обхода складывалось в основном из этих
|
||||||
|
* запусков; теперь на всё ожидание приходится один. Сам опрос внутри CIM — десятки мс.
|
||||||
*/
|
*/
|
||||||
export async function queryService(): Promise<ServiceInfo | null> {
|
function queryScript(until: readonly WaitState[], timeoutMs: number): string {
|
||||||
const script =
|
return `
|
||||||
`Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` +
|
$targets = @(${until.map((state) => `'${state}'`).join(',')})
|
||||||
'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress'
|
$deadline = [DateTime]::UtcNow.AddMilliseconds(${timeoutMs})
|
||||||
|
for (;;) {
|
||||||
|
$svc = Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" |
|
||||||
|
Select-Object State,StartMode,PathName
|
||||||
|
$state = if ($svc) { $svc.State } else { 'Absent' }
|
||||||
|
if ($targets.Count -eq 0 -or $targets -contains $state) { break }
|
||||||
|
if ([DateTime]::UtcNow -ge $deadline) { break }
|
||||||
|
Start-Sleep -Milliseconds ${POLL_INTERVAL_MS}
|
||||||
|
}
|
||||||
|
if ($svc) { $svc | ConvertTo-Json -Compress } else { 'null' }
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQuery(script: string): Promise<ServiceInfo | null> {
|
||||||
let stdout: string
|
let stdout: string
|
||||||
try {
|
try {
|
||||||
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
|
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
|
||||||
@@ -108,6 +123,19 @@ export async function queryService(): Promise<ServiceInfo | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Текущее состояние службы; `null` — службы в системе нет. */
|
||||||
|
export function queryService(): Promise<ServiceInfo | null> {
|
||||||
|
return runQuery(queryScript([], 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ждёт одного из состояний и возвращает последнее увиденное — в том числе по таймауту,
|
||||||
|
* поэтому результат у вызывающего кода обязателен к проверке.
|
||||||
|
*/
|
||||||
|
function waitForService(...states: WaitState[]): Promise<ServiceInfo | null> {
|
||||||
|
return runQuery(queryScript(states, POLL_TIMEOUT_MS))
|
||||||
|
}
|
||||||
|
|
||||||
function explainScError(error: unknown, verb: string): string {
|
function explainScError(error: unknown, verb: string): string {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
if (/access is denied/i.test(message)) {
|
if (/access is denied/i.test(message)) {
|
||||||
@@ -119,18 +147,6 @@ function explainScError(error: unknown, verb: string): string {
|
|||||||
return `Не удалось ${verb} службу: ${message}`
|
return `Не удалось ${verb} службу: ${message}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollUntil(
|
|
||||||
predicate: (info: ServiceInfo | null) => boolean
|
|
||||||
): Promise<ServiceInfo | null> {
|
|
||||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
|
||||||
for (;;) {
|
|
||||||
const info = await queryService()
|
|
||||||
if (predicate(info)) return info
|
|
||||||
if (Date.now() >= deadline) return info
|
|
||||||
await sleep(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
|
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
|
||||||
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
|
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
|
||||||
@@ -147,7 +163,7 @@ export async function startService(): Promise<ServiceInfo | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped')
|
const info = await waitForService('Running', 'Stopped')
|
||||||
if (info?.state !== 'Running') {
|
if (info?.state !== 'Running') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
|
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
|
||||||
@@ -169,12 +185,11 @@ export async function stopService(): Promise<ServiceInfo | null> {
|
|||||||
throw new Error(explainScError(error, 'остановить'), { cause: error })
|
throw new Error(explainScError(error, 'остановить'), { cause: error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката,
|
// Результат обязательно проверяем: «остановка», не уложившаяся в POLL_TIMEOUT_MS, иначе
|
||||||
// поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS,
|
// молча сошла бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
|
||||||
// молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
|
|
||||||
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
|
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
|
||||||
// остановка и защищает.
|
// остановка и защищает.
|
||||||
const info = await pollUntil((i) => i === null || i.state === 'Stopped')
|
const info = await waitForService('Stopped', 'Absent')
|
||||||
if (info && info.state !== 'Stopped') {
|
if (info && info.state !== 'Stopped') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
|
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
|
||||||
@@ -270,6 +285,46 @@ function Invoke-ScRaw($rawArgs) {
|
|||||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
|
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# WinDivert64.sys — образ драйвера, загруженного в ядро: пока драйвер не выгружен, файл
|
||||||
|
# занят, и Copy-Item поверх него падает с «файл используется другим процессом». SCM
|
||||||
|
# рапортует Stopped, как только winws.exe отдал сигнал, а выгрузка идёт уже после — при
|
||||||
|
# быстром переподключении попадаем ровно в этот зазор.
|
||||||
|
#
|
||||||
|
# Отсюда три ступени. Сравнение по ХЕШУ, а не по времени изменения: fetch-zapret.mjs кладёт
|
||||||
|
# бинарники через copyFile, тот ставит текущее время, и после каждой сборки CI mtime новее
|
||||||
|
# при тех же байтах — по времени мы лезли копировать занятый .sys после каждого обновления
|
||||||
|
# приложения. Дальше короткие повторы — вдруг драйвер вот-вот выгрузится. Последняя ступень:
|
||||||
|
# уводим занятый файл в сторону переименованием (NTFS разрешает переименовать загруженный
|
||||||
|
# образ, хотя удалить его не даёт) и кладём новый на освободившееся имя.
|
||||||
|
function Copy-IfChanged($source, $destination) {
|
||||||
|
if (Test-Path -LiteralPath $destination) {
|
||||||
|
$sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash
|
||||||
|
$copyHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash
|
||||||
|
if ($sourceHash -eq $copyHash) { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le 6; $attempt++) {
|
||||||
|
try {
|
||||||
|
Copy-Item -LiteralPath $source -Destination $destination -Force
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stale = "$destination.old"
|
||||||
|
Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue
|
||||||
|
Move-Item -LiteralPath $destination -Destination $stale -Force
|
||||||
|
Copy-Item -LiteralPath $source -Destination $destination -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw "Не удалось обновить $destination : $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
|
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
|
||||||
|
|
||||||
@@ -298,10 +353,15 @@ try {
|
|||||||
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
|
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
|
||||||
$sourceDir = Split-Path -Parent $payload.winwsPath
|
$sourceDir = Split-Path -Parent $payload.winwsPath
|
||||||
$protectedWinws = Join-Path $dir 'winws.exe'
|
$protectedWinws = Join-Path $dir 'winws.exe'
|
||||||
foreach ($name in @('winws.exe', 'cygwin1.dll', 'WinDivert.dll', 'WinDivert64.sys')) {
|
foreach ($name in $payload.binaryNames) {
|
||||||
Copy-Item -Path (Join-Path $sourceDir $name) -Destination (Join-Path $dir $name) -Force
|
Copy-IfChanged (Join-Path $sourceDir $name) (Join-Path $dir $name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Хвосты от прошлой перенастройки: тогда файл был занят загруженным драйвером, сейчас
|
||||||
|
# он, скорее всего, уже свободен. Не вышло — не беда, попробуем в следующий раз.
|
||||||
|
Get-ChildItem -LiteralPath $dir -Filter '*.old' -ErrorAction SilentlyContinue |
|
||||||
|
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
|
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
|
||||||
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
|
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
|
||||||
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
|
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
|
||||||
@@ -317,11 +377,11 @@ try {
|
|||||||
$result = Invoke-ScRaw $scArgs
|
$result = Invoke-ScRaw $scArgs
|
||||||
if ($result.ExitCode -ne 0) { throw "sc config завершился с кодом $($result.ExitCode) : $($result.Output)" }
|
if ($result.ExitCode -ne 0) { throw "sc config завершился с кодом $($result.ExitCode) : $($result.Output)" }
|
||||||
} else {
|
} else {
|
||||||
$scArgs = 'create ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType + ' DisplayName= "RKNboost: обход блокировок"'
|
$scArgs = 'create ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType + ' DisplayName= "SenBoost: обход блокировок"'
|
||||||
$result = Invoke-ScRaw $scArgs
|
$result = Invoke-ScRaw $scArgs
|
||||||
if ($result.ExitCode -ne 0) { throw "sc create завершился с кодом $($result.ExitCode) : $($result.Output)" }
|
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
|
||||||
|
|
||||||
# Разрешаем интерактивным пользователям запуск/остановку/чтение статуса службы, но не
|
# Разрешаем интерактивным пользователям запуск/остановку/чтение статуса службы, но не
|
||||||
# смену конфигурации и не смену прав — иначе любой пользователь на машине мог бы подменить
|
# смену конфигурации и не смену прав — иначе любой пользователь на машине мог бы подменить
|
||||||
@@ -346,8 +406,18 @@ try {
|
|||||||
& sc.exe stop $ServiceName 2>$null | Out-Null
|
& sc.exe stop $ServiceName 2>$null | Out-Null
|
||||||
Start-Sleep -Milliseconds 500
|
Start-Sleep -Milliseconds 500
|
||||||
& sc.exe delete $ServiceName | Out-Null
|
& sc.exe delete $ServiceName | Out-Null
|
||||||
if (Test-Path $payload.serviceDataDir) {
|
# Тот же занятый WinDivert64.sys, что и при install: сразу после остановки драйвер ещё
|
||||||
|
# загружен, и Remove-Item падает. Повторяем, а если так и не вышло — не проваливаем
|
||||||
|
# удаление: служба уже снята, а оставшиеся файлы система освободит и без нас.
|
||||||
|
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||||
|
if (-not (Test-Path $payload.serviceDataDir)) { break }
|
||||||
|
try {
|
||||||
Remove-Item -Path $payload.serviceDataDir -Recurse -Force
|
Remove-Item -Path $payload.serviceDataDir -Recurse -Force
|
||||||
|
break
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Write-Result $true $null
|
Write-Result $true $null
|
||||||
}
|
}
|
||||||
@@ -381,7 +451,7 @@ async function readResult(path: string): Promise<{ ok: boolean; error: string |
|
|||||||
* Результат в обоих случаях читаем из `result.json`, а не из кода возврата процесса.
|
* Результат в обоих случаях читаем из `result.json`, а не из кода возврата процесса.
|
||||||
*/
|
*/
|
||||||
async function runElevated(payload: Record<string, unknown>): Promise<void> {
|
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 workerPath = join(dir, TASK_FILES.worker)
|
||||||
const outerPath = join(dir, 'outer.ps1')
|
const outerPath = join(dir, 'outer.ps1')
|
||||||
const payloadPath = join(dir, TASK_FILES.payload)
|
const payloadPath = join(dir, TASK_FILES.payload)
|
||||||
@@ -456,6 +526,7 @@ export async function applyServiceConfig(options: ApplyServiceConfigOptions): Pr
|
|||||||
configPath: serviceConfigPath(),
|
configPath: serviceConfigPath(),
|
||||||
configBody: options.configBody,
|
configBody: options.configBody,
|
||||||
winwsPath: winwsPath(),
|
winwsPath: winwsPath(),
|
||||||
|
binaryNames: SERVICE_BINARY_NAMES,
|
||||||
autoStart: options.autoStart
|
autoStart: options.autoStart
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import type { ZapretStrategy } from '../../shared/ipc-contract'
|
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'
|
import { WIN32_STRATEGIES } from './strategies.win32.generated'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,191 +9,55 @@ import { WIN32_STRATEGIES } from './strategies.win32.generated'
|
|||||||
* Универсального набора параметров не существует: что сработает, зависит от DPI
|
* Универсального набора параметров не существует: что сработает, зависит от DPI
|
||||||
* конкретного провайдера.
|
* конкретного провайдера.
|
||||||
*
|
*
|
||||||
* `tpws` (macOS) — прокси уровня TCP-соединений, а не пакетов: умеет только резать/
|
* `winws` (Windows), `nfqws` (Linux) и `utunws` (macOS) — один и тот же движок zapret на
|
||||||
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
|
* уровне пакетов с общим парсером аргументов (winws — сборка `nfq/nfqws.c` под WinDivert
|
||||||
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
|
* вместо netfilter, utunws — та же сборка под utun+BPF, см. Flowseal/zapret-mac-discord-
|
||||||
|
* youtube), поэтому все три платформы делят один набор пресетов — 22 варианта из
|
||||||
|
* Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
|
||||||
|
* генератором `scripts/parser-strategies.mjs` (не батниками — кодом).
|
||||||
*
|
*
|
||||||
* `winws` (Windows) работает на уровне пакетов через WinDivert. Его пресеты — 22 варианта
|
* `--wf-tcp`/`--wf-udp` из тех же аргументов сам движок понимает только на Windows
|
||||||
* из Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
|
* (фильтр WinDivert); на Linux и macOS фильтрацию делает внешний слой — nftables и pf
|
||||||
* генератором `scripts/import-flowseal-strategies.mjs` (не батниками — кодом).
|
* соответственно, — а сами порты вырезаются из аргументов и отдаются вызывающей стороне
|
||||||
|
* отдельно, см. `packetEngineConfig()` ниже, `resources/linux-helper/` и
|
||||||
|
* `resources/darwin-helper/`.
|
||||||
*
|
*
|
||||||
* Оба движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
|
* Все три движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
|
||||||
* `{LISTS}/<id>.txt` — путь резолвится в `resolvePlaceholders()` ниже.
|
* `{LISTS}/<id>.txt` — путь резолвится в `resolvePlaceholders()` ниже.
|
||||||
*/
|
*/
|
||||||
interface DarwinStrategyDefinition extends ZapretStrategy {
|
|
||||||
/** Аргументы tpws после общих `--socks --port=...`; плейсхолдеры ещё не резолвлены. */
|
|
||||||
tpws: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const LISTS_PLACEHOLDER = '{LISTS}'
|
const LISTS_PLACEHOLDER = '{LISTS}'
|
||||||
const FAKES_PLACEHOLDER = '{FAKES}'
|
const FAKES_PLACEHOLDER = '{FAKES}'
|
||||||
const AUTO_PLACEHOLDER = '{AUTO}'
|
|
||||||
|
|
||||||
/** `--hostlist-auto` заставляет zapret самому копить сюда домены, похожие на заблокированные. */
|
/** Все три платформы делят один набор id (все читают `WIN32_STRATEGIES`), поэтому и дефолт общий. */
|
||||||
const AUTO_HOSTLIST_ARGS = [`--hostlist-auto=${AUTO_PLACEHOLDER}`]
|
|
||||||
|
|
||||||
const DARWIN_DEFINITIONS: DarwinStrategyDefinition[] = [
|
|
||||||
{
|
|
||||||
id: 'split',
|
|
||||||
name: 'Разрез + переупорядочивание',
|
|
||||||
description: 'Базовый вариант из поставки zapret: разрез TLS ClientHello по середине SNI.',
|
|
||||||
platforms: ['darwin'],
|
|
||||||
tpws: [
|
|
||||||
'--filter-tcp=80',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
'--methodeol',
|
|
||||||
'--new',
|
|
||||||
'--filter-tcp=443',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
...AUTO_HOSTLIST_ARGS,
|
|
||||||
'--split-pos=1,midsld',
|
|
||||||
'--disorder'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'multisplit',
|
|
||||||
name: 'Многократный разрез',
|
|
||||||
description: 'Больше точек разреза. Помогает, когда DPI склеивает сегменты обратно.',
|
|
||||||
platforms: ['darwin'],
|
|
||||||
tpws: [
|
|
||||||
'--filter-tcp=80',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
'--methodeol',
|
|
||||||
'--new',
|
|
||||||
'--filter-tcp=443',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
...AUTO_HOSTLIST_ARGS,
|
|
||||||
// --oob здесь не добавляем: tpws отказывается совмещать его с --disorder вне Linux.
|
|
||||||
'--split-pos=1,midsld,host+1',
|
|
||||||
'--disorder'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tlsrec',
|
|
||||||
name: 'Разрез TLS-записи',
|
|
||||||
description: 'Другой способ разреза (--tlsrec) — стоит попробовать, если «Разрез» не помогает.',
|
|
||||||
platforms: ['darwin'],
|
|
||||||
tpws: [
|
|
||||||
'--filter-tcp=80',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
'--methodeol',
|
|
||||||
'--new',
|
|
||||||
'--filter-tcp=443',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
...AUTO_HOSTLIST_ARGS,
|
|
||||||
'--split-pos=1',
|
|
||||||
'--tlsrec=midsld'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'oob',
|
|
||||||
name: 'Разрез с OOB-байтом',
|
|
||||||
description: 'Внеполосный байт вместо переупорядочивания — третий вариант на выбор.',
|
|
||||||
platforms: ['darwin'],
|
|
||||||
tpws: [
|
|
||||||
'--filter-tcp=80',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
'--methodeol',
|
|
||||||
'--new',
|
|
||||||
'--filter-tcp=443',
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
|
|
||||||
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
|
|
||||||
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
|
|
||||||
...AUTO_HOSTLIST_ARGS,
|
|
||||||
'--split-pos=1,midsld',
|
|
||||||
'--oob'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'all-traffic',
|
|
||||||
name: 'Весь трафик, без списков',
|
|
||||||
description:
|
|
||||||
'Разрез применяется ко всем сайтам, а не только из списка «Обходить». Диагностика.',
|
|
||||||
platforms: ['darwin'],
|
|
||||||
tpws: [
|
|
||||||
'--filter-tcp=80',
|
|
||||||
'--methodeol',
|
|
||||||
'--new',
|
|
||||||
'--filter-tcp=443',
|
|
||||||
'--split-pos=1,midsld',
|
|
||||||
'--disorder'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'passthrough',
|
|
||||||
name: 'Без изменения трафика',
|
|
||||||
description:
|
|
||||||
'Прокси работает, но пакеты не трогает. Нужен, чтобы отделить проблемы сети от стратегии.',
|
|
||||||
platforms: ['darwin'],
|
|
||||||
tpws: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Стратегия по умолчанию — своя на каждой платформе, наборы id не пересекаются. */
|
|
||||||
export function defaultStrategyId(platform: NodeJS.Platform): string {
|
export function defaultStrategyId(platform: NodeJS.Platform): string {
|
||||||
return platform === 'win32' ? 'general' : 'split'
|
void platform
|
||||||
|
return 'general'
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePlaceholders(arg: string): string {
|
function resolvePlaceholders(arg: string): string {
|
||||||
return arg
|
return arg.replaceAll(LISTS_PLACEHOLDER, userListsDir()).replaceAll(FAKES_PLACEHOLDER, fakesDir())
|
||||||
.replaceAll(LISTS_PLACEHOLDER, userListsDir())
|
|
||||||
.replaceAll(FAKES_PLACEHOLDER, fakesDir())
|
|
||||||
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пресеты, применимые к текущей платформе. */
|
/** win32, linux и darwin показывают один и тот же список Flowseal-пресетов. */
|
||||||
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
|
function flowsealStrategies(platform: 'win32' | 'linux' | 'darwin'): ZapretStrategy[] {
|
||||||
if (platform === 'darwin') {
|
|
||||||
return DARWIN_DEFINITIONS.map(({ id, name, description, platforms }) => ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
platforms
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if (platform === 'win32') {
|
|
||||||
return WIN32_STRATEGIES.map(({ id, name }) => ({
|
return WIN32_STRATEGIES.map(({ id, name }) => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
|
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
|
||||||
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
|
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
|
||||||
platforms: ['win32']
|
platforms: [platform]
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Пресеты, применимые к текущей платформе. */
|
||||||
|
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
|
||||||
|
if (platform === 'darwin') return flowsealStrategies('darwin')
|
||||||
|
if (platform === 'win32') return flowsealStrategies('win32')
|
||||||
|
if (platform === 'linux') return flowsealStrategies('linux')
|
||||||
return []
|
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`. */
|
/** Аргументы winws — служба читает их из файла как `winws.exe @strategy.txt`. */
|
||||||
export function winwsArgs(strategyId: string): string[] {
|
export function winwsArgs(strategyId: string): string[] {
|
||||||
const strategy = WIN32_STRATEGIES.find((item) => item.id === strategyId)
|
const strategy = WIN32_STRATEGIES.find((item) => item.id === strategyId)
|
||||||
@@ -201,12 +65,13 @@ export function winwsArgs(strategyId: string): string[] {
|
|||||||
return strategy.args.map(resolvePlaceholders)
|
return strategy.args.map(resolvePlaceholders)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Читает служба: `winws.exe @strategy.cfg`, ограничение размера у самого winws — 16 КБ. */
|
/** Читают winws и nfqws через `@strategy.cfg` — ограничение размера общее у обоих, 16 КБ. */
|
||||||
const MAX_CONFIG_FILE_BYTES = 16000
|
const MAX_CONFIG_FILE_BYTES = 16000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* winws разбирает файл конфигурации через POSIX `wordexp()` (см. `nfq/nfqws.c` в поставке
|
* winws и nfqws — один и тот же парсер аргументов (`nfq/nfqws.c` в поставке zapret,
|
||||||
* zapret) — без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
|
* winws — его сборка под WinDivert), файл конфигурации разбирают через POSIX `wordexp()` —
|
||||||
|
* без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
|
||||||
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
|
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
|
||||||
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
|
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
|
||||||
*/
|
*/
|
||||||
@@ -215,33 +80,130 @@ function quoteConfigArg(arg: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Первая строка — маркер: по нему `Win32Engine.sync()` узнаёт, какая стратегия уже установлена
|
* Первая строка — маркер: по нему `Win32Engine.sync()`/`LinuxEngine.sync()` узнают, какая
|
||||||
* в службу, не перечитывая все аргументы. Сам маркер — штатная опция `--comment` winws,
|
* стратегия уже установлена, не перечитывая все аргументы. Сам маркер — штатная опция
|
||||||
* в работу процесса не вмешивается.
|
* `--comment` (есть и у winws, и у nfqws), в работу процесса не вмешивается.
|
||||||
*/
|
*/
|
||||||
function configMarker(strategyId: string, body: string): string {
|
function configMarker(strategyId: string, body: string): string {
|
||||||
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
|
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
|
||||||
return `--comment=rknboost:${strategyId}:${hash}`
|
return `--comment=senboost:${strategyId}:${hash}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WinwsConfig {
|
const MARKER_PATTERN = /^--comment=senboost:([^:]+):/
|
||||||
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws (docs/windows.md zapret). */
|
|
||||||
|
/**
|
||||||
|
* Обратная операция к `configMarker()` — достаёт id стратегии из первой строки уже
|
||||||
|
* установленного конфига. Общий разбор для `Win32Engine.sync()` и `LinuxEngine.sync()`,
|
||||||
|
* чтобы регэксп маркера не разъехался на два места.
|
||||||
|
*/
|
||||||
|
export function parseStrategyMarker(line: string | null): string | null {
|
||||||
|
if (!line) return null
|
||||||
|
return MARKER_PATTERN.exec(line)?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngineConfig {
|
||||||
|
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws/nfqws (docs/windows.md zapret). */
|
||||||
body: string
|
body: string
|
||||||
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
|
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
|
||||||
marker: string
|
marker: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `WinwsConfig`. */
|
/** Общий рендер: кавычки, маркер первой строкой, проверка лимита в 16 КБ. */
|
||||||
export function winwsConfigFile(strategyId: string): WinwsConfig {
|
function renderConfigFile(strategyId: string, args: string[]): EngineConfig {
|
||||||
const args = winwsArgs(strategyId).map(quoteConfigArg).join('\n')
|
const quoted = args.map(quoteConfigArg).join('\n')
|
||||||
const marker = configMarker(strategyId, args)
|
const marker = configMarker(strategyId, quoted)
|
||||||
const body = `${marker}\n${args}\n`
|
const body = `${marker}\n${quoted}\n`
|
||||||
|
|
||||||
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
|
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Стратегия «${strategyId}» не помещается в лимит winws на файл конфигурации (16 КБ).`
|
`Стратегия «${strategyId}» не помещается в лимит winws/nfqws на файл конфигурации (16 КБ).`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { body, marker }
|
return { body, marker }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `EngineConfig`. */
|
||||||
|
export function winwsConfigFile(strategyId: string): EngineConfig {
|
||||||
|
return renderConfigFile(strategyId, winwsArgs(strategyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const WF_TCP_PREFIX = '--wf-tcp='
|
||||||
|
const WF_UDP_PREFIX = '--wf-udp='
|
||||||
|
|
||||||
|
export interface PacketEngineConfig extends EngineConfig {
|
||||||
|
/** Порты TCP из `--wf-tcp`, формат `80,443,8443` — см. вызывающую сторону за смыслом. */
|
||||||
|
tcpPorts: string
|
||||||
|
/** Порты UDP из `--wf-udp`, тот же формат. */
|
||||||
|
udpPorts: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* На Windows `--wf-tcp`/`--wf-udp` — это фильтр самого WinDivert, зашитый в те же аргументы
|
||||||
|
* winws. На Linux и macOS фильтрацию делает внешний слой — nftables (`senboost-helper.sh`)
|
||||||
|
* и pf (`resources/darwin-helper/daemon.sh`) соответственно, — а сам движок (nfqws/utunws)
|
||||||
|
* эти два аргумента не понимает: вырезаем их и отдаём отдельно вызывающей стороне.
|
||||||
|
*/
|
||||||
|
export function packetEngineConfig(strategyId: string): PacketEngineConfig {
|
||||||
|
const args = winwsArgs(strategyId)
|
||||||
|
let tcpPorts: string | null = null
|
||||||
|
let udpPorts: string | null = null
|
||||||
|
const engineArgs: string[] = []
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg.startsWith(WF_TCP_PREFIX)) {
|
||||||
|
tcpPorts = arg.slice(WF_TCP_PREFIX.length)
|
||||||
|
} else if (arg.startsWith(WF_UDP_PREFIX)) {
|
||||||
|
udpPorts = arg.slice(WF_UDP_PREFIX.length)
|
||||||
|
} else {
|
||||||
|
engineArgs.push(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// На всех 22 стратегиях сейчас ровно по одному --wf-tcp/--wf-udp (проверено вручную при
|
||||||
|
// добавлении Linux), но если очередной апдейт Flowseal формат изменит — лучше явная ошибка
|
||||||
|
// здесь, чем правило фильтрации без портов.
|
||||||
|
if (tcpPorts === null || udpPorts === null) {
|
||||||
|
throw new Error(`Стратегия «${strategyId}»: не найден --wf-tcp/--wf-udp в аргументах.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение уходит прямо в правило nftables (senboost-helper.sh) или pf (daemon.sh) без
|
||||||
|
// дополнительного экранирования — только цифры, запятые и дефисы диапазонов. Если Flowseal
|
||||||
|
// когда-нибудь передаст что-то ещё (например, `~` — отрицание порта), лучше явная ошибка
|
||||||
|
// тут, чем нераспознанный текст в командной строке nft/pfctl, которую выполняет root.
|
||||||
|
for (const [flag, ports] of [
|
||||||
|
['--wf-tcp', tcpPorts],
|
||||||
|
['--wf-udp', udpPorts]
|
||||||
|
] as const) {
|
||||||
|
if (!/^\d+(-\d+)?(,\d+(-\d+)?)*$/.test(ports)) {
|
||||||
|
throw new Error(`Стратегия «${strategyId}»: неожиданный формат ${flag}=${ports}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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).
|
* Flowseal/zapret-discord-youtube 1.10.2 (commit dfd8e613b099676cf2aa7b474ee5923801514dec).
|
||||||
* Не редактировать руками — перезапустить генератор и закоммитить результат.
|
* Не редактировать руками — перезапустить генератор и закоммитить результат.
|
||||||
*
|
*
|
||||||
@@ -1581,7 +1581,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--new',
|
'--new',
|
||||||
'--filter-tcp=443',
|
'--filter-tcp=443',
|
||||||
@@ -1592,7 +1592,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--new',
|
'--new',
|
||||||
'--filter-tcp=80,443',
|
'--filter-tcp=80,443',
|
||||||
@@ -1604,7 +1604,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
||||||
'--new',
|
'--new',
|
||||||
@@ -1625,7 +1625,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
||||||
'--new',
|
'--new',
|
||||||
@@ -1639,7 +1639,7 @@ export const WIN32_STRATEGIES: readonly GeneratedWin32Strategy[] = [
|
|||||||
'--dpi-desync-repeats=11',
|
'--dpi-desync-repeats=11',
|
||||||
'--dpi-desync-fooling=badseq',
|
'--dpi-desync-fooling=badseq',
|
||||||
'--dpi-desync-fake-tls=0x00000000',
|
'--dpi-desync-fake-tls=0x00000000',
|
||||||
'--dpi-desync-fake-tls=^!',
|
'--dpi-desync-fake-tls=!',
|
||||||
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
'--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com',
|
||||||
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
'--dpi-desync-fake-http={FAKES}/tls_clienthello_max_ru.bin',
|
||||||
'--dpi-desync-fake-unknown=0x00000000',
|
'--dpi-desync-fake-unknown=0x00000000',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
import type {
|
import type {
|
||||||
|
BackgroundSettings,
|
||||||
IpcChannel,
|
IpcChannel,
|
||||||
IpcEventChannel,
|
IpcEventChannel,
|
||||||
IpcEvents,
|
IpcEvents,
|
||||||
IpcHandlers,
|
IpcHandlers,
|
||||||
ThemeState,
|
ThemeState,
|
||||||
ZapretDiagnosticResult,
|
ZapretDiagnosticResult,
|
||||||
|
ZapretDiagnosticTarget,
|
||||||
ZapretList,
|
ZapretList,
|
||||||
ZapretListId,
|
ZapretListId,
|
||||||
ZapretLogLine,
|
ZapretLogLine,
|
||||||
@@ -42,6 +44,9 @@ const api = {
|
|||||||
* службы только на Windows (см. SettingsView.tsx). */
|
* службы только на Windows (см. SettingsView.tsx). */
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|
||||||
|
/** Версия запущенной сборки — из package.json через `app.getVersion()` в main. */
|
||||||
|
getAppVersion: (): Promise<string> => invoke('app:version'),
|
||||||
|
|
||||||
getTheme: (): Promise<ThemeState> => invoke('theme:get'),
|
getTheme: (): Promise<ThemeState> => invoke('theme:get'),
|
||||||
onThemeChanged: (listener: (state: ThemeState) => void): (() => void) =>
|
onThemeChanged: (listener: (state: ThemeState) => void): (() => void) =>
|
||||||
subscribe('theme:changed', listener),
|
subscribe('theme:changed', listener),
|
||||||
@@ -49,12 +54,15 @@ const api = {
|
|||||||
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
|
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
|
||||||
uninstallApp: (): Promise<void> => invoke('app:uninstall'),
|
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'),
|
getZapretStatus: (): Promise<ZapretStatus> => invoke('zapret:status'),
|
||||||
getZapretStrategies: (): Promise<ZapretStrategy[]> => invoke('zapret:strategies'),
|
getZapretStrategies: (): Promise<ZapretStrategy[]> => invoke('zapret:strategies'),
|
||||||
startZapret: (strategyId: string): Promise<ZapretStatus> => invoke('zapret:start', strategyId),
|
startZapret: (strategyId: string): Promise<ZapretStatus> => invoke('zapret:start', strategyId),
|
||||||
stopZapret: (): Promise<ZapretStatus> => invoke('zapret:stop'),
|
stopZapret: (): Promise<ZapretStatus> => invoke('zapret:stop'),
|
||||||
setZapretSystemProxy: (enabled: boolean): Promise<ZapretStatus> =>
|
|
||||||
invoke('zapret:set-system-proxy', enabled),
|
|
||||||
setZapretStrategy: (strategyId: string): Promise<ZapretStatus> =>
|
setZapretStrategy: (strategyId: string): Promise<ZapretStatus> =>
|
||||||
invoke('zapret:set-strategy', strategyId),
|
invoke('zapret:set-strategy', strategyId),
|
||||||
setZapretAutoStart: (enabled: boolean): Promise<ZapretStatus> =>
|
setZapretAutoStart: (enabled: boolean): Promise<ZapretStatus> =>
|
||||||
@@ -68,10 +76,11 @@ const api = {
|
|||||||
saveZapretList: (id: ZapretListId, entries: string[]): Promise<ZapretList> =>
|
saveZapretList: (id: ZapretListId, entries: string[]): Promise<ZapretList> =>
|
||||||
invoke('zapret:list-save', id, entries),
|
invoke('zapret:list-save', id, entries),
|
||||||
resetZapretList: (id: ZapretListId): Promise<ZapretList> => invoke('zapret:list-reset', id),
|
resetZapretList: (id: ZapretListId): Promise<ZapretList> => invoke('zapret:list-reset', id),
|
||||||
getZapretAutoHostlist: (): Promise<string[]> => invoke('zapret:auto-hostlist'),
|
|
||||||
clearZapretAutoHostlist: (): Promise<void> => invoke('zapret:auto-hostlist-clear'),
|
|
||||||
|
|
||||||
runZapretDiagnostics: (): Promise<ZapretDiagnosticResult[]> => invoke('zapret:diagnose')
|
/** Запускает проверку и отдаёт список сайтов; результаты — через `onZapretDiagnosticResult`. */
|
||||||
|
runZapretDiagnostics: (): Promise<ZapretDiagnosticTarget[]> => invoke('zapret:diagnose'),
|
||||||
|
onZapretDiagnosticResult: (listener: (result: ZapretDiagnosticResult) => void): (() => void) =>
|
||||||
|
subscribe('zapret:diagnostic-result', listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RendererApi = typeof api
|
export type RendererApi = typeof api
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>RKNboost</title>
|
<title>SenBoost</title>
|
||||||
<!-- Жёсткий CSP: никаких внешних скриптов, стилей и запросов.
|
<!-- Жёсткий CSP: никаких внешних скриптов, стилей и запросов.
|
||||||
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta
|
<meta
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function App(): React.JSX.Element {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="app-header">
|
<div className="app-header">
|
||||||
<span className="app-header__name">RKNboost</span>
|
<span className="app-header__name">SenBoost</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-header__settings"
|
className="app-header__settings"
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
|
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
|
||||||
import { GITHUB_URL } from '@shared/links'
|
import { GITHUB_URL } from '@shared/links'
|
||||||
|
import { useAppVersion } from '../hooks/useAppVersion'
|
||||||
|
import { useBackgroundSettings } from '../hooks/useBackgroundSettings'
|
||||||
import { useZapretLists } from '../hooks/useZapretLists'
|
import { useZapretLists } from '../hooks/useZapretLists'
|
||||||
import { LIST_TITLES } from '../lib/zapretListMeta'
|
import { LIST_TITLES } from '../lib/zapretListMeta'
|
||||||
import ListEditor from './ListEditor'
|
import ListEditor from './ListEditor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Настройки: списки сайтов + автозапуск (Windows). Выбор стратегии и проверка
|
* Настройки: списки сайтов + автозапуск (macOS/Windows). Выбор стратегии и проверка
|
||||||
* соединения переехали на главный экран (ZapretCard.tsx) — рядом с кнопкой
|
* соединения переехали на главный экран (ZapretCard.tsx) — рядом с кнопкой
|
||||||
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
|
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
|
||||||
*/
|
*/
|
||||||
const isWindows = window.api.platform === 'win32'
|
const isWindows = window.api.platform === 'win32'
|
||||||
|
const isDarwin = window.api.platform === 'darwin'
|
||||||
|
const supportsAutoStart = isWindows || isDarwin
|
||||||
const APP_CHANNEL = 'beta'
|
const APP_CHANNEL = 'beta'
|
||||||
// Держать в синхроне с "version" в package.json — оттуда её берёт electron-builder
|
|
||||||
// для имени установщика и для «Установленные приложения» в Windows.
|
|
||||||
const APP_VERSION = '0.4.0'
|
|
||||||
|
|
||||||
export default function SettingsView({
|
export default function SettingsView({
|
||||||
onBack,
|
onBack,
|
||||||
@@ -25,8 +26,10 @@ export default function SettingsView({
|
|||||||
status: ZapretStatus | null
|
status: ZapretStatus | null
|
||||||
onSetAutoStart: (enabled: boolean) => Promise<void>
|
onSetAutoStart: (enabled: boolean) => Promise<void>
|
||||||
}): React.JSX.Element {
|
}): 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 [openListId, setOpenListId] = useState<ZapretListId | null>(null)
|
||||||
|
const appVersion = useAppVersion()
|
||||||
|
|
||||||
const openList = lists.find((list) => list.id === openListId)
|
const openList = lists.find((list) => list.id === openListId)
|
||||||
if (openList) {
|
if (openList) {
|
||||||
@@ -53,22 +56,67 @@ export default function SettingsView({
|
|||||||
<h2 className="settings__title">Настройки</h2>
|
<h2 className="settings__title">Настройки</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isWindows && (
|
{background.settings?.trayAvailable && (
|
||||||
<div className="settings__group">
|
<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>
|
||||||
|
<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">
|
||||||
|
Приложение открывается свёрнутым в трей и обход можно включить одним кликом по значку.
|
||||||
|
Это не то же самое, что автозапуск обхода ниже: тот работает вообще без запущенного
|
||||||
|
приложения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supportsAutoStart && (
|
||||||
|
<div className="settings__group">
|
||||||
|
<span className="settings__label">Автозапуск обхода</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="settings-row"
|
className="settings-row"
|
||||||
onClick={() => void onSetAutoStart(!status?.autoStart)}
|
onClick={() => void onSetAutoStart(!status?.autoStart)}
|
||||||
>
|
>
|
||||||
<span>Запускать вместе с Windows</span>
|
<span>Запускать вместе с {isWindows ? 'Windows' : 'macOS'}</span>
|
||||||
<span className={`chip ${status?.autoStart ? 'chip--success' : 'chip--neutral'}`}>
|
<span className={`chip ${status?.autoStart ? 'chip--success' : 'chip--neutral'}`}>
|
||||||
{status?.autoStart ? 'Включён' : 'Выключен'}
|
{status?.autoStart ? 'Включён' : 'Выключен'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="list-editor__hint">
|
<p className="list-editor__hint">
|
||||||
Меняется через запрос прав администратора. Если выключить обход кнопкой на главном
|
{isWindows
|
||||||
экране, при включённом автозапуске он вернётся не раньше следующей перезагрузки.
|
? 'Меняется через запрос прав администратора. Если выключить обход кнопкой на ' +
|
||||||
|
'главном экране, при включённом автозапуске он вернётся не раньше следующей ' +
|
||||||
|
'перезагрузки.'
|
||||||
|
: 'Меняется через пароль администратора. Действует, пока обход включён: ' +
|
||||||
|
'выключение кнопкой на главном экране полностью снимает установку — ' +
|
||||||
|
'автозапуск снова понадобится включить вместе с обходом.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -87,26 +135,6 @@ export default function SettingsView({
|
|||||||
<span className="settings-row__count">{list.entries.length} ›</span>
|
<span className="settings-row__count">{list.entries.length} ›</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* На Windows ни одна из 22 стратегий Flowseal не использует --hostlist-auto — счётчик
|
|
||||||
всегда пуст, показывать его смысла нет. */}
|
|
||||||
{!isWindows && (
|
|
||||||
<div className="settings-row settings-row--static">
|
|
||||||
<span>Найдено автоматически</span>
|
|
||||||
<span className="settings-row__count">
|
|
||||||
{autoHostlist.length}
|
|
||||||
{autoHostlist.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="settings-row__clear"
|
|
||||||
onClick={() => void clearAuto()}
|
|
||||||
>
|
|
||||||
Очистить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="card settings__support">
|
<section className="card settings__support">
|
||||||
@@ -119,9 +147,13 @@ export default function SettingsView({
|
|||||||
<p className="settings__support-hint">Поддержите звёздочкой</p>
|
<p className="settings__support-hint">Поддержите звёздочкой</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Пока версия не пришла из main, строки нет вовсе: иначе на первом кадре
|
||||||
|
мелькнуло бы «beta--win32» с пустотой на месте номера. */}
|
||||||
|
{appVersion && (
|
||||||
<p className="settings__version">
|
<p className="settings__version">
|
||||||
{APP_CHANNEL}-{APP_VERSION}-{window.api.platform}
|
{APP_CHANNEL}-{appVersion}-{window.api.platform}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{isWindows && (
|
{isWindows && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useZapretDiagnostics } from '../hooks/useZapretDiagnostics'
|
|||||||
/** Карточка обхода блокировок: статус и включение сверху, выбор стратегии и проверка соединения ниже. */
|
/** Карточка обхода блокировок: статус и включение сверху, выбор стратегии и проверка соединения ниже. */
|
||||||
export default function ZapretCard(): React.JSX.Element {
|
export default function ZapretCard(): React.JSX.Element {
|
||||||
const { status, strategies, start, stop, setStrategy, busy } = useZapret()
|
const { status, strategies, start, stop, setStrategy, busy } = useZapret()
|
||||||
const { results, running: testing, run: runTest } = useZapretDiagnostics()
|
const { targets, results, running: testing, run: runTest } = useZapretDiagnostics()
|
||||||
|
|
||||||
const running = status?.state === 'running' || status?.state === 'starting'
|
const running = status?.state === 'running' || status?.state === 'starting'
|
||||||
const strategyId = status?.strategyId ?? strategies[0]?.id
|
const strategyId = status?.strategyId ?? strategies[0]?.id
|
||||||
@@ -47,12 +47,14 @@ export default function ZapretCard(): React.JSX.Element {
|
|||||||
{toggleLabel}
|
{toggleLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{status?.state === 'error' && status.error && (
|
{/* Не только при state === 'error': UnsupportedEngine (платформа без сборки движка)
|
||||||
<p className="list-editor__error">{status.error}</p>
|
пишет причину в error, оставляя state 'stopped' — иначе текст был бы недостижим,
|
||||||
)}
|
а пользователь на неподдерживаемой платформе видел бы пустой список стратегий
|
||||||
|
без единого объяснения. */}
|
||||||
|
{status?.error && <p className="list-editor__error">{status.error}</p>}
|
||||||
|
|
||||||
<div className="settings__field">
|
<div className="settings__field">
|
||||||
<span className="settings__label">Стратегия</span>
|
<span className="settings__label">Способ</span>
|
||||||
{/* На время переключения список заблокирован: смена стратегии на работающем
|
{/* На время переключения список заблокирован: смена стратегии на работающем
|
||||||
обходе останавливает и перенастраивает службу, и второй выбор посреди этого
|
обходе останавливает и перенастраивает службу, и второй выбор посреди этого
|
||||||
наложился бы на первый. */}
|
наложился бы на первый. */}
|
||||||
@@ -79,23 +81,32 @@ export default function ZapretCard(): React.JSX.Element {
|
|||||||
{testing ? 'Проверяю…' : 'Протестить'}
|
{testing ? 'Проверяю…' : 'Протестить'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{results.length > 0 && (
|
{/* Строки появляются сразу все, ещё до первого ответа, и заполняются по мере
|
||||||
|
готовности — иначе список прыгал бы, дорисовываясь по одной строке. */}
|
||||||
|
{targets.length > 0 && (
|
||||||
<div className="settings__group">
|
<div className="settings__group">
|
||||||
{results.map((result) => (
|
{targets.map((target) => {
|
||||||
<div key={result.id} className="settings-row settings-row--static">
|
const result = results[target.id]
|
||||||
|
return (
|
||||||
|
<div key={target.id} className="settings-row settings-row--static">
|
||||||
{/* Причину показываем текстом, а не только подсказкой при наведении:
|
{/* Причину показываем текстом, а не только подсказкой при наведении:
|
||||||
«Недоступно» само по себе не говорит, что чинить. */}
|
«Недоступно» само по себе не говорит, что чинить. */}
|
||||||
<span className="diagnostic__label">
|
<span className="diagnostic__label">
|
||||||
{result.label}
|
{target.label}
|
||||||
{!result.ok && result.error && (
|
{result && !result.ok && result.error && (
|
||||||
<small className="diagnostic__error">{result.error}</small>
|
<small className="diagnostic__error">{result.error}</small>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{result ? (
|
||||||
<span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}>
|
<span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}>
|
||||||
{result.ok ? `${result.ms} мс` : 'Недоступно'}
|
{result.ok ? `${result.ms} мс` : 'Недоступно'}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="chip chip--neutral">Проверяю…</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Версия запущенной сборки — та же, что в имени установщика и в «Установленных
|
||||||
|
* приложениях»: main отдаёт её через `app.getVersion()`, то есть прямо из package.json.
|
||||||
|
*
|
||||||
|
* Раньше renderer держал версию своей константой с пометкой «держать в синхроне с
|
||||||
|
* package.json». Синхронизировать вручную забыли: константа осталась на 0.4.0, и релизы
|
||||||
|
* 0.5.0 и 0.5.1 показывали пользователю чужую версию. Спрашивать у main дороже на один
|
||||||
|
* IPC-вызов, зато разъехаться уже нечему.
|
||||||
|
*
|
||||||
|
* `null` — версия ещё не пришла; показывать её в этот момент нечего.
|
||||||
|
*/
|
||||||
|
export function useAppVersion(): string | null {
|
||||||
|
const [version, setVersion] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Окно могло закрыться, пока шёл ответ — тогда setState уже некому применять.
|
||||||
|
let cancelled = false
|
||||||
|
void window.api.getAppVersion().then((value) => {
|
||||||
|
if (!cancelled) setVersion(value)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ export function useZapret(): {
|
|||||||
log: ZapretLogLine[]
|
log: ZapretLogLine[]
|
||||||
start: (strategyId: string) => Promise<void>
|
start: (strategyId: string) => Promise<void>
|
||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
setSystemProxy: (enabled: boolean) => Promise<void>
|
|
||||||
setStrategy: (strategyId: string) => Promise<void>
|
setStrategy: (strategyId: string) => Promise<void>
|
||||||
setAutoStart: (enabled: boolean) => Promise<void>
|
setAutoStart: (enabled: boolean) => Promise<void>
|
||||||
busy: boolean
|
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) => {
|
const setStrategy = useCallback(async (strategyId: string) => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,46 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import type { ZapretDiagnosticResult } from '@shared/ipc-contract'
|
import type { ZapretDiagnosticResult, ZapretDiagnosticTarget } from '@shared/ipc-contract'
|
||||||
|
|
||||||
/** Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists. */
|
/**
|
||||||
|
* Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists.
|
||||||
|
*
|
||||||
|
* `run()` только запускает проверку и получает список сайтов; результаты приходят по
|
||||||
|
* одному событием и складываются в `results` по мере готовности — живой сайт отвечает за
|
||||||
|
* десятки миллисекунд, заблокированный молчит до таймаута в 10 с, и ждать общего ответа
|
||||||
|
* значило бы держать пользователя перед пустым экраном ровно столько же.
|
||||||
|
*/
|
||||||
export function useZapretDiagnostics(): {
|
export function useZapretDiagnostics(): {
|
||||||
results: ZapretDiagnosticResult[]
|
targets: ZapretDiagnosticTarget[]
|
||||||
|
results: Record<string, ZapretDiagnosticResult>
|
||||||
running: boolean
|
running: boolean
|
||||||
run: () => Promise<void>
|
run: () => Promise<void>
|
||||||
} {
|
} {
|
||||||
const [results, setResults] = useState<ZapretDiagnosticResult[]>([])
|
const [targets, setTargets] = useState<ZapretDiagnosticTarget[]>([])
|
||||||
const [running, setRunning] = useState(false)
|
const [results, setResults] = useState<Record<string, ZapretDiagnosticResult>>({})
|
||||||
|
const [started, setStarted] = useState(false)
|
||||||
|
|
||||||
|
// Подписка живёт всё время жизни компонента, а не только на время проверки: результат
|
||||||
|
// приходит асинхронно, и подписываться в момент клика — значит гоняться с первым же
|
||||||
|
// быстрым ответом.
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
window.api.onZapretDiagnosticResult((result) => {
|
||||||
|
setResults((previous) => ({ ...previous, [result.id]: result }))
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const run = useCallback(async () => {
|
const run = useCallback(async () => {
|
||||||
setRunning(true)
|
setStarted(true)
|
||||||
try {
|
// Чистим до запроса: иначе прошлые результаты остались бы на экране рядом со свежими,
|
||||||
setResults(await window.api.runZapretDiagnostics())
|
// и было бы не понять, какие из них к какой проверке.
|
||||||
} finally {
|
setResults({})
|
||||||
setRunning(false)
|
setTargets(await window.api.runZapretDiagnostics())
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { results, running, run }
|
// Проверка идёт, пока пришли не все результаты. Каждый сайт отдаёт ровно один результат
|
||||||
|
// (см. runDiagnostics — там перехвачена и ошибка), так что счёт всегда сходится.
|
||||||
|
const finished = targets.length > 0 && targets.every((target) => results[target.id])
|
||||||
|
|
||||||
|
return { targets, results, running: started && !finished, run }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,22 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import type { ZapretList, ZapretListId } from '@shared/ipc-contract'
|
import type { ZapretList, ZapretListId } from '@shared/ipc-contract'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Списки сайтов + журнал автообнаружения, в синхроне с main-процессом.
|
* Списки сайтов, в синхроне с main-процессом. Тот же паттерн, что и `useZapret`/`useTheme`.
|
||||||
* Тот же паттерн, что и `useZapret`/`useTheme`.
|
|
||||||
*/
|
*/
|
||||||
export function useZapretLists(): {
|
export function useZapretLists(): {
|
||||||
lists: ZapretList[]
|
lists: ZapretList[]
|
||||||
autoHostlist: string[]
|
|
||||||
save: (id: ZapretListId, entries: string[]) => Promise<ZapretList>
|
save: (id: ZapretListId, entries: string[]) => Promise<ZapretList>
|
||||||
reset: (id: ZapretListId) => Promise<ZapretList>
|
reset: (id: ZapretListId) => Promise<ZapretList>
|
||||||
clearAuto: () => Promise<void>
|
|
||||||
loading: boolean
|
loading: boolean
|
||||||
} {
|
} {
|
||||||
const [lists, setLists] = useState<ZapretList[]>([])
|
const [lists, setLists] = useState<ZapretList[]>([])
|
||||||
const [autoHostlist, setAutoHostlist] = useState<string[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([window.api.getZapretLists(), window.api.getZapretAutoHostlist()]).then(
|
void window.api.getZapretLists().then((nextLists) => {
|
||||||
([nextLists, nextAuto]) => {
|
|
||||||
setLists(nextLists)
|
setLists(nextLists)
|
||||||
setAutoHostlist(nextAuto)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const save = useCallback(async (id: ZapretListId, entries: string[]) => {
|
const save = useCallback(async (id: ZapretListId, entries: string[]) => {
|
||||||
@@ -39,10 +32,5 @@ export function useZapretLists(): {
|
|||||||
return updated
|
return updated
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearAuto = useCallback(async () => {
|
return { lists, save, reset, loading }
|
||||||
await window.api.clearZapretAutoHostlist()
|
|
||||||
setAutoHostlist([])
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { lists, autoHostlist, save, reset, clearAuto, loading }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,38 @@ export interface ThemeState {
|
|||||||
shouldUseDarkColors: boolean
|
shouldUseDarkColors: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фоновая работа: трей + автозапуск самого приложения.
|
||||||
|
*
|
||||||
|
* Не путать с `ZapretStatus.autoStart` — тот про автозапуск обхода (служба Windows,
|
||||||
|
* LaunchDaemon macOS) вообще без приложения.
|
||||||
|
*/
|
||||||
|
export interface BackgroundSettings {
|
||||||
|
/** Закрытие окна прячет его в трей, а не завершает приложение. */
|
||||||
|
closeToTray: boolean
|
||||||
|
/** Приложение запускается вместе с системой, сразу свёрнутым в трей. */
|
||||||
|
launchAtLogin: boolean
|
||||||
|
/**
|
||||||
|
* Иконка в трее реально создалась. На Linux без AppIndicator трея нет — тогда прятать
|
||||||
|
* туда окно нельзя, и всю группу настроек показывать бессмысленно.
|
||||||
|
*/
|
||||||
|
trayAvailable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** Состояние обхода блокировок. */
|
/** Состояние обхода блокировок. */
|
||||||
export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'
|
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;
|
* • `winws-service` — Windows: служба с драйвером WinDivert;
|
||||||
* • `unsupported` — платформа без поддержки (Linux у нас не собран).
|
* • `nfqws-nftables` — Linux: демон nfqws + правила nftables (NFQUEUE), root через pkexec
|
||||||
|
* на каждое включение;
|
||||||
|
* • `unsupported` — платформа без поддержки (не macOS/Windows/Linux).
|
||||||
*/
|
*/
|
||||||
export type ZapretBackend = 'tpws-socks' | 'winws-service' | 'unsupported'
|
export type ZapretBackend = 'utunws-pf' | 'winws-service' | 'nfqws-nftables' | 'unsupported'
|
||||||
|
|
||||||
export interface ZapretStatus {
|
export interface ZapretStatus {
|
||||||
state: ZapretState
|
state: ZapretState
|
||||||
@@ -39,13 +61,9 @@ export interface ZapretStatus {
|
|||||||
startedAt: string | null
|
startedAt: string | null
|
||||||
/** Текст последней ошибки — показываем пользователю как есть. */
|
/** Текст последней ошибки — показываем пользователю как есть. */
|
||||||
error: string | null
|
error: string | null
|
||||||
/** macOS: адрес socks5-прокси, пока обход работает. */
|
|
||||||
socksAddress: string | null
|
|
||||||
/** macOS: прописан ли прокси в системных настройках сети. */
|
|
||||||
systemProxyApplied: boolean
|
|
||||||
/** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */
|
/** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */
|
||||||
serviceInstalled: boolean
|
serviceInstalled: boolean
|
||||||
/** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). */
|
/** macOS/Windows: автозапуск с системой без запущенного приложения. На Linux не используется. */
|
||||||
autoStart: boolean
|
autoStart: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +95,18 @@ export interface ZapretList {
|
|||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Сайт, который проверяет `src/main/zapret/diagnostics.ts`. */
|
||||||
* Результат проверки соединения (`src/main/zapret/diagnostics.ts`): успех — это успешный
|
export interface ZapretDiagnosticTarget {
|
||||||
* TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос.
|
|
||||||
*/
|
|
||||||
export interface ZapretDiagnosticResult {
|
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
host: string
|
host: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат проверки одного сайта (`src/main/zapret/diagnostics.ts`): успех — это успешный
|
||||||
|
* TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос.
|
||||||
|
*/
|
||||||
|
export interface ZapretDiagnosticResult extends ZapretDiagnosticTarget {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
ms: number | null
|
ms: number | null
|
||||||
error: string | null
|
error: string | null
|
||||||
@@ -97,32 +119,51 @@ export interface ZapretDiagnosticResult {
|
|||||||
export interface IpcHandlers {
|
export interface IpcHandlers {
|
||||||
'theme:get': () => ThemeState
|
'theme:get': () => ThemeState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Версия приложения — `app.getVersion()`, то есть `"version"` из package.json той сборки,
|
||||||
|
* которая сейчас запущена. Раньше renderer держал её отдельной константой «в синхроне
|
||||||
|
* с package.json» вручную: константа осталась на 0.4.0 и релизы 0.5.0 и 0.5.1 показали
|
||||||
|
* пользователю чужую версию. Спрашивать у main — единственный способ не разъехаться.
|
||||||
|
*/
|
||||||
|
'app:version': () => string
|
||||||
|
|
||||||
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
|
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
|
||||||
'app:uninstall': () => void
|
'app:uninstall': () => void
|
||||||
|
|
||||||
|
'app:background-get': () => BackgroundSettings
|
||||||
|
/**
|
||||||
|
* Меняет переданные поля и возвращает состояние целиком. `launchAtLogin` идёт в систему
|
||||||
|
* (планировщик задач, SMAppService, каталог автозапуска) и может не примениться — тогда
|
||||||
|
* канал отвечает ошибкой, а не молча возвращает старое значение.
|
||||||
|
*/
|
||||||
|
'app:background-set': (patch: Partial<BackgroundSettings>) => BackgroundSettings
|
||||||
|
|
||||||
'zapret:status': () => ZapretStatus
|
'zapret:status': () => ZapretStatus
|
||||||
'zapret:strategies': () => ZapretStrategy[]
|
'zapret:strategies': () => ZapretStrategy[]
|
||||||
'zapret:start': (strategyId: string) => ZapretStatus
|
'zapret:start': (strategyId: string) => ZapretStatus
|
||||||
'zapret:stop': () => ZapretStatus
|
'zapret:stop': () => ZapretStatus
|
||||||
/** macOS: прописать/убрать socks-прокси в системных настройках сети. */
|
|
||||||
'zapret:set-system-proxy': (enabled: boolean) => ZapretStatus
|
|
||||||
/**
|
/**
|
||||||
* Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS
|
* Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS
|
||||||
* перезапускает tpws, на Windows перенастраивает службу (потребуется UAC).
|
* переустанавливает LaunchDaemon с utunws (потребуется пароль администратора), на Windows
|
||||||
|
* перенастраивает службу (потребуется UAC), на Linux перезапускает nfqws и правила
|
||||||
|
* nftables (потребуется пароль через pkexec).
|
||||||
*/
|
*/
|
||||||
'zapret:set-strategy': (strategyId: string) => ZapretStatus
|
'zapret:set-strategy': (strategyId: string) => ZapretStatus
|
||||||
/** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */
|
/** macOS/Windows: автозапуск с системой; требует пароль администратора/UAC. */
|
||||||
'zapret:set-autostart': (enabled: boolean) => ZapretStatus
|
'zapret:set-autostart': (enabled: boolean) => ZapretStatus
|
||||||
|
|
||||||
'zapret:lists': () => ZapretList[]
|
'zapret:lists': () => ZapretList[]
|
||||||
'zapret:list-save': (id: ZapretListId, entries: string[]) => ZapretList
|
'zapret:list-save': (id: ZapretListId, entries: string[]) => ZapretList
|
||||||
'zapret:list-reset': (id: ZapretListId) => ZapretList
|
'zapret:list-reset': (id: ZapretListId) => ZapretList
|
||||||
/** Список, который zapret пополняет сам через --hostlist-auto (только macOS-профили). */
|
|
||||||
'zapret:auto-hostlist': () => string[]
|
|
||||||
'zapret:auto-hostlist-clear': () => void
|
|
||||||
|
|
||||||
/** Проверка соединения: успешный TLS-хендшейк до нескольких сайтов через текущий бэкенд. */
|
/**
|
||||||
'zapret:diagnose': () => ZapretDiagnosticResult[]
|
* Запустить проверку соединения и сразу вернуть список проверяемых сайтов — сами
|
||||||
|
* результаты приходят по одному событием `zapret:diagnostic-result`, как только будет
|
||||||
|
* готов каждый. Сайты проверяются параллельно и отвечают вразнобой: ждать самый
|
||||||
|
* медленный, чтобы показать все разом, значит держать пользователя перед пустым
|
||||||
|
* экраном до последнего таймаута.
|
||||||
|
*/
|
||||||
|
'zapret:diagnose': () => ZapretDiagnosticTarget[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IpcChannel = keyof IpcHandlers
|
export type IpcChannel = keyof IpcHandlers
|
||||||
@@ -132,6 +173,8 @@ export interface IpcEvents {
|
|||||||
'theme:changed': ThemeState
|
'theme:changed': ThemeState
|
||||||
'zapret:status-changed': ZapretStatus
|
'zapret:status-changed': ZapretStatus
|
||||||
'zapret:log': ZapretLogLine
|
'zapret:log': ZapretLogLine
|
||||||
|
/** Один готовый результат запущенной через `zapret:diagnose` проверки. */
|
||||||
|
'zapret:diagnostic-result': ZapretDiagnosticResult
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IpcEventChannel = keyof IpcEvents
|
export type IpcEventChannel = keyof IpcEvents
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
* в настройках (`SettingsView.tsx`), и приветственное окно при первом запуске
|
* в настройках (`SettingsView.tsx`), и приветственное окно при первом запуске
|
||||||
* (`src/main/first-run.ts`).
|
* (`src/main/first-run.ts`).
|
||||||
*/
|
*/
|
||||||
export const GITHUB_URL = 'https://github.com/maeneko/rknboost'
|
export const GITHUB_URL = 'https://github.com/maeneko/senboost'
|
||||||
|
|||||||