Release 4.2.0+1: store-compliance формулировки и UX — убраны все цены/продажи из приложения (оффер «3 дня бесплатно» без ₽), «Активировать ключ» → «Добавить конфигурацию» (URL-ссылка, placeholder https://), блок «Ключ доступа» заменён статусом «Конфигурация добавлена/не добавлена» (URL не отображается), глобально Подписка→Конфигурация/Ваучер→URL-ссылка/Тариф→Профиль в UI и Go-строках; большая круглая кнопка ПОДКЛЮЧИТЬСЯ/ОТКЛЮЧИТЬСЯ со статусом, убран футер «Готово к подключению», протокол в списке — буквенный бейдж только при отличии от активного, трафик «ДОСТУПНО X ГБ» + «Активен до DD.MM.YYYY» + процент; пинг отдельного сервера кнопкой в строке, экран Статистика (за сегодня: трафик/время в сети/сессии + график 7 дней из localStorage), Kill Switch для VPN-режима (blackhole-маршруты сохраняются при обрыве туннеля, для прокси — системный прокси остаётся на мёртвый порт), тумблер «Авто-лучший сервер» в Настройках. Windows 4.2.0.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 22:34:43 +03:00
co-authored by Cursor
parent 74ffbd87d7
commit 2a57987aea
22 changed files with 670 additions and 127 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 4_010_201
versionName = "4.1.2+1"
versionCode = 4_020_001
versionName = "4.2.0+1"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
+55 -3
View File
@@ -64,6 +64,10 @@ type uiState struct {
Mode string `json:"mode"`
VPNSupported bool `json:"vpn_mode_supported"`
VPNActive bool `json:"vpn_active"`
// KillSwitch — persisted setting; KillSwitchActive — tunnel died and
// traffic is intentionally blocked until disconnect / reconnect.
KillSwitch bool `json:"kill_switch"`
KillSwitchActive bool `json:"kill_switch_active,omitempty"`
}
func main() {
@@ -146,7 +150,9 @@ func main() {
mustBind(w, "installCore", a.installCore)
mustBind(w, "openURL", openURL)
mustBind(w, "pingServers", a.pingServers)
mustBind(w, "pingServer", a.pingServer)
mustBind(w, "pingBest", a.pingBest)
mustBind(w, "setKillSwitch", a.setKillSwitch)
mustBind(w, "checkUpdate", a.checkUpdate)
mustBind(w, "applyUpdate", a.applyUpdate)
mustBind(w, "saveHy2", a.saveHy2)
@@ -225,9 +231,11 @@ func (a *app) getState() (uiState, error) {
Remnawave: a.mgr.RemnawaveSettings(),
Hy2: a.mgr.ActiveHy2Options(),
StorePackaged: update.IsStorePackaged(),
Mode: a.mgr.Mode(),
VPNSupported: a.mgr.VPNModeSupported(),
VPNActive: st.VPNActive,
Mode: a.mgr.Mode(),
VPNSupported: a.mgr.VPNModeSupported(),
VPNActive: st.VPNActive,
KillSwitch: cfg.KillSwitch,
KillSwitchActive: st.KillSwitchActive,
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
@@ -380,6 +388,50 @@ func (a *app) pingServers() ([]netcheck.Result, error) {
return res, err
}
// pingServer measures latency of one server (📊 button on a server row)
// and merges the result into the cached ping list.
func (a *app) pingServer(name string) (netcheck.Result, error) {
name = strings.TrimSpace(name)
a.mu.Lock()
list := a.mgr.Profiles()
a.mu.Unlock()
var target *netcheck.Target
for _, p := range list {
if p.Name == name {
target = &netcheck.Target{Name: p.Name, Protocol: config.Protocol(p.Protocol), Proxy: p.Proxy}
break
}
}
if target == nil {
return netcheck.Result{}, fmt.Errorf("сервер %q не найден", name)
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
out := netcheck.PingAll(ctx, []netcheck.Target{*target})
if len(out) == 0 {
return netcheck.Result{}, fmt.Errorf("не удалось измерить пинг")
}
res := out[0]
a.mu.Lock()
replaced := false
for i := range a.pings {
if a.pings[i].Name == res.Name {
a.pings[i] = res
replaced = true
break
}
}
if !replaced {
a.pings = append(a.pings, res)
}
a.mu.Unlock()
return res, nil
}
func (a *app) setKillSwitch(enabled bool) error {
return a.mgr.SetKillSwitch(enabled)
}
// pingBest measures all nodes, activates the fastest OK one, and optionally connects.
func (a *app) pingBest(autoConnect bool) (pingBestResult, error) {
pings, err := a.runPings()
+4 -4
View File
@@ -1,16 +1,16 @@
{
"version": "4.1.2",
"notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются",
"version": "4.2.0",
"notes": "4.2.0: обновлённые формулировки, Kill Switch, статистика, кнопка подключения",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d",
"os": "windows",
"arch": "amd64"
},
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -1,16 +1,16 @@
{
"version": "4.1.2",
"notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются",
"version": "4.2.0",
"notes": "4.2.0: обновлённые формулировки, Kill Switch, статистика, кнопка подключения",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d",
"os": "windows",
"arch": "amd64"
},
+58 -3
View File
@@ -62,6 +62,10 @@ type UIState struct {
Mode string `json:"mode"`
VPNSupported bool `json:"vpn_mode_supported"`
VPNActive bool `json:"vpn_active"`
// KillSwitch is the persisted setting; KillSwitchActive means the tunnel
// died unexpectedly and traffic is blocked until disconnect/reconnect.
KillSwitch bool `json:"kill_switch"`
KillSwitchActive bool `json:"kill_switch_active,omitempty"`
}
type PingBestResult struct {
@@ -141,9 +145,11 @@ func (a *App) GetState() (UIState, error) {
Remnawave: a.Mgr.RemnawaveSettings(),
Hy2: a.Mgr.ActiveHy2Options(),
StorePackaged: update.IsStorePackaged(),
Mode: a.Mgr.Mode(),
VPNSupported: a.Mgr.VPNModeSupported(),
VPNActive: st.VPNActive,
Mode: a.Mgr.Mode(),
VPNSupported: a.Mgr.VPNModeSupported(),
VPNActive: st.VPNActive,
KillSwitch: cfg.KillSwitch,
KillSwitchActive: st.KillSwitchActive,
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
@@ -281,6 +287,51 @@ func (a *App) PingServers() ([]netcheck.Result, error) {
return a.runPings()
}
// PingServer measures latency of a single server (📊 button on a server row)
// and merges the result into the cached ping list.
func (a *App) PingServer(name string) (netcheck.Result, error) {
name = strings.TrimSpace(name)
a.mu.Lock()
list := a.Mgr.Profiles()
a.mu.Unlock()
var target *netcheck.Target
for _, p := range list {
if p.Name == name {
target = &netcheck.Target{Name: p.Name, Protocol: config.Protocol(p.Protocol), Proxy: p.Proxy}
break
}
}
if target == nil {
return netcheck.Result{}, fmt.Errorf("сервер %q не найден", name)
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
out := netcheck.PingAll(ctx, []netcheck.Target{*target})
if len(out) == 0 {
return netcheck.Result{}, fmt.Errorf("не удалось измерить пинг")
}
res := out[0]
a.mu.Lock()
replaced := false
for i := range a.Pings {
if a.Pings[i].Name == res.Name {
a.Pings[i] = res
replaced = true
break
}
}
if !replaced {
a.Pings = append(a.Pings, res)
}
a.mu.Unlock()
return res, nil
}
// SetKillSwitch persists the leak-protection setting.
func (a *App) SetKillSwitch(enabled bool) error {
return a.Mgr.SetKillSwitch(enabled)
}
func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
pings, err := a.runPings()
out := PingBestResult{Pings: pings}
@@ -533,6 +584,10 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return nil, a.OpenShopURL(arg(args, 0, ""))
case "pingServers":
return a.PingServers()
case "pingServer":
return a.PingServer(arg(args, 0, ""))
case "setKillSwitch":
return nil, a.SetKillSwitch(arg(args, 0, false))
case "pingBest":
return a.PingBest(arg(args, 0, false))
case "checkUpdate":
+431 -85
View File
@@ -76,8 +76,10 @@
--side-bg: rgba(255, 255, 255, 0.65);
--side-active: rgba(8, 145, 178, 0.12);
--side-active-fg: #0e7490;
--power-off: linear-gradient(160deg, #e2e8f0, #cbd5e1);
--power-on: linear-gradient(160deg, #0d9488, #0891b2);
/* Кнопка-выключатель: акцентная в выключенном состоянии («нажми меня»),
красноватая — когда подключено (действие = отключить). */
--power-off: linear-gradient(160deg, #0891b2, #22d3ee);
--power-on: linear-gradient(160deg, #b91c1c, #ef4444);
--log-bg: rgba(15, 23, 42, 0.04);
}
html[data-theme="dark"] {
@@ -131,8 +133,8 @@
--side-bg: rgba(12, 18, 30, 0.72);
--side-active: rgba(34, 211, 238, 0.14);
--side-active-fg: #22d3ee;
--power-off: linear-gradient(160deg, #243247, #16202f);
--power-on: linear-gradient(160deg, #0d9488, #0891b2);
--power-off: linear-gradient(160deg, #0e7490, #06b6d4);
--power-on: linear-gradient(160deg, #b91c1c, #ef4444);
--log-bg: rgba(2, 6, 14, 0.6);
}
* { box-sizing: border-box; }
@@ -323,35 +325,45 @@
background: var(--hero-bg-on);
}
.power-btn {
width: 132px;
height: 132px;
width: 148px;
height: 148px;
border-radius: 50%;
border: 0;
cursor: pointer;
background: var(--power-off);
color: var(--muted);
display: grid;
place-items: center;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
margin: 4px auto 14px;
transition: background .25s, color .25s, transform .12s, box-shadow .25s;
box-shadow: 0 14px 34px rgba(15, 23, 42, .18);
box-shadow: 0 14px 34px rgba(8, 145, 178, .3);
position: relative;
}
.power-btn:hover { transform: translateY(-2px); }
.power-btn:active { transform: none; }
.power-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
.power-btn svg { width: 46px; height: 46px; }
.power-btn svg { width: 40px; height: 40px; }
.power-btn .power-label {
font-family: Outfit, sans-serif;
font-size: .64rem;
font-weight: 800;
letter-spacing: .09em;
line-height: 1;
}
.power-btn.on {
background: var(--power-on);
color: #f0fdfa;
box-shadow: 0 16px 44px rgba(13, 148, 136, .45);
color: #fff5f5;
box-shadow: 0 16px 44px rgba(220, 38, 38, .4);
}
.power-btn.on::after {
content: "";
position: absolute;
inset: -9px;
border-radius: 50%;
border: 2px solid rgba(45, 212, 191, .45);
border: 2px solid rgba(248, 113, 113, .5);
animation: ring 2s ease-out infinite;
}
@keyframes ring {
@@ -853,7 +865,40 @@
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: 0 0 auto; }
.server-row .right { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
/* Протокол — крошечная буква-иконка с подсказкой (не повторяем «vless» под каждой строкой). */
.proto-badge {
flex: 0 0 auto;
width: 18px;
height: 18px;
display: grid;
place-items: center;
border-radius: 6px;
font-size: .6rem;
font-weight: 800;
color: var(--muted);
background: var(--meta-bg);
border: 1px solid var(--line);
cursor: default;
}
/* Проверка скорости одного сервера — маленькая кнопка [📊] в строке. */
.row-ping {
appearance: none;
flex: 0 0 auto;
width: 26px;
height: 26px;
padding: 0;
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid var(--line);
background: transparent;
font-size: .78rem;
cursor: pointer;
transition: border-color .15s;
}
.row-ping:hover { border-color: rgba(8,145,178,.45); }
.row-ping:disabled { opacity: .5; cursor: default; }
.server-row .name-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* Пилюля «АКТИВЕН» вместо строчного текста «активный сервер» */
.badge-active {
@@ -934,6 +979,58 @@
.meta.ok { color: var(--ok); background: var(--meta-ok-bg); border-color: rgba(13,138,102,.18); }
.meta.err { color: var(--danger); background: var(--meta-err-bg); border-color: rgba(192,54,44,.16); }
/* ==== Статистика: карточки за сегодня + недельный график ==== */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.stat-card {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--row-bg);
padding: 12px 10px;
text-align: center;
}
.stat-val {
font-family: Outfit, sans-serif;
font-weight: 800;
font-size: 1.02rem;
letter-spacing: -.01em;
font-variant-numeric: tabular-nums;
}
.stat-k { font-size: .68rem; color: var(--muted); margin-top: 3px; }
.week-chart {
display: flex;
align-items: stretch;
gap: 6px;
height: 118px;
padding: 10px 12px 8px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--row-bg);
margin-bottom: 12px;
}
.week-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 4px;
min-width: 0;
}
.week-bar {
width: 100%;
max-width: 34px;
border-radius: 6px 6px 2px 2px;
background: linear-gradient(180deg, var(--accent), var(--accent-deep));
min-height: 2px;
}
.week-day { font-size: .62rem; color: var(--muted); white-space: nowrap; }
.week-empty { width: 100%; align-self: center; text-align: center; color: var(--muted); font-size: .8rem; }
/* Logs */
.log-view {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -1034,6 +1131,8 @@
font-size: .84rem;
font-weight: 700;
}
/* «📊 ДОСТУПНО X ГБ» — заголовок блока трафика капсом */
.traffic-row > span:first-child { text-transform: uppercase; letter-spacing: .03em; font-size: .8rem; }
.traffic-row .pct { color: var(--muted); font-weight: 600; font-size: .78rem; }
.traffic-bar {
height: 9px;
@@ -1083,11 +1182,9 @@
html[data-theme="dark"] .prov-card.ours {
background: linear-gradient(150deg, rgba(34,211,238,.16), var(--surface-2) 70%);
}
/* Бейдж «Рекомендуем» лежит НА карточке EvilFox (верхний угол), а не между карточками. */
/* Бейдж «Рекомендуемый сервис» — первая строка карточки EvilFox. */
.prov-badge {
position: absolute;
top: 10px;
right: 10px;
align-self: flex-start;
font-size: .64rem;
font-weight: 800;
letter-spacing: .03em;
@@ -1098,7 +1195,6 @@
padding: 3px 9px;
box-shadow: 0 4px 10px rgba(251, 191, 36, .35);
}
.prov-card.ours .prov-name { padding-right: 96px; }
/* Приглушённая подпись «Сторонний сервис» на карточках конкурентов */
.prov-tag {
font-size: .62rem;
@@ -1144,7 +1240,7 @@
.prov-ping.mid .val { color: var(--ms-mid); }
@media (max-width: 560px) { .prov-grid { grid-template-columns: 1fr; } }
/* ==== Активация ключа ==== */
/* ==== Добавление конфигурации ==== */
.key-box {
border: 1px solid var(--shop-border);
border-radius: var(--radius);
@@ -1156,6 +1252,8 @@
}
.key-box h3 { font-family: Outfit, sans-serif; font-size: .95rem; letter-spacing: -.02em; margin: 0; }
.key-hint { margin: 0; font-size: .76rem; color: var(--muted); line-height: 1.4; }
/* Статус конфигурации: 🟢 добавлена / 🔴 не добавлена (сам URL не показываем). */
.key-status { margin: 0; font-size: .8rem; font-weight: 700; }
.key-link {
border: 0;
background: transparent;
@@ -1238,7 +1336,7 @@
<div class="brand-row">
<div class="logo" aria-hidden="true"><span class="fox">🦊</span></div>
<h1 class="brand">EvilFox</h1>
<span class="badge-ver" id="badgeVer">4.1.2</span>
<span class="badge-ver" id="badgeVer">4.2.0</span>
</div>
<button class="nav-btn active" type="button" data-page="guard">
@@ -1297,23 +1395,23 @@
<path d="M12 3v8"/>
<path d="M6.3 6.6a8 8 0 1 0 11.4 0"/>
</svg>
<span class="power-label" id="powerLabel">ПОДКЛЮЧИТЬСЯ</span>
</button>
<div class="status-line">
<span class="dot" id="dot"></span>
<span id="statusText">Отключено</span>
<span id="statusText">🔴 Отключено</span>
</div>
<!-- Протокол показываем на карточке активного сервера ниже — в статусной строке не дублируем. -->
<div class="chips-line">
<span class="mode-chip" id="vpnChip" hidden>VPN · весь трафик</span>
</div>
<p class="hero-hint" id="heroHint">Нажмите на кнопку, чтобы включить защиту</p>
<p class="hero-hint" id="heroHint">Нажмите, чтобы включить защиту</p>
</section>
<button class="cur-server" id="curServer" type="button" title="Открыть список серверов">
<span class="flag" id="curFlag">·</span>
<span>
<span class="name" id="curName">Нет сервера</span>
<span class="sub" id="curHost">активируйте ключ на вкладке «Мои серверы»</span>
<span class="sub" id="curHost">добавьте конфигурацию на вкладке «Мои серверы»</span>
</span>
<span class="right">
<span class="proto" id="curProto"></span>
@@ -1334,10 +1432,10 @@
<div class="section-title"><span>Выберите провайдера</span></div>
<div class="prov-grid" id="provGrid">
<div class="prov-card ours">
<span class="prov-badge">⭐ Рекомендуем</span>
<span class="prov-badge">⭐ Рекомендуемый сервис</span>
<div class="prov-name">🦊 EvilFox VPN</div>
<div class="prov-sub">Оптимальные серверы рядом с вами · защита без настройки</div>
<span class="prov-gift" id="provGift">🚀 3 дня бесплатно. Затем — от 99 ₽/мес</span>
<span class="prov-gift" id="provGift">🚀 3 дня бесплатно</span>
<button class="action primary" id="provConnectBtn" type="button">🔥 Подключиться</button>
<button class="prov-ping" id="provPingOurs" type="button">📊 <span class="val">Проверить скорость</span></button>
</div>
@@ -1370,12 +1468,13 @@
<section class="page" data-page="profiles">
<h2 class="page-title">Мои серверы</h2>
<section class="key-box" aria-label="Активация ключа">
<h3>Ключ доступа</h3>
<input id="subUrl" type="text" placeholder="Вставьте ключ или ссылку доступа" spellcheck="false" />
<button class="action primary" id="subBtn" type="button">Активировать ключ</button>
<p class="key-hint">Введите ваучер для получения доступа</p>
<button class="key-link" id="buyKeyLink" type="button">Где купить ключ?</button>
<section class="key-box" aria-label="Добавить конфигурацию">
<h3>🔗 Добавить конфигурацию</h3>
<p class="key-status" id="subStatus">🔴 Конфигурация не добавлена</p>
<input id="subUrl" type="text" placeholder="https://" spellcheck="false" />
<button class="action primary" id="subBtn" type="button">Добавить конфигурацию</button>
<p class="key-hint">Вставьте URL-ссылку для подключения</p>
<button class="key-link" id="buyKeyLink" type="button">Где взять URL-ссылку?</button>
</section>
<div class="server-toolbar">
@@ -1456,8 +1555,8 @@
</div>
<div class="row">
<span class="row-text">Авто-лучший сервер
<span class="row-sub">Пинг всех серверов и автоподключение к лучшему</span>
<span class="row-text">🚀 Авто-лучший сервер
<span class="row-sub">При подключении автоматически выбирается сервер с наименьшим пингом</span>
</span>
<label class="switch">
<input id="autoBest" type="checkbox" />
@@ -1465,6 +1564,16 @@
</label>
</div>
<div class="row">
<span class="row-text">🔒 Защита от утечек (Kill Switch)
<span class="row-sub">При обрыве VPN блокировать весь интернет-трафик до ручного отключения или переподключения. Полноценно работает в режиме VPN; в режиме прокси защищены только программы, использующие системный прокси.</span>
</span>
<label class="switch">
<input id="killSwitch" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<details class="panel hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / obfuscation</summary>
<div class="grid2">
@@ -1554,6 +1663,27 @@
<div class="traffic-sub" id="trafficExp2" hidden></div>
</section>
<div class="section-title"><span>Статистика за сегодня</span></div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-val" id="statUsed"></div>
<div class="stat-k">Использовано</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statOnline">0 мин</div>
<div class="stat-k">Время в сети</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statSessions">0</div>
<div class="stat-k">Сессий</div>
</div>
</div>
<div class="section-title"><span>Трафик за 7 дней</span></div>
<div class="week-chart" id="weekChart">
<div class="week-empty">Пока нет данных</div>
</div>
<div class="section-title" style="margin-top:4px"><span>Журнал подключения</span></div>
<p class="adv-line" id="logsProxyLine" hidden style="margin:0 0 8px"></p>
<pre class="log-view" id="logView">Пусто — подключитесь, чтобы увидеть журнал.</pre>
@@ -1569,15 +1699,15 @@
<details class="panel">
<summary>Как подключиться?</summary>
<p class="remna-hint" style="margin-top:6px">Активируйте ключ на вкладке «Мои серверы», затем на вкладке «Защита» нажмите большую круглую кнопку. Приложение само выберет оптимальный сервер.</p>
<p class="remna-hint" style="margin-top:6px">Добавьте конфигурацию на вкладке «Мои серверы», затем на вкладке «Защита» нажмите большую круглую кнопку. Приложение само выберет оптимальный сервер.</p>
</details>
<details class="panel">
<summary>Где купить ключ?</summary>
<p class="remna-hint" style="margin-top:6px">Ключи и тарифы — на сайте evilfox.win (кнопка ниже). После оплаты вставьте полученный ключ на вкладке «Мои серверы».</p>
<summary>Где взять URL-ссылку?</summary>
<p class="remna-hint" style="margin-top:6px">URL-ссылку для подключения можно получить на сайте evilfox.win (кнопка ниже) или у вашего провайдера. Полученную ссылку вставьте на вкладке «Мои серверы».</p>
</details>
<details class="panel">
<summary>Не подключается — что делать?</summary>
<p class="remna-hint" style="margin-top:6px">Проверьте, что ключ активирован и не истёк («Мои серверы» → «Информация об аккаунте»). Попробуйте другой сервер (кнопка «Лучший»). Подробности — в журнале на вкладке «Статистика».</p>
<p class="remna-hint" style="margin-top:6px">Проверьте, что конфигурация добавлена и срок её действия не истёк («Мои серверы» → «Информация об аккаунте»). Попробуйте другой сервер (кнопка «Лучший»). Подробности — в журнале на вкладке «Статистика».</p>
</details>
<div class="about-card">
@@ -1589,10 +1719,10 @@
<div class="tools" style="margin-top:0">
<button class="action secondary" id="aboutUpdBtn" type="button">Проверить обновление</button>
</div>
<section class="shop" aria-label="Купить ключ">
<h3>Нужен ключ?</h3>
<p>Ключи и тарифы на evilfox.win — 3 дня бесплатно, затем от 99 ₽/мес. Техподдержка — в Telegram.</p>
<button class="action primary" id="shopBtn" type="button">Открыть магазин</button>
<section class="shop" aria-label="Получить конфигурацию">
<h3>Нужна конфигурация?</h3>
<p>URL-ссылку для подключения можно получить на evilfox.win. Техподдержка — в Telegram.</p>
<button class="action primary" id="shopBtn" type="button">Перейти на сайт</button>
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section>
<!-- Рекомендуемые сервисы: ненавязчивый блок в нижнем углу «О программе».
@@ -1634,9 +1764,9 @@
(function () {
const methods = [
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingServer","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave",
"setMode","getLogs","quit"
"setMode","setKillSwitch","getLogs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
@@ -1667,8 +1797,9 @@
const nameInput = $("name");
const sysproxy = $("sysproxy");
const profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
const powerLabel = $("powerLabel");
const killSwitch = $("killSwitch");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
@@ -1885,7 +2016,10 @@
currentPage = name;
document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
document.querySelectorAll(".page").forEach((p) => p.classList.toggle("active", p.dataset.page === name));
if (name === "logs") refreshLogs().catch(() => {});
if (name === "logs") {
renderStats();
refreshLogs().catch(() => {});
}
}
$("curServer").addEventListener("click", () => showPage("profiles"));
@@ -1949,7 +2083,8 @@
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
// Текст оффера — правьте здесь (показывается на карточке EvilFox на вкладке «Защита»).
const OFFER_TEXT = "🚀 3 дня бесплатно. Затем — от 99 ₽/мес";
// Правила Store: никаких цен и упоминаний оплаты внутри приложения.
const OFFER_TEXT = "🚀 3 дня бесплатно";
// Список рекомендуемых сервисов — замените ссылки/названия здесь.
// Хост каждой ссылки должен быть разрешён в allowlist openURL
// (internal/apphost/app.go → allowedLinkHosts), иначе ссылка не откроется.
@@ -1971,6 +2106,133 @@
try { localStorage.setItem(AUTO_BEST_KEY, autoBest.checked ? "1" : "0"); } catch (_) {}
});
// Kill Switch — настройка живёт в config.json на стороне Go.
let killSwitchSyncing = false;
if (killSwitch) killSwitch.addEventListener("change", async () => {
if (killSwitchSyncing) return;
try {
await setKillSwitch(!!killSwitch.checked);
setMeta(killSwitch.checked
? "Kill Switch включён: при обрыве VPN трафик будет заблокирован"
: "Kill Switch выключен", "ok");
} catch (e) {
killSwitch.checked = !killSwitch.checked;
setMeta(String(e), "err");
}
});
// ==== Локальная статистика (localStorage) ====
// Источники данных:
// • трафик — дельты счётчика «использовано» из sub_info (заголовок
// Subscription-Userinfo / Remnawave API), накапливаются по дням;
// • время в сети и число сессий — отслеживание подключений в этом UI.
const STATS_DAILY_KEY = "navis.stats.daily"; // {"YYYY-MM-DD": {t: bytes, o: sec, s: sessions}}
const STATS_LAST_USED_KEY = "navis.stats.lastUsed";
let onlineFlushMs = 0; // отметка последнего накопления «времени в сети»
let wasConnected = false;
function dayKey(d) {
const x = d || new Date();
return x.getFullYear() + "-" + String(x.getMonth() + 1).padStart(2, "0") + "-" + String(x.getDate()).padStart(2, "0");
}
function loadDaily() {
try {
const o = JSON.parse(localStorage.getItem(STATS_DAILY_KEY) || "{}");
return (o && typeof o === "object") ? o : {};
} catch (_) { return {}; }
}
function saveDaily(o) {
// Храним только последние 14 дней.
const keys = Object.keys(o).sort();
while (keys.length > 14) delete o[keys.shift()];
try { localStorage.setItem(STATS_DAILY_KEY, JSON.stringify(o)); } catch (_) {}
}
function bumpToday(field, delta) {
if (!delta || delta <= 0) return;
const o = loadDaily();
const k = dayKey();
const d = o[k] || {};
d[field] = (d[field] || 0) + delta;
o[k] = d;
saveDaily(o);
}
// Дельта трафика из sub_info: счётчик «использовано» растёт → копим в сегодня.
function trackTraffic(info) {
if (!info || (!info.total && !info.download && !info.upload)) return;
const used = (info.upload || 0) + (info.download || 0);
let last = -1;
try { last = parseInt(localStorage.getItem(STATS_LAST_USED_KEY), 10); } catch (_) {}
if (Number.isFinite(last) && last >= 0 && used > last) {
bumpToday("t", used - last);
}
try { localStorage.setItem(STATS_LAST_USED_KEY, String(used)); } catch (_) {}
}
// Подключение/отключение: сессии и время в сети.
function trackConnection(connectedNow) {
const now = Date.now();
if (connectedNow && !wasConnected) {
bumpToday("s", 1);
onlineFlushMs = now;
} else if (connectedNow && wasConnected && onlineFlushMs) {
// Периодический сброс, чтобы время не терялось при закрытии приложения.
const sec = Math.floor((now - onlineFlushMs) / 1000);
if (sec >= 1) { bumpToday("o", sec); onlineFlushMs = now; }
} else if (!connectedNow && wasConnected && onlineFlushMs) {
bumpToday("o", Math.floor((now - onlineFlushMs) / 1000));
onlineFlushMs = 0;
}
wasConnected = connectedNow;
}
function fmtOnline(sec) {
if (!sec || sec < 60) return (sec || 0) + " сек";
const min = Math.floor(sec / 60);
if (min < 60) return min + " мин";
return Math.floor(min / 60) + " ч " + (min % 60) + " мин";
}
const DAY_RU = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
function renderStats() {
const daily = loadDaily();
const today = daily[dayKey()] || {};
const usedEl = $("statUsed"), onlineEl = $("statOnline"), sessEl = $("statSessions");
if (usedEl) usedEl.textContent = today.t > 0 ? fmtTraffic(today.t) : "—";
if (onlineEl) onlineEl.textContent = fmtOnline((today.o || 0) + (wasConnected && onlineFlushMs ? Math.floor((Date.now() - onlineFlushMs) / 1000) : 0));
if (sessEl) sessEl.textContent = String(today.s || 0);
const chart = $("weekChart");
if (!chart) return;
const days = [];
let maxT = 0;
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const rec = daily[dayKey(d)] || {};
days.push({ label: DAY_RU[d.getDay()], t: rec.t || 0 });
if ((rec.t || 0) > maxT) maxT = rec.t;
}
chart.innerHTML = "";
if (!maxT) {
const empty = document.createElement("div");
empty.className = "week-empty";
empty.textContent = "Пока нет данных";
chart.appendChild(empty);
return;
}
days.forEach((d) => {
const col = document.createElement("div");
col.className = "week-col";
const bar = document.createElement("div");
bar.className = "week-bar";
bar.style.height = Math.max(2, Math.round(d.t / maxT * 100)) + "%";
bar.title = d.t ? fmtTraffic(d.t) : "нет данных";
const label = document.createElement("div");
label.className = "week-day";
label.textContent = d.label;
col.appendChild(bar);
col.appendChild(label);
chart.appendChild(col);
});
}
function readHy2() {
return {
congestion: $("hy2Congestion").value,
@@ -2014,6 +2276,7 @@
let busy = false;
let connected = false;
let ksActive = false; // Kill Switch сработал: трафик заблокирован намеренно
let formHydrated = false;
let dirty = false;
let profiles = [];
@@ -2034,6 +2297,8 @@
function setMeta(text, kind) {
meta.textContent = text || "";
// Пустая строка — нижняя полоса скрыта целиком (нет «Готово к подключению»).
meta.hidden = !text;
meta.classList.remove("ok", "err");
if (kind === "ok") meta.classList.add("ok");
if (kind === "err") meta.classList.add("err");
@@ -2138,7 +2403,7 @@
if (!p) {
setFlagEl($("curFlag"), null);
$("curName").textContent = "Нет сервера";
$("curHost").textContent = "активируйте ключ на вкладке «Мои серверы»";
$("curHost").textContent = "добавьте конфигурацию на вкладке «Мои серверы»";
$("curProto").textContent = "—";
$("curPing").textContent = "…";
$("curPing").className = "ms";
@@ -2180,7 +2445,7 @@
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — активируйте ключ доступа выше";
empty.textContent = "Нет серверов — добавьте конфигурацию выше";
serverList.appendChild(empty);
return;
}
@@ -2210,17 +2475,16 @@
badge.textContent = "АКТИВЕН";
nameRow.appendChild(badge);
}
// Протокол в списке — мелкой приглушённой подписью (чип есть на карточке активного сервера).
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = (p.protocol || "").toString() || "защищённое соединение";
metaCol.appendChild(nameRow);
metaCol.appendChild(sub);
left.appendChild(flag);
left.appendChild(metaCol);
const right = document.createElement("div");
right.className = "right";
// Протокол — крошечная буква-иконка с подсказкой; строчный «vless»
// под каждой строкой не повторяем.
const protoBadge = protoLetterBadge(p.protocol);
if (protoBadge) right.appendChild(protoBadge);
const ms = document.createElement("span");
const pr = pingMap[p.name];
if (pr && pr.ok) {
@@ -2236,6 +2500,17 @@
ms.title = "пинг не измерен";
}
right.appendChild(ms);
// [📊] — проверить скорость только этого сервера.
const pingOne = document.createElement("button");
pingOne.type = "button";
pingOne.className = "row-ping";
pingOne.textContent = "📊";
pingOne.title = "Проверить скорость этого сервера";
pingOne.addEventListener("click", (e) => {
e.stopPropagation();
runPingOne(p.name, pingOne, ms);
});
right.appendChild(pingOne);
row.appendChild(left);
row.appendChild(right);
@@ -2245,6 +2520,39 @@
});
}
const PROTO_LETTER = {
vless: "V", vmess: "V", trojan: "T", hysteria2: "H", hy2: "H",
naive: "N", awg: "A"
};
function protoLetterBadge(protocol) {
const p = String(protocol || "").toLowerCase();
if (!p) return null;
const el = document.createElement("span");
el.className = "proto-badge";
el.textContent = PROTO_LETTER[p] || p.charAt(0).toUpperCase();
el.title = "Протокол: " + p;
return el;
}
// Пинг одного сервера по кнопке [📊] в строке.
async function runPingOne(name, btnEl, msEl) {
if (btnEl.disabled) return;
btnEl.disabled = true;
const prevText = msEl.textContent;
msEl.textContent = "…";
try {
const res = await pingServer(name);
if (res) pingMap[name] = res;
rebuildFriendlyNames();
renderServerList(profile.value);
renderCurServer(profile.value);
} catch (e) {
msEl.textContent = prevText;
setMeta("Не удалось измерить пинг: " + String(e), "err");
btnEl.disabled = false;
}
}
async function onServerClick(name) {
if (busy) return;
if (connected) {
@@ -2289,6 +2597,15 @@
}
}
// DD.MM.YYYY для строки «Активен до …».
function fmtDateShort(unix) {
const d = new Date(unix * 1000);
return String(d.getDate()).padStart(2, "0") + "." + String(d.getMonth() + 1).padStart(2, "0") + "." + d.getFullYear();
}
function isSameCalendarDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function renderMode(state) {
currentMode = state.mode === "vpn" ? "vpn" : "proxy";
if (modeSection) {
@@ -2330,15 +2647,18 @@
fill.classList.remove("warn");
}
if (exp) {
// Реальная дата окончания; «сгорает сегодня» — только когда
// календарный день окончания совпадает с сегодняшним.
let expText = "";
if (info.expire) {
const daysLeft = Math.floor((info.expire * 1000 - Date.now()) / 86400000);
if (daysLeft === 0) {
expText = "🔥 Сгорает сегодня";
} else if (daysLeft > 0) {
expText = "🔥 Сгорает через " + daysLeft + " " + pluralRu(daysLeft, "день", "дня", "дней");
const expDate = new Date(info.expire * 1000);
const now = new Date();
if (expDate.getTime() < now.getTime() && !isSameCalendarDay(expDate, now)) {
expText = "Срок доступа истёк " + fmtDateShort(info.expire);
} else if (isSameCalendarDay(expDate, now)) {
expText = "⏳ Сгорает сегодня (до 23:59)";
} else {
expText = "Срок доступа истёк";
expText = "Активен до " + fmtDateShort(info.expire);
}
}
exp.hidden = !expText;
@@ -2349,6 +2669,7 @@
function renderSubInfo(info) {
renderTraffic(info);
trackTraffic(info);
const card = $("subInfoCard");
if (!card) return;
const expireRow = $("subExpireRow"), trafficRow = $("subTrafficRow"), devicesRow = $("subDevicesRow");
@@ -2433,11 +2754,30 @@
const syncForm = opts && opts.syncForm;
storePackaged = !!(state && state.store_packaged);
connected = !!state.connected;
dot.classList.toggle("on", connected);
ksActive = !!state.kill_switch_active;
trackConnection(connected);
hero.classList.toggle("on", connected);
btn.classList.toggle("on", connected);
statusText.textContent = connected ? "Защищённое соединение" : "Отключено";
btn.title = connected ? "Отключить" : "Подключить";
btn.classList.toggle("on", connected || ksActive);
if (ksActive) {
statusText.textContent = "⛔ Соединение прервано — трафик заблокирован";
if (powerLabel) powerLabel.textContent = "ОТКЛЮЧИТЬСЯ";
} else {
statusText.textContent = connected ? "🟢 Защита активна" : "🔴 Отключено";
if (powerLabel) powerLabel.textContent = connected ? "ОТКЛЮЧИТЬСЯ" : "ПОДКЛЮЧИТЬСЯ";
}
btn.title = (connected || ksActive) ? "Отключить" : "Подключить";
if (killSwitch && document.activeElement !== killSwitch) {
killSwitchSyncing = true;
killSwitch.checked = !!state.kill_switch;
killSwitchSyncing = false;
}
// Статус конфигурации на «Мои серверы»: сам URL не показываем (он
// хранится в config.json и нужен для обновления списка серверов).
const subStatus = $("subStatus");
if (subStatus) {
const hasSub = !!(state.subscription_url && String(state.subscription_url).trim());
subStatus.textContent = hasSub ? "🟢 Конфигурация добавлена" : "🔴 Конфигурация не добавлена";
}
const lock = connected || busy;
proxy.disabled = lock;
@@ -2465,9 +2805,6 @@
renderUpdate(state.update, state.version);
renderSubInfo(state.sub_info);
renderMode(state);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
@@ -2503,26 +2840,29 @@
renderProvConnect();
renderProfHost();
let detail = "";
if (connected) {
detail = state.vpn_active
? "VPN: весь трафик под защитой · Ваш IP скрыт"
: "Защищённое соединение активно · Ваш IP скрыт";
heroHint.textContent = "Ваш IP скрыт · Трафик зашифрован";
// Нижняя полоса статуса — только для важных сообщений; постоянного
// «Готово к подключению» больше нет (статус виден на большой кнопке).
let detail = "", detailKind = "";
if (ksActive) {
heroHint.textContent = "Kill Switch активен: интернет заблокирован до отключения или переподключения";
detail = "⛔ Kill Switch: соединение прервано, трафик заблокирован. Нажмите «Отключиться» или переподключитесь.";
detailKind = "err";
} else if (connected) {
heroHint.textContent = "Трафик зашифрован";
} else if (state.core_ready === false) {
detail = "Сначала установите cores в Настройках («Сервис»)";
detailKind = "err";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
// Количество серверов на главном экране не показываем — оно есть на «Мои серверы».
detail = "Готово к подключению";
heroHint.textContent = currentMode === "vpn"
? "Режим VPN: защита всего трафика, включая игры и приложения"
: "Нажмите на кнопку, чтобы включить защиту";
: "Нажмите, чтобы включить защиту";
}
if (!busy && Date.now() > metaHoldUntil) {
setMeta(detail, state.core_ready === false ? "err" : "");
setMeta(detail, detailKind);
metaHoldUntil = 0;
}
renderStats();
}
// Кнопка «Подключиться» на карточке EvilFox (вкладка «Защита»).
@@ -2598,10 +2938,15 @@
btn.addEventListener("click", () => withBusy(async () => {
try {
if (connected) {
if (connected || ksActive) {
// ksActive: отключение снимает блокировку Kill Switch и
// восстанавливает обычную сеть.
setMeta("Отключение…");
await disconnect();
setMeta("Отключено", "ok");
} else if (autoBest.checked && profiles.length > 1) {
// «Авто-лучший сервер»: пинг всех и подключение к самому быстрому.
await runBest(true);
} else {
setMeta("Подключение…");
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
@@ -2708,27 +3053,28 @@
async function runImportSubscription() {
const url = subUrl.value.trim();
if (!url) {
setMeta("Вставьте ключ доступа (ваучер)", "err");
setMeta("Вставьте URL-ссылку для подключения", "err");
return;
}
setMeta("Активация ключа…");
setMeta("Добавление конфигурации…");
const r = await importSubscription(url);
formHydrated = false;
dirty = false;
subUrl.value = "";
setMeta(importSummary(r) + " · подбираю оптимальный сервер…", "ok");
await runBest(!!autoBest.checked);
}
function importSummary(r) {
if (r && typeof r === "object") {
let s = "Ключ активирован · серверов: " + (r.imported || 0);
let s = "Конфигурация добавлена · серверов: " + (r.imported || 0);
if (r.skipped > 0) s += " (пропущено " + r.skipped + ")";
return s;
}
return "Ключ активирован: " + r;
return "Конфигурация добавлена: " + r;
}
// «Где купить ключ?» — открывает evilfox.win в браузере.
// «Где взять URL-ссылку?» — открывает evilfox.win в браузере.
const buyKeyLink = $("buyKeyLink");
if (buyKeyLink) buyKeyLink.addEventListener("click", openShop);
+6
View File
@@ -31,6 +31,12 @@ type Config struct {
// system proxy) or "vpn" (Windows: TUN device, ALL traffic incl. apps/games).
Mode string `json:"mode,omitempty"`
// KillSwitch keeps leak protection engaged when the tunnel dies
// unexpectedly: in VPN mode the TUN blackhole routes stay up, in proxy
// mode the system proxy keeps pointing at the dead local port, so traffic
// is blocked until the user disconnects manually or reconnects.
KillSwitch bool `json:"kill_switch,omitempty"`
// BinDir is where protocol binaries (naive.exe / hysteria.exe) are stored.
BinDir string `json:"bin_dir,omitempty"`
+4 -1
View File
@@ -25,5 +25,8 @@ type Status struct {
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
VPNActive bool `json:"vpn_active,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
// KillSwitchActive: the tunnel died unexpectedly and traffic is being
// blocked on purpose until manual disconnect / reconnect.
KillSwitchActive bool `json:"kill_switch_active,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
+83 -2
View File
@@ -39,6 +39,12 @@ type Manager struct {
// vpn is the active VPN-mode (TUN) session, nil in proxy mode.
vpn *vpnmode.Session
// watchGen invalidates engine watchdogs from previous connects.
watchGen uint64
// ksEngaged is true after an unexpected engine death with kill switch on:
// blackhole routes / dead system proxy are intentionally left in place.
ksEngaged bool
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -66,6 +72,18 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
return fmt.Errorf("already connected; disconnect first")
}
// Reconnect releases a previously engaged kill switch (blackhole state).
if m.ksEngaged {
if m.vpn != nil {
_ = m.vpn.Close()
m.vpn = nil
}
if m.sys.Enabled() {
_ = m.sys.Disable()
}
m.ksEngaged = false
}
if profileName != "" {
m.cfg.Active = profileName
}
@@ -135,9 +153,70 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
m.engine = engine
m.profile = profile
m.watchGen++
go m.watchEngine(m.watchGen, engine)
return nil
}
// watchEngine polls the engine and reacts to an UNEXPECTED death (crash of the
// core process). A normal Disconnect bumps watchGen first, so the watcher just
// exits. With kill switch enabled the blackhole state is kept, otherwise the
// network is restored (system proxy off, TUN routes removed).
func (m *Manager) watchEngine(gen uint64, engine Engine) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
m.mu.Lock()
if m.watchGen != gen || m.engine != engine {
m.mu.Unlock()
return
}
if engine.Running() {
m.mu.Unlock()
continue
}
// Unexpected death: engine stopped without Disconnect.
m.engine = nil
m.profile = nil
ks := m.cfg.KillSwitch
vpn := m.vpn
sysOn := m.sys.Enabled()
if ks && (vpn != nil || sysOn) {
// Keep TUN blackhole routes / dead system proxy: traffic is
// blocked until the user disconnects manually or reconnects.
m.ksEngaged = true
m.mu.Unlock()
fmt.Fprintf(m.stderr, "kill switch: соединение прервано — трафик заблокирован до ручного отключения или переподключения\n")
return
}
m.vpn = nil
m.mu.Unlock()
if vpn != nil {
_ = vpn.Close()
}
if sysOn {
_ = m.sys.Disable()
}
fmt.Fprintf(m.stderr, "соединение прервано — сетевые настройки восстановлены\n")
return
}
}
// KillSwitchEnabled returns the persisted kill switch setting.
func (m *Manager) KillSwitchEnabled() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg.KillSwitch
}
// SetKillSwitch persists the kill switch setting.
func (m *Manager) SetKillSwitch(enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
m.cfg.KillSwitch = enabled
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) Disconnect() error {
m.mu.Lock()
sys := m.sys
@@ -146,6 +225,8 @@ func (m *Manager) Disconnect() error {
m.engine = nil
m.profile = nil
m.vpn = nil
m.watchGen++ // cancel the engine watchdog — this stop is intentional
m.ksEngaged = false
m.mu.Unlock()
var first error
@@ -181,7 +262,7 @@ func (m *Manager) Status() Status {
m.mu.Lock()
defer m.mu.Unlock()
st := Status{SystemProxy: m.sys.Enabled(), VPNActive: m.vpn != nil}
st := Status{SystemProxy: m.sys.Enabled(), VPNActive: m.vpn != nil, KillSwitchActive: m.ksEngaged}
if m.engine == nil || !m.engine.Running() {
return st
}
@@ -608,7 +689,7 @@ func (m *Manager) applySubscription(ctx context.Context, res *subscription.Resul
return ImportResult{Skipped: res.Skipped, Warnings: res.Warnings},
fmt.Errorf("нет валидных серверов: %d записей пропущено (%s)", res.Skipped, strings.Join(res.Warnings, "; "))
}
return ImportResult{}, fmt.Errorf(подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
return ImportResult{}, fmt.Errorf(конфигурации нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
}
// Subscription usage info: header data first, enriched via Remnawave API
+1 -1
View File
@@ -64,7 +64,7 @@ func Normalize(proto config.Protocol, raw string) (normalized string, detected c
// URL, not a naive proxy link — callers should have routed it to the
// subscription import flow (see LooksLikeSubscriptionURL).
if LooksLikeSubscriptionURL(raw) {
return "", config.ProtocolNaive, "", fmt.Errorf("это ссылка подписки (нет логина и пароля) — вставьте её в поле «URL подписки» или сохраните ещё раз: импорт запустится автоматически")
return "", config.ProtocolNaive, "", fmt.Errorf("это URL-ссылка конфигурации (нет логина и пароля) — вставьте её в поле «Добавить конфигурацию» или сохраните ещё раз: импорт запустится автоматически")
}
if xray.Detect(raw) {
u, rem, err := xray.NormalizeShareLink(raw)
+2 -2
View File
@@ -38,7 +38,7 @@ func TestNormalizeSubscriptionURLError(t *testing.T) {
if strings.Contains(err.Error(), "missing username") {
t.Fatalf("raw parser error leaked: %v", err)
}
if !strings.Contains(err.Error(), "подписк") {
t.Fatalf("error should mention subscription: %v", err)
if !strings.Contains(err.Error(), "конфигурац") {
t.Fatalf("error should mention configuration URL: %v", err)
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ func Fetch(ctx context.Context, s Settings) (*subscription.Result, string, error
if lastErr != nil {
return nil, "", lastErr
}
return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа подписки)")
return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа конфигурации)")
}
func fetchWithToken(ctx context.Context, client *http.Client, s Settings) (*subscription.Result, string, error) {
+7 -7
View File
@@ -67,11 +67,11 @@ var userAgents = []string{
func Fetch(ctx context.Context, rawURL string) (*Result, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, fmt.Errorf("пустой URL подписки")
return nil, fmt.Errorf("пустой URL конфигурации")
}
u, err := url.Parse(rawURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return nil, fmt.Errorf("нужен http(s) URL подписки")
return nil, fmt.Errorf("нужен http(s) URL конфигурации")
}
var (
@@ -104,7 +104,7 @@ func Fetch(ctx context.Context, rawURL string) (*Result, error) {
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf(подписке нет поддерживаемых ссылок")
return nil, fmt.Errorf(конфигурации нет поддерживаемых ссылок")
}
best.Info = info
return best, nil
@@ -120,11 +120,11 @@ func download(ctx context.Context, rawURL, userAgent string) (string, http.Heade
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("не удалось скачать подписку: %w", err)
return "", nil, fmt.Errorf("не удалось загрузить конфигурацию: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode)
return "", nil, fmt.Errorf("конфигурация недоступна (HTTP %d)", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
@@ -183,7 +183,7 @@ func ParseBody(body string) ([]Item, error) {
func ParseBodyDetailed(body string) (*Result, error) {
body = strings.TrimSpace(body)
if body == "" {
return &Result{}, fmt.Errorf("пустая подписка")
return &Result{}, fmt.Errorf("пустая конфигурация")
}
candidates := []string{body}
@@ -212,7 +212,7 @@ func ParseBodyDetailed(body string) (*Result, error) {
return best, fmt.Errorf("нет поддерживаемых ссылок: %d записей пропущено (%s)",
best.Skipped, strings.Join(best.Warnings, "; "))
}
return best, fmt.Errorf(подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
return best, fmt.Errorf(конфигурации нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
}
func parseText(text string) *Result {
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "4.1.2"
const CurrentVersion = "4.2.0"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
+1 -1
View File
@@ -33,7 +33,7 @@
<Identity
Name="EvilFox.EvilFox"
Publisher="CN=EvilFox"
Version="4.1.2.1"
Version="4.2.0.1"
ProcessorArchitecture="x64" />
<Properties>
+2 -2
View File
@@ -15,7 +15,7 @@
param(
[string]$ExePath = "",
[string]$OutDir = "",
[string]$Version = "4.1.2.1",
[string]$Version = "4.2.0.1",
[string]$Name = "EvilFox.EvilFox",
[string]$Publisher = "CN=EvilFox",
[string]$DisplayName = "EvilFox",
@@ -82,7 +82,7 @@ $manifestText = Get-Content -LiteralPath $ManifestSrc -Raw -Encoding UTF8
# Prefer exact placeholder swaps so we never touch <?xml version=...?>.
$manifestText = $manifestText.Replace('Name="EvilFox.EvilFox"', "Name=`"$Name`"")
$manifestText = $manifestText.Replace('Publisher="CN=EvilFox"', "Publisher=`"$Publisher`"")
$manifestText = $manifestText.Replace('Version="4.1.2.1"', "Version=`"$Version`"")
$manifestText = $manifestText.Replace('Version="4.2.0.1"', "Version=`"$Version`"")
$manifestText = $manifestText.Replace('<DisplayName>EvilFox</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
$manifestText = $manifestText.Replace('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
$manifestText = $manifestText.Replace('DisplayName="EvilFox"', "DisplayName=`"$DisplayName`"")
+4 -4
View File
@@ -1,16 +1,16 @@
{
"version": "4.1.2",
"notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются",
"version": "4.2.0",
"notes": "4.2.0: обновлённые формулировки, Kill Switch, статистика, кнопка подключения",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d",
"os": "windows",
"arch": "amd64"
},
+4 -4
View File
@@ -1,7 +1,7 @@
{
"FixedFileInfo": {
"FileVersion": { "Major": 4, "Minor": 1, "Patch": 2, "Build": 1 },
"ProductVersion": { "Major": 4, "Minor": 1, "Patch": 2, "Build": 1 },
"FileVersion": { "Major": 4, "Minor": 2, "Patch": 0, "Build": 1 },
"ProductVersion": { "Major": 4, "Minor": 2, "Patch": 0, "Build": 1 },
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "40004",
@@ -11,12 +11,12 @@
"StringFileInfo": {
"CompanyName": "EvilFox",
"FileDescription": "EvilFox — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "4.1.2.1",
"FileVersion": "4.2.0.1",
"InternalName": "EvilFox",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "EvilFox.exe",
"ProductName": "EvilFox",
"ProductVersion": "4.1.2.1",
"ProductVersion": "4.2.0.1",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {