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:
+58
-3
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user