Compare commits

...

18 Commits

Author SHA1 Message Date
nineap fe92ccb734 Новый ключ подписи обновлений Tauri
Старый pubkey в конфиге был без парного приватного ключа — обновления
никогда не смогли бы пройти проверку подписи. Сгенерирована новая пара,
приватная часть — в secrets.TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) этого
репозитория.
2026-07-10 18:18:57 +07:00
nineap e0bc67b146 Добавляет .github/workflows/release.yml (GitHub-версия через tauri-action)
Существовала на диске, но ни разу не была закоммичена. Работает как и
раньше через tauri-apps/tauri-action — Gitea-версия (.gitea/workflows/release.yml)
отдельная, т.к. tauri-action жёстко ходит в api.github.com.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:39 +07:00
nineap 2f7ef55856 CI: GITEA_SERVER_URL переносит из secrets в vars
Публичный URL Gitea-сервера сам по себе не секрет — только GITEA_TOKEN
остаётся в secrets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:39 +07:00
nineap 5af5042575 Обновляет lock-файл под новые зависимости updater/process
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:39 +07:00
nineap 3067b42de4 Локали: ключи для ErrorBoundary и UpdateChecker (все 10 языков)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:39 +07:00
nineap 0dd4869cbb api.ts: retry с экспоненциальным backoff для сетевых сбоев
Различает настоящие сетевые сбои/5xx (ретраить) от осмысленных 4xx-ответов
приложения (не ретраить) — раньше единичный сетевой глитч ронял весь флоу
без единой попытки повтора.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:39 +07:00
nineap b8e1e925b3 Добавляет UpdateChecker (десктоп) и React ErrorBoundary
UpdateChecker: динамический импорт @tauri-apps/plugin-updater (no-op вне
Tauri/на мобилке), диалог с прогрессом, downloadAndInstall + relaunch.
ErrorBoundary оборачивает всё приложение — раньше необработанное
исключение в рендере роняло весь SPA в белый экран без возможности
восстановиться без ручной перезагрузки страницы.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:39 +07:00
nineap 24f3e5f454 Добавляет tauri-plugin-updater/process (desktop-only)
Регистрируется только под cfg(desktop) — Android/iOS самообновление
запрещено политиками сторов, там плагин не подключается вообще.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:39 +07:00
nineap 948f15d44a Gitea-native механизм публикации релиза (без tauri-action)
tauri-apps/tauri-action жёстко ходит в api.github.com для создания релиза и
загрузки апдейтер-артефактов — не настраиваемый эндпоинт, для Gitea не
годится ни при какой замене секретов. Вместо неё:

- scripts/gitea-publish-release.mjs — публикует релиз через прямые вызовы
  Gitea Release API: собирает latest.json (формат манифеста Tauri v2
  updater) из .sig-файлов подписанных бандлов, создаёт версионный релиз
  (тег = версия, для истории) и пересоздаёт релиз "latest" — у Gitea нет
  built-in алиаса /releases/latest/download/<file> как у GitHub, поэтому
  тег "latest" удаляется и создаётся заново на каждом релизе с теми же
  ассетами, чтобы URL апдейтера был стабильным. Оба релиза
  удаляются-и-пересоздаются — идемпотентно к повторному прогону тега.
- .gitea/workflows/release.yml — job "build" (та же матрица платформ) вызывает
  `tauri build` напрямую (подпись через TAURI_SIGNING_PRIVATE_KEY читает сам
  Tauri CLI, без разницы кто его вызывает), собирает пары <файл>+.sig в
  dist-bundle/ и грузит артефактом bundle-<updater_key>; job "publish"
  скачивает все артефакты и вызывает скрипт выше.
- tauri.conf.json: добавлен второй updater-эндпоинт (Gitea, плейсхолдер
  CHANGE_ME_GITEA_SERVER — заполнить реальным адресом) рядом с
  существующим GitHub — апдейтер пробует оба по порядку.

GitHub-версия пайплайна не тронута, продолжает работать через tauri-action
как раньше. Не проверено вживую на реальном Gitea/Windows/macOS раннере —
нет доступа в этой среде.

Нужные секреты в Gitea: GITEA_SERVER_URL, GITEA_TOKEN (личный токен с
правом записи в репозиторий), плюс уже существующие
TAURI_SIGNING_PRIVATE_KEY(_PASSWORD).
2026-07-09 14:36:08 +07:00
nineap 136fa29d4b Дублирует пайплайн релиза для Gitea Actions (.gitea/workflows/)
Копия .github/workflows/release.yml — но см. предупреждение в самом файле:
tauri-apps/tauri-action жёстко работает с api.github.com (создание релиза,
загрузка артефактов/latest.json), это не переключается сменой секрета.
Реально держит автообновление приложения GitHub-версия пайплайна; для
переезда публикации на Gitea понадобится отдельный release-механизм и
смена updater-эндпоинта в tauri.conf.json.
2026-07-09 14:28:16 +07:00
nineap 59c38d5f49 Android: включает генерацию BuildConfig для vpn-plugin модуля
С AGP 8+ генерация BuildConfig — opt-in per-модуль, а не глобальная
настройка. VpnPlugin.kt ссылается на BuildConfig.DEBUG (гейт debug-инспектора
WebView) — без buildFeatures.buildConfig=true релизная сборка падала
"Unresolved reference: BuildConfig" ещё на этапе компиляции Kotlin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 23:46:55 +07:00
nineap da77c8d755 Локализует приложение на 10 языков + RTL для арабского и персидского
Добавляет zh-CN, fa, ar, tr, uk, be, vi, uz локали (81 ключ, идентичная
структура) к существующим en/ru; ToggleLanguge.tsx переделан из
бинарного ru/en тумблера в выпадающий список всех 10 языков.

TelegramWeb.tsx был полностью на захардкоженном русском несмотря на импорт
useTranslation — переведён на i18n-ключи. rtlLanguages.ts выносит список
RTL-языков в отдельный .ts-файл (именованный импорт из .js падал на
allowJs). App.tsx реактивно переключает document.dir/lang при смене языка.

Логические CSS-свойства (ps-/pe-/start-/end-/border-e) вместо физических
в badge/button/dialog/select и BurgerMenu — Flexbox иначе визуально
переворачивает детей под dir=rtl, ломая анимацию бургер-меню.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:52:20 +07:00
nineap 5f6e06589c Вход через Telegram: код из бота вместо сломанного deep-link
Deep-link авторизация в Telegram-виджете не работала (не было отдельной
кнопки для завершения флоу). Заменяет на код-флоу: бот выдаёт разовый код,
пользователь вводит его в Login.tsx/LoginButton.tsx, useAuthStore дергает
loginTelegramCode(). api.ts вместо startTelegramLogin/TelegramLoginSession
получает getBotUsername() (кэширует bot_username из GET /api/v1/config) для
кнопки "Открыть бота".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:52:04 +07:00
nineap 626e3b8fd4 Разделяет dev/prod окружения сборки для десктопа и мобилки
Отдельные .env.development/.env.production под VITE_API_BASE_URL,
tauri.dev.conf.json (десктоп с отдельным identity) и
tauri.test-backend.conf.json (мобилка, переключение только бэкенда),
плюс новые Makefile-таргеты и build:dev в package.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:51:48 +07:00
nineap 00765b12cd Android: чинит R8/ProGuard, обработку VPN permission и debug-инспектор
- consumerProguardFiles + усиленные keep-правила для Jackson — без них R8 в
  релизной сборке ломал десериализацию TunnelConfig, и кнопка подключения
  переставала что-либо делать без единой ошибки в логах.
- Проверка на null у builder.establish() и корректный resolve/reject запроса
  VPN-разрешения.
- WebView.setWebContentsDebuggingEnabled включается только под BuildConfig.DEBUG,
  а не безусловно в релизе.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:51:36 +07:00
nineap 79c9cb48aa Экран входа как гейт перед подключением, фикс matrix-engine и Android-иконки
Home теперь рендерится только когда isLoggedIn — до этого юзер видит
полноэкранный Login (анонимно/seed/Telegram), а не экран подключения; кнопка
VPN дополнительно блокируется на уровне VpnControl как defense-in-depth.
isLoggedIn — отдельный персистентный флаг, не совпадает с фактом наличия
фонового Ghost-токена (тот может тихо появиться до явного логина).

Заодно: matrix-engine в package.json указывал на путь другого пользователя
(/home/kirill/...) — поправил под текущую машину; прогнал `tauri icon`,
чтобы Android больше не показывал дефолтный логотип Tauri (иконка никогда не
генерировалась для этой платформы); dev-узел Singapore (DEV_IP) переведён на
порт 8443 вслед за прокси.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:21:40 +07:00
nineap 0cb6ad2860 Add seed/Telegram login button and traffic usage indicator
New non-blocking login panel on the Home screen (seed backup/restore +
Telegram magic-link via the bot deep-link, polled through the new backend
endpoints) — uses its own popover instead of a full-screen Dialog so the
burger menu stays clickable while it's open. VpnStats now shows a used/limit
usage bar backed by a new useUsageStore.

Also registers tauri-plugin-shell (needed to open the t.me deep-link) and
threads an optional auth token from the app down through TunnelConfig to the
proxy client (desktop + Android), for instances running --require-auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:31:27 +07:00
nineap bf50824833 local ip fix 2026-07-03 13:31:01 +07:00
91 changed files with 2575 additions and 105 deletions
+6
View File
@@ -6,6 +6,12 @@
"runtimeExecutable": "wsl.exe", "runtimeExecutable": "wsl.exe",
"runtimeArgs": ["-e", "bash", "-ic", "cd ~/netrunner-app && node_modules/.bin/vite --port 5183 --strictPort"], "runtimeArgs": ["-e", "bash", "-ic", "cd ~/netrunner-app && node_modules/.bin/vite --port 5183 --strictPort"],
"port": 5183 "port": 5183
},
{
"name": "vite-dev-linux",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["exec", "vite", "--port", "5183", "--strictPort"],
"port": 5183
} }
] ]
} }
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=https://dev.netrunner-vpn.com
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=https://netrunner-vpn.com
+130
View File
@@ -0,0 +1,130 @@
# Gitea-native версия — публикует релиз через прямые вызовы Gitea Release API
# (scripts/gitea-publish-release.mjs), а не через tauri-apps/tauri-action:
# та жёстко ходит в api.github.com, для Gitea не годится (не настраиваемый
# эндпоинт). GitHub-версия пайплайна (.github/workflows/release.yml) не тронута
# и продолжает работать как раньше через tauri-action.
#
# У Gitea нет built-in алиаса /releases/latest/download/<file> как у GitHub —
# эмулируем его: тег "latest" пересоздаётся на каждом релизе с теми же
# ассетами, что и версионный тег. Апдейтер (tauri.conf.json) ходит именно
# по этому стабильному URL.
#
# Только десктоп (Windows/macOS/Linux) — Android/iOS обновляются через сторы,
# самообновление там запрещено политиками платформ (см. src-tauri/src/lib.rs).
# Триггер — тег вида v1.2.3 (совпадает с полем "version" в tauri.conf.json).
#
# Нужные секреты в Gitea: TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) — как и раньше,
# плюс GITEA_TOKEN (личный токен с правом записи в этот репозиторий).
# GITEA_SERVER_URL (напр. https://git.example.com, без слэша на конце) — это
# НЕ секрет (публичный URL сервера), задаётся как repo Variable (vars.*), не
# Actions secret.
#
# shell: bash на windows-latest требует Git for Windows/bash в PATH раннера —
# на GitHub-хостед раннерах это есть из коробки, на самохостед Gitea-раннере
# под Windows нужно проверить отдельно (не проверялось вживую — нет доступа
# к реальному Windows/macOS раннеру в этой среде).
name: Release Desktop
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
updater_key: darwin-aarch64
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
updater_key: darwin-x86_64
- platform: "ubuntu-24.04"
args: ""
updater_key: linux-x86_64
- platform: "windows-latest"
args: ""
updater_key: windows-x86_64
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install Linux system deps (webkit2gtk, etc.)
if: matrix.platform == 'ubuntu-24.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- run: pnpm install --frozen-lockfile
# Tauri CLI напрямую, не tauri-action — она сама публикует релиз на
# GitHub попутно со сборкой, тут публикация вынесена в отдельный job.
# Подписывающие переменные окружения читает сам Tauri CLI, без разницы,
# кто его вызывает — .sig-файлы создаются точно так же, как и раньше.
- name: Build
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm tauri build ${{ matrix.args }}
# Собираем только то, что реально нужно апдейтеру (парами файл+.sig) —
# не гадаем про точные имена/подпапки бандлов на каждой платформе,
# просто берём всё, что Tauri CLI подписал.
- name: Collect signed updater bundles
shell: bash
run: |
mkdir -p dist-bundle
find src-tauri/target -name "*.sig" | while read -r sig; do
base="${sig%.sig}"
cp "$sig" dist-bundle/
cp "$base" dist-bundle/
done
ls -la dist-bundle
- uses: actions/upload-artifact@v4
with:
name: bundle-${{ matrix.updater_key }}
path: dist-bundle/*
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Publish release to Gitea
env:
GITEA_SERVER_URL: ${{ vars.GITEA_SERVER_URL }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_REPO: ${{ github.repository }}
RELEASE_VERSION: ${{ github.ref_name }}
run: node scripts/gitea-publish-release.mjs artifacts
+66
View File
@@ -0,0 +1,66 @@
name: Release Desktop
# Только десктоп (Windows/macOS/Linux) — Android/iOS обновляются через сторы,
# самообновление там запрещено политиками платформ (см. src-tauri/src/lib.rs).
# Триггер — тег вида v1.2.3 (совпадает с полем "version" в tauri.conf.json).
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
release:
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
- platform: "ubuntu-24.04"
args: ""
- platform: "windows-latest"
args: ""
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install Linux system deps (webkit2gtk, etc.)
if: matrix.platform == 'ubuntu-24.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- run: pnpm install --frozen-lockfile
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Приватный ключ для подписи апдейтов — БЕЗ него downloadAndInstall()
# на клиентах будет отклонять пакет (сигнатура не совпадёт).
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: "Netrunner VPN ${{ github.ref_name }}"
releaseDraft: true
prerelease: false
includeUpdaterJson: true
args: ${{ matrix.args }}
+23
View File
@@ -47,15 +47,38 @@ build-windows:
build-macos: build-macos:
pnpm tauri build --target $(TARGET_MAC) pnpm tauri build --target $(TARGET_MAC)
# Дев-флейвор десктопа: отдельные identifier/название окна (src-tauri/tauri.dev.conf.json),
# бэкенд — https://dev.netrunner-vpn.com (см. .env.development, build:dev в package.json).
# Ставится рядом с прод-версией на одну машину без конфликта.
build-linux-dev:
pnpm tauri build --target $(TARGET_LINUX) --config src-tauri/tauri.dev.conf.json
build-windows-dev:
pnpm tauri build --target $(TARGET_WIN) --config src-tauri/tauri.dev.conf.json
build-android-arm64: build-android-arm64:
pnpm tauri android build --target aarch64 pnpm tauri android build --target aarch64
build-android-all: build-android-all:
pnpm tauri android build pnpm tauri android build
# Тестовый бэкенд (https://dev.netrunner-vpn.com) в том же приложении: applicationId
# в gen/android фиксируется один раз при `tauri android init` и не меняется задним
# числом через --config (см. tauri.dev.conf.json на десктопе) — поэтому здесь только
# переключение бэкенда (src-tauri/tauri.test-backend.conf.json), без второго app id.
# Ставит поверх прод-версии на устройстве, не рядом.
build-android-arm64-test:
pnpm tauri android build --target aarch64 --config src-tauri/tauri.test-backend.conf.json
build-android-all-test:
pnpm tauri android build --config src-tauri/tauri.test-backend.conf.json
build-ios: build-ios:
pnpm tauri ios build pnpm tauri ios build
build-ios-test:
pnpm tauri ios build --config src-tauri/tauri.test-backend.conf.json
install: install:
adb install src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk adb install src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
+4 -1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "npm run prep:matrix && tsc && vite build", "build": "npm run prep:matrix && tsc && vite build",
"build:dev": "npm run prep:matrix && tsc && vite build --mode development",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"prep:matrix": "mkdir -p public/wasm-matrix && cp -rf node_modules/matrix-engine/* public/wasm-matrix/", "prep:matrix": "mkdir -p public/wasm-matrix && cp -rf node_modules/matrix-engine/* public/wasm-matrix/",
@@ -19,14 +20,16 @@
"@hugeicons/react": "^1.1.5", "@hugeicons/react": "^1.1.5",
"@tabler/icons-react": "^3.40.0", "@tabler/icons-react": "^3.40.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.35.2", "framer-motion": "^12.35.2",
"i18next": "^25.8.16", "i18next": "^25.8.16",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"matrix-engine": "link:/home/kirill/netrunner-landing/matrix-engine/pkg", "matrix-engine": "link:../netrunner-landing/matrix-engine/pkg",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-country-flag": "^3.1.0", "react-country-flag": "^3.1.0",
+21 -9
View File
@@ -26,18 +26,21 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.10.1 version: 2.10.1
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-shell': '@tauri-apps/plugin-shell':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.3.5 version: 2.3.5
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
eruda:
specifier: ^3.4.3
version: 3.4.3
framer-motion: framer-motion:
specifier: ^12.35.2 specifier: ^12.35.2
version: 12.35.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 12.35.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -51,7 +54,7 @@ importers:
specifier: ^0.577.0 specifier: ^0.577.0
version: 0.577.0(react@19.2.4) version: 0.577.0(react@19.2.4)
matrix-engine: matrix-engine:
specifier: link:/home/kirill/netrunner-landing/matrix-engine/pkg specifier: link:../netrunner-landing/matrix-engine/pkg
version: link:../netrunner-landing/matrix-engine/pkg version: link:../netrunner-landing/matrix-engine/pkg
radix-ui: radix-ui:
specifier: ^1.4.3 specifier: ^1.4.3
@@ -1587,9 +1590,15 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-shell@2.3.5': '@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@ts-morph/common@0.27.0': '@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -1917,9 +1926,6 @@ packages:
error-ex@1.3.4: error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
eruda@3.4.3:
resolution: {integrity: sha512-J2TsF4dXSspOXev5bJ6mljv0dRrxj21wklrDzbvPmYaEmVoC+2psylyRi70nUPFh1mTQfIBsSusUtAMZtUN+/w==}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4524,10 +4530,18 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-shell@2.3.5': '@tauri-apps/plugin-shell@2.3.5':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@ts-morph/common@0.27.0': '@ts-morph/common@0.27.0':
dependencies: dependencies:
fast-glob: 3.3.3 fast-glob: 3.3.3
@@ -4819,8 +4833,6 @@ snapshots:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
eruda@3.4.3: {}
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: false
msw: false
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
// Публикует Tauri-релиз в Gitea напрямую через Release API — вместо
// tauri-apps/tauri-action, который жёстко ходит в api.github.com (не
// настраиваемый эндпоинт, для Gitea не годится). Используется только в
// .gitea/workflows/release.yml, job "publish".
//
// Схема:
// 1. Job "build" (матрица платформ) собирает бандлы через `tauri build`
// напрямую (не через tauri-action) и складывает каждую подписанную пару
// <файл>+<файл>.sig в dist-bundle/, которая грузится как build-артефакт
// с именем bundle-<updater_key> (updater_key — "darwin-aarch64" и т.п.,
// см. matrix в workflow).
// 2. Этот скрипт (job "publish") скачивает все артефакты, находит пары
// <файл>+.sig, собирает latest.json (формат манифеста апдейтера Tauri v2,
// см. https://v2.tauri.app/plugin/updater/) и публикует:
// - версионный релиз (тег = версия, для истории/changelog в Gitea UI);
// - релиз "latest" — Gitea, в отличие от GitHub, не имеет built-in алиаса
// /releases/latest/download/<file>, поэтому эмулируем его вручную:
// тег "latest" удаляется и пересоздаётся на каждом релизе с теми же
// ассетами, что и версионный — так у апдейтера в tauri.conf.json
// стабильный URL, не привязанный к конкретной версии.
//
// Оба релиза удаляются-и-пересоздаются при каждом запуске (idempotent —
// повторный прогон того же тега не падает на "уже существует").
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, basename } from "node:path";
const GITEA_SERVER_URL = requireEnv("GITEA_SERVER_URL").replace(/\/$/, "");
const GITEA_TOKEN = requireEnv("GITEA_TOKEN");
const REPO = requireEnv("GITEA_REPO"); // "owner/name"
const VERSION = requireEnv("RELEASE_VERSION"); // напр. "v1.2.3"
const ARTIFACTS_DIR = process.argv[2] || "artifacts";
function requireEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Переменная окружения ${name} обязательна`);
return v;
}
function apiUrl(path) {
return `${GITEA_SERVER_URL}/api/v1/repos/${REPO}${path}`;
}
async function giteaFetch(path, options = {}) {
const res = await fetch(apiUrl(path), {
...options,
headers: {
Authorization: `token ${GITEA_TOKEN}`,
...(options.headers || {}),
},
});
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`Gitea API ${path} -> ${res.status}: ${text}`);
}
return res;
}
/** Рекурсивно находит все пары <файл>+<файл>.sig под dir. */
function collectSigPairs(dir) {
const pairs = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
pairs.push(...collectSigPairs(full));
continue;
}
if (entry.name.endsWith(".sig")) {
pairs.push({ sigPath: full, artifactPath: full.slice(0, -4), dir });
}
}
return pairs;
}
/** Удаляет (если есть) и заново создаёт релиз на теге — гарантирует idempotent-повтор. */
async function ensureRelease(tag) {
const existing = await giteaFetch(`/releases/tags/${tag}`);
if (existing.status === 200) {
const { id } = await existing.json();
await giteaFetch(`/releases/${id}`, { method: "DELETE" });
}
// Релиз и git-тег в Gitea — разные сущности; удаление релиза не удаляет тег.
await giteaFetch(`/tags/${tag}`, { method: "DELETE" });
const created = await giteaFetch(`/releases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag_name: tag,
name: tag,
draft: false,
prerelease: false,
target_commitish: process.env.GITHUB_SHA || "main",
}),
});
if (!created.ok) {
throw new Error(`Не удалось создать релиз ${tag}: HTTP ${created.status}`);
}
return created.json();
}
async function uploadAsset(releaseId, filePath, fileName) {
const data = readFileSync(filePath);
const form = new FormData();
form.append("attachment", new Blob([data]), fileName);
const res = await giteaFetch(
`/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
{ method: "POST", body: form },
);
if (!res.ok) {
throw new Error(`Не удалось загрузить ${fileName}: HTTP ${res.status}`);
}
return res.json();
}
async function main() {
const pairs = collectSigPairs(ARTIFACTS_DIR);
if (pairs.length === 0) {
throw new Error(
`Не найдено ни одной пары <файл>+.sig в ${ARTIFACTS_DIR} — сборка не создала подписанные updater-бандлы?`,
);
}
const versionedRelease = await ensureRelease(VERSION);
const latestRelease = await ensureRelease("latest");
const platforms = {};
for (const { sigPath, artifactPath, dir } of pairs) {
const signature = readFileSync(sigPath, "utf-8").trim();
const fileName = basename(artifactPath);
// Папка артефакта matrix-job'а называется bundle-<updater_key>, см. workflow.
const updaterKey = basename(dir).replace(/^bundle-/, "");
console.log(`${updaterKey}: ${fileName}`);
await uploadAsset(versionedRelease.id, artifactPath, fileName);
await uploadAsset(latestRelease.id, artifactPath, fileName);
platforms[updaterKey] = {
signature,
// Всегда через тег "latest" — стабильный URL независимо от версии.
url: `${GITEA_SERVER_URL}/${REPO}/releases/download/latest/${fileName}`,
};
}
const manifest = {
version: VERSION.replace(/^v/, ""),
notes: `Netrunner VPN ${VERSION}`,
pub_date: new Date().toISOString(),
platforms,
};
const manifestPath = join(ARTIFACTS_DIR, "latest.json");
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
await uploadAsset(versionedRelease.id, manifestPath, "latest.json");
await uploadAsset(latestRelease.id, manifestPath, "latest.json");
console.log("✅ Опубликовано:\n" + JSON.stringify(manifest, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+241 -4
View File
@@ -70,6 +70,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.2" version = "1.9.2"
@@ -900,6 +909,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -1183,6 +1203,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -2165,6 +2195,25 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2548,6 +2597,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2629,8 +2684,12 @@ dependencies = [
"ndk-context", "ndk-context",
"netrunner-logger", "netrunner-logger",
"serde", "serde",
"serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
"tauri-plugin-vpn", "tauri-plugin-vpn",
"winres", "winres",
] ]
@@ -2638,7 +2697,7 @@ dependencies = [
[[package]] [[package]]
name = "netrunner-client" name = "netrunner-client"
version = "0.1.0" version = "0.1.0"
source = "git+ssh://git@github.com/nineAp/netrunner-core.git?branch=main#8d9dfeee41cadc260c500edac28afa9026de464d" source = "git+ssh://git@github.com/nineAp/netrunner-core.git?branch=main#40ddb0a0f62f3161e4e4618370216735bde34e35"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2664,7 +2723,7 @@ dependencies = [
[[package]] [[package]]
name = "netrunner-core" name = "netrunner-core"
version = "0.1.0" version = "0.1.0"
source = "git+ssh://git@github.com/nineAp/netrunner-core.git?branch=main#8d9dfeee41cadc260c500edac28afa9026de464d" source = "git+ssh://git@github.com/nineAp/netrunner-core.git?branch=main#40ddb0a0f62f3161e4e4618370216735bde34e35"
dependencies = [ dependencies = [
"aead", "aead",
"arc-swap", "arc-swap",
@@ -2691,7 +2750,7 @@ dependencies = [
[[package]] [[package]]
name = "netrunner-logger" name = "netrunner-logger"
version = "0.1.0" version = "0.1.0"
source = "git+ssh://git@github.com/nineAp/netrunner-core.git?branch=main#8d9dfeee41cadc260c500edac28afa9026de464d" source = "git+ssh://git@github.com/nineAp/netrunner-core.git?branch=main#40ddb0a0f62f3161e4e4618370216735bde34e35"
dependencies = [ dependencies = [
"log", "log",
"regex", "regex",
@@ -2851,6 +2910,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2866,6 +2926,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2920,6 +2992,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.2.1" version = "0.2.1"
@@ -2932,6 +3015,30 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -3762,6 +3869,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -4187,12 +4295,44 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shared_child"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -4258,7 +4398,7 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]] [[package]]
name = "smoltcp" name = "smoltcp"
version = "0.13.0" version = "0.13.0"
source = "git+ssh://git@github.com/nineAp/nxrp-smoltcp.git#e9ba23b47ab789eb480d2eef2ffc4d7cdfedb381" source = "git+ssh://git@github.com/nineAp/nxrp-smoltcp.git#c8093df6ec24f75c13bedf0e74e53cefe26997e0"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"byteorder", "byteorder",
@@ -4547,6 +4687,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4684,6 +4835,70 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
dependencies = [
"encoding_rs",
"log",
"open",
"os_pipe",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-plugin-vpn" name = "tauri-plugin-vpn"
version = "0.1.0" version = "0.1.0"
@@ -6579,6 +6794,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"
@@ -6696,6 +6921,18 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.1",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+9
View File
@@ -15,12 +15,21 @@ winres = "0.1"
jni = "0.22.3" jni = "0.22.3"
tauri = { version = "2.0.0", features = [] } tauri = { version = "2.0.0", features = [] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
ndk-context = "0.1" ndk-context = "0.1"
log = "0.4" log = "0.4"
tauri-plugin-vpn = { path = "./src/plugins/vpn-plugin" } tauri-plugin-vpn = { path = "./src/plugins/vpn-plugin" }
tauri-plugin-shell = "2.0.0"
netrunner-logger = { git = "ssh://git@github.com/nineAp/netrunner-core.git", branch = "main", features = ["desktop"] } netrunner-logger = { git = "ssh://git@github.com/nineAp/netrunner-core.git", branch = "main", features = ["desktop"] }
# Автообновление есть только на десктопе — Android/iOS запрещают самообновление
# в обход стора, поэтому эти крейты не тянутся и не регистрируются на мобильных
# таргетах (см. src/lib.rs, #[cfg(desktop)]).
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2.0.0"
tauri-plugin-process = "2.0.0"
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
lto = true lto = true
+2 -1
View File
@@ -6,6 +6,7 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"vpn:allow-vpn-control", "vpn:allow-vpn-control",
"vpn:allow-check-actual-vpn-status" "vpn:allow-check-actual-vpn-status",
"shell:allow-open"
] ]
} }
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "updater",
"description": "Автообновление — только десктоп (Windows/macOS/Linux). Мобильные сборки обновляются через сторы.",
"windows": ["main"],
"platforms": ["macOS", "windows", "linux"],
"permissions": [
"updater:default",
"process:allow-restart"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 B

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+11 -1
View File
@@ -1,7 +1,17 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_vpn::init()) .plugin(tauri_plugin_vpn::init())
.plugin(tauri_plugin_shell::init());
// Android/iOS не разрешают самообновление в обход стора — плагин
// регистрируется только на десктопе (Windows/macOS/Linux).
#[cfg(desktop)]
let builder = builder
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build());
builder
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
@@ -10,6 +10,13 @@ android {
defaultConfig { defaultConfig {
minSdk = 21 minSdk = 21
// Без этого proguard-rules.pro лежит мёртвым грузом — app-модуль (где
// реально включена минификация в release) собирает *.pro только из
// своей собственной директории, а этот модуль в другой. Без keep-правил
// R8 переименовывает TunnelConfig, Jackson теряет reflection-доступ к
// конструктору и падает "Cannot construct instance of `j0.a`..." —
// именно так и было при первом релизном (не dev) билде.
consumerProguardFiles("proguard-rules.pro")
} }
compileOptions { compileOptions {
@@ -21,6 +28,12 @@ android {
jvmTarget = "17" jvmTarget = "17"
} }
buildFeatures {
// Начиная с AGP 8 генерация BuildConfig — opt-in per-модуль. Нужен
// BuildConfig.DEBUG в VpnPlugin.kt (гейт debug-инспектора WebView).
buildConfig = true
}
sourceSets { sourceSets {
getByName("main") { getByName("main") {
// Добавляем папку с исходным кодом (где лежат твои uniffi .kt файлы) // Добавляем папку с исходным кодом (где лежат твои uniffi .kt файлы)
@@ -3,6 +3,13 @@
} }
-keep class uniffi.netrunner_client.** { *; } -keep class uniffi.netrunner_client.** { *; }
# Jackson находит @JsonCreator/@JsonProperty только через runtime-reflection
# без сохранённых атрибутов аннотаций и параметров R8 может вырезать саму
# видимость аннотаций, даже если сами классы/поля ниже помечены -keep.
-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod
-keep class com.fasterxml.jackson.** { *; }
-dontwarn com.fasterxml.jackson.**
-keep class app.netrunner.vpn.TunnelConfig { -keep class app.netrunner.vpn.TunnelConfig {
@com.fasterxml.jackson.annotation.JsonProperty <fields>; @com.fasterxml.jackson.annotation.JsonProperty <fields>;
<init>(...); <init>(...);
@@ -14,5 +21,10 @@
-keepnames class app.netrunner.vpn.TunVpnService { *; } -keepnames class app.netrunner.vpn.TunVpnService { *; }
-keepnames class app.netrunner.vpn.TunnelConfig { *; } -keepnames class app.netrunner.vpn.TunnelConfig { *; }
# @InvokeArg-классы Tauri-плагина (VpnPlugin$VpnArgs, LaunchAppArgs) тот же
# reflection-путь десериализации, что и у TunnelConfig; VpnArgs как раз и
# оборачивает TunnelConfig в реальном invoke-запросе.
-keep class app.netrunner.vpn.VpnPlugin$* { *; }
-keep class com.sun.jna.** { *; } -keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.** -dontwarn com.sun.jna.**
@@ -42,7 +42,10 @@ data class TunnelConfig @JsonCreator constructor(
@JsonProperty("excludedApps") val excludedApps: List<String>, @JsonProperty("excludedApps") val excludedApps: List<String>,
@JsonProperty("excludedDomains") val excludedDomains: List<String>, @JsonProperty("excludedDomains") val excludedDomains: List<String>,
// Новое поле для режима песочницы // Новое поле для режима песочницы
@JsonProperty("allowedApps") val allowedApps: List<String>? = null @JsonProperty("allowedApps") val allowedApps: List<String>? = null,
// Bearer-токен клиента для проверки авторизации на прокси (--require-auth);
// null — сервер не требует авторизацию, либо приложение ещё не залогинено.
@JsonProperty("authToken") val authToken: String? = null
) : Parcelable ) : Parcelable
class TunVpnService : VpnService() { class TunVpnService : VpnService() {
@@ -110,7 +113,18 @@ class TunVpnService : VpnService() {
try { builder.addDisallowedApplication(packageName) } catch (e: Exception) { Log.e(TAG, "Exclude fail") } try { builder.addDisallowedApplication(packageName) } catch (e: Exception) { Log.e(TAG, "Exclude fail") }
} }
vpnInterface = builder.establish() ?: return vpnInterface = builder.establish()
if (vpnInterface == null) {
// Раньше здесь было тихое `?: return` — ни одной строки в логах, ни
// сброса статуса на JS-стороне (кнопка навсегда виснет в
// "ПОДКЛЮЧЕНИЕ..."). Типичные причины: в Настройках Android
// "Always-on VPN" залочен на другое приложение, другой VPN уже
// держит интерфейс, либо разрешение отозвано после prepare().
Log.e(TAG, "VpnService.Builder.establish() returned null — VPN interface not created (Always-on VPN on another app? Another VPN active? Permission revoked?)")
sendStatusBroadcast("idle")
stopSelf()
return
}
val fd = vpnInterface!!.detachFd() val fd = vpnInterface!!.detachFd()
try { try {
@@ -122,7 +136,8 @@ class TunVpnService : VpnService() {
cacheDir.absolutePath, cacheDir.absolutePath,
config.killswitchEnabled, config.killswitchEnabled,
config.excludedApps, config.excludedApps,
config.excludedDomains config.excludedDomains,
config.authToken
) )
triggerVibration(true) triggerVibration(true)
sendStatusBroadcast("connected") sendStatusBroadcast("connected")
@@ -359,6 +374,12 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
override fun load(webView: WebView) { override fun load(webView: WebView) {
super.load(webView) super.load(webView)
// Tauri сам по себе нигде не зовёт setWebContentsDebuggingEnabled — в
// релизной сборке chrome://inspect не видит WebView вообще. Включаем
// только в debug-сборках (BuildConfig.DEBUG), а не безусловно.
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
try { System.loadLibrary("netrunner_client") } catch (e: Exception) { Log.e(TAG, "Lib fail") } try { System.loadLibrary("netrunner_client") } catch (e: Exception) { Log.e(TAG, "Lib fail") }
requestNotificationPermission() requestNotificationPermission()
@@ -408,12 +429,16 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
val args = invoke.parseArgs(VpnArgs::class.java) val args = invoke.parseArgs(VpnArgs::class.java)
val intent = VpnService.prepare(activity) val intent = VpnService.prepare(activity)
if (intent != null) { if (intent != null) {
// Диалог разрешения VPN ещё не показан/не подтверждён — resolve()
// здесь был бы преждевременным: JS увидел бы "успех" ещё до того,
// как юзер вообще ответил на системный диалог. Настоящий
// resolve()/reject() — в onVpnPermissionResult, по факту ответа.
pendingArgs = args pendingArgs = args
startActivityForResult(invoke, intent, "onVpnPermissionResult") startActivityForResult(invoke, intent, "onVpnPermissionResult")
} else { } else {
startVpnService(args) startVpnService(args)
invoke.resolve()
} }
invoke.resolve()
} }
private fun startVpnService(args: VpnArgs) { private fun startVpnService(args: VpnArgs) {
@@ -430,9 +455,15 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
@ActivityCallback @ActivityCallback
fun onVpnPermissionResult(invoke: Invoke, result: ActivityResult) { fun onVpnPermissionResult(invoke: Invoke, result: ActivityResult) {
if (result.resultCode == Activity.RESULT_OK) pendingArgs?.let { startVpnService(it) } if (result.resultCode == Activity.RESULT_OK) {
pendingArgs?.let { startVpnService(it) }
invoke.resolve()
} else {
// Юзер отклонил системный диалог VPN-разрешения — раньше это тихо
// резолвилось как успех, и кнопка навсегда зависала в "ПОДКЛЮЧЕНИЕ...".
invoke.reject("VPN permission denied")
}
pendingArgs = null pendingArgs = null
invoke.resolve()
} }
@Command @Command
@@ -9,6 +9,8 @@ export interface TunnelConfig {
excludedApps: string[]; excludedApps: string[];
excludedDomains: string[]; excludedDomains: string[];
allowedApps?: string[]; allowedApps?: string[];
/** Bearer-токен для прокси-инстансов с `--require-auth`; `undefined` — не проверяется. */
authToken?: string;
} }
export interface TrafficStatsResponse { export interface TrafficStatsResponse {
@@ -57,6 +57,7 @@ impl<R: Runtime> VpnInterface<R> for Vpn<R> {
config.killswitch_enabled, config.killswitch_enabled,
config.excluded_apps.clone(), config.excluded_apps.clone(),
config.excluded_domains.clone(), config.excluded_domains.clone(),
config.auth_token.clone(),
); );
// Сохраняем сессию // Сохраняем сессию
@@ -12,6 +12,11 @@ pub struct TunnelConfig {
pub excluded_domains: Vec<String>, pub excluded_domains: Vec<String>,
#[serde(default)] #[serde(default)]
pub allowed_apps: Vec<String>, pub allowed_apps: Vec<String>,
/// Bearer-токен клиента для проверки авторизации на прокси
/// (`--require-auth`); `None` — сервер не требует авторизацию, либо
/// приложение ещё не залогинено.
#[serde(default)]
pub auth_token: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
+10
View File
@@ -33,5 +33,15 @@
"icons/icon.ico", "icons/icon.ico",
"icons/icon.icns" "icons/icon.icns"
] ]
},
"plugins": {
"updater": {
"active": true,
"endpoints": [
"https://github.com/nineAp/netrunner-app/releases/latest/download/latest.json",
"https://CHANGE_ME_GITEA_SERVER/nineAp/netrunner-app/releases/download/latest/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDcxQTFBM0JEMTk5OEEwQTAKUldTZ29KZ1p2YU9oY1JzTlZZeTlzTGFLS0lyVEdsZDRmODAySWZFajNISlpxdHhibTRUV01UZGkK"
}
} }
} }
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Netrunner VPN Dev",
"identifier": "com.netrunner.vpn.dev",
"build": {
"beforeBuildCommand": "pnpm run build:dev"
},
"app": {
"windows": [
{
"title": "Netrunner VPN — DEV",
"width": 500,
"height": 800
}
]
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"build": {
"beforeBuildCommand": "pnpm run build:dev"
}
}
+25 -4
View File
@@ -1,27 +1,44 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { RTL_LANGUAGES } from "./lib/rtlLanguages";
import { Header } from "./features/Header"; import { Header } from "./features/Header";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { BurgerMenu } from "./features/BurgerMenu"; import { BurgerMenu } from "./features/BurgerMenu";
import { Home } from "./pages/Home"; import { Home } from "./pages/Home";
import { Login } from "./pages/Login";
import { About } from "./pages/About"; import { About } from "./pages/About";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
import { useVpnStore } from "./store/useVpnStore"; import { useVpnStore } from "./store/useVpnStore";
import { useTrafficStore } from "./store/useTrafficStore"; import { useTrafficStore } from "./store/useTrafficStore";
import { useNodesStore } from "./store/useNodesStore"; import { useNodesStore } from "./store/useNodesStore";
import { useAuthStore } from "./store/useAuthStore";
import { ThemeProvider, useTheme } from "./ThemeProvider"; import { ThemeProvider, useTheme } from "./ThemeProvider";
import { SplashScreen } from "./features/SplashScreen"; import { SplashScreen } from "./features/SplashScreen";
import { NetrunnerMatrix } from "matrix-engine/NetrunnerMatrix"; import { NetrunnerMatrix } from "matrix-engine/NetrunnerMatrix";
import { useSettingsStore } from "./store/useSettingsStore"; import { useSettingsStore } from "./store/useSettingsStore";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TelegramWeb } from "./pages/TelegramWeb"; import { TelegramWeb } from "./pages/TelegramWeb";
import { UpdateChecker } from "./features/UpdateChecker";
import { ErrorBoundary } from "./features/ErrorBoundary";
function AppContent() { function AppContent() {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { i18n } = useTranslation();
// Арабский/фарси — письмо справа налево, меняем direction всего документа
// реактивно на смену языка (а не только при старте), т.к. LanguageToggle
// переключает язык без перезагрузки страницы.
useEffect(() => {
const lang = (i18n.language || "en").split("-")[0];
document.documentElement.dir = RTL_LANGUAGES.includes(lang) ? "rtl" : "ltr";
document.documentElement.lang = i18n.language || "en";
}, [i18n.language]);
const initVpn = useVpnStore((state) => state.init); const initVpn = useVpnStore((state) => state.init);
const initStat = useTrafficStore((state) => state.init); const initStat = useTrafficStore((state) => state.init);
const loadNodes = useNodesStore((state) => state.loadNodes); const loadNodes = useNodesStore((state) => state.loadNodes);
const vpnStatus = useVpnStore((state) => state.status); const vpnStatus = useVpnStore((state) => state.status);
const isLoggedIn = useAuthStore((state) => state.isLoggedIn);
const { isDark } = useTheme(); const { isDark } = useTheme();
const reduceMotion = useSettingsStore((state) => state.reduceMotion); const reduceMotion = useSettingsStore((state) => state.reduceMotion);
@@ -87,9 +104,11 @@ function AppContent() {
onClose={() => setIsMenuOpen(false)} onClose={() => setIsMenuOpen(false)}
/> />
<UpdateChecker />
<main className="flex-1 relative flex flex-col items-center justify-center overflow-hidden"> <main className="flex-1 relative flex flex-col items-center justify-center overflow-hidden">
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={isLoggedIn ? <Home /> : <Login />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/telegram" element={<TelegramWeb />} /> <Route path="/telegram" element={<TelegramWeb />} />
@@ -103,8 +122,10 @@ function AppContent() {
export default function App() { export default function App() {
return ( return (
<ThemeProvider> <ErrorBoundary>
<AppContent /> <ThemeProvider>
</ThemeProvider> <AppContent />
</ThemeProvider>
</ErrorBoundary>
); );
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{ {
variants: { variants: {
variant: { variant: {
+4 -4
View File
@@ -22,10 +22,10 @@ const buttonVariants = cva(
}, },
size: { size: {
default: default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pe-2.5 has-data-[icon=inline-start]:ps-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pe-3 has-data-[icon=inline-start]:ps-3",
icon: "size-9", icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8", "icon-sm": "size-8",
+6 -3
View File
@@ -1,5 +1,6 @@
import * as React from "react" import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui"
import { useTranslation } from "react-i18next"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -53,6 +54,7 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
const { t } = useTranslation()
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
@@ -69,12 +71,12 @@ function DialogContent({
<DialogPrimitive.Close data-slot="dialog-close" asChild> <DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button <Button
variant="ghost" variant="ghost"
className="absolute top-4 right-4" className="absolute top-4 end-4"
size="icon-sm" size="icon-sm"
> >
<IconX <IconX
/> />
<span className="sr-only">Close</span> <span className="sr-only">{t("dialog_close")}</span>
</Button> </Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
@@ -101,6 +103,7 @@ function DialogFooter({
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
const { t } = useTranslation()
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
@@ -113,7 +116,7 @@ function DialogFooter({
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close asChild> <DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button> <Button variant="outline">{t("dialog_close")}</Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
+2 -2
View File
@@ -110,12 +110,12 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default items-center gap-2.5 rounded-xl py-2 pe-8 ps-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className
)} )}
{...props} {...props}
> >
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center"> <span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<IconCheck className="pointer-events-none" /> <IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
+13 -3
View File
@@ -1,5 +1,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { RTL_LANGUAGES } from "@/lib/rtlLanguages";
interface BurgerMenuProps { interface BurgerMenuProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -7,7 +9,10 @@ interface BurgerMenuProps {
export function BurgerMenu({ isOpen, onClose }: BurgerMenuProps) { export function BurgerMenu({ isOpen, onClose }: BurgerMenuProps) {
if (!isOpen) return null; if (!isOpen) return null;
const { t } = useTranslation(); const { t, i18n } = useTranslation();
// slide-in-from-left/right — буквальные keyframe-имена (не логические
// start/end), поэтому направление анимации приходится выбирать вручную.
const isRtl = RTL_LANGUAGES.includes((i18n.language || "").split("-")[0]);
return ( return (
<> <>
{/* затемнение только под хедером */} {/* затемнение только под хедером */}
@@ -16,8 +21,13 @@ export function BurgerMenu({ isOpen, onClose }: BurgerMenuProps) {
onClick={onClose} onClick={onClose}
/> />
{/* меню */} {/* меню — открывается от смыслового "начала" строки: слева в LTR, справа в RTL */}
<div className="fixed left-0 top-25 z-60 h-[calc(100vh-4rem)] w-64 bg-card border-r p-6 shadow-xl animate-in slide-in-from-left"> <div
className={cn(
"fixed start-0 top-25 z-60 h-[calc(100vh-4rem)] w-64 bg-card border-e p-6 shadow-xl animate-in",
isRtl ? "slide-in-from-right" : "slide-in-from-left",
)}
>
<nav className="flex flex-col gap-6" onClick={onClose}> <nav className="flex flex-col gap-6" onClick={onClose}>
<Link <Link
to="/" to="/"
+53
View File
@@ -0,0 +1,53 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface Props extends WithTranslation {
children: ReactNode;
}
interface State {
error: Error | null;
}
/**
* Ловит непойманные ошибки рендера где угодно в дереве ниже — без неё любой
* баг в компоненте (например, неожиданный ответ бэкенда, на который никто не
* рассчитывал) валит всё приложение в белый экран без единого сообщения.
* React error boundary обязан быть классовым компонентом — хуки этого не умеют.
*/
class ErrorBoundaryImpl extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[ErrorBoundary] Непойманная ошибка рендера:", error, info.componentStack);
}
handleReload = () => {
this.setState({ error: null });
window.location.reload();
};
render() {
const { error } = this.state;
const { t } = this.props;
if (!error) return this.props.children;
return (
<div className="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<h1 className="text-lg font-semibold">{t("error_boundary_title")}</h1>
<p className="max-w-sm text-sm text-muted-foreground">
{t("error_boundary_desc")}
</p>
<Button onClick={this.handleReload}>{t("error_boundary_reload")}</Button>
</div>
);
}
}
export const ErrorBoundary = withTranslation()(ErrorBoundaryImpl);
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/plugin-shell";
import {
IconUserCircle,
IconCopy,
IconCheck,
IconBrandTelegram,
} from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuthStore } from "@/store/useAuthStore";
import { getBotUsername } from "@/lib/api";
// Кнопка входа по seed/Telegram на экране VPN. Панель — не полноэкранный
// Dialog (его затемнение перекрывает весь вьюпорт, включая хедер, и блокирует
// клик по бургеру), а собственный поповер с затемнением только ниже хедера —
// тот же приём, что и в BurgerMenu (см. `top-25`), чтобы боковое меню всегда
// оставалось доступным, даже пока эта панель открыта.
export function LoginButton() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [seedInput, setSeedInput] = useState("");
const [codeInput, setCodeInput] = useState("");
const [copied, setCopied] = useState(false);
const [botUsername, setBotUsername] = useState<string | null>(null);
const seed = useAuthStore((s) => s.seed);
const seedLoading = useAuthStore((s) => s.seedLoading);
const seedError = useAuthStore((s) => s.seedError);
const loadSeed = useAuthStore((s) => s.loadSeed);
const loginSeed = useAuthStore((s) => s.loginSeed);
const telegramCodeLoading = useAuthStore((s) => s.telegramCodeLoading);
const telegramCodeError = useAuthStore((s) => s.telegramCodeError);
const loginTelegramCode = useAuthStore((s) => s.loginTelegramCode);
useEffect(() => {
if (isOpen) loadSeed();
}, [isOpen, loadSeed]);
useEffect(() => {
getBotUsername().then(setBotUsername);
}, []);
const handleCopy = async () => {
if (!seed) return;
await navigator.clipboard.writeText(seed);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const handleSeedSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!seedInput.trim()) return;
await loginSeed(seedInput.trim());
};
const handleTelegramSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!codeInput.trim()) return;
await loginTelegramCode(codeInput.trim());
};
const handleOpenBot = () => {
if (botUsername) open(`https://t.me/${botUsername}`).catch(() => {});
};
return (
<>
<button
onClick={() => setIsOpen(true)}
className="absolute top-3 end-3 z-10 flex items-center gap-1.5 rounded-full border border-white/10 bg-background/30 px-3 py-1.5 text-xs font-medium text-foreground/80 backdrop-blur-md transition-colors hover:border-white/20 hover:text-foreground"
>
<IconUserCircle className="size-4" />
{t("login_button")}
</button>
{isOpen && (
<>
<div
className="fixed inset-x-0 bottom-0 top-25 z-30 bg-black/20 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
/>
<div className="fixed left-1/2 top-25 z-40 mt-4 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 rounded-2xl border border-white/10 bg-card p-5 shadow-xl animate-in slide-in-from-top-4">
<h2 className="mb-4 text-base font-semibold">{t("login_title")}</h2>
<Tabs defaultValue="seed">
<TabsList className="w-full">
<TabsTrigger value="seed">{t("login_tab_seed")}</TabsTrigger>
<TabsTrigger value="telegram">{t("login_tab_telegram")}</TabsTrigger>
</TabsList>
<TabsContent value="seed" className="mt-4 flex flex-col gap-4">
{seed ? (
<div>
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("login_seed_current_label")}
</label>
<div className="mt-1 flex items-center gap-2">
<code className="flex-1 truncate rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-mono">
{seed}
</code>
<Button type="button" size="icon-sm" variant="outline" onClick={handleCopy}>
{copied ? <IconCheck className="size-4" /> : <IconCopy className="size-4" />}
</Button>
</div>
</div>
) : (
!seedLoading && (
<p className="text-sm text-muted-foreground">{t("login_seed_no_seed")}</p>
)
)}
<form onSubmit={handleSeedSubmit} className="flex flex-col gap-2">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("login_seed_input_label")}
</label>
<input
type="text"
value={seedInput}
onChange={(e) => setSeedInput(e.target.value)}
placeholder={t("login_seed_input_placeholder")}
className="w-full rounded-xl border border-white/10 bg-white/5 p-3 text-sm text-foreground outline-none transition-colors focus:border-primary"
/>
{seedError && (
<p className="text-xs text-destructive">{t("login_seed_invalid")}</p>
)}
<Button type="submit" disabled={seedLoading}>
{t("login_seed_submit")}
</Button>
</form>
</TabsContent>
<TabsContent value="telegram" className="mt-4 flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t("login_telegram_desc")}</p>
<Button
type="button"
variant="outline"
onClick={handleOpenBot}
disabled={!botUsername}
className="gap-2"
>
<IconBrandTelegram className="size-4" />
{t("login_telegram_open_bot")}
</Button>
<form onSubmit={handleTelegramSubmit} className="flex flex-col gap-2">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("login_telegram_code_label")}
</label>
<input
type="text"
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
placeholder={t("login_telegram_code_placeholder")}
className="w-full rounded-xl border border-white/10 bg-white/5 p-3 text-sm text-foreground outline-none transition-colors focus:border-primary"
/>
{telegramCodeError && (
<p className="text-xs text-destructive">{t("login_telegram_error")}</p>
)}
<Button type="submit" disabled={telegramCodeLoading}>
{t("login_telegram_submit")}
</Button>
</form>
</TabsContent>
</Tabs>
</div>
</>
)}
</>
);
}
+4 -2
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
interface SplashScreenProps { interface SplashScreenProps {
onComplete: () => void; onComplete: () => void;
@@ -9,6 +10,7 @@ interface SplashScreenProps {
export function SplashScreen({ onComplete }: SplashScreenProps) { export function SplashScreen({ onComplete }: SplashScreenProps) {
const [isFadingOut, setIsFadingOut] = useState(false); const [isFadingOut, setIsFadingOut] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -51,11 +53,11 @@ export function SplashScreen({ onComplete }: SplashScreenProps) {
{/* Контентная часть */} {/* Контентная часть */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-[12px] font-mono text-white font-bold tracking-[0.5em] uppercase"> <p className="text-[12px] font-mono text-white font-bold tracking-[0.5em] uppercase">
The System Watches Everyone {t("splash_tagline_1")}
</p> </p>
<p className="text-xl md:text-2xl font-mono font-black tracking-[0.8em] uppercase text-[#00E5F2] drop-shadow-[0_0_15px_#00E5F2] animate-pulse ml-[0.8em]"> <p className="text-xl md:text-2xl font-mono font-black tracking-[0.8em] uppercase text-[#00E5F2] drop-shadow-[0_0_15px_#00E5F2] animate-pulse ml-[0.8em]">
Except You {t("splash_tagline_2")}
</p> </p>
</div> </div>
</motion.div> </motion.div>
+3 -1
View File
@@ -1,9 +1,11 @@
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTheme } from "@/ThemeProvider"; import { useTheme } from "@/ThemeProvider";
export function ThemeToggle() { export function ThemeToggle() {
const { isDark, toggleDark } = useTheme(); const { isDark, toggleDark } = useTheme();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -21,7 +23,7 @@ export function ThemeToggle() {
) : ( ) : (
<Moon className="h-[1.2rem] w-[1.2rem]" /> <Moon className="h-[1.2rem] w-[1.2rem]" />
)} )}
<span className="sr-only">Переключить тему</span> <span className="sr-only">{t("theme_toggle")}</span>
</Button> </Button>
); );
} }
+44 -17
View File
@@ -1,24 +1,51 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Код -> нативное название языка (не переводим — так проще узнать свой язык
// в списке, даже если сейчас выставлен совсем другой).
const LANGUAGES: { code: string; label: string }[] = [
{ code: "en", label: "English" },
{ code: "ru", label: "Русский" },
{ code: "zh-CN", label: "中文" },
{ code: "fa", label: "فارسی" },
{ code: "ar", label: "العربية" },
{ code: "tr", label: "Türkçe" },
{ code: "uk", label: "Українська" },
{ code: "be", label: "Беларуская" },
{ code: "vi", label: "Tiếng Việt" },
{ code: "uz", label: "Oʻzbekcha" },
];
export function LanguageToggle() { export function LanguageToggle() {
const { i18n } = useTranslation(); const { i18n, t } = useTranslation();
const current =
const toggleLanguage = () => { LANGUAGES.find((l) => l.code === i18n.language)?.code ??
const currentLang = i18n.language || "en"; LANGUAGES.find((l) => i18n.language?.startsWith(l.code.split("-")[0]))
const newLang = currentLang.startsWith("en") ? "ru" : "en"; ?.code ??
i18n.changeLanguage(newLang); "en";
};
return ( return (
<Button <Select value={current} onValueChange={(lang) => i18n.changeLanguage(lang)}>
variant="outline" <SelectTrigger
size="icon" size="sm"
onClick={toggleLanguage} aria-label={t("language_toggle")}
className="rounded-full transition-all duration-300 hover:scale-105 font-bold text-xs" className="rounded-full font-bold text-xs px-3"
> >
{i18n.language?.substring(0, 2).toUpperCase() || "EN"} <SelectValue />
<span className="sr-only">Toggle language</span> </SelectTrigger>
</Button> <SelectContent align="end">
{LANGUAGES.map(({ code, label }) => (
<SelectItem key={code} value={code}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
); );
} }
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Update } from "@tauri-apps/plugin-updater";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
type Status = "idle" | "downloading" | "error";
/**
* Проверка обновлений — только десктоп (Windows/macOS/Linux). На мобильных
* сборках плагин не зарегистрирован (см. src-tauri/src/lib.rs, #[cfg(desktop)]),
* поэтому check() там просто недоступен — ошибка тихо проглатывается ниже,
* без падения остального приложения.
*/
export function UpdateChecker() {
const { t } = useTranslation();
const [update, setUpdate] = useState<Update | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [progress, setProgress] = useState(0);
const installing = useRef(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const { check } = await import("@tauri-apps/plugin-updater");
const result = await check();
if (!cancelled && result?.available) {
setUpdate(result);
}
} catch {
// Мобильная сборка или плагин недоступен в этом окружении — не критично.
}
})();
return () => {
cancelled = true;
};
}, []);
if (!update) return null;
const installUpdate = async () => {
if (installing.current) return;
installing.current = true;
setStatus("downloading");
try {
let downloaded = 0;
let total = 0;
await update.downloadAndInstall((event) => {
if (event.event === "Started") {
total = event.data.contentLength ?? 0;
} else if (event.event === "Progress") {
downloaded += event.data.chunkLength;
setProgress(total > 0 ? Math.round((downloaded / total) * 100) : 0);
}
});
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (e) {
console.error("[UpdateChecker] Установка обновления не удалась:", e);
setStatus("error");
installing.current = false;
}
};
return (
<Dialog open onOpenChange={() => {}}>
<DialogContent showCloseButton={status !== "downloading"}>
<DialogHeader>
<DialogTitle>{t("update_available_title")}</DialogTitle>
<DialogDescription>
{t("update_available_desc", { version: update.version })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={installUpdate} disabled={status === "downloading"}>
{status === "downloading"
? t("update_downloading", { progress })
: status === "error"
? t("update_retry")
: t("update_install_now")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+13 -2
View File
@@ -10,18 +10,25 @@ import VpnControlButton from "@/components/shared/VpnControlButton";
import { useTheme } from "@/ThemeProvider"; import { useTheme } from "@/ThemeProvider";
import { useSettingsStore } from "@/store/useSettingsStore"; import { useSettingsStore } from "@/store/useSettingsStore";
import { useNodesStore } from "@/store/useNodesStore"; import { useNodesStore } from "@/store/useNodesStore";
import { useAuthStore } from "@/store/useAuthStore";
import { getCachedToken } from "@/lib/api";
export function VpnControl() { export function VpnControl() {
const { status, setStatus } = useVpnStore(); const { status, setStatus } = useVpnStore();
const { t } = useTranslation(); const { t } = useTranslation();
const { killSwitch, excludedApps, excludedDomains } = useSettingsStore(); const { killSwitch, excludedApps, excludedDomains } = useSettingsStore();
const { getSelectedNode } = useNodesStore(); const { getSelectedNode } = useNodesStore();
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
// Подключаем тему // Подключаем тему
const { isDark } = useTheme(); const { isDark } = useTheme();
const currentTheme = isDark ? "dark" : "light"; const currentTheme = isDark ? "dark" : "light";
const handleToggle = async () => { const handleToggle = async () => {
// Защита на всякий случай — основной гейт на уровне роута ("/" рендерит
// Login, а не Home, пока не вошли), но кнопка не должна работать даже
// если это состояние как-то обошли.
if (!isLoggedIn) return;
try { try {
if (status === "idle") { if (status === "idle") {
setStatus("connecting"); setStatus("connecting");
@@ -37,6 +44,9 @@ export function VpnControl() {
killswitchEnabled: killSwitch, killswitchEnabled: killSwitch,
excludedApps: excludedApps, excludedApps: excludedApps,
excludedDomains: excludedDomains, excludedDomains: excludedDomains,
// Есть только если сервер требует `--require-auth`; на инстансах без
// этого флага прокси значение просто игнорирует.
authToken: getCachedToken() ?? undefined,
}; };
await startVpn(config, node.ipAddress, node.port, node.sni); await startVpn(config, node.ipAddress, node.port, node.sni);
} else { } else {
@@ -87,8 +97,9 @@ export function VpnControl() {
isOn={isActive} isOn={isActive}
onClick={handleToggle} onClick={handleToggle}
style={{ style={{
pointerEvents: status === "connecting" ? "none" : "auto", pointerEvents: status === "connecting" || !isLoggedIn ? "none" : "auto",
opacity: status === "connecting" ? 0.8 : 1, opacity: !isLoggedIn ? 0.4 : status === "connecting" ? 0.8 : 1,
cursor: !isLoggedIn ? "not-allowed" : undefined,
}} }}
/> />
</div> </div>
+65 -18
View File
@@ -1,4 +1,7 @@
import { useEffect } from "react";
import { useTrafficStore } from "@/store/useTrafficStore"; // путь к твоему стору import { useTrafficStore } from "@/store/useTrafficStore"; // путь к твоему стору
import { useUsageStore } from "@/store/useUsageStore";
import { useVpnStore } from "@/store/useVpnStore";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,33 +22,77 @@ export function VpnStats({ className }: { className?: string }) {
const rx = useTrafficStore((state) => state.rx); const rx = useTrafficStore((state) => state.rx);
const tx = useTrafficStore((state) => state.tx); const tx = useTrafficStore((state) => state.tx);
const status = useVpnStore((state) => state.status);
const usedBytes = useUsageStore((state) => state.usedBytes);
const limitBytes = useUsageStore((state) => state.limitBytes);
const startAutoRefresh = useUsageStore((state) => state.startAutoRefresh);
const stopAutoRefresh = useUsageStore((state) => state.stopAutoRefresh);
useEffect(() => {
if (status === "connected") {
startAutoRefresh();
} else {
stopAutoRefresh();
}
return () => stopAutoRefresh();
}, [status, startAutoRefresh, stopAutoRefresh]);
const usagePercent =
usedBytes !== null && limitBytes ? Math.min(100, (usedBytes / limitBytes) * 100) : null;
return ( return (
<div <div
className={cn( className={cn(
"w-full max-w-70 relative flex items-center justify-between px-8 py-4 rounded-2xl", "w-full max-w-70 relative flex flex-col gap-3 px-8 py-4 rounded-2xl",
"bg-background/20 backdrop-blur-md border border-white/10 shadow-lg", "bg-background/20 backdrop-blur-md border border-white/10 shadow-lg",
className, className,
)} )}
> >
<div className="flex flex-col items-center flex-1"> <div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold"> <div className="flex flex-col items-center flex-1">
{t("rx")} <span className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
</span> {t("rx")}
<span className="text-sm font-mono text-foreground font-semibold"> </span>
{formatBytes(rx)} <span className="text-sm font-mono text-foreground font-semibold">
</span> {formatBytes(rx)}
</span>
</div>
<div className="h-8 w-px bg-border/50 mx-4" />
<div className="flex flex-col items-center flex-1">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
{t("tx")}
</span>
<span className="text-sm font-mono text-foreground font-semibold">
{formatBytes(tx)}
</span>
</div>
</div> </div>
<div className="h-8 w-px bg-border/50 mx-4" /> {usedBytes !== null && (
<div className="flex flex-col gap-1 pt-1 border-t border-white/10">
<div className="flex flex-col items-center flex-1"> <div className="flex items-center justify-between text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold"> <span>{t("usage_label")}</span>
{t("tx")} <span className="font-mono normal-case tracking-normal">
</span> {limitBytes
<span className="text-sm font-mono text-foreground font-semibold"> ? `${formatBytes(usedBytes)} / ${formatBytes(limitBytes)}`
{formatBytes(tx)} : `${formatBytes(usedBytes)} / ${t("usage_unlimited")}`}
</span> </span>
</div> </div>
{usagePercent !== null && (
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usagePercent >= 90 ? "bg-destructive" : "bg-primary",
)}
style={{ width: `${usagePercent}%` }}
/>
</div>
)}
</div>
)}
</div> </div>
); );
} }
+16
View File
@@ -3,6 +3,14 @@ import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import en from "./locales/en.json"; import en from "./locales/en.json";
import ru from "./locales/ru.json"; import ru from "./locales/ru.json";
import zhCN from "./locales/zh-CN.json";
import fa from "./locales/fa.json";
import ar from "./locales/ar.json";
import tr from "./locales/tr.json";
import uk from "./locales/uk.json";
import be from "./locales/be.json";
import vi from "./locales/vi.json";
import uz from "./locales/uz.json";
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
@@ -11,6 +19,14 @@ i18n
resources: { resources: {
en: { translation: en }, en: { translation: en },
ru: { translation: ru }, ru: { translation: ru },
"zh-CN": { translation: zhCN },
fa: { translation: fa },
ar: { translation: ar },
tr: { translation: tr },
uk: { translation: uk },
be: { translation: be },
vi: { translation: vi },
uz: { translation: uz },
}, },
fallbackLng: "en", fallbackLng: "en",
detection: { detection: {
+130 -3
View File
@@ -15,6 +15,52 @@ const API_V1 = `${API_BASE}/api/v1`;
const SEED_KEY = "nrxp_seed"; const SEED_KEY = "nrxp_seed";
const TOKEN_KEY = "nrxp_token"; const TOKEN_KEY = "nrxp_token";
// --- Ретраи с backoff для сетевых сбоев ---
// Раньше любой fetch() к бэкенду падал с первой же сетевой ошибки/5xx без
// единой попытки повторить — временная просадка сети/перезапуск бэкенда сразу
// приводили к нечитаемой ошибке в UI ("anon_failed" и т.п.). Ретраим только
// сетевые сбои (fetch throws) и 5xx (backend сам сломан) — 4xx-ответы (401 на
// просроченный код, 410 и т.д.) НЕ ретраим, это осмысленный ответ сервера,
// а не транзиентный сбой, повторять его бессмысленно.
const RETRY_ATTEMPTS = 2; // + первая попытка = до 3 всего
const RETRY_BASE_DELAY_MS = 400;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export class NetworkUnavailableError extends Error {
constructor(cause?: unknown) {
super("NETWORK_UNAVAILABLE");
this.name = "NetworkUnavailableError";
if (cause) this.cause = cause;
}
}
async function fetchWithRetry(
input: string,
init?: RequestInit,
): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
const res = await fetch(input, init);
if (res.status >= 500 && attempt < RETRY_ATTEMPTS) {
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
continue;
}
return res;
} catch (e) {
lastError = e;
if (attempt < RETRY_ATTEMPTS) {
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
continue;
}
}
}
throw new NetworkUnavailableError(lastError);
}
// --- Доменная модель сервера (камелкейс-зеркало VpnNode из бэкенда) --- // --- Доменная модель сервера (камелкейс-зеркало VpnNode из бэкенда) ---
export interface ServerNode { export interface ServerNode {
id: string; id: string;
@@ -89,7 +135,7 @@ function isTokenValid(token: string | null): token is string {
} }
async function generateSeed(): Promise<string> { async function generateSeed(): Promise<string> {
const res = await fetch(`${API_V1}/auth/seed/generate`, { const res = await fetchWithRetry(`${API_V1}/auth/seed/generate`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
@@ -101,7 +147,7 @@ async function generateSeed(): Promise<string> {
} }
async function loginSeed(seed: string): Promise<string> { async function loginSeed(seed: string): Promise<string> {
const res = await fetch(`${API_V1}/auth/seed/login`, { const res = await fetchWithRetry(`${API_V1}/auth/seed/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed }), body: JSON.stringify({ seed }),
@@ -133,10 +179,91 @@ async function ensureToken(): Promise<string> {
export async function fetchNodes(): Promise<ServerNode[]> { export async function fetchNodes(): Promise<ServerNode[]> {
const token = await ensureToken(); const token = await ensureToken();
const res = await fetch(`${API_V1}/nodes`, { const res = await fetchWithRetry(`${API_V1}/nodes`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`); if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`);
const data = (await res.json()) as RawVpnNode[]; const data = (await res.json()) as RawVpnNode[];
return data.map(mapNode); return data.map(mapNode);
} }
/** Текущий JWT, если уже есть (для передачи прокси на `--require-auth` инстансах). */
export function getCachedToken(): string | null {
const cached = localStorage.getItem(TOKEN_KEY);
return isTokenValid(cached) ? cached : null;
}
/** Гарантирует, что seed/токен существуют (Ghost Protocol), и возвращает seed для бэкапа. */
export async function getCurrentSeed(): Promise<string> {
await ensureToken();
const seed = localStorage.getItem(SEED_KEY);
if (!seed) {
// Юзер залогинен через Telegram (seed никогда не выдавался) — сгенерировать
// отдельный seed поверх текущей сессии сервер сейчас не умеет, только через
// новую анонимную регистрацию, что разлогинило бы текущий аккаунт.
throw new Error("NO_SEED_FOR_ACCOUNT");
}
return seed;
}
/** Вход по чужому/сохранённому seed (например, с другого устройства). */
export async function loginWithSeed(seed: string): Promise<void> {
await loginSeed(seed);
localStorage.setItem(SEED_KEY, seed);
}
/**
* Обменивает код на сессию. Код теперь генерирует сам бот по кнопке
* "Код для входа" (уже подтверждённым — см. AuthService::create_bot_login_code),
* поэтому это одноразовый обмен, а не поллинг: `true` при первом же успешном вызове.
*/
export async function pollTelegramLogin(authCode: string): Promise<boolean> {
const res = await fetchWithRetry(`${API_V1}/auth/telegram/session/${authCode}`);
if (res.status === 401 || res.status === 410) {
throw new Error("TELEGRAM_LOGIN_EXPIRED");
}
if (!res.ok) throw new Error(`telegram/session poll failed: ${res.status}`);
const data = (await res.json()) as { status: string; token?: string };
if (data.status === "success" && data.token) {
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.removeItem(SEED_KEY);
return true;
}
return false;
}
export interface UsageInfo {
usedBytes: number;
limitBytes: number | null;
}
/** Текущий расход/лимит трафика юзера — для индикатора на `VpnStats`. */
export async function fetchUsage(): Promise<UsageInfo> {
const token = await ensureToken();
const res = await fetchWithRetry(`${API_V1}/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`GET /users/me failed: ${res.status}`);
const data = (await res.json()) as {
data_used_bytes: number;
data_limit_bytes: number | null;
};
return { usedBytes: data.data_used_bytes, limitBytes: data.data_limit_bytes };
}
let cachedBotUsername: string | null = null;
/** Юзернейм бота для ссылки "открыть чат" на экране входа (без `?start=` —
* код теперь вводится вручную, см. pollTelegramLogin). */
export async function getBotUsername(): Promise<string | null> {
if (cachedBotUsername) return cachedBotUsername;
try {
const res = await fetchWithRetry(`${API_V1}/config`);
if (!res.ok) return null;
const data = (await res.json()) as { bot_username: string };
cachedBotUsername = data.bot_username;
return cachedBotUsername;
} catch {
return null;
}
}
+3
View File
@@ -0,0 +1,3 @@
// Языки, письмо справа налево — см. использование в i18n.js (список
// resources) и App.tsx (реактивный dir="rtl"/"ltr" на <html> при смене языка).
export const RTL_LANGUAGES = ["ar", "fa"];
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "تنزيل",
"tx": "رفع",
"status_label": "الحالة",
"disconnected": "غير متصل",
"connecting": "جارٍ الاتصال...",
"connected": "متصل",
"about_title": "Netrunner VPN",
"about_desc": "أنشأنا Netrunner ليس كشبكة VPN عادية، بل كنفق آمن يراه مزوّدو الإنترنت مجرد حركة ويب عادية.",
"proto_title": "بروتوكول خاص",
"proto_desc": "محاكاة TLS تجعل اتصالك غير مرئي لأنظمة DPI.",
"zk_title": "انعدام المعرفة (Zero-Knowledge)",
"zk_desc": "نحن لا نحتفظ بأي سجلات. تضمن بنيتنا التقنية أننا لا نستطيع تقنياً رؤية ما تقوم بإرساله.",
"version_info": "الإصدار 1.0.0-stable | 2026",
"footer_text": "طُوّر بعناية من أجل حرية الشبكة.",
"select_location_placeholder": "اختر موقع VPN",
"loading_servers": "جارٍ تحميل الخوادم...",
"section_network": "الشبكة",
"kill_switch": "مفتاح القطع التلقائي",
"kill_switch_desc": "حظر الإنترنت عند انقطاع الاتصال",
"stealth_mode": "وضع التخفي",
"stealth_mode_desc": "تمويه TLS معزّز",
"section_interface": "الواجهة",
"reduce_motion": "تقليل الحركة",
"reduce_motion_desc": "لتوفير الموارد",
"theme_toggle": "مظهر السمة",
"language_toggle": "لغة الواجهة",
"home_label": "الرئيسية",
"settings": "الإعدادات",
"about_menu": "حول التطبيق",
"excluded_domains": "النطاقات المستثناة (مفصولة بفواصل)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "التطبيقات المستثناة (أسماء الحزم / PID)",
"excluded_apps": "التطبيقات المستثناة",
"select_apps": "اختر التطبيقات",
"search_apps_placeholder": "ابحث بالاسم أو الحزمة...",
"loading_apps": "جارٍ جلب قائمة التطبيقات...",
"no_apps_found": "لم يتم العثور على شيء",
"tg_menu": "تجديد الاشتراك",
"tg_title": "جلسة آمنة",
"tg_desc": "فقدت الوصول إلى تيليجرام ولا يمكنك تجديد اشتراكك؟ ابدأ جلسة آمنة. يوجّه هذا العميل المدمج حركة بياناتك حصرياً عبر نفق VPN الخاص بنا.",
"tg_start_btn": "بدء الجلسة",
"tg_connecting": "جارٍ إنشاء اتصال آمن...",
"usage_label": "البيانات المستخدمة",
"usage_unlimited": "غير محدود",
"login_button": "تسجيل الدخول",
"login_title": "الوصول إلى الحساب",
"login_tab_seed": "العبارة الأساسية (Seed)",
"login_tab_telegram": "تيليجرام",
"login_seed_current_label": "عبارتك الأساسية الحالية",
"login_seed_copy": "نسخ",
"login_seed_copied": "تم النسخ",
"login_seed_no_seed": "هذا الحساب مرتبط بتيليجرام وليس له عبارة أساسية خاصة به.",
"login_seed_input_label": "تسجيل الدخول بعبارة أساسية موجودة",
"login_seed_input_placeholder": "الصق العبارة الأساسية",
"login_seed_submit": "تسجيل الدخول",
"login_seed_invalid": "عبارة أساسية غير صالحة أو غير معروفة",
"login_telegram_desc": "افتح بوت Netrunner في تيليجرام، اضغط على \"رمز تسجيل الدخول للتطبيق\" في القائمة، ثم أدخل الرمز الذي تحصل عليه هنا.",
"login_telegram_open_bot": "فتح البوت",
"login_telegram_code_label": "الرمز من البوت",
"login_telegram_code_placeholder": "مثال: aB3xQ9pL",
"login_telegram_submit": "تسجيل الدخول",
"login_telegram_error": "الرمز غير صالح أو منتهي الصلاحية، اطلب رمزاً جديداً من البوت",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "سجّل الدخول للاتصال بالشبكة",
"login_screen_continue_anon": "المتابعة بشكل مجهول",
"login_screen_or": "أو",
"login_anon_error": "تعذر إنشاء وصول مجهول، حاول مرة أخرى",
"connect_locked_hint": "سجّل الدخول أولاً",
"tg_desktop_only_title": "للهواتف المحمولة فقط",
"tg_desktop_only_desc": "تتطلب الجلسة الآمنة ذات تغليف حركة البيانات تشغيل تطبيق تيليجرام الأصلي. يرجى فتح هذا القسم من هاتفك الذكي.",
"tg_allowlist_badge": "القائمة المسموحة",
"tg_scope_detail": "حركة الهاتف محظورة على مستوى الجهاز. <bold>تطبيق تيليجرام</bold> فقط يحصل على وصول للشبكة — إغلاق التطبيق يقطع النفق فوراً.",
"tg_error_connect_failed": "تعذر إنشاء الاتصال.",
"tg_error_not_installed": "تيليجرام غير مثبت على هذا الجهاز.",
"tg_active_status": "نشط",
"tg_session_bound_label": "الجلسة مرتبطة بالتطبيق",
"tg_active_desc": "يعمل تيليجرام في وضع محمي. إذا كنت تقرأ هذا النص، فهذا يعني أنك صغّرت تيليجرام وسيتم قطع النفق.",
"tg_reset_btn": "إعادة تعيين الاتصال",
"splash_tagline_1": "النظام يراقب الجميع",
"splash_tagline_2": "ما عداك",
"dialog_close": "إغلاق",
"update_available_title": "يتوفر تحديث",
"update_available_desc": "الإصدار الجديد ({{version}}) جاهز للتثبيت.",
"update_downloading": "جارٍ التنزيل... {{progress}}%",
"update_install_now": "تثبيت وإعادة التشغيل",
"update_retry": "إعادة المحاولة",
"error_boundary_title": "حدث خطأ ما",
"error_boundary_desc": "واجه التطبيق خطأ غير متوقع. عادةً ما تحل إعادة التحميل المشكلة.",
"error_boundary_reload": "إعادة التحميل"
}
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "Атрымана",
"tx": "Адпраўлена",
"status_label": "Статус",
"disconnected": "АДКЛЮЧАНА",
"connecting": "ПАДКЛЮЧЭННЕ...",
"connected": "ПАДКЛЮЧАНА",
"about_title": "Netrunner VPN",
"about_desc": "Мы стварылі Netrunner не як звычайны VPN, а як абаронены тунэль, які правайдэры бачаць проста як звычайны вэб-трафік.",
"proto_title": "Уласны пратакол",
"proto_desc": "Імітацыя TLS робіць ваша злучэнне нябачным для сістэм DPI.",
"zk_title": "Zero-Knowledge",
"zk_desc": "Мы не захоўваем логі. Наша архітэктура гарантуе, што мы тэхнічна не можам бачыць, што вы перадаяце.",
"version_info": "Версія 1.0.0-stable | 2026",
"footer_text": "Распрацавана з клопатам пра свабоду сеткі.",
"select_location_placeholder": "Абярыце лакацыю VPN",
"loading_servers": "Загрузка сервераў...",
"section_network": "Сетка",
"kill_switch": "Kill Switch",
"kill_switch_desc": "Блакаваць інтэрнэт пры зрыве злучэння",
"stealth_mode": "Стэлс-рэжым",
"stealth_mode_desc": "Узмоцненае маскіраванне TLS",
"section_interface": "Інтэрфейс",
"reduce_motion": "Паменшыць анімацыю",
"reduce_motion_desc": "Для эканоміі рэсурсаў",
"theme_toggle": "Тэма афармлення",
"language_toggle": "Мова інтэрфейсу",
"home_label": "Галоўная",
"settings": "Налады",
"about_menu": "Пра праграму",
"excluded_domains": "Выключаныя дамены (праз коску)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "Выключаныя праграмы (назвы пакетаў / PID)",
"excluded_apps": "Выключэнні",
"select_apps": "Абярыце праграмы",
"search_apps_placeholder": "Пошук па назве ці пакеце...",
"loading_apps": "Атрыманне спісу праграм...",
"no_apps_found": "Нічога не знойдзена",
"tg_menu": "Працягнуць падпіску",
"tg_title": "Абароненая сесія",
"tg_desc": "Няма доступу да Telegram, каб аплаціць ці працягнуць падпіску? Запусціце бяспечную сесію. Гэты ўбудаваны кліент гарантавана накіруе трафік праз наш VPN-тунэль.",
"tg_start_btn": "Пачаць сесію",
"tg_connecting": "Устанаўленне абароненага злучэння...",
"usage_label": "Выкарыстанне трафіку",
"usage_unlimited": "Безліміт",
"login_button": "Увайсці",
"login_title": "Доступ да акаўнта",
"login_tab_seed": "Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "Ваш бягучы seed",
"login_seed_copy": "Капіяваць",
"login_seed_copied": "Скапіявана",
"login_seed_no_seed": "Гэты акаўнт прывязаны да Telegram і не мае ўласнага seed.",
"login_seed_input_label": "Увайсці па наяўным seed",
"login_seed_input_placeholder": "Устаўце seed-фразу",
"login_seed_submit": "Увайсці",
"login_seed_invalid": "Няправільны ці невядомы seed",
"login_telegram_desc": "Адкрыйце бота Netrunner у Telegram, націсніце «Код уваходу ў праграму» ў меню і ўвядзіце атрыманы код тут.",
"login_telegram_open_bot": "Адкрыць бота",
"login_telegram_code_label": "Код ад бота",
"login_telegram_code_placeholder": "Напрыклад, aB3xQ9pL",
"login_telegram_submit": "Увайсці",
"login_telegram_error": "Няправільны ці прастрочаны код, запытайце новы ў бота",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "Увайдзіце, каб падключыцца да сеткі",
"login_screen_continue_anon": "Працягнуць ананімна",
"login_screen_or": "ці",
"login_anon_error": "Не атрымалася стварыць ананімны доступ, паспрабуйце зноў",
"connect_locked_hint": "Спачатку ўвайдзіце ў акаўнт",
"tg_desktop_only_title": "Толькі для мабільных",
"tg_desktop_only_desc": "Бяспечная сесія з інкапсуляцыяй трафіку патрабуе запуску натыўнай праграмы Telegram. Калі ласка, адкрыйце гэты раздзел са свайго смартфона.",
"tg_allowlist_badge": "Allow-List",
"tg_scope_detail": "Трафік тэлефона блакуецца. Доступ да сеткі атрымлівае <bold>толькі праграма Telegram</bold>. Пры закрыцці мэсэнджара тунэль неадкладна разрываецца.",
"tg_error_connect_failed": "Не атрымалася ўсталяваць злучэнне.",
"tg_error_not_installed": "Telegram не ўсталяваны на гэтай прыладзе.",
"tg_active_status": "АКТЫЎНА",
"tg_session_bound_label": "Сесія прывязана да праграмы",
"tg_active_desc": "Telegram запушчаны ў абароненым рэжыме. Калі вы чытаеце гэты тэкст, значыць вы згарнулі Telegram і тунэль будзе адключаны.",
"tg_reset_btn": "СКІНУЦЬ ЗЛУЧЭННЕ",
"splash_tagline_1": "Сістэма сочыць за ўсімі",
"splash_tagline_2": "Акрамя цябе",
"dialog_close": "Закрыць",
"update_available_title": "Даступна абнаўленне",
"update_available_desc": "Новая версія ({{version}}) гатовая да ўсталявання.",
"update_downloading": "Загрузка... {{progress}}%",
"update_install_now": "Усталяваць і перазапусціць",
"update_retry": "Паўтарыць",
"error_boundary_title": "Нешта пайшло не так",
"error_boundary_desc": "Праграма сутыкнулася з нечаканай памылкай. Звычайна дапамагае перазагрузка.",
"error_boundary_reload": "Перазагрузіць"
}
+48 -1
View File
@@ -40,5 +40,52 @@
"tg_title": "Secure Session", "tg_title": "Secure Session",
"tg_desc": "Lost access to Telegram and can't renew your subscription? Start a secure session. This built-in client routes your traffic strictly through our VPN tunnel.", "tg_desc": "Lost access to Telegram and can't renew your subscription? Start a secure session. This built-in client routes your traffic strictly through our VPN tunnel.",
"tg_start_btn": "Start Session", "tg_start_btn": "Start Session",
"tg_connecting": "Establishing secure connection..." "tg_connecting": "Establishing secure connection...",
"usage_label": "Data used",
"usage_unlimited": "Unlimited",
"login_button": "Log in",
"login_title": "Account access",
"login_tab_seed": "Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "Your current seed",
"login_seed_copy": "Copy",
"login_seed_copied": "Copied",
"login_seed_no_seed": "This account is linked to Telegram and has no seed of its own.",
"login_seed_input_label": "Log in with an existing seed",
"login_seed_input_placeholder": "Paste your seed phrase",
"login_seed_submit": "Log in",
"login_seed_invalid": "Invalid or unknown seed",
"login_telegram_desc": "Open the Netrunner bot in Telegram, tap \"App login code\" in the menu, and enter the code you get here.",
"login_telegram_open_bot": "Open bot",
"login_telegram_code_label": "Code from the bot",
"login_telegram_code_placeholder": "e.g. aB3xQ9pL",
"login_telegram_submit": "Log in",
"login_telegram_error": "Invalid or expired code, request a new one in the bot",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "Log in to connect to the network",
"login_screen_continue_anon": "Continue anonymously",
"login_screen_or": "or",
"login_anon_error": "Couldn't create anonymous access, try again",
"connect_locked_hint": "Log in first",
"tg_desktop_only_title": "Mobile Only",
"tg_desktop_only_desc": "A secure session with traffic encapsulation requires launching the native Telegram app. Please open this section from your smartphone.",
"tg_allowlist_badge": "Allow-List",
"tg_scope_detail": "Traffic is blocked at the device level. Only <bold>the Telegram app</bold> gets network access — closing the messenger drops the tunnel immediately.",
"tg_error_connect_failed": "Failed to establish connection.",
"tg_error_not_installed": "Telegram is not installed on this device.",
"tg_active_status": "ACTIVE",
"tg_session_bound_label": "Session bound to the app",
"tg_active_desc": "Telegram is running in protected mode. If you're reading this, you've minimized Telegram and the tunnel will be disconnected.",
"tg_reset_btn": "RESET CONNECTION",
"splash_tagline_1": "The System Watches Everyone",
"splash_tagline_2": "Except You",
"dialog_close": "Close",
"update_available_title": "Update Available",
"update_available_desc": "A new version ({{version}}) is ready to install.",
"update_downloading": "Downloading... {{progress}}%",
"update_install_now": "Install & Restart",
"update_retry": "Retry",
"error_boundary_title": "Something went wrong",
"error_boundary_desc": "The app hit an unexpected error. Reloading usually fixes it.",
"error_boundary_reload": "Reload"
} }
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "دریافت",
"tx": "ارسال",
"status_label": "وضعیت",
"disconnected": "قطع شده",
"connecting": "در حال اتصال...",
"connected": "متصل",
"about_title": "Netrunner VPN",
"about_desc": "ما Netrunner را نه به‌عنوان یک VPN معمولی، بلکه به‌عنوان یک تونل امن ساختیم که ارائه‌دهندگان اینترنت آن را ترافیک وب معمولی می‌بینند.",
"proto_title": "پروتکل اختصاصی",
"proto_desc": "شبیه‌سازی TLS اتصال شما را برای سیستم‌های DPI نامرئی می‌کند.",
"zk_title": "بدون دانش (Zero-Knowledge)",
"zk_desc": "ما هیچ لاگی نگه نمی‌داریم. معماری ما تضمین می‌کند که از نظر فنی نمی‌توانیم ببینیم چه چیزی ارسال می‌کنید.",
"version_info": "نسخه ۱.۰.۰-پایدار | ۲۰۲۶",
"footer_text": "با دقت برای آزادی شبکه توسعه داده شده است.",
"select_location_placeholder": "یک مکان VPN انتخاب کنید",
"loading_servers": "در حال بارگذاری سرورها...",
"section_network": "شبکه",
"kill_switch": "کلید قطع اضطراری",
"kill_switch_desc": "قطع اینترنت هنگام افت اتصال",
"stealth_mode": "حالت مخفی",
"stealth_mode_desc": "پنهان‌سازی پیشرفته TLS",
"section_interface": "رابط کاربری",
"reduce_motion": "کاهش انیمیشن‌ها",
"reduce_motion_desc": "برای صرفه‌جویی در منابع",
"theme_toggle": "پوسته ظاهری",
"language_toggle": "زبان رابط",
"home_label": "خانه",
"settings": "تنظیمات",
"about_menu": "درباره",
"excluded_domains": "دامنه‌های مستثنی (با کاما جدا شوند)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "برنامه‌های مستثنی (نام بسته / PID)",
"excluded_apps": "برنامه‌های مستثنی",
"select_apps": "انتخاب برنامه‌ها",
"search_apps_placeholder": "جستجو بر اساس نام یا بسته...",
"loading_apps": "در حال دریافت لیست برنامه‌ها...",
"no_apps_found": "چیزی یافت نشد",
"tg_menu": "تمدید اشتراک",
"tg_title": "نشست امن",
"tg_desc": "به تلگرام دسترسی ندارید و نمی‌توانید اشتراک خود را تمدید کنید؟ یک نشست امن آغاز کنید. این کلاینت داخلی ترافیک شما را دقیقاً از طریق تونل VPN ما مسیردهی می‌کند.",
"tg_start_btn": "شروع نشست",
"tg_connecting": "در حال برقراری اتصال امن...",
"usage_label": "مصرف داده",
"usage_unlimited": "نامحدود",
"login_button": "ورود",
"login_title": "دسترسی به حساب",
"login_tab_seed": "عبارت بازیابی (Seed)",
"login_tab_telegram": "تلگرام",
"login_seed_current_label": "عبارت بازیابی فعلی شما",
"login_seed_copy": "کپی",
"login_seed_copied": "کپی شد",
"login_seed_no_seed": "این حساب به تلگرام متصل است و عبارت بازیابی مخصوص به خود ندارد.",
"login_seed_input_label": "ورود با عبارت بازیابی موجود",
"login_seed_input_placeholder": "عبارت بازیابی را وارد کنید",
"login_seed_submit": "ورود",
"login_seed_invalid": "عبارت بازیابی نامعتبر یا ناشناخته است",
"login_telegram_desc": "ربات Netrunner را در تلگرام باز کنید، روی «کد ورود برنامه» در منو ضربه بزنید و کدی که دریافت می‌کنید را اینجا وارد کنید.",
"login_telegram_open_bot": "باز کردن ربات",
"login_telegram_code_label": "کد دریافتی از ربات",
"login_telegram_code_placeholder": "مثلاً aB3xQ9pL",
"login_telegram_submit": "ورود",
"login_telegram_error": "کد نامعتبر یا منقضی شده است، کد جدیدی از ربات درخواست کنید",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "برای اتصال به شبکه وارد شوید",
"login_screen_continue_anon": "ادامه به‌صورت ناشناس",
"login_screen_or": "یا",
"login_anon_error": "ایجاد دسترسی ناشناس ممکن نشد، دوباره تلاش کنید",
"connect_locked_hint": "ابتدا وارد شوید",
"tg_desktop_only_title": "فقط برای موبایل",
"tg_desktop_only_desc": "یک نشست امن با کپسوله‌سازی ترافیک نیاز به اجرای برنامه بومی تلگرام دارد. لطفاً این بخش را از گوشی هوشمند خود باز کنید.",
"tg_allowlist_badge": "لیست مجاز",
"tg_scope_detail": "ترافیک دستگاه در سطح سیستم مسدود شده است. فقط <bold>برنامه تلگرام</bold> به شبکه دسترسی دارد — با بستن پیام‌رسان، تونل بلافاصله قطع می‌شود.",
"tg_error_connect_failed": "برقراری اتصال ممکن نشد.",
"tg_error_not_installed": "تلگرام روی این دستگاه نصب نیست.",
"tg_active_status": "فعال",
"tg_session_bound_label": "نشست به برنامه متصل است",
"tg_active_desc": "تلگرام در حالت محافظت‌شده در حال اجراست. اگر این متن را می‌بینید، یعنی تلگرام را کوچک کرده‌اید و تونل قطع خواهد شد.",
"tg_reset_btn": "بازنشانی اتصال",
"splash_tagline_1": "سیستم همه را زیر نظر دارد",
"splash_tagline_2": "به‌جز تو",
"dialog_close": "بستن",
"update_available_title": "به‌روزرسانی موجود است",
"update_available_desc": "نسخه جدید ({{version}}) آماده نصب است.",
"update_downloading": "در حال دانلود... {{progress}}%",
"update_install_now": "نصب و راه‌اندازی مجدد",
"update_retry": "تلاش مجدد",
"error_boundary_title": "مشکلی پیش آمد",
"error_boundary_desc": "برنامه با خطای غیرمنتظره‌ای مواجه شد. معمولاً بارگذاری مجدد آن را حل می‌کند.",
"error_boundary_reload": "بارگذاری مجدد"
}
+48 -1
View File
@@ -40,5 +40,52 @@
"tg_title": "Защищенная сессия", "tg_title": "Защищенная сессия",
"tg_desc": "Нет доступа к Telegram, чтобы оплатить или продлить подписку? Запустите безопасную сессию. Этот встроенный клиент гарантированно направит трафик через наш VPN-туннель.", "tg_desc": "Нет доступа к Telegram, чтобы оплатить или продлить подписку? Запустите безопасную сессию. Этот встроенный клиент гарантированно направит трафик через наш VPN-туннель.",
"tg_start_btn": "Начать сессию", "tg_start_btn": "Начать сессию",
"tg_connecting": "Установка защищенного соединения..." "tg_connecting": "Установка защищенного соединения...",
"usage_label": "Расход трафика",
"usage_unlimited": "Безлимит",
"login_button": "Войти",
"login_title": "Доступ к аккаунту",
"login_tab_seed": "Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "Ваш текущий seed",
"login_seed_copy": "Копировать",
"login_seed_copied": "Скопировано",
"login_seed_no_seed": "Этот аккаунт привязан к Telegram и не имеет собственного seed.",
"login_seed_input_label": "Войти по существующему seed",
"login_seed_input_placeholder": "Вставьте seed-фразу",
"login_seed_submit": "Войти",
"login_seed_invalid": "Неверный или неизвестный seed",
"login_telegram_desc": "Откройте бота Netrunner в Telegram, нажмите «Код для входа в приложение» в меню и введите полученный код здесь.",
"login_telegram_open_bot": "Открыть бота",
"login_telegram_code_label": "Код из бота",
"login_telegram_code_placeholder": "Например, aB3xQ9pL",
"login_telegram_submit": "Войти",
"login_telegram_error": "Неверный или просроченный код, запросите новый в боте",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "Войдите, чтобы подключиться к сети",
"login_screen_continue_anon": "Продолжить анонимно",
"login_screen_or": "или",
"login_anon_error": "Не удалось создать анонимный доступ, попробуйте снова",
"connect_locked_hint": "Сначала войдите в аккаунт",
"tg_desktop_only_title": "Только для мобильных",
"tg_desktop_only_desc": "Безопасная сессия с инкапсуляцией трафика требует запуска нативного приложения Telegram. Пожалуйста, откройте этот раздел со своего смартфона.",
"tg_allowlist_badge": "Allow-List",
"tg_scope_detail": "Трафик телефона блокируется. Доступ в сеть получает <bold>только приложение Telegram</bold>. При закрытии мессенджера туннель немедленно разрывается.",
"tg_error_connect_failed": "Не удалось установить соединение.",
"tg_error_not_installed": "Telegram не установлен на этом устройстве.",
"tg_active_status": "АКТИВНО",
"tg_session_bound_label": "Сессия привязана к приложению",
"tg_active_desc": "Telegram запущен в защищенном режиме. Если вы читаете этот текст, значит вы свернули Telegram и туннель будет отключен.",
"tg_reset_btn": "СБРОСИТЬ СОЕДИНЕНИЕ",
"splash_tagline_1": "Система следит за всеми",
"splash_tagline_2": "Кроме тебя",
"dialog_close": "Закрыть",
"update_available_title": "Доступно обновление",
"update_available_desc": "Готова к установке новая версия ({{version}}).",
"update_downloading": "Загрузка... {{progress}}%",
"update_install_now": "Установить и перезапустить",
"update_retry": "Повторить",
"error_boundary_title": "Что-то пошло не так",
"error_boundary_desc": "Приложение столкнулось с неожиданной ошибкой. Обычно помогает перезагрузка.",
"error_boundary_reload": "Перезагрузить"
} }
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "İndirme",
"tx": "Yükleme",
"status_label": "Durum",
"disconnected": "BAĞLANTI KESİLDİ",
"connecting": "BAĞLANIYOR...",
"connected": "BAĞLANDI",
"about_title": "Netrunner VPN",
"about_desc": "Netrunner'ı standart bir VPN olarak değil, internet sağlayıcılarının sıradan web trafiği olarak algıladığı güvenli bir tünel olarak tasarladık.",
"proto_title": "Özel Protokol",
"proto_desc": "TLS taklidi, bağlantınızı DPI sistemlerine karşı görünmez kılar.",
"zk_title": "Sıfır Bilgi (Zero-Knowledge)",
"zk_desc": "Günlük tutmuyoruz. Mimarimiz, ilettiğiniz veriyi teknik olarak görememizi garanti eder.",
"version_info": "Sürüm 1.0.0-stable | 2026",
"footer_text": "Ağ özgürlüğü için özenle geliştirildi.",
"select_location_placeholder": "Bir VPN konumu seçin",
"loading_servers": "Sunucular yükleniyor...",
"section_network": "Ağ",
"kill_switch": "Kill Switch",
"kill_switch_desc": "Bağlantı koptuğunda interneti engelle",
"stealth_mode": "Gizli Mod",
"stealth_mode_desc": "Gelişmiş TLS gizleme",
"section_interface": "Arayüz",
"reduce_motion": "Hareketi Azalt",
"reduce_motion_desc": "Kaynak verimliliği için",
"theme_toggle": "Görünüm Teması",
"language_toggle": "Arayüz Dili",
"home_label": "Ana Sayfa",
"settings": "Ayarlar",
"about_menu": "Hakkında",
"excluded_domains": "Hariç Tutulan Alan Adları (virgülle ayrılmış)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "Hariç Tutulan Uygulamalar (paket adları / PID)",
"excluded_apps": "Hariç Tutulan Uygulamalar",
"select_apps": "Uygulamaları Seç",
"search_apps_placeholder": "İsme veya pakete göre ara...",
"loading_apps": "Uygulama listesi alınıyor...",
"no_apps_found": "Hiçbir şey bulunamadı",
"tg_menu": "Aboneliği Yenile",
"tg_title": "Güvenli Oturum",
"tg_desc": "Telegram'a erişiminizi kaybettiniz ve aboneliğinizi yenileyemiyor musunuz? Güvenli bir oturum başlatın. Bu yerleşik istemci, trafiğinizi kesinlikle VPN tünelimiz üzerinden yönlendirir.",
"tg_start_btn": "Oturumu Başlat",
"tg_connecting": "Güvenli bağlantı kuruluyor...",
"usage_label": "Kullanılan Veri",
"usage_unlimited": "Sınırsız",
"login_button": "Giriş yap",
"login_title": "Hesap erişimi",
"login_tab_seed": "Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "Mevcut seed'iniz",
"login_seed_copy": "Kopyala",
"login_seed_copied": "Kopyalandı",
"login_seed_no_seed": "Bu hesap Telegram'a bağlı ve kendine ait bir seed'i yok.",
"login_seed_input_label": "Mevcut bir seed ile giriş yap",
"login_seed_input_placeholder": "Seed ifadesini yapıştırın",
"login_seed_submit": "Giriş yap",
"login_seed_invalid": "Geçersiz veya bilinmeyen seed",
"login_telegram_desc": "Telegram'da Netrunner botunu açın, menüden \"Uygulama giriş kodu\"na dokunun ve aldığınız kodu buraya girin.",
"login_telegram_open_bot": "Botu aç",
"login_telegram_code_label": "Bottan gelen kod",
"login_telegram_code_placeholder": "Örn. aB3xQ9pL",
"login_telegram_submit": "Giriş yap",
"login_telegram_error": "Kod geçersiz veya süresi dolmuş, bottan yeni bir kod isteyin",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "Ağa bağlanmak için giriş yapın",
"login_screen_continue_anon": "Anonim olarak devam et",
"login_screen_or": "veya",
"login_anon_error": "Anonim erişim oluşturulamadı, tekrar deneyin",
"connect_locked_hint": "Önce giriş yapın",
"tg_desktop_only_title": "Yalnızca Mobil",
"tg_desktop_only_desc": "Trafik kapsülleme özellikli güvenli oturum, yerel Telegram uygulamasının başlatılmasını gerektirir. Lütfen bu bölümü akıllı telefonunuzdan açın.",
"tg_allowlist_badge": "İzin Listesi",
"tg_scope_detail": "Telefon trafiği cihaz düzeyinde engellenir. Yalnızca <bold>Telegram uygulaması</bold> ağ erişimi alır — mesajlaşma uygulamasını kapatmak tüneli anında düşürür.",
"tg_error_connect_failed": "Bağlantı kurulamadı.",
"tg_error_not_installed": "Bu cihazda Telegram yüklü değil.",
"tg_active_status": "AKTİF",
"tg_session_bound_label": "Oturum uygulamaya bağlı",
"tg_active_desc": "Telegram korumalı modda çalışıyor. Bu metni okuyorsanız, Telegram'ı küçülttünüz demektir ve tünel kesilecektir.",
"tg_reset_btn": "BAĞLANTIYI SIFIRLA",
"splash_tagline_1": "Sistem Herkesi İzliyor",
"splash_tagline_2": "Senin Dışında",
"dialog_close": "Kapat",
"update_available_title": "Güncelleme mevcut",
"update_available_desc": "Yeni sürüm ({{version}}) yüklenmeye hazır.",
"update_downloading": "İndiriliyor... {{progress}}%",
"update_install_now": "Yükle ve yeniden başlat",
"update_retry": "Tekrar dene",
"error_boundary_title": "Bir şeyler ters gitti",
"error_boundary_desc": "Uygulama beklenmeyen bir hatayla karşılaştı. Genellikle yeniden yükleme sorunu çözer.",
"error_boundary_reload": "Yeniden yükle"
}
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "Отримано",
"tx": "Надіслано",
"status_label": "Статус",
"disconnected": "ВІД'ЄДНАНО",
"connecting": "ПІДКЛЮЧЕННЯ...",
"connected": "ПІДКЛЮЧЕНО",
"about_title": "Netrunner VPN",
"about_desc": "Ми створили Netrunner не як звичайний VPN, а як захищений тунель, який провайдери бачать просто як звичайний веб-трафік.",
"proto_title": "Власний протокол",
"proto_desc": "Імітація TLS робить ваше з'єднання невидимим для систем DPI.",
"zk_title": "Zero-Knowledge",
"zk_desc": "Ми не зберігаємо логи. Наша архітектура гарантує, що ми технічно не можемо бачити, що ви передаєте.",
"version_info": "Версія 1.0.0-stable | 2026",
"footer_text": "Розроблено з турботою про свободу мережі.",
"select_location_placeholder": "Виберіть локацію VPN",
"loading_servers": "Завантаження серверів...",
"section_network": "Мережа",
"kill_switch": "Kill Switch",
"kill_switch_desc": "Блокувати інтернет при обриві з'єднання",
"stealth_mode": "Стелс-режим",
"stealth_mode_desc": "Посилене TLS-маскування",
"section_interface": "Інтерфейс",
"reduce_motion": "Зменшити анімацію",
"reduce_motion_desc": "Для економії ресурсів",
"theme_toggle": "Тема оформлення",
"language_toggle": "Мова інтерфейсу",
"home_label": "Головна",
"settings": "Налаштування",
"about_menu": "Про застосунок",
"excluded_domains": "Виключені домени (через кому)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "Виключені застосунки (назви пакетів / PID)",
"excluded_apps": "Виключення",
"select_apps": "Виберіть застосунки",
"search_apps_placeholder": "Пошук за назвою або пакетом...",
"loading_apps": "Отримання списку застосунків...",
"no_apps_found": "Нічого не знайдено",
"tg_menu": "Продовжити підписку",
"tg_title": "Захищена сесія",
"tg_desc": "Немає доступу до Telegram, щоб оплатити чи продовжити підписку? Запустіть безпечну сесію. Цей вбудований клієнт гарантовано спрямує трафік через наш VPN-тунель.",
"tg_start_btn": "Почати сесію",
"tg_connecting": "Встановлення захищеного з'єднання...",
"usage_label": "Витрата трафіку",
"usage_unlimited": "Безліміт",
"login_button": "Увійти",
"login_title": "Доступ до акаунту",
"login_tab_seed": "Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "Ваш поточний seed",
"login_seed_copy": "Копіювати",
"login_seed_copied": "Скопійовано",
"login_seed_no_seed": "Цей акаунт прив'язаний до Telegram і не має власного seed.",
"login_seed_input_label": "Увійти за наявним seed",
"login_seed_input_placeholder": "Вставте seed-фразу",
"login_seed_submit": "Увійти",
"login_seed_invalid": "Невірний або невідомий seed",
"login_telegram_desc": "Відкрийте бота Netrunner у Telegram, натисніть «Код входу в застосунок» у меню та введіть отриманий код тут.",
"login_telegram_open_bot": "Відкрити бота",
"login_telegram_code_label": "Код від бота",
"login_telegram_code_placeholder": "Наприклад, aB3xQ9pL",
"login_telegram_submit": "Увійти",
"login_telegram_error": "Невірний або прострочений код, запросіть новий у бота",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "Увійдіть, щоб підключитися до мережі",
"login_screen_continue_anon": "Продовжити анонімно",
"login_screen_or": "або",
"login_anon_error": "Не вдалося створити анонімний доступ, спробуйте ще раз",
"connect_locked_hint": "Спочатку увійдіть в акаунт",
"tg_desktop_only_title": "Тільки для мобільних",
"tg_desktop_only_desc": "Безпечна сесія з інкапсуляцією трафіку вимагає запуску нативного застосунку Telegram. Будь ласка, відкрийте цей розділ зі свого смартфона.",
"tg_allowlist_badge": "Allow-List",
"tg_scope_detail": "Трафік телефону блокується. Доступ до мережі отримує <bold>лише застосунок Telegram</bold>. При закритті месенджера тунель негайно розривається.",
"tg_error_connect_failed": "Не вдалося встановити з'єднання.",
"tg_error_not_installed": "Telegram не встановлено на цьому пристрої.",
"tg_active_status": "АКТИВНО",
"tg_session_bound_label": "Сесія прив'язана до застосунку",
"tg_active_desc": "Telegram запущено в захищеному режимі. Якщо ви читаєте цей текст, значить ви згорнули Telegram і тунель буде відключено.",
"tg_reset_btn": "СКИНУТИ З'ЄДНАННЯ",
"splash_tagline_1": "Система стежить за всіма",
"splash_tagline_2": "Крім тебе",
"dialog_close": "Закрити",
"update_available_title": "Доступне оновлення",
"update_available_desc": "Нова версія ({{version}}) готова до встановлення.",
"update_downloading": "Завантаження... {{progress}}%",
"update_install_now": "Встановити й перезапустити",
"update_retry": "Повторити",
"error_boundary_title": "Щось пішло не так",
"error_boundary_desc": "Застосунок зіткнувся з неочікуваною помилкою. Зазвичай допомагає перезавантаження.",
"error_boundary_reload": "Перезавантажити"
}
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "Yuklab olindi",
"tx": "Yuklandi",
"status_label": "Holat",
"disconnected": "ULANMAGAN",
"connecting": "ULANMOQDA...",
"connected": "ULANGAN",
"about_title": "Netrunner VPN",
"about_desc": "Biz Netrunner'ni oddiy VPN sifatida emas, balki internet-provayderlar oddiy veb-trafik sifatida ko'radigan xavfsiz tunnel sifatida yaratdik.",
"proto_title": "Maxsus Protokol",
"proto_desc": "TLS niqoblash ulanishingizni DPI tizimlari uchun ko'rinmas qiladi.",
"zk_title": "Zero-Knowledge",
"zk_desc": "Biz jurnal (log) saqlamaymiz. Bizning arxitekturamiz siz uzatayotgan narsani texnik jihatdan ko'ra olmasligimizni kafolatlaydi.",
"version_info": "Versiya 1.0.0-stable | 2026",
"footer_text": "Tarmoq erkinligi uchun g'amxo'rlik bilan ishlab chiqilgan.",
"select_location_placeholder": "VPN joylashuvini tanlang",
"loading_servers": "Serverlar yuklanmoqda...",
"section_network": "Tarmoq",
"kill_switch": "Favqulodda O'chirish",
"kill_switch_desc": "Ulanish uzilganda internetni bloklash",
"stealth_mode": "Yashirin Rejim",
"stealth_mode_desc": "Kuchaytirilgan TLS niqoblash",
"section_interface": "Interfeys",
"reduce_motion": "Animatsiyani Kamaytirish",
"reduce_motion_desc": "Resurslarni tejash uchun",
"theme_toggle": "Ko'rinish Mavzusi",
"language_toggle": "Interfeys Tili",
"home_label": "Bosh Sahifa",
"settings": "Sozlamalar",
"about_menu": "Ilova Haqida",
"excluded_domains": "Chiqarib Tashlangan Domenlar (vergul bilan ajratilgan)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "Chiqarib Tashlangan Ilovalar (paket nomlari / PID)",
"excluded_apps": "Chiqarib Tashlangan Ilovalar",
"select_apps": "Ilovalarni Tanlash",
"search_apps_placeholder": "Nomi yoki paket bo'yicha qidirish...",
"loading_apps": "Ilovalar ro'yxati olinmoqda...",
"no_apps_found": "Hech narsa topilmadi",
"tg_menu": "Obunani Uzaytirish",
"tg_title": "Xavfsiz Sessiya",
"tg_desc": "Telegramga kirish imkoningiz yo'qmi va obunangizni uzaytira olmayapsizmi? Xavfsiz sessiyani boshlang. Ushbu o'rnatilgan klient trafigingizni faqat bizning VPN tunnelimiz orqali yo'naltiradi.",
"tg_start_btn": "Sessiyani Boshlash",
"tg_connecting": "Xavfsiz ulanish o'rnatilmoqda...",
"usage_label": "Ishlatilgan Trafik",
"usage_unlimited": "Cheksiz",
"login_button": "Kirish",
"login_title": "Hisobga Kirish",
"login_tab_seed": "Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "Sizning joriy seed'ingiz",
"login_seed_copy": "Nusxalash",
"login_seed_copied": "Nusxalandi",
"login_seed_no_seed": "Ushbu hisob Telegramga bog'langan va o'ziga xos seed'ga ega emas.",
"login_seed_input_label": "Mavjud seed bilan kirish",
"login_seed_input_placeholder": "Seed iborasini joylashtiring",
"login_seed_submit": "Kirish",
"login_seed_invalid": "Noto'g'ri yoki noma'lum seed",
"login_telegram_desc": "Telegram'da Netrunner botini oching, menyuda \"Ilovaga kirish kodi\"ni bosing va olingan kodni shu yerga kiriting.",
"login_telegram_open_bot": "Botni ochish",
"login_telegram_code_label": "Botdan olingan kod",
"login_telegram_code_placeholder": "Masalan, aB3xQ9pL",
"login_telegram_submit": "Kirish",
"login_telegram_error": "Kod noto'g'ri yoki muddati o'tgan, botdan yangisini so'rang",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "Tarmoqqa ulanish uchun kiring",
"login_screen_continue_anon": "Anonim tarzda davom etish",
"login_screen_or": "yoki",
"login_anon_error": "Anonim kirish yaratib bo'lmadi, qayta urinib ko'ring",
"connect_locked_hint": "Avval hisobga kiring",
"tg_desktop_only_title": "Faqat Mobil Uchun",
"tg_desktop_only_desc": "Trafikni inkapsulyatsiya qiluvchi xavfsiz sessiya asl Telegram ilovasini ishga tushirishni talab qiladi. Iltimos, bu bo'limni smartfoningizdan oching.",
"tg_allowlist_badge": "Ruxsat Ro'yxati",
"tg_scope_detail": "Telefon trafigi qurilma darajasida bloklangan. Faqat <bold>Telegram ilovasi</bold> tarmoqqa kirish huquqiga ega — messenjerni yopish tunnelni darhol uzadi.",
"tg_error_connect_failed": "Ulanishni o'rnatib bo'lmadi.",
"tg_error_not_installed": "Bu qurilmada Telegram o'rnatilmagan.",
"tg_active_status": "FAOL",
"tg_session_bound_label": "Sessiya ilovaga bog'langan",
"tg_active_desc": "Telegram himoyalangan rejimda ishlamoqda. Agar siz shu matnni o'qiyotgan bo'lsangiz, demak siz Telegramni kichraytirdingiz va tunnel uzib qo'yiladi.",
"tg_reset_btn": "ULANISHNI QAYTA O'RNATISH",
"splash_tagline_1": "Tizim Hammani Kuzatmoqda",
"splash_tagline_2": "Sizdan Tashqari",
"dialog_close": "Yopish",
"update_available_title": "Yangilanish mavjud",
"update_available_desc": "Yangi versiya ({{version}}) o'rnatishga tayyor.",
"update_downloading": "Yuklab olinmoqda... {{progress}}%",
"update_install_now": "O'rnatish va qayta ishga tushirish",
"update_retry": "Qayta urinish",
"error_boundary_title": "Nimadir xato ketdi",
"error_boundary_desc": "Ilovada kutilmagan xatolik yuz berdi. Odatda qayta yuklash yordam beradi.",
"error_boundary_reload": "Qayta yuklash"
}
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "Tải xuống",
"tx": "Tải lên",
"status_label": "Trạng thái",
"disconnected": "ĐÃ NGẮT KẾT NỐI",
"connecting": "ĐANG KẾT NỐI...",
"connected": "ĐÃ KẾT NỐI",
"about_title": "Netrunner VPN",
"about_desc": "Chúng tôi tạo ra Netrunner không phải như một VPN thông thường, mà là một đường hầm bảo mật mà nhà mạng chỉ thấy như lưu lượng web bình thường.",
"proto_title": "Giao Thức Độc Quyền",
"proto_desc": "Giả lập TLS khiến kết nối của bạn vô hình trước hệ thống DPI.",
"zk_title": "Zero-Knowledge",
"zk_desc": "Chúng tôi không lưu nhật ký. Kiến trúc của chúng tôi đảm bảo rằng về mặt kỹ thuật chúng tôi không thể thấy những gì bạn truyền tải.",
"version_info": "Phiên bản 1.0.0-stable | 2026",
"footer_text": "Được phát triển tận tâm vì tự do mạng.",
"select_location_placeholder": "Chọn vị trí VPN",
"loading_servers": "Đang tải máy chủ...",
"section_network": "Mạng",
"kill_switch": "Công Tắc Ngắt Khẩn Cấp",
"kill_switch_desc": "Chặn internet khi mất kết nối",
"stealth_mode": "Chế Độ Ẩn Danh",
"stealth_mode_desc": "Tăng cường làm rối TLS",
"section_interface": "Giao Diện",
"reduce_motion": "Giảm Chuyển Động",
"reduce_motion_desc": "Để tiết kiệm tài nguyên",
"theme_toggle": "Giao Diện Hiển Thị",
"language_toggle": "Ngôn Ngữ",
"home_label": "Trang Chủ",
"settings": "Cài Đặt",
"about_menu": "Giới Thiệu",
"excluded_domains": "Tên Miền Loại Trừ (cách nhau bằng dấu phẩy)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "Ứng Dụng Loại Trừ (tên gói / PID)",
"excluded_apps": "Ứng Dụng Loại Trừ",
"select_apps": "Chọn Ứng Dụng",
"search_apps_placeholder": "Tìm theo tên hoặc gói...",
"loading_apps": "Đang lấy danh sách ứng dụng...",
"no_apps_found": "Không tìm thấy gì",
"tg_menu": "Gia Hạn Đăng Ký",
"tg_title": "Phiên Bảo Mật",
"tg_desc": "Mất quyền truy cập Telegram và không thể gia hạn đăng ký? Hãy bắt đầu một phiên bảo mật. Ứng dụng khách tích hợp này sẽ định tuyến lưu lượng của bạn hoàn toàn qua đường hầm VPN của chúng tôi.",
"tg_start_btn": "Bắt Đầu Phiên",
"tg_connecting": "Đang thiết lập kết nối bảo mật...",
"usage_label": "Dữ Liệu Đã Dùng",
"usage_unlimited": "Không Giới Hạn",
"login_button": "Đăng nhập",
"login_title": "Truy Cập Tài Khoản",
"login_tab_seed": "Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "Seed hiện tại của bạn",
"login_seed_copy": "Sao chép",
"login_seed_copied": "Đã sao chép",
"login_seed_no_seed": "Tài khoản này được liên kết với Telegram và không có seed riêng.",
"login_seed_input_label": "Đăng nhập bằng seed hiện có",
"login_seed_input_placeholder": "Dán cụm từ seed",
"login_seed_submit": "Đăng nhập",
"login_seed_invalid": "Seed không hợp lệ hoặc không xác định",
"login_telegram_desc": "Mở bot Netrunner trong Telegram, nhấn \"Mã đăng nhập ứng dụng\" trong menu, rồi nhập mã bạn nhận được vào đây.",
"login_telegram_open_bot": "Mở bot",
"login_telegram_code_label": "Mã từ bot",
"login_telegram_code_placeholder": "Ví dụ: aB3xQ9pL",
"login_telegram_submit": "Đăng nhập",
"login_telegram_error": "Mã không hợp lệ hoặc đã hết hạn, hãy yêu cầu mã mới từ bot",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "Đăng nhập để kết nối vào mạng",
"login_screen_continue_anon": "Tiếp tục ẩn danh",
"login_screen_or": "hoặc",
"login_anon_error": "Không thể tạo quyền truy cập ẩn danh, hãy thử lại",
"connect_locked_hint": "Vui lòng đăng nhập trước",
"tg_desktop_only_title": "Chỉ Dành Cho Di Động",
"tg_desktop_only_desc": "Phiên bảo mật với đóng gói lưu lượng yêu cầu khởi chạy ứng dụng Telegram gốc. Vui lòng mở phần này từ điện thoại thông minh của bạn.",
"tg_allowlist_badge": "Danh Sách Cho Phép",
"tg_scope_detail": "Lưu lượng điện thoại bị chặn ở cấp thiết bị. Chỉ <bold>ứng dụng Telegram</bold> được truy cập mạng — đóng ứng dụng nhắn tin sẽ ngắt đường hầm ngay lập tức.",
"tg_error_connect_failed": "Không thể thiết lập kết nối.",
"tg_error_not_installed": "Telegram chưa được cài đặt trên thiết bị này.",
"tg_active_status": "ĐANG HOẠT ĐỘNG",
"tg_session_bound_label": "Phiên được liên kết với ứng dụng",
"tg_active_desc": "Telegram đang chạy ở chế độ được bảo vệ. Nếu bạn đang đọc dòng này, nghĩa là bạn đã thu nhỏ Telegram và đường hầm sẽ bị ngắt.",
"tg_reset_btn": "ĐẶT LẠI KẾT NỐI",
"splash_tagline_1": "Hệ Thống Theo Dõi Mọi Người",
"splash_tagline_2": "Trừ Bạn",
"dialog_close": "Đóng",
"update_available_title": "Có bản cập nhật",
"update_available_desc": "Phiên bản mới ({{version}}) đã sẵn sàng để cài đặt.",
"update_downloading": "Đang tải xuống... {{progress}}%",
"update_install_now": "Cài đặt và khởi động lại",
"update_retry": "Thử lại",
"error_boundary_title": "Đã xảy ra sự cố",
"error_boundary_desc": "Ứng dụng gặp lỗi ngoài dự kiến. Tải lại thường sẽ khắc phục được.",
"error_boundary_reload": "Tải lại"
}
+91
View File
@@ -0,0 +1,91 @@
{
"rx": "下载",
"tx": "上传",
"status_label": "状态",
"disconnected": "未连接",
"connecting": "连接中...",
"connected": "已连接",
"about_title": "Netrunner VPN",
"about_desc": "我们打造 Netrunner 不是普通的 VPN,而是一条让运营商只能看到普通网页流量的加密隧道。",
"proto_title": "专有协议",
"proto_desc": "TLS 伪装让你的连接对 DPI 系统不可见。",
"zk_title": "零知识",
"zk_desc": "我们不保留任何日志。我们的架构从技术上确保我们无法看到你传输的内容。",
"version_info": "版本 1.0.0-stable | 2026",
"footer_text": "用心打造,守护网络自由。",
"select_location_placeholder": "选择 VPN 节点",
"loading_servers": "正在加载服务器...",
"section_network": "网络",
"kill_switch": "网络切断开关",
"kill_switch_desc": "连接中断时阻止上网",
"stealth_mode": "隐身模式",
"stealth_mode_desc": "增强的 TLS 混淆",
"section_interface": "界面",
"reduce_motion": "减少动画",
"reduce_motion_desc": "节省系统资源",
"theme_toggle": "外观主题",
"language_toggle": "界面语言",
"home_label": "首页",
"settings": "设置",
"about_menu": "关于",
"excluded_domains": "排除的域名(用逗号分隔)",
"excluded_domains_placeholder": "youtube.com, netflix.com",
"excluded_apps_desc": "排除的应用(包名 / PID",
"excluded_apps": "排除的应用",
"select_apps": "选择应用",
"search_apps_placeholder": "按名称或包名搜索...",
"loading_apps": "正在获取应用列表...",
"no_apps_found": "未找到任何内容",
"tg_menu": "续费订阅",
"tg_title": "安全会话",
"tg_desc": "无法访问 Telegram 因而无法续费订阅?启动安全会话。这个内置客户端会强制将你的流量通过我们的 VPN 隧道传输。",
"tg_start_btn": "开始会话",
"tg_connecting": "正在建立安全连接...",
"usage_label": "已用流量",
"usage_unlimited": "无限制",
"login_button": "登录",
"login_title": "账户访问",
"login_tab_seed": "种子(Seed",
"login_tab_telegram": "Telegram",
"login_seed_current_label": "你当前的 Seed",
"login_seed_copy": "复制",
"login_seed_copied": "已复制",
"login_seed_no_seed": "此账户已绑定 Telegram,没有自己的 Seed。",
"login_seed_input_label": "使用已有 Seed 登录",
"login_seed_input_placeholder": "粘贴 Seed 短语",
"login_seed_submit": "登录",
"login_seed_invalid": "Seed 无效或不存在",
"login_telegram_desc": "在 Telegram 中打开 Netrunner 机器人,点击菜单中的“应用登录码”,然后在此输入获取到的验证码。",
"login_telegram_open_bot": "打开机器人",
"login_telegram_code_label": "机器人验证码",
"login_telegram_code_placeholder": "例如:aB3xQ9pL",
"login_telegram_submit": "登录",
"login_telegram_error": "验证码无效或已过期,请在机器人中重新获取",
"login_screen_title": "Netrunner VPN",
"login_screen_subtitle": "登录以连接网络",
"login_screen_continue_anon": "匿名继续",
"login_screen_or": "或",
"login_anon_error": "无法创建匿名访问,请重试",
"connect_locked_hint": "请先登录",
"tg_desktop_only_title": "仅限移动设备",
"tg_desktop_only_desc": "具有流量封装的安全会话需要启动原生 Telegram 应用。请在你的手机上打开此部分。",
"tg_allowlist_badge": "白名单",
"tg_scope_detail": "手机流量已在设备层面被阻断。只有<bold>Telegram 应用</bold>可以访问网络——关闭该消息应用会立即断开隧道。",
"tg_error_connect_failed": "无法建立连接。",
"tg_error_not_installed": "此设备未安装 Telegram。",
"tg_active_status": "运行中",
"tg_session_bound_label": "会话已绑定到应用",
"tg_active_desc": "Telegram 正在受保护模式下运行。如果你看到这段文字,说明你已最小化 Telegram,隧道即将断开。",
"tg_reset_btn": "重置连接",
"splash_tagline_1": "系统监视着所有人",
"splash_tagline_2": "除了你",
"dialog_close": "关闭",
"update_available_title": "有可用更新",
"update_available_desc": "新版本({{version}})已准备好安装。",
"update_downloading": "正在下载... {{progress}}%",
"update_install_now": "安装并重启",
"update_retry": "重试",
"error_boundary_title": "出了点问题",
"error_boundary_desc": "应用遇到了意外错误。通常重新加载即可解决。",
"error_boundary_reload": "重新加载"
}
+2
View File
@@ -1,10 +1,12 @@
import { VpnControl } from "../features/VpnControl"; import { VpnControl } from "../features/VpnControl";
import { VpnStats } from "../features/VpnStats"; import { VpnStats } from "../features/VpnStats";
import { CountrySelect } from "../features/CountrySelect"; import { CountrySelect } from "../features/CountrySelect";
import { LoginButton } from "../features/LoginButton";
export function Home() { export function Home() {
return ( return (
<div className="relative flex flex-col gap-6 items-center justify-center w-full h-full"> <div className="relative flex flex-col gap-6 items-center justify-center w-full h-full">
<LoginButton />
<VpnControl /> <VpnControl />
<CountrySelect /> <CountrySelect />
<VpnStats /> <VpnStats />
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/plugin-shell";
import { IconBrandTelegram } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuthStore } from "@/store/useAuthStore";
import { getBotUsername } from "@/lib/api";
// Экран-шлюз перед подключением к VPN: пока юзер явно не вошёл (анонимно,
// по seed или через Telegram), сюда рендерится этот экран вместо Home
// (см. App.tsx) — без этого экрана нет доступа к экрану включения VPN.
export function Login() {
const { t } = useTranslation();
const [seedInput, setSeedInput] = useState("");
const [codeInput, setCodeInput] = useState("");
const [botUsername, setBotUsername] = useState<string | null>(null);
const seedLoading = useAuthStore((s) => s.seedLoading);
const seedError = useAuthStore((s) => s.seedError);
const continueAnonymously = useAuthStore((s) => s.continueAnonymously);
const loginSeed = useAuthStore((s) => s.loginSeed);
const telegramCodeLoading = useAuthStore((s) => s.telegramCodeLoading);
const telegramCodeError = useAuthStore((s) => s.telegramCodeError);
const loginTelegramCode = useAuthStore((s) => s.loginTelegramCode);
useEffect(() => {
getBotUsername().then(setBotUsername);
}, []);
const handleSeedSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!seedInput.trim()) return;
await loginSeed(seedInput.trim());
};
const handleTelegramSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!codeInput.trim()) return;
await loginTelegramCode(codeInput.trim());
};
const handleOpenBot = () => {
if (botUsername) open(`https://t.me/${botUsername}`).catch(() => {});
};
return (
<div className="w-full max-w-sm mx-auto flex flex-col items-center gap-8 px-6 py-12">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground">
{t("login_screen_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("login_screen_subtitle")}</p>
</div>
<Button
type="button"
size="lg"
className="w-full"
disabled={seedLoading}
onClick={continueAnonymously}
>
{t("login_screen_continue_anon")}
</Button>
{seedError === "anon_failed" && (
<p className="-mt-6 text-xs text-destructive">{t("login_anon_error")}</p>
)}
<div className="flex items-center gap-3 w-full">
<div className="h-px flex-1 bg-border/50" />
<span className="text-xs uppercase tracking-widest text-muted-foreground">
{t("login_screen_or")}
</span>
<div className="h-px flex-1 bg-border/50" />
</div>
<Tabs defaultValue="seed" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="seed">{t("login_tab_seed")}</TabsTrigger>
<TabsTrigger value="telegram">{t("login_tab_telegram")}</TabsTrigger>
</TabsList>
<TabsContent value="seed" className="mt-4">
<form onSubmit={handleSeedSubmit} className="flex flex-col gap-2">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("login_seed_input_label")}
</label>
<input
type="text"
value={seedInput}
onChange={(e) => setSeedInput(e.target.value)}
placeholder={t("login_seed_input_placeholder")}
className="w-full rounded-xl border border-white/10 bg-white/5 p-3 text-sm text-foreground outline-none transition-colors focus:border-primary"
/>
{seedError === "invalid_seed" && (
<p className="text-xs text-destructive">{t("login_seed_invalid")}</p>
)}
<Button type="submit" variant="outline" disabled={seedLoading}>
{t("login_seed_submit")}
</Button>
</form>
</TabsContent>
<TabsContent value="telegram" className="mt-4 flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t("login_telegram_desc")}</p>
<Button
type="button"
variant="outline"
onClick={handleOpenBot}
disabled={!botUsername}
className="gap-2"
>
<IconBrandTelegram className="size-4" />
{t("login_telegram_open_bot")}
</Button>
<form onSubmit={handleTelegramSubmit} className="flex flex-col gap-2">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("login_telegram_code_label")}
</label>
<input
type="text"
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
placeholder={t("login_telegram_code_placeholder")}
className="w-full rounded-xl border border-white/10 bg-white/5 p-3 text-sm text-foreground outline-none transition-colors focus:border-primary"
/>
{telegramCodeError && (
<p className="text-xs text-destructive">{t("login_telegram_error")}</p>
)}
<Button type="submit" disabled={telegramCodeLoading}>
{t("login_telegram_submit")}
</Button>
</form>
</TabsContent>
</Tabs>
</div>
);
}
+20 -19
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useVpnStore } from "@/store/useVpnStore"; import { useVpnStore } from "@/store/useVpnStore";
import { useSettingsStore } from "@/store/useSettingsStore"; import { useSettingsStore } from "@/store/useSettingsStore";
import { TunnelConfig, startVpn, stopVpn, launchApp } from "vpn-plugin"; import { TunnelConfig, startVpn, stopVpn, launchApp } from "vpn-plugin";
@@ -72,7 +72,7 @@ export function TelegramWeb() {
} catch (error) { } catch (error) {
setStatus("idle"); setStatus("idle");
setSessionRequested(false); setSessionRequested(false);
setErrorMsg("Не удалось установить соединение."); setErrorMsg(t("tg_error_connect_failed"));
return; return;
} }
} }
@@ -90,7 +90,7 @@ export function TelegramWeb() {
} }
if (!launched) { if (!launched) {
setErrorMsg("Telegram не установлен на этом устройстве."); setErrorMsg(t("tg_error_not_installed"));
handleStopSession(); handleStopSession();
} }
}; };
@@ -103,12 +103,10 @@ export function TelegramWeb() {
<ShieldAlert className="size-12 text-primary opacity-80" /> <ShieldAlert className="size-12 text-primary opacity-80" />
</div> </div>
<h2 className="text-2xl font-black uppercase tracking-tighter"> <h2 className="text-2xl font-black uppercase tracking-tighter">
Только для мобильных {t("tg_desktop_only_title")}
</h2> </h2>
<p className="text-sm text-muted-foreground mt-4 leading-relaxed"> <p className="text-sm text-muted-foreground mt-4 leading-relaxed">
Безопасная сессия с инкапсуляцией трафика требует запуска нативного {t("tg_desktop_only_desc")}
приложения Telegram. Пожалуйста, откройте этот раздел со своего
смартфона.
</p> </p>
</div> </div>
); );
@@ -124,18 +122,19 @@ export function TelegramWeb() {
<div className="mx-auto size-24 bg-primary/10 rounded-full flex items-center justify-center relative border border-primary/20"> <div className="mx-auto size-24 bg-primary/10 rounded-full flex items-center justify-center relative border border-primary/20">
<Send className="size-10 text-primary drop-shadow-[0_0_15px_rgba(130,0,219,0.5)] relative right-1" /> <Send className="size-10 text-primary drop-shadow-[0_0_15px_rgba(130,0,219,0.5)] relative right-1" />
<div className="absolute -bottom-2 bg-primary text-black text-[10px] font-black px-2 py-1 rounded-md uppercase"> <div className="absolute -bottom-2 bg-primary text-black text-[10px] font-black px-2 py-1 rounded-md uppercase">
Allow-List {t("tg_allowlist_badge")}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-2xl font-black uppercase italic tracking-tighter"> <h2 className="text-2xl font-black uppercase italic tracking-tighter">
Защита клиента {t("tg_title")}
</h2> </h2>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Трафик телефона блокируется. Доступ в сеть получает{" "} {t("tg_desc")}
<b>только приложение Telegram</b>. При закрытии мессенджера </p>
туннель немедленно разрывается. <p className="text-sm text-muted-foreground leading-relaxed">
<Trans i18nKey="tg_scope_detail" components={{ bold: <b /> }} />
</p> </p>
</div> </div>
@@ -151,9 +150,12 @@ export function TelegramWeb() {
className="w-full h-14 rounded-2xl font-black tracking-widest" className="w-full h-14 rounded-2xl font-black tracking-widest"
> >
{status === "connecting" ? ( {status === "connecting" ? (
<Loader2 className="animate-spin" /> <span className="flex items-center gap-2 justify-center">
<Loader2 className="animate-spin size-4" />
{t("tg_connecting")}
</span>
) : ( ) : (
"ОТКРЫТЬ TELEGRAM" t("tg_start_btn")
)} )}
</Button> </Button>
</motion.div> </motion.div>
@@ -161,15 +163,14 @@ export function TelegramWeb() {
<motion.div key="active" className="space-y-8 max-w-sm"> <motion.div key="active" className="space-y-8 max-w-sm">
<div className="space-y-4"> <div className="space-y-4">
<div className="text-5xl font-mono font-black text-primary tracking-tighter animate-pulse"> <div className="text-5xl font-mono font-black text-primary tracking-tighter animate-pulse">
ACTIVE {t("tg_active_status")}
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<p className="text-primary uppercase text-[10px] font-black tracking-[0.3em]"> <p className="text-primary uppercase text-[10px] font-black tracking-[0.3em]">
Сессия привязана к приложению {t("tg_session_bound_label")}
</p> </p>
<span className="px-3 py-2 bg-white/5 border border-white/10 rounded-xl text-xs text-muted-foreground leading-relaxed"> <span className="px-3 py-2 bg-white/5 border border-white/10 rounded-xl text-xs text-muted-foreground leading-relaxed">
Telegram запущен в защищенном режиме. Если вы читаете этот {t("tg_active_desc")}
текст, значит вы свернули Telegram и туннель будет отключен.
</span> </span>
</div> </div>
</div> </div>
@@ -179,7 +180,7 @@ export function TelegramWeb() {
onClick={handleStopSession} onClick={handleStopSession}
className="gap-2 text-destructive border-destructive hover:bg-destructive hover:text-white w-full h-12 rounded-xl transition-all" className="gap-2 text-destructive border-destructive hover:bg-destructive hover:text-white w-full h-12 rounded-xl transition-all"
> >
СБРОСИТЬ СОЕДИНЕНИЕ {t("tg_reset_btn")}
</Button> </Button>
</motion.div> </motion.div>
)} )}
+97
View File
@@ -0,0 +1,97 @@
import { create } from "zustand";
import { getCurrentSeed, loginWithSeed, pollTelegramLogin } from "@/lib/api";
const LOGGED_IN_KEY = "nrxp_logged_in";
function markLoggedIn() {
localStorage.setItem(LOGGED_IN_KEY, "1");
}
interface AuthState {
/** Явный флаг "юзер прошёл экран входа" — отдельно от факта наличия
* Ghost-токена: seed/токен могут тихо появиться в фоне (fetchNodes), это
* не должно само по себе пускать на экран подключения. */
isLoggedIn: boolean;
seed: string | null;
seedError: string | null;
seedLoading: boolean;
telegramCodeError: string | null;
telegramCodeLoading: boolean;
loadSeed: () => Promise<void>;
/** Быстрый старт без явного seed/Telegram — переиспользует уже
* сгенерированный в фоне Ghost-аккаунт либо создаёт новый. */
continueAnonymously: () => Promise<void>;
loginSeed: (seed: string) => Promise<void>;
/** Код генерирует сам бот (кнопка "Код для входа") — уже подтверждённый,
* поэтому это одноразовый обмен, а не поллинг deep-link'а (тот ненадёжен
* для юзеров, уже открывавших бота — см. AuthService::create_bot_login_code). */
loginTelegramCode: (code: string) => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
isLoggedIn: localStorage.getItem(LOGGED_IN_KEY) === "1",
seed: null,
seedError: null,
seedLoading: false,
telegramCodeError: null,
telegramCodeLoading: false,
loadSeed: async () => {
set({ seedLoading: true, seedError: null });
try {
const seed = await getCurrentSeed();
set({ seed, seedLoading: false });
} catch {
// Аккаунт залогинен через Telegram — своего seed нет, это не ошибка UI.
set({ seed: null, seedLoading: false });
}
},
continueAnonymously: async () => {
set({ seedLoading: true, seedError: null });
try {
const seed = await getCurrentSeed();
markLoggedIn();
set({ seed, seedLoading: false, isLoggedIn: true });
} catch {
set({ seedLoading: false, seedError: "anon_failed" });
}
},
loginSeed: async (seed: string) => {
set({ seedLoading: true, seedError: null });
try {
await loginWithSeed(seed);
markLoggedIn();
set({ seed, seedLoading: false, isLoggedIn: true });
} catch {
set({
seedError: "invalid_seed",
seedLoading: false,
});
}
},
loginTelegramCode: async (code: string) => {
set({ telegramCodeLoading: true, telegramCodeError: null });
try {
const confirmed = await pollTelegramLogin(code.trim());
if (confirmed) {
markLoggedIn();
set({ telegramCodeLoading: false, isLoggedIn: true });
await get().loadSeed();
} else {
// Код существует, но не подтверждён — при новом bot-флоу такого не
// бывает (бот отдаёт код уже подтверждённым), но на всякий случай.
set({ telegramCodeLoading: false, telegramCodeError: "not_verified" });
}
} catch {
set({ telegramCodeLoading: false, telegramCodeError: "invalid_or_expired" });
}
},
}));
+2 -2
View File
@@ -15,7 +15,7 @@ export const STATIC_NODES: ServerNode[] = [
id: "nl-1", id: "nl-1",
name: "Netherlands", name: "Netherlands",
countryCode: "nl", countryCode: "nl",
ipAddress: "77.110.106.113", ipAddress: "147.45.43.70",
port: 443, port: 443,
sni: "ubuntu.com", sni: "ubuntu.com",
status: "online", status: "online",
@@ -30,7 +30,7 @@ export const STATIC_NODES: ServerNode[] = [
name: "Singapore", name: "Singapore",
countryCode: "sg", countryCode: "sg",
ipAddress: "45.76.161.137", ipAddress: "45.76.161.137",
port: 443, port: 8443,
sni: "debian.org", sni: "debian.org",
status: "online", status: "online",
currentLoad: 0, currentLoad: 0,
+42
View File
@@ -0,0 +1,42 @@
import { create } from "zustand";
import { fetchUsage } from "@/lib/api";
const REFRESH_INTERVAL_MS = 60_000;
interface UsageState {
usedBytes: number | null;
limitBytes: number | null;
_unsub: (() => void) | null;
refresh: () => Promise<void>;
/** Периодическое обновление, пока VPN активен — см. `VpnStats`. */
startAutoRefresh: () => void;
stopAutoRefresh: () => void;
}
export const useUsageStore = create<UsageState>((set, get) => ({
usedBytes: null,
limitBytes: null,
_unsub: null,
refresh: async () => {
try {
const usage = await fetchUsage();
set({ usedBytes: usage.usedBytes, limitBytes: usage.limitBytes });
} catch {
// Бэкенд недоступен — оставляем последнее известное значение.
}
},
startAutoRefresh: () => {
if (get()._unsub) return;
get().refresh();
const id = setInterval(() => get().refresh(), REFRESH_INTERVAL_MS);
set({ _unsub: () => clearInterval(id) });
},
stopAutoRefresh: () => {
get()._unsub?.();
set({ _unsub: null });
},
}));