Compare commits

..

8 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
23 changed files with 565 additions and 25 deletions
+5 -3
View File
@@ -14,8 +14,10 @@
# Триггер — тег вида v1.2.3 (совпадает с полем "version" в tauri.conf.json).
#
# Нужные секреты в Gitea: TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) — как и раньше,
# плюс GITEA_SERVER_URL (напр. https://git.example.com, без слэша на конце) и
# GITEA_TOKEN (личный токен с правом записи в этот репозиторий).
# плюс 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-раннере
@@ -121,7 +123,7 @@ jobs:
- name: Publish release to Gitea
env:
GITEA_SERVER_URL: ${{ secrets.GITEA_SERVER_URL }}
GITEA_SERVER_URL: ${{ vars.GITEA_SERVER_URL }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_REPO: ${{ github.repository }}
RELEASE_VERSION: ${{ github.ref_name }}
+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 }}
+2
View File
@@ -20,7 +20,9 @@
"@hugeicons/react": "^1.1.5",
"@tabler/icons-react": "^3.40.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.35.2",
+20
View File
@@ -26,9 +26,15 @@ importers:
'@tauri-apps/api':
specifier: ^2.0.0
version: 2.10.1
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-shell':
specifier: ^2.0.0
version: 2.3.5
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -1584,9 +1590,15 @@ packages:
engines: {node: '>= 10'}
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':
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':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -4518,10 +4530,18 @@ snapshots:
'@tauri-apps/cli-win32-ia32-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':
dependencies:
'@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':
dependencies:
fast-glob: 3.3.3
+143
View File
@@ -70,6 +70,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "arc-swap"
version = "1.9.2"
@@ -900,6 +909,17 @@ dependencies = [
"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]]
name = "derive_more"
version = "0.99.20"
@@ -1183,6 +1203,16 @@ dependencies = [
"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]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2567,6 +2597,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2648,9 +2684,12 @@ dependencies = [
"ndk-context",
"netrunner-logger",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
"tauri-plugin-vpn",
"winres",
]
@@ -2871,6 +2910,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2886,6 +2926,18 @@ dependencies = [
"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]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2973,6 +3025,20 @@ dependencies = [
"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]]
name = "pango"
version = "0.18.3"
@@ -3803,6 +3869,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -4620,6 +4687,17 @@ dependencies = [
"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]]
name = "target-lexicon"
version = "0.12.16"
@@ -4757,6 +4835,16 @@ dependencies = [
"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"
@@ -4778,6 +4866,39 @@ dependencies = [
"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]]
name = "tauri-plugin-vpn"
version = "0.1.0"
@@ -6673,6 +6794,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.2"
@@ -6790,6 +6921,18 @@ dependencies = [
"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]]
name = "zmij"
version = "1.0.21"
+8
View File
@@ -15,6 +15,7 @@ winres = "0.1"
jni = "0.22.3"
tauri = { version = "2.0.0", features = [] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
ndk-context = "0.1"
log = "0.4"
@@ -22,6 +23,13 @@ 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"] }
# Автообновление есть только на десктопе — 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]
opt-level = "z"
lto = true
+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"
]
}
+11 -2
View File
@@ -1,8 +1,17 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let builder = tauri::Builder::default()
.plugin(tauri_plugin_vpn::init())
.plugin(tauri_plugin_shell::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!())
.expect("error while running tauri application");
}
+1 -1
View File
@@ -41,7 +41,7 @@
"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": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDczRUU1OTM0MzM1N0M2MzkKUldRNXhsY3pORm51Yzl0YStpa1ZiWkgvWHByMTFBdlpCODJjWmt2UEEyMVI3WWg1MXZFZGt3ZmgK"
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDcxQTFBM0JEMTk5OEEwQTAKUldTZ29KZ1p2YU9oY1JzTlZZeTlzTGFLS0lyVEdsZDRmODAySWZFajNISlpxdHhibTRUV01UZGkK"
}
}
}
+6
View File
@@ -18,6 +18,8 @@ import { NetrunnerMatrix } from "matrix-engine/NetrunnerMatrix";
import { useSettingsStore } from "./store/useSettingsStore";
import { cn } from "@/lib/utils";
import { TelegramWeb } from "./pages/TelegramWeb";
import { UpdateChecker } from "./features/UpdateChecker";
import { ErrorBoundary } from "./features/ErrorBoundary";
function AppContent() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -102,6 +104,8 @@ function AppContent() {
onClose={() => setIsMenuOpen(false)}
/>
<UpdateChecker />
<main className="flex-1 relative flex flex-col items-center justify-center overflow-hidden">
<Routes>
<Route path="/" element={isLoggedIn ? <Home /> : <Login />} />
@@ -118,8 +122,10 @@ function AppContent() {
export default function App() {
return (
<ErrorBoundary>
<ThemeProvider>
<AppContent />
</ThemeProvider>
</ErrorBoundary>
);
}
+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);
+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>
);
}
+52 -6
View File
@@ -15,6 +15,52 @@ const API_V1 = `${API_BASE}/api/v1`;
const SEED_KEY = "nrxp_seed";
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 из бэкенда) ---
export interface ServerNode {
id: string;
@@ -89,7 +135,7 @@ function isTokenValid(token: string | null): token is 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",
headers: { "Content-Type": "application/json" },
});
@@ -101,7 +147,7 @@ async function generateSeed(): 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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed }),
@@ -133,7 +179,7 @@ async function ensureToken(): Promise<string> {
export async function fetchNodes(): Promise<ServerNode[]> {
const token = await ensureToken();
const res = await fetch(`${API_V1}/nodes`, {
const res = await fetchWithRetry(`${API_V1}/nodes`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`);
@@ -172,7 +218,7 @@ export async function loginWithSeed(seed: string): Promise<void> {
* поэтому это одноразовый обмен, а не поллинг: `true` при первом же успешном вызове.
*/
export async function pollTelegramLogin(authCode: string): Promise<boolean> {
const res = await fetch(`${API_V1}/auth/telegram/session/${authCode}`);
const res = await fetchWithRetry(`${API_V1}/auth/telegram/session/${authCode}`);
if (res.status === 401 || res.status === 410) {
throw new Error("TELEGRAM_LOGIN_EXPIRED");
}
@@ -194,7 +240,7 @@ export interface UsageInfo {
/** Текущий расход/лимит трафика юзера — для индикатора на `VpnStats`. */
export async function fetchUsage(): Promise<UsageInfo> {
const token = await ensureToken();
const res = await fetch(`${API_V1}/users/me`, {
const res = await fetchWithRetry(`${API_V1}/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`GET /users/me failed: ${res.status}`);
@@ -212,7 +258,7 @@ let cachedBotUsername: string | null = null;
export async function getBotUsername(): Promise<string | null> {
if (cachedBotUsername) return cachedBotUsername;
try {
const res = await fetch(`${API_V1}/config`);
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;
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "إعادة تعيين الاتصال",
"splash_tagline_1": "النظام يراقب الجميع",
"splash_tagline_2": "ما عداك",
"dialog_close": "إغلاق"
"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": "إعادة التحميل"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "СКІНУЦЬ ЗЛУЧЭННЕ",
"splash_tagline_1": "Сістэма сочыць за ўсімі",
"splash_tagline_2": "Акрамя цябе",
"dialog_close": "Закрыць"
"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": "Перазагрузіць"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "RESET CONNECTION",
"splash_tagline_1": "The System Watches Everyone",
"splash_tagline_2": "Except You",
"dialog_close": "Close"
"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"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "بازنشانی اتصال",
"splash_tagline_1": "سیستم همه را زیر نظر دارد",
"splash_tagline_2": "به‌جز تو",
"dialog_close": "بستن"
"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": "بارگذاری مجدد"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "СБРОСИТЬ СОЕДИНЕНИЕ",
"splash_tagline_1": "Система следит за всеми",
"splash_tagline_2": "Кроме тебя",
"dialog_close": "Закрыть"
"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": "Перезагрузить"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "BAĞLANTIYI SIFIRLA",
"splash_tagline_1": "Sistem Herkesi İzliyor",
"splash_tagline_2": "Senin Dışında",
"dialog_close": "Kapat"
"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"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "СКИНУТИ З'ЄДНАННЯ",
"splash_tagline_1": "Система стежить за всіма",
"splash_tagline_2": "Крім тебе",
"dialog_close": "Закрити"
"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": "Перезавантажити"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "ULANISHNI QAYTA O'RNATISH",
"splash_tagline_1": "Tizim Hammani Kuzatmoqda",
"splash_tagline_2": "Sizdan Tashqari",
"dialog_close": "Yopish"
"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"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"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"
"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"
}
+9 -1
View File
@@ -79,5 +79,13 @@
"tg_reset_btn": "重置连接",
"splash_tagline_1": "系统监视着所有人",
"splash_tagline_2": "除了你",
"dialog_close": "关闭"
"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": "重新加载"
}