Files
navi/internal/appui/index.html
T

4097 lines
197 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EvilFox</title>
<script>
(function () {
try {
var k = "navis.theme";
var t = localStorage.getItem(k);
if (t !== "light" && t !== "dark") {
t = "light";
}
document.documentElement.setAttribute("data-theme", t);
} catch (_) {
document.documentElement.setAttribute("data-theme", "light");
}
try {
var lang = localStorage.getItem("navis.lang");
if (lang !== "en" && lang !== "ru") lang = "ru";
document.documentElement.setAttribute("lang", lang);
} catch (_) {
document.documentElement.setAttribute("lang", "ru");
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
/* Dark-first slate + cyan accent; sidebar layout (Hiddify/Nord style) */
:root {
color-scheme: light;
--ink: #0f172a;
--text: var(--ink);
--muted: #64748b;
--line: rgba(15, 23, 42, 0.10);
--accent: #0891b2;
--accent-deep: #0e7490;
--accent-soft: #cffafe;
--danger: #dc2626;
--ok: #0d9488;
--ms-good: #059669;
--ms-mid: #d97706;
--surface: rgba(255, 255, 255, 0.88);
--surface-2: rgba(248, 250, 252, 0.92);
--input-bg: #ffffff;
--row-bg: rgba(255, 255, 255, 0.7);
--hero-bg: linear-gradient(155deg, #f8fafc 0%, #ecfeff 55%, #f1f5f9 100%);
--hero-bg-on: linear-gradient(155deg, #ecfeff 0%, #e0f2fe 50%, #f8fafc 100%);
--chip-bg: rgba(255, 255, 255, 0.85);
--server-list-bg: rgba(241, 245, 249, 0.65);
--server-row-bg: rgba(255, 255, 255, 0.92);
--server-row-hover: #fff;
--server-row-active: linear-gradient(135deg, #ecfeff, #fff);
--meta-bg: rgba(241, 245, 249, 0.8);
--meta-ok-bg: rgba(204, 251, 241, 0.75);
--meta-err-bg: rgba(254, 226, 226, 0.85);
--shop-bg: linear-gradient(135deg, rgba(8,145,178,.08), rgba(255,255,255,.7));
--shop-border: rgba(8, 145, 178, 0.18);
--modal-bg: #f8fafc;
--modal-scrim: rgba(15, 23, 42, 0.45);
--update-bg: linear-gradient(135deg, rgba(8,145,178,.12), rgba(255,255,255,.85));
--update-border: rgba(8, 145, 178, 0.28);
--icon-btn-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--icon-btn-border: rgba(15, 23, 42, 0.12);
--icon-btn-hover: linear-gradient(180deg, #f1f5f9, #e2e8f0);
--mini-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #f0fdfa;
--switch-off: #cbd5e1;
--switch-knob: #fff;
--dot-off: #94a3b8;
--shadow: 0 20px 50px rgba(15, 23, 42, 0.10);
--radius: 18px;
--ease: cubic-bezier(.22,.8,.24,1);
--page-bg:
radial-gradient(700px 380px at 0% -5%, #a5f3fc 0%, transparent 50%),
radial-gradient(560px 320px at 100% 0%, #bae6fd 0%, transparent 45%),
linear-gradient(165deg, #f1f5f9 0%, #f8fafc 50%, #eef2ff 100%);
--theme-toggle-bg: rgba(255, 255, 255, 0.75);
--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, #0891b2, #22d3ee);
--power-on: linear-gradient(160deg, #b91c1c, #ef4444);
--log-bg: rgba(15, 23, 42, 0.04);
}
html[data-theme="dark"] {
color-scheme: dark;
--ink: #e8eef7;
--text: var(--ink);
--muted: #8b9bb4;
--line: rgba(148, 163, 184, 0.14);
--accent: #22d3ee;
--accent-deep: #06b6d4;
--accent-soft: rgba(34, 211, 238, 0.12);
--danger: #f87171;
--ok: #2dd4bf;
--ms-good: #34d399;
--ms-mid: #fbbf24;
--surface: rgba(15, 20, 30, 0.92);
--surface-2: rgba(22, 30, 44, 0.88);
--input-bg: rgba(10, 14, 22, 0.95);
--row-bg: rgba(20, 28, 40, 0.8);
--hero-bg: linear-gradient(155deg, #121826 0%, #0f172a 55%, #111827 100%);
--hero-bg-on: linear-gradient(155deg, #0c2a32 0%, #132033 50%, #121826 100%);
--chip-bg: rgba(30, 41, 59, 0.9);
--server-list-bg: rgba(8, 12, 20, 0.55);
--server-row-bg: rgba(22, 30, 44, 0.9);
--server-row-hover: rgba(30, 41, 59, 0.95);
--server-row-active: linear-gradient(135deg, rgba(8,47,55,.95), rgba(22,30,44,.95));
--meta-bg: rgba(10, 14, 22, 0.7);
--meta-ok-bg: rgba(6, 78, 72, 0.45);
--meta-err-bg: rgba(69, 26, 26, 0.55);
--shop-bg: linear-gradient(135deg, rgba(34,211,238,.08), rgba(15,20,30,.85));
--shop-border: rgba(34, 211, 238, 0.18);
--modal-bg: #121826;
--modal-scrim: rgba(2, 6, 14, 0.72);
--update-bg: linear-gradient(135deg, rgba(34,211,238,.12), rgba(15,20,30,.9));
--update-border: rgba(34, 211, 238, 0.28);
--icon-btn-bg: linear-gradient(180deg, #1e293b, #162032);
--icon-btn-border: rgba(34, 211, 238, 0.22);
--icon-btn-hover: linear-gradient(180deg, #243247, #1a2536);
--mini-bg: linear-gradient(180deg, #1e293b, #162032);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #ecfeff;
--switch-off: #334155;
--switch-knob: #e2e8f0;
--dot-off: #64748b;
--shadow: 0 22px 55px rgba(0, 0, 0, 0.5);
--page-bg:
radial-gradient(680px 380px at 5% -8%, rgba(8, 90, 110, 0.35) 0%, transparent 55%),
radial-gradient(520px 300px at 100% 0%, rgba(30, 58, 95, 0.32) 0%, transparent 48%),
linear-gradient(165deg, #070b12 0%, #0c1220 48%, #0a1018 100%);
--theme-toggle-bg: rgba(30, 41, 59, 0.85);
--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, #0e7490, #06b6d4);
--power-on: linear-gradient(160deg, #b91c1c, #ef4444);
--log-bg: rgba(2, 6, 14, 0.6);
}
* { box-sizing: border-box; }
/* Атрибут hidden должен побеждать классовые display:grid/flex (иначе пустые карточки видны). */
[hidden] { display: none !important; }
html, body {
margin: 0;
width: 100%;
height: 100%;
height: 100dvh;
font-family: Manrope, sans-serif;
color: var(--ink);
background: var(--page-bg);
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: 216px minmax(0, 1fr);
width: 100%;
height: 100%;
height: 100vh;
height: 100dvh;
min-height: 0;
}
/* ==== Sidebar ==== */
.side {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 10px 12px;
background: var(--side-bg);
backdrop-filter: blur(18px) saturate(1.15);
border-right: 1px solid var(--line);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.side .brand-row {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px 14px;
}
.logo {
width: 40px;
height: 40px;
border-radius: 12px;
overflow: hidden;
flex: 0 0 auto;
box-shadow: 0 10px 24px rgba(8, 145, 178, 0.28);
background: linear-gradient(145deg, #0e7490, #22d3ee);
display: grid;
place-items: center;
}
.logo svg { width: 26px; height: 26px; }
.logo .fox { font-size: 1.35rem; line-height: 1; }
.brand {
font-family: Outfit, sans-serif;
font-size: 1.35rem;
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
margin: 0;
}
.badge-ver {
font-size: .62rem;
font-weight: 700;
letter-spacing: .04em;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(8,145,178,.22);
border-radius: 7px;
padding: 2px 6px;
}
.nav-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 700;
font-size: .9rem;
padding: 11px 14px;
border-radius: 13px;
cursor: pointer;
transition: background .15s, color .15s;
text-align: left;
}
.nav-btn svg { width: 20px; height: 20px; flex: 0 0 auto; }
.nav-btn:hover { background: var(--side-active); }
.nav-btn.active {
background: var(--side-active);
color: var(--side-active-fg);
}
.side .spacer { flex: 1; }
.side-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 0;
border-top: 1px solid var(--line);
}
.side-foot .ver {
font-size: .7rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-toggle {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--theme-toggle-bg);
color: var(--ink);
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s, border-color .15s, transform .12s;
padding: 0;
}
.theme-toggle:hover { border-color: rgba(8, 145, 178, 0.4); }
.theme-toggle svg { width: 18px; height: 18px; display: none; }
html[data-theme="light"] .theme-toggle .icon-moon { display: block; }
html[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
.side-foot-controls {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.lang-seg {
display: inline-flex;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--theme-toggle-bg);
overflow: hidden;
height: 36px;
}
.lang-seg button {
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: .68rem;
font-weight: 800;
letter-spacing: .04em;
padding: 0 9px;
cursor: pointer;
min-width: 32px;
transition: background .15s, color .15s;
}
.lang-seg button.active {
background: var(--side-active);
color: var(--side-active-fg);
}
.lang-seg button:hover:not(.active) { color: var(--ink); }
.lang-row-seg {
display: inline-flex;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--surface-2);
overflow: hidden;
height: 32px;
}
.lang-row-seg button {
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: .75rem;
font-weight: 800;
letter-spacing: .03em;
padding: 0 12px;
cursor: pointer;
transition: background .15s, color .15s;
}
.lang-row-seg button.active {
background: var(--side-active);
color: var(--side-active-fg);
}
/* ==== Content ==== */
.content {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
height: 100%;
}
.pages {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 18px clamp(12px, 2.2vw, 20px) 8px;
-webkit-overflow-scrolling: touch;
}
/* Inactive pages must not paint (WebView2 overflow ghosts from hidden siblings). */
.page {
display: none !important;
width: 100%;
max-width: min(720px, 100%);
margin: 0 auto;
box-sizing: border-box;
}
.page.active { display: block !important; animation: rise .3s var(--ease) both; }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.page-title {
font-family: Outfit, sans-serif;
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -.02em;
margin: 0 0 14px;
}
.update-banner {
display: none;
margin: 0 20px 0;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--update-border);
background: var(--update-bg);
animation: rise .4s var(--ease) both;
}
.update-banner.show { display: block; margin-top: 14px; }
.update-banner strong {
font-family: Outfit, sans-serif;
display: block;
margin-bottom: 4px;
font-size: .95rem;
}
.update-banner p { margin: 0 0 10px; color: var(--muted); font-size: .84rem; }
.update-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.update-actions .action { flex: 1 1 auto; min-width: 140px; }
/* ==== Home / power ==== */
/* Sticky so the big connect control never scrolls under the provider list. */
.hero {
position: sticky;
top: 0;
z-index: 6;
overflow: visible;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--hero-bg);
padding: clamp(14px, 2.6vw, 26px) clamp(10px, 2vw, 16px) clamp(12px, 2vw, 20px);
margin-bottom: 14px;
text-align: center;
transition: border-color .25s, box-shadow .25s, background .25s;
min-height: clamp(160px, 28vh, 220px);
box-sizing: border-box;
width: 100%;
}
.hero.on {
border-color: rgba(13,138,102,.35);
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
background: var(--hero-bg-on);
}
.power-btn {
--power-size: clamp(104px, 22vw, 148px);
width: var(--power-size);
height: var(--power-size);
min-width: var(--power-size);
min-height: var(--power-size);
border-radius: 50%;
border: 0;
cursor: pointer;
background: var(--power-off);
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(4px, 1vw, 7px);
margin: 4px auto 14px;
transition: background .25s, color .25s, transform .12s, box-shadow .25s;
box-shadow: 0 14px 34px rgba(8, 145, 178, .3);
position: relative;
flex-shrink: 0;
}
.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: clamp(28px, 6vw, 40px);
height: clamp(28px, 6vw, 40px);
}
.power-btn .power-label {
font-family: Outfit, sans-serif;
font-size: clamp(.52rem, 1.5vw, .64rem);
font-weight: 800;
letter-spacing: .09em;
line-height: 1;
}
.power-btn.on {
background: var(--power-on);
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(248, 113, 113, .5);
animation: ring 2s ease-out infinite;
}
@keyframes ring {
0% { transform: scale(.96); opacity: .9; }
100% { transform: scale(1.12); opacity: 0; }
}
/* Состояние «подключение…» — вращающаяся дуга вокруг кнопки */
.power-btn.busy::after {
content: "";
position: absolute;
inset: -9px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status-line {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-weight: 800;
font-size: 1.02rem;
font-family: Outfit, sans-serif;
}
.dot {
width: 11px; height: 11px; border-radius: 50%;
background: var(--dot-off);
transition: background .2s;
}
.dot.on {
background: var(--ok);
animation: pulse 1.8s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(13,138,102,.4); }
70% { box-shadow: 0 0 0 12px rgba(13,138,102,0); }
100% { box-shadow: 0 0 0 0 rgba(13,138,102,0); }
}
.chips-line {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.proto-chip {
font-size: .72rem;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--muted);
background: var(--chip-bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.proto-chip.active {
color: var(--accent-deep);
background: var(--accent-soft);
border-color: rgba(13,138,102,.22);
}
.mode-chip {
font-size: .72rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
border-radius: 999px;
padding: 5px 10px;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(8,145,178,.25);
}
.hero-hint {
margin: 10px 0 0;
font-size: .8rem;
color: var(--muted);
min-height: 1.2em;
line-height: 1.35;
}
/* Current server card (home, bottom) */
.cur-server {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: clamp(8px, 1.5vw, 12px);
width: 100%;
max-width: 100%;
padding: clamp(10px, 1.8vw, 12px) clamp(10px, 1.8vw, 14px);
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--surface-2);
cursor: pointer;
font: inherit;
color: inherit;
text-align: left;
margin-bottom: 12px;
transition: border-color .15s, background .15s;
box-sizing: border-box;
}
.cur-server:hover { border-color: rgba(8,145,178,.35); }
.cur-server .flag {
width: 40px; height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.45rem;
background: var(--chip-bg);
border: 1px solid var(--line);
}
.cur-server .name { font-weight: 800; font-size: .95rem; }
.cur-server .sub { font-size: .76rem; color: var(--muted); margin-top: 2px; }
.cur-server .right { text-align: right; }
.cur-server .proto {
font-size: .64rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--accent-deep);
background: var(--accent-soft);
border-radius: 999px;
padding: 3px 8px;
display: inline-block;
}
.cur-server .ms { display: block; font-size: .82rem; font-weight: 800; margin-top: 4px; color: var(--muted); font-variant-numeric: tabular-nums; }
.cur-server .ms.good { color: var(--ms-good); }
.cur-server .ms.ok { color: var(--ok); }
.cur-server .ms.mid { color: var(--ms-mid); }
.cur-server .ms.bad { color: var(--danger); }
/* Subscription card */
.subinfo {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--row-bg);
padding: 12px 14px;
margin-bottom: 12px;
display: grid;
gap: 7px;
}
.subinfo-title {
font-family: Outfit, sans-serif;
font-size: .72rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
}
.subinfo-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: .86rem;
}
.subinfo-row .k { color: var(--muted); flex: 0 0 auto; }
.subinfo-row .v { font-weight: 700; text-align: right; min-width: 0; }
.subinfo-row .v.warn { color: #dc2626; }
[data-theme="dark"] .subinfo-row .v.warn { color: #f87171; }
.subinfo .action { margin-top: 6px; }
.section { margin-bottom: 12px; }
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
font-family: Outfit, sans-serif;
font-size: .78rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--muted);
}
.section-title .count-note {
font-size: .68rem;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
opacity: .75;
}
label.field {
display: block;
font-size: .72rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 6px;
}
.sub-bar { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
select, input[type="text"], input[type="password"], textarea {
width: 100%;
border: 1px solid var(--line);
background: var(--input-bg);
border-radius: 14px;
padding: 11px 13px;
font: inherit;
color: var(--ink);
outline: none;
transition: border-color .15s, box-shadow .15s;
}
textarea {
min-height: 78px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .78rem;
line-height: 1.4;
}
select:focus, input:focus, textarea:focus {
border-color: rgba(13,138,102,.5);
box-shadow: 0 0 0 4px rgba(13,138,102,.12);
}
.icon-btn {
width: 44px;
padding: 0;
display: grid;
place-items: center;
border-radius: 14px;
border: 1px solid var(--icon-btn-border);
background: var(--icon-btn-bg);
color: var(--accent-deep);
font-size: 1.15rem;
font-weight: 700;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08);
}
.icon-btn:hover {
background: var(--icon-btn-hover);
border-color: rgba(8, 90, 68, 0.38);
box-shadow: 0 6px 14px rgba(8, 90, 68, 0.14);
}
.icon-btn.danger {
color: #9e2a22;
background: linear-gradient(180deg, #f8d8d4, #efc4bf);
border-color: rgba(158, 42, 34, 0.28);
}
.icon-btn.danger:hover { background: linear-gradient(180deg, #f3c5bf, #e8aea7); color: #7f1f19; }
.icon-btn.wide { width: auto; padding: 0 14px; font-size: .82rem; font-weight: 700; }
.stack { display: grid; gap: 10px; }
details.panel {
margin-bottom: 10px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 8px 12px 10px;
background: var(--surface-2);
}
details.panel summary {
cursor: pointer;
font-weight: 700;
font-size: .86rem;
color: var(--ink);
list-style: none;
padding: 6px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
details.panel summary::-webkit-details-marker { display: none; }
details.panel summary::after {
content: "+";
color: var(--muted);
font-weight: 700;
font-size: 1rem;
transition: transform .2s;
}
details.panel[open] summary::after { content: ""; }
details.panel .grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
details.panel label.field { margin-top: 4px; }
.legal-jump {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin: 0 0 12px;
font-size: .84rem;
}
.legal-jump-btn {
appearance: none;
border: 0;
background: none;
padding: 0;
margin: 0;
color: var(--accent);
font: inherit;
font-weight: 650;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.legal-jump-btn:hover { color: var(--accent-deep); }
.legal-body {
margin-top: 6px;
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
font-size: .8rem;
line-height: 1.45;
color: var(--muted);
}
.legal-body h4 {
margin: 12px 0 4px;
font-size: .82rem;
font-weight: 700;
color: var(--ink);
}
.legal-body h4:first-child { margin-top: 4px; }
.legal-body p { margin: 0 0 8px; }
.legal-body ul {
margin: 0 0 8px;
padding-left: 1.2em;
}
.legal-body li { margin: 0 0 4px; }
.check { display: flex; align-items: center; gap: 8px; font-size: .86rem; margin-top: 8px; }
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--row-bg);
margin-bottom: 10px;
font-weight: 600;
font-size: .9rem;
}
.row .row-text { min-width: 0; }
.row .row-sub { display: block; font-size: .74rem; color: var(--muted); font-weight: 500; margin-top: 3px; line-height: 1.35; }
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: var(--switch-off);
border-radius: 999px; cursor: pointer; transition: background .15s;
}
.slider::before {
content: "";
position: absolute;
width: 20px; height: 20px;
left: 3px; top: 3px;
background: var(--switch-knob);
border-radius: 50%;
transition: transform .15s;
box-shadow: 0 2px 6px rgba(0,0,0,.12);
}
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(18px); }
/* Mode segmented control */
.mode-seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 5px;
border: 1px solid var(--line);
border-radius: 15px;
background: var(--server-list-bg);
margin-bottom: 8px;
}
.mode-seg button {
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 700;
font-size: .86rem;
padding: 10px 12px;
border-radius: 11px;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.mode-seg button.active {
background: var(--server-row-active);
color: var(--accent-deep);
border-color: rgba(8,145,178,.4);
}
.mode-seg button:disabled { opacity: .55; cursor: not-allowed; }
button.action {
appearance: none;
border: 0;
border-radius: 15px;
padding: 13px 15px;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform .12s, background .15s, opacity .15s, box-shadow .15s, border-color .15s, color .15s;
letter-spacing: -0.01em;
}
button.action:active { transform: translateY(1px); }
button.action:disabled { opacity: .55; cursor: not-allowed; transform: none; box-shadow: none; }
.primary {
background: linear-gradient(145deg, var(--accent-deep), var(--accent));
color: #f3fff9;
box-shadow: 0 12px 28px rgba(13, 138, 102, 0.28);
}
.primary:hover:not(:disabled) {
background: linear-gradient(145deg, #0a7a59, #12a078);
box-shadow: 0 14px 30px rgba(13, 138, 102, 0.34);
}
.primary.danger {
background: linear-gradient(145deg, #a52c24, #d14a3f);
box-shadow: 0 12px 28px rgba(192, 54, 44, 0.24);
}
.primary.danger:hover:not(:disabled) { background: linear-gradient(145deg, #b8342b, #e0574b); }
.secondary {
background: linear-gradient(180deg, #2f6f5f, #25584b);
color: #f2fff9;
border: 1px solid rgba(12, 48, 40, 0.35);
box-shadow: 0 8px 18px rgba(20, 70, 56, 0.18);
}
.secondary:hover:not(:disabled) {
background: linear-gradient(180deg, #37806d, #2c6757);
color: #ffffff;
border-color: rgba(12, 48, 40, 0.45);
box-shadow: 0 10px 22px rgba(20, 70, 56, 0.24);
}
.actions { display: grid; gap: 8px; }
.tools { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.tools .action { min-height: 44px; font-size: .9rem; }
.server-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.server-toolbar .section-title { margin: 0; }
.server-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.server-actions .mini {
appearance: none;
border: 1px solid var(--icon-btn-border);
background: var(--mini-bg);
color: var(--accent-deep);
border-radius: 999px;
padding: 6px 10px;
font: inherit;
font-size: .72rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.server-actions .mini:hover { filter: brightness(.97); }
.server-actions .mini.accent {
background: var(--mini-accent-bg);
color: var(--mini-accent-fg);
border-color: rgba(12, 48, 40, 0.35);
}
.auto-best {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: .82rem;
font-weight: 600;
color: var(--muted);
}
.server-list {
max-height: min(48vh, 420px);
overflow: auto;
display: grid;
gap: 3px;
margin-bottom: 10px;
padding: 4px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--server-list-bg);
}
.server-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid transparent;
background: var(--server-row-bg);
cursor: pointer;
transition: background .12s, border-color .12s, box-shadow .12s;
text-align: left;
width: 100%;
font: inherit;
color: inherit;
}
.server-row:hover { border-color: rgba(8,145,178,.3); background: var(--server-row-hover); }
.server-row.active {
border-color: rgba(8,145,178,.45);
background: var(--server-row-active);
box-shadow: 0 4px 14px rgba(8,145,178,.12);
}
.server-row .left { min-width: 0; display: flex; align-items: center; gap: 10px; }
.server-row .flag {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 1.1rem;
line-height: 1;
background: var(--chip-bg);
border: 1px solid var(--line);
}
.server-row .flag.unknown { font-size: .7rem; font-weight: 700; color: var(--muted); }
/* Настоящие SVG-флаги: Windows не отображает эмодзи-флаги (Segoe UI Emoji
рисует пары региональных индикаторов буквами «BE», «LV» и т.п.). */
.flag svg.fl { display: block; width: 22px; height: 15px; border-radius: 3px; box-shadow: 0 0 0 1px rgba(127,127,127,.3); }
.cur-server .flag svg.fl { width: 28px; height: 19px; border-radius: 4px; }
.cur-server .flag.unknown { font-size: .78rem; font-weight: 700; color: var(--muted); }
.server-row .meta-col { min-width: 0; }
.server-row .name {
font-weight: 700;
font-size: .84rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .sub {
margin-top: 2px;
font-size: .72rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .protos {
margin-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.server-row .proto-tag {
display: inline-block;
font-size: .62rem;
font-weight: 700;
letter-spacing: .02em;
color: var(--muted);
background: var(--meta-bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 7px;
cursor: pointer;
line-height: 1.35;
user-select: none;
}
.server-row .proto-tag:hover { border-color: rgba(8,145,178,.45); color: var(--text); }
.server-row .proto-tag.selected {
color: var(--accent-deep);
background: var(--accent-soft);
border-color: rgba(8,145,178,.35);
}
.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 {
flex: 0 0 auto;
font-size: .58rem;
font-weight: 800;
letter-spacing: .06em;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(8,145,178,.35);
border-radius: 999px;
padding: 2px 7px;
line-height: 1.2;
}
html[data-theme="dark"] .badge-active { color: var(--accent); }
.server-row .ms {
font-size: .78rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.server-row .ms::before {
content: "";
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
margin-right: 5px;
vertical-align: 1px;
background: var(--dot-off);
}
.server-row .ms.good::before, .server-row .ms.ok::before { background: var(--ms-good); }
.server-row .ms.mid::before { background: var(--ms-mid); }
.server-row .ms.bad::before { background: var(--danger); }
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); }
.remna-box {
margin-top: 4px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--surface-2);
display: grid;
gap: 8px;
}
.remna-title {
font-family: Outfit, sans-serif;
font-weight: 700;
font-size: .92rem;
letter-spacing: -.02em;
}
.remna-hint { margin: 0; font-size: .75rem; color: var(--muted); line-height: 1.4; }
.server-empty { padding: 14px 10px; text-align: center; color: var(--muted); font-size: .82rem; }
.profile-edit summary {
cursor: pointer;
list-style: none;
font-size: .78rem;
font-weight: 700;
color: var(--muted);
padding: 4px 0;
}
.profile-edit summary::-webkit-details-marker { display: none; }
.meta {
margin: 8px 20px 12px;
font-size: .82rem;
color: var(--muted);
line-height: 1.45;
min-height: 2.4em;
padding: 8px 12px;
border-radius: 12px;
background: var(--meta-bg);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
flex: 0 0 auto;
}
.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;
font-size: .72rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
background: var(--log-bg);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
max-height: 62vh;
overflow: auto;
margin: 0 0 10px;
min-height: 200px;
color: var(--ink);
}
.shop {
margin-top: 12px;
padding: 13px 14px 12px;
border-radius: 18px;
border: 1px solid var(--shop-border);
background: var(--shop-bg);
}
.shop h3 { font-family: Outfit, sans-serif; font-size: 1rem; letter-spacing: -0.02em; margin: 0 0 5px; }
.shop p { margin: 0 0 10px; color: var(--muted); font-size: .82rem; line-height: 1.4; }
.shop .action { width: 100%; }
.shop-link {
display: block;
width: 100%;
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
text-align: center;
font: inherit;
font-size: .76rem;
color: var(--accent);
font-weight: 600;
cursor: pointer;
box-shadow: none;
}
.shop-link:hover { text-decoration: underline; }
/* «Рекомендуемые сервисы» — маленький ненавязчивый блок в углу «О программе» */
.svc {
margin-top: 12px;
padding: 10px 12px 11px;
border-radius: 14px;
border: 1px dashed var(--line);
background: transparent;
}
.svc h4 {
margin: 0 0 7px;
font-family: Outfit, sans-serif;
font-size: .66rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.svc-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.svc-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: .74rem;
font-weight: 600;
cursor: pointer;
box-shadow: none;
transition: color .15s, border-color .15s;
}
.svc-link:hover { color: var(--accent-deep); border-color: rgba(13,138,102,.45); }
.svc-link .ic { font-size: .85rem; line-height: 1; }
/* ==== Traffic progress (главный экран + Статистика) ==== */
.traffic {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--row-bg);
padding: clamp(10px, 1.8vw, 12px) clamp(10px, 1.8vw, 14px);
margin-bottom: 12px;
display: grid;
gap: 8px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.traffic-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
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;
border-radius: 999px;
background: var(--meta-bg);
overflow: hidden;
}
.traffic-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-deep), var(--accent));
transition: width .4s var(--ease);
}
.traffic-fill.warn { background: linear-gradient(90deg, #d97706, #f59e0b); }
.traffic-sub { font-size: .74rem; color: var(--muted); }
/* ==== EvilFox (вкладка «Защита») ==== */
.prov-featured { margin-bottom: 12px; }
.prov-card {
position: relative;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
padding: clamp(10px, 1.8vw, 14px);
width: 100%;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
}
.prov-card .prov-name {
font-family: Outfit, sans-serif;
font-weight: 800;
font-size: 1rem;
letter-spacing: -.02em;
}
.prov-card .prov-sub { font-size: .76rem; color: var(--muted); line-height: 1.35; flex: 1; }
.prov-card.ours {
border-color: rgba(8,145,178,.5);
background: linear-gradient(150deg, rgba(8,145,178,.16), var(--surface-2) 70%);
box-shadow: 0 12px 30px rgba(8,145,178,.16);
}
html[data-theme="dark"] .prov-card.ours {
background: linear-gradient(150deg, rgba(34,211,238,.16), var(--surface-2) 70%);
}
/* Бейдж «Рекомендуемый» — первая строка карточки EvilFox. */
.prov-badge {
align-self: flex-start;
font-size: .64rem;
font-weight: 800;
letter-spacing: .03em;
color: #7c5b00;
background: linear-gradient(180deg, #fde68a, #fbbf24);
border: 1px solid rgba(124, 91, 0, .25);
border-radius: 999px;
padding: 3px 9px;
box-shadow: 0 4px 10px rgba(251, 191, 36, .35);
}
.prov-gift { font-size: .72rem; font-weight: 700; color: var(--accent-deep); }
html[data-theme="dark"] .prov-gift { color: var(--accent); }
.prov-card .action { padding: 10px 12px; font-size: .84rem; }
/* ==== Добавление конфигурации ==== */
.key-box {
border: 1px solid var(--shop-border);
border-radius: var(--radius);
background: var(--shop-bg);
padding: 13px 14px;
margin-bottom: 12px;
display: grid;
gap: 8px;
}
.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;
padding: 0;
font: inherit;
font-size: .76rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
text-align: center;
}
.key-link:hover { text-decoration: underline; }
/* Технические строки (параметры прокси, адрес сервера) */
.adv-line {
margin: 4px 0 0;
font-size: .74rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.host-line {
margin: 6px 0 2px;
font-size: .72rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all;
}
.about-card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
padding: 18px 16px;
text-align: center;
margin-bottom: 12px;
}
.about-card .logo { margin: 0 auto 10px; width: 56px; height: 56px; border-radius: 16px; }
.about-card .logo svg { width: 34px; height: 34px; }
.about-card .logo .fox { font-size: 1.9rem; }
.about-card h2 { font-family: Outfit, sans-serif; margin: 0 0 4px; letter-spacing: -.03em; }
.about-card p { margin: 0; color: var(--muted); font-size: .84rem; line-height: 1.45; }
.modal-backdrop {
position: fixed; inset: 0;
background: var(--modal-scrim);
backdrop-filter: blur(4px);
display: none;
place-items: center;
padding: 18px;
z-index: 20;
}
.modal-backdrop.open { display: grid; }
.modal {
width: min(400px, 100%);
background: var(--modal-bg);
border-radius: 22px;
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 18px;
animation: rise .35s var(--ease) both;
}
.modal h2 { font-family: Outfit, sans-serif; margin: 0 0 12px; font-size: 1.2rem; letter-spacing: -.02em; }
.modal .actions { margin-top: 12px; grid-template-columns: 1fr 1fr; }
/* Medium: keep labels, slightly narrower sidebar */
@media (max-width: 900px) and (min-width: 701px) {
.app { grid-template-columns: 176px minmax(0, 1fr); }
.side { padding: 14px 8px 10px; }
.nav-btn { gap: 10px; padding: 10px 12px; font-size: .84rem; }
.brand { font-size: 1.2rem; }
}
/* Narrow: icon sidebar — main pane stays usable (fluid power/cards) */
@media (max-width: 700px) {
.app { grid-template-columns: 64px minmax(0, 1fr); }
.side {
padding: 12px 6px 10px;
align-items: stretch;
}
.side .brand-row { justify-content: center; padding-left: 0; padding-right: 0; padding-bottom: 10px; }
.side .brand, .side .badge-ver, .nav-btn .nav-label, .side-foot .ver { display: none; }
.nav-btn { justify-content: center; padding: 12px 0; }
.side-foot {
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
padding: 8px 0 0;
}
.side-foot-controls {
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.lang-seg {
flex-direction: column;
height: auto;
width: 40px;
}
.lang-seg button {
min-width: 0;
width: 100%;
padding: 7px 0;
}
.theme-toggle { width: 40px; height: 40px; }
.pages { padding: 12px 10px 8px; }
.page { max-width: 100%; }
.hero { min-height: 0; }
.power-btn { --power-size: clamp(96px, 28vw, 132px); }
.update-banner { margin-left: 10px; margin-right: 10px; }
details.panel .grid2 { grid-template-columns: 1fr; }
.tools { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app">
<!-- ===== Sidebar ===== -->
<nav class="side">
<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" hidden></span>
</div>
<button class="nav-btn active" type="button" data-page="guard" title="Защита" data-i18n-title="nav_guard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7.5 3v5.2c0 4.6-3.1 8-7.5 9.8-4.4-1.8-7.5-5.2-7.5-9.8V6z"/><path d="M9 12l2.2 2.2L15.4 10"/></svg>
<span class="nav-label" data-i18n="nav_guard">Защита</span>
</button>
<button class="nav-btn" type="button" data-page="profiles" title="Мои серверы" data-i18n-title="nav_profiles">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="6.5" rx="2"/><rect x="3.5" y="13.5" width="17" height="6.5" rx="2"/><path d="M7 7.2h.01M7 16.7h.01"/></svg>
<span class="nav-label" data-i18n="nav_profiles">Мои серверы</span>
</button>
<button class="nav-btn" type="button" data-page="settings" title="Настройки" data-i18n-title="nav_settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 13.5a7.6 7.6 0 0 0 0-3l2-1.5-2-3.4-2.3 1a7.7 7.7 0 0 0-2.6-1.5L14.2 2h-4l-.3 2.6a7.7 7.7 0 0 0-2.6 1.5l-2.3-1-2 3.4 2 1.5a7.6 7.6 0 0 0 0 3l-2 1.5 2 3.4 2.3-1a7.7 7.7 0 0 0 2.6 1.5l.3 2.6h4l.3-2.6a7.7 7.7 0 0 0 2.6-1.5l2.3 1 2-3.4z"/></svg>
<span class="nav-label" data-i18n="nav_settings">Настройки</span>
</button>
<button class="nav-btn" type="button" data-page="logs" title="Статистика" data-i18n-title="nav_stats">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V10M12 20V4M19 20v-7"/></svg>
<span class="nav-label" data-i18n="nav_stats">Статистика</span>
</button>
<button class="nav-btn" type="button" data-page="about" title="Помощь" data-i18n-title="nav_help">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1.1 1-1.1 1.8"/><path d="M12 16.6h.01"/></svg>
<span class="nav-label" data-i18n="nav_help">Помощь</span>
</button>
<div class="spacer"></div>
<div class="side-foot">
<span class="ver" id="verLabel">EvilFox</span><!-- версия только в «Помощь» -->
<div class="side-foot-controls">
<div class="lang-seg" id="langSeg" role="group" aria-label="Language">
<button type="button" data-lang="ru" class="active" title="Русский">RU</button>
<button type="button" data-lang="en" title="English">EN</button>
</div>
<button type="button" class="theme-toggle" id="themeToggle" title="Тема" aria-label="Переключить тему" data-i18n-title="theme_toggle_title" data-i18n-aria="theme_toggle_aria">
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M20.5 14.2A8.2 8.2 0 0 1 9.8 3.5 8.5 8.5 0 1 0 20.5 14.2z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="4.2" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2.8v2.2M12 19v2.2M2.8 12h2.2M19 12h2.2M5.1 5.1l1.6 1.6M17.3 17.3l1.6 1.6M17.3 6.7l1.6-1.6M5.1 18.9l1.6-1.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
</nav>
<!-- ===== Content ===== -->
<div class="content">
<div class="update-banner" id="updateBanner">
<strong id="updateTitle" data-i18n="update_available">Доступно обновление</strong>
<p id="updateNotes"></p>
<div class="update-actions">
<button class="action primary" id="updateBtn" type="button" data-i18n="update_btn">Обновить</button>
<button class="action secondary" id="skipUpdateBtn" type="button" data-i18n="skip_update">Пропустить эту версию</button>
</div>
</div>
<div class="pages">
<!-- ============ Защита ============ -->
<section class="page active" data-page="guard">
<section class="hero" id="hero">
<button class="power-btn" id="toggleBtn" type="button" aria-label="Подключиться или отключиться" data-i18n-aria="power_aria">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" aria-hidden="true">
<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 id="statusText">🔴 Отключено</span>
</div>
<!-- Протокол показываем на карточке активного сервера ниже — в статусной строке не дублируем. -->
<div class="chips-line">
<span class="mode-chip" id="vpnChip" hidden data-i18n="vpn_chip">VPN · весь трафик</span>
</div>
<p class="hero-hint" id="heroHint">Нажмите большую кнопку, чтобы включить защиту</p>
</section>
<button class="cur-server" id="curServer" type="button" title="Открыть список серверов" data-i18n-title="cur_server_title">
<span class="flag" id="curFlag">·</span>
<span>
<span class="name" id="curName">Нет сервера</span>
<span class="sub" id="curHost">добавьте конфигурацию на вкладке «Мои серверы»</span>
</span>
<span class="right">
<span class="proto" id="curProto"></span>
<span class="ms" id="curPing"></span>
</span>
</button>
<section class="traffic" id="trafficCard" hidden>
<div class="traffic-row">
<span>📊 <span id="trafficLabel"></span></span>
<span class="pct" id="trafficPct"></span>
</div>
<div class="traffic-bar"><div class="traffic-fill" id="trafficFill"></div></div>
<div class="traffic-sub" id="trafficExp" hidden></div>
</section>
<div class="section" id="provSection">
<div class="prov-featured" id="provGrid">
<div class="prov-card ours">
<span class="prov-badge" data-i18n="prov_badge">⭐ Рекомендуемый</span>
<div class="prov-name">🦊 EvilFox VPN</div>
<div class="prov-sub" data-i18n="prov_sub">Оптимальные серверы рядом с вами · защита без настройки</div>
<span class="prov-gift" id="provGift">🎁 3 дня бесплатного доступа</span>
<button class="action primary" id="provConnectBtn" type="button">🔥 Подключиться</button>
</div>
</div>
</div>
</section>
<!-- ============ Мои серверы ============ -->
<section class="page" data-page="profiles">
<h2 class="page-title" data-i18n="page_profiles">Мои серверы</h2>
<section class="key-box" aria-label="Добавить конфигурацию" data-i18n-aria="add_config_aria">
<h3 data-i18n="add_config_title">🔗 Добавить конфигурацию</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" data-i18n="add_config_btn">Добавить конфигурацию</button>
<p class="key-hint" data-i18n="add_config_hint">Вставьте URL-ссылку для подключения</p>
<button class="key-link" id="buyKeyLink" type="button" data-i18n="where_url">Где взять URL-ссылку?</button>
</section>
<div class="server-toolbar">
<div class="section-title"><span data-i18n="servers_title">🌍 Серверы</span> <span class="count-note" id="srvCount"></span></div>
<div class="server-actions">
<button class="mini" id="pingBtn" type="button" data-i18n="ping_btn">Пинг</button>
<button class="mini accent" id="bestBtn" type="button" title="Выбрать сервер с наименьшим пингом" data-i18n="best_btn" data-i18n-title="best_btn_title">Выбрать лучший</button>
</div>
</div>
<div class="server-list" id="serverList"></div>
<select id="profile" hidden></select>
<section class="subinfo" id="subInfoCard" hidden>
<div class="subinfo-title" data-i18n="account_info">Информация об аккаунте</div>
<div class="subinfo-row" id="subExpireRow" hidden>
<span class="k" data-i18n="expires_label">Действует до</span>
<span class="v" id="subExpireVal"></span>
</div>
<div class="subinfo-row" id="subTrafficRow" hidden>
<span class="k" data-i18n="traffic_label">Трафик</span>
<span class="v" id="subTrafficVal"></span>
</div>
<div class="subinfo-row" id="subDevicesRow" hidden>
<span class="k" data-i18n="devices_label">Устройства</span>
<span class="v" id="subDevicesVal"></span>
</div>
</section>
<details class="profile-edit" id="profileEdit">
<summary data-i18n="server_props">Свойства сервера</summary>
<div class="stack" style="margin-top:8px" id="profProps">
<div class="subinfo-row"><span class="k" data-i18n="prop_server">Сервер</span><span class="v" id="propName"></span></div>
<div class="subinfo-row"><span class="k" data-i18n="prop_ping">Пинг</span><span class="v" id="propPing"></span></div>
<div class="subinfo-row"><span class="k" data-i18n="prop_proto">Протокол</span><span class="v" id="propProto"></span></div>
<div class="subinfo-row"><span class="k" data-i18n="prop_status">Статус</span><span class="v" id="propStatus"></span></div>
</div>
<!-- Скрытые поля — нужны для connect/save, в UI не показываем. -->
<input id="name" type="text" hidden />
<textarea id="proxy" hidden rows="1"></textarea>
<button class="action secondary" id="saveBtn" type="button" hidden data-i18n="save_profile">Сохранить профиль</button>
</details>
</section>
<!-- ============ Настройки ============ -->
<section class="page" data-page="settings">
<h2 class="page-title" data-i18n="page_settings">Настройки</h2>
<div class="section" id="modeSection">
<div class="section-title"><span data-i18n="traffic_mode">Режим трафика</span></div>
<div class="mode-seg" id="modeSeg">
<button type="button" id="modeProxyBtn" data-i18n="mode_proxy">Режим прокси</button>
<button type="button" id="modeVpnBtn" data-i18n="mode_vpn">Режим VPN</button>
</div>
<p class="remna-hint" id="modeHint" data-i18n="mode_hint">Прокси — трафик идёт через локальный прокси (и системный прокси Windows). VPN — весь трафик, включая игры и приложения, через TUN-адаптер; нужны права администратора и wintun.dll рядом с EvilFox.exe. Меняется в отключённом состоянии.</p>
</div>
<div class="row">
<span class="row-text"><span data-i18n="sysproxy">Системный прокси</span>
<span class="row-sub" data-i18n="sysproxy_sub">В режиме VPN не используется — TUN и так захватывает всё</span>
</span>
<label class="switch">
<input id="sysproxy" type="checkbox" checked />
<span class="slider"></span>
</label>
</div>
<div class="row">
<span class="row-text" data-i18n="dark_theme">Тёмная тема</span>
<label class="switch">
<input id="themeSwitch" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<div class="row">
<span class="row-text" data-i18n="language">Язык / Language</span>
<div class="lang-row-seg" id="langSegSettings" role="group" aria-label="Language">
<button type="button" data-lang="ru" class="active">RU</button>
<button type="button" data-lang="en">EN</button>
</div>
</div>
<div class="row">
<span class="row-text"><span data-i18n="auto_best">🚀 Авто-лучший сервер</span>
<span class="row-sub" data-i18n="auto_best_sub">При подключении автоматически выбирается сервер с наименьшим пингом</span>
</span>
<label class="switch">
<input id="autoBest" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<div class="row">
<span class="row-text"><span data-i18n="kill_switch">🔒 Защита от утечек (Kill Switch)</span>
<span class="row-sub" data-i18n="kill_switch_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">
<div>
<label class="field" for="hy2Congestion">Congestion</label>
<select id="hy2Congestion">
<option value="bbr">BBR</option>
<option value="reno">Reno</option>
</select>
</div>
<div>
<label class="field" for="hy2Bbr">BBR profile</label>
<select id="hy2Bbr">
<option value="standard">standard</option>
<option value="conservative">conservative</option>
<option value="aggressive">aggressive</option>
</select>
</div>
<div>
<label class="field" for="hy2Up">Brutal up</label>
<input id="hy2Up" type="text" placeholder="пусто = BBR" data-i18n-placeholder="ph_empty_bbr" />
</div>
<div>
<label class="field" for="hy2Down">Brutal down</label>
<input id="hy2Down" type="text" placeholder="пусто = BBR" data-i18n-placeholder="ph_empty_bbr" />
</div>
<div>
<label class="field" for="hy2Obfs">Obfs</label>
<select id="hy2Obfs">
<option value="" data-i18n="obfs_none">нет</option>
<option value="salamander">salamander</option>
<option value="gecko">gecko</option>
</select>
</div>
<div>
<label class="field" for="hy2ObfsPass">Obfs password</label>
<input id="hy2ObfsPass" type="text" placeholder="пароль" data-i18n-placeholder="ph_password" />
</div>
<div>
<label class="field" for="hy2Sni">SNI</label>
<input id="hy2Sni" type="text" placeholder="cdn.example.com" />
</div>
<div>
<label class="field" for="hy2Pin">pinSHA256</label>
<input id="hy2Pin" type="text" placeholder="опционально" data-i18n-placeholder="ph_optional" />
</div>
<div>
<label class="field" for="hy2Hop">Hop interval</label>
<input id="hy2Hop" type="text" placeholder="30s" />
</div>
</div>
<label class="check"><input id="hy2Insecure" type="checkbox" /> TLS insecure</label>
<label class="check"><input id="hy2Fast" type="checkbox" /> Fast Open</label>
<label class="check"><input id="hy2Lazy" type="checkbox" /> Lazy connect</label>
</details>
<details class="panel" id="toolsBox">
<summary data-i18n="svc_cores">Сервис · cores, обновления</summary>
<div class="tools">
<button class="action secondary" id="updCheckBtn" type="button" data-i18n="check_update">Проверить обновление</button>
<button class="action secondary" id="coreBtn" type="button" data-i18n="install_cores">Установить cores</button>
</div>
<p class="meta" id="storeUpdateNote" hidden style="margin:8px 0 0;min-height:0" data-i18n="store_updates">Обновления этой сборки — через Microsoft Store.</p>
</details>
<details class="panel" id="advBox">
<summary data-i18n="adv_settings">Расширенные настройки</summary>
<div style="margin-top:6px" id="proxyParamsBlock" hidden>
<div class="section-title" style="margin-bottom:2px"><span data-i18n="proxy_params">Параметры прокси</span></div>
<p class="adv-line">HTTP: <span id="advHttp"></span></p>
<p class="adv-line">SOCKS: <span id="advSocks"></span></p>
<p class="remna-hint" style="margin-top:8px" data-i18n="proxy_params_hint">Локальные адреса прокси на этом компьютере — нужны только для ручной настройки отдельных программ.</p>
</div>
</details>
</section>
<!-- ============ Статистика ============ -->
<section class="page" data-page="logs">
<h2 class="page-title" data-i18n="page_stats">Статистика</h2>
<section class="traffic" id="trafficCard2" hidden>
<div class="traffic-row">
<span>📊 <span id="trafficLabel2"></span></span>
<span class="pct" id="trafficPct2"></span>
</div>
<div class="traffic-bar"><div class="traffic-fill" id="trafficFill2"></div></div>
<div class="traffic-sub" id="trafficExp2" hidden></div>
</section>
<div class="section-title"><span data-i18n="stats_today">Статистика за сегодня</span></div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-val" id="statUsed"></div>
<div class="stat-k" data-i18n="stat_used">Использовано</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statOnline">0 мин</div>
<div class="stat-k" data-i18n="stat_online">Время в сети</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statSessions">0</div>
<div class="stat-k" data-i18n="stat_sessions">Сессий</div>
</div>
</div>
<div class="section-title"><span data-i18n="stats_week">Трафик за 7 дней</span></div>
<div class="week-chart" id="weekChart">
<div class="week-empty" data-i18n="no_data">Пока нет данных</div>
</div>
<div class="section-title" style="margin-top:4px"><span data-i18n="conn_log">Журнал подключения</span></div>
<p class="adv-line" id="logsProxyLine" hidden style="margin:0 0 8px"></p>
<pre class="log-view" id="logView">Пусто — подключитесь, чтобы увидеть журнал.</pre>
<div class="tools" style="margin-top:0">
<button class="action secondary" id="logsRefreshBtn" type="button" data-i18n="refresh">Обновить</button>
<button class="action secondary" id="logsClearBtn" type="button" data-i18n="clear_screen">Очистить экран</button>
</div>
</section>
<!-- ============ Помощь ============ -->
<section class="page" data-page="about">
<h2 class="page-title" data-i18n="page_help">Помощь</h2>
<div class="legal-jump" aria-label="Legal">
<button type="button" class="legal-jump-btn" id="jumpPrivacy" data-i18n="privacy_title">Политика конфиденциальности</button>
<button type="button" class="legal-jump-btn" id="jumpTerms" data-i18n="terms_title">Условия использования</button>
</div>
<details class="panel">
<summary data-i18n="faq_how_q">Как подключиться?</summary>
<p class="remna-hint" style="margin-top:6px" data-i18n="faq_how_a">Добавьте конфигурацию на вкладке «Мои серверы», затем на вкладке «Защита» нажмите большую круглую кнопку. Приложение само выберет оптимальный сервер.</p>
</details>
<details class="panel">
<summary data-i18n="faq_url_q">Где взять URL-ссылку?</summary>
<p class="remna-hint" style="margin-top:6px" data-i18n="faq_url_a">URL-ссылку для подключения можно получить на сайте evilfox.win (кнопка ниже) или у вашего провайдера. Полученную ссылку вставьте на вкладке «Мои серверы».</p>
</details>
<details class="panel">
<summary data-i18n="faq_fail_q">Не подключается — что делать?</summary>
<p class="remna-hint" style="margin-top:6px" data-i18n="faq_fail_a">Проверьте, что конфигурация добавлена и срок её действия не истёк («Мои серверы» → «Информация об аккаунте»). Попробуйте другой сервер (кнопка «Выбрать лучший»). Подробности — в журнале на вкладке «Статистика».</p>
</details>
<details class="panel" id="helpPrivacy">
<summary data-i18n="privacy_title">Политика конфиденциальности</summary>
<div class="legal-body" data-lang-block="ru">
<p><strong>Издатель:</strong> EvilFox. <strong>Дата вступления в силу:</strong> 2 августа 2026 г.</p>
<h4>1. Кто мы</h4>
<p>EvilFox — локальный VPN/прокси-клиент для Windows. Вы сами добавляете конфигурации и URL подключения. В сборке Microsoft Store нет встроенных покупок. Мы не продаём персональные данные.</p>
<h4>2. Какие данные хранятся локально</h4>
<p>На вашем устройстве приложение может сохранять:</p>
<ul>
<li>профили и URL/параметры подключения, которые вы вводите;</li>
<li>настройки (режим, тема, язык, Kill Switch и т.п.);</li>
<li>локальную статистику трафика и сессий (localStorage / файлы конфигурации).</li>
</ul>
<p>Эти данные остаются на устройстве, пока вы их не удалите или не удалите приложение.</p>
<h4>3. Что мы не собираем</h4>
<p>EvilFox как издатель клиента не ведёт централизованный сбор аккаунтов, платёжных данных, контактов, истории браузера или содержимого вашего трафика для рекламы или продажи. Мы не продаём персональные данные.</p>
<h4>4. Сетевой трафик и сторонние серверы</h4>
<p>При подключении трафик идёт через серверы, указанные в <em>вашей</em> конфигурации. Оператором этих серверов является поставщик конфигурации (вы или ваш провайдер), а не обязательно EvilFox. Их политика конфиденциальности может отличаться — ознакомьтесь с условиями вашего провайдера.</p>
<h4>5. Проверка обновлений</h4>
<p>По вашей инициативе (кнопка проверки/обновления) приложение может обратиться к ленте обновлений, например <code>https://git.de4ima.uk/…/update.json</code> (хостинг git). Сборка из Microsoft Store обычно обновляется через Store; встроенная проверка может быть ограничена.</p>
<h4>6. Внешние ссылки</h4>
<p>Сайт <code>https://evilfox.win/</code> и Telegram <code>https://t.me/evilfox100Bot</code> открываются только после вашего нажатия. Тогда действуют правила соответствующих сервисов.</p>
<h4>7. Как используются данные</h4>
<p>Локальные данные нужны для работы клиента: подключение, выбор сервера, отображение статистики и настроек. Сетевые обращения — для соединения с выбранными вами серверами и (по желанию) проверки обновлений.</p>
<h4>8. Хранение и удаление</h4>
<p>Данные хранятся на устройстве до удаления приложения, очистки данных приложения или ручного удаления профилей. Удалённые локальные данные мы не можем восстановить с наших серверов, потому что центрального профиля пользователя у клиента нет.</p>
<h4>9. Безопасность</h4>
<p>Мы применяем разумные меры защиты локальных настроек в рамках возможностей ОС. Абсолютной безопасности не существует; ответственность за конфиденциальность URL/ключей в конфигурациях лежит на вас.</p>
<h4>10. Дети</h4>
<p>Приложение не предназначено для детей младше 13 лет (в ЕС — ориентир 16 лет, если применимо местное право). Не используйте приложение, если вам меньше указанного возраста, без согласия родителя/опекуна где это требуется.</p>
<h4>11. Международная передача (ЕС)</h4>
<p>Клиент работает локально. Если вы подключаетесь к серверам или открываете сайты за пределами своей страны, данные могут обрабатываться в других юрисдикциях операторами этих сервисов. Это следует из вашего выбора конфигурации и внешних ссылок.</p>
<h4>12. Ваши права</h4>
<p>Вы можете просмотреть и удалить локальные данные в приложении или удалив EvilFox / очистив данные приложения. Для вопросов по этой политике свяжитесь с нами (ниже). Жители Калифорнии (CCPA): мы не продаём и не «делимся» персональными данными в смысле CCPA для перекрёстной рекламы. Права на доступ/удаление реализуются локально на устройстве.</p>
<h4>13. Кратко о GDPR (ЕЭЗ/Великобритания)</h4>
<p>Контролёр в отношении локальных данных клиента: EvilFox (издатель приложения). Правовые основания: исполнение договора на предоставление функций клиента и законный интерес в обеспечении работы и обновлений. Обработка на сторонних VPN/прокси-серверах — ответственность оператора тех серверов.</p>
<h4>14. Изменения</h4>
<p>Мы можем обновлять эту политику. Актуальная версия доступна в приложении (Помощь) и на сайте. Продолжение использования после обновления означает ознакомление с новой редакцией.</p>
<h4>15. Контакты</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · поддержка: <code>https://t.me/evilfox100Bot</code></p>
</div>
<div class="legal-body" data-lang-block="en" hidden>
<p><strong>Publisher:</strong> EvilFox. <strong>Effective date:</strong> August 2, 2026.</p>
<h4>1. Who we are</h4>
<p>EvilFox is a local VPN/proxy client for Windows. You supply your own connection configurations and URLs. The Microsoft Store build has no in-app purchases. We do not sell personal data.</p>
<h4>2. Data stored on your device</h4>
<p>On your device the app may store:</p>
<ul>
<li>profiles and connection URLs/parameters you enter;</li>
<li>settings (mode, theme, language, Kill Switch, etc.);</li>
<li>local traffic/session statistics (localStorage / config files).</li>
</ul>
<p>This data stays on the device until you delete it or uninstall the app.</p>
<h4>3. What we do not collect</h4>
<p>As the client publisher, EvilFox does not operate a central service that collects accounts, payment details, contacts, browsing history, or your traffic contents for advertising or sale. We do not sell personal data.</p>
<h4>4. Network traffic and third-party servers</h4>
<p>When connected, traffic goes through servers specified in <em>your</em> configuration. The operator of those servers is your config provider (you or your provider), not necessarily EvilFox. Their privacy practices may differ — review your providers terms.</p>
<h4>5. Update checks</h4>
<p>When you choose to check/apply updates, the app may contact an update feed such as <code>https://git.de4ima.uk/…/update.json</code> (git hosting). Microsoft Store builds are normally updated via the Store; in-app update checks may be limited.</p>
<h4>6. External links</h4>
<p><code>https://evilfox.win/</code> and Telegram <code>https://t.me/evilfox100Bot</code> open only after you tap them. Those services own policies then apply.</p>
<h4>7. How data is used</h4>
<p>Local data is used to run the client: connect, pick servers, show stats and settings. Network requests are for connecting to servers you choose and (optionally) checking for updates.</p>
<h4>8. Retention and deletion</h4>
<p>Data remains on-device until you uninstall, clear app data, or delete profiles. We cannot restore deleted local data from our servers because the client has no central user account store.</p>
<h4>9. Security</h4>
<p>We use reasonable measures appropriate for on-device settings within OS capabilities. No method is perfectly secure; you are responsible for keeping configuration URLs/keys confidential.</p>
<h4>10. Childrens privacy</h4>
<p>The app is not directed to children under 13 (in the EU, 16 where local law so requires). Do not use it below that age without parental/guardian consent where required.</p>
<h4>11. International transfers (EU)</h4>
<p>The client runs locally. If you connect to servers or open websites outside your country, data may be processed in other jurisdictions by those operators — as a result of your config and link choices.</p>
<h4>12. Your rights</h4>
<p>You can view and delete local data in the app or by uninstalling EvilFox / clearing app data. For questions about this policy, contact us below. California (CCPA): we do not sell or “share” personal information for cross-context behavioral advertising. Access/deletion is exercised locally on the device.</p>
<h4>13. EU GDPR summary (EEA/UK)</h4>
<p>Controller for on-device client data: EvilFox (app publisher). Legal bases: performance of the contract to provide client features, and legitimate interests in operating and updating the app. Processing on third-party VPN/proxy servers is the responsibility of those server operators.</p>
<h4>14. Changes</h4>
<p>We may update this policy. The current version is in the app (Help) and on the website. Continued use after an update means you have had the chance to review the new text.</p>
<h4>15. Contact</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · support: <code>https://t.me/evilfox100Bot</code></p>
</div>
</details>
<details class="panel" id="helpTerms">
<summary data-i18n="terms_title">Условия использования</summary>
<div class="legal-body" data-lang-block="ru">
<p><strong>Издатель:</strong> EvilFox. <strong>Дата вступления в силу:</strong> 2 августа 2026 г.</p>
<h4>1. Принятие условий</h4>
<p>Устанавливая или используя EvilFox, вы соглашаетесь с этими Условиями. Если не согласны — не используйте приложение.</p>
<h4>2. Лицензия</h4>
<p>Вам предоставляется ограниченная, неисключительная, непередаваемая лицензия на использование клиента EvilFox на ваших устройствах в личных или внутренних целях в соответствии с этими Условиями и применимым правом (включая правила Microsoft Store, если приложение установлено из Store).</p>
<h4>3. Допустимое использование</h4>
<p>Используйте EvilFox только законно. Запрещены действия, нарушающие законы вашей страны или права третьих лиц, а также попытки нарушить работу приложения или чужих систем. Вы несёте ответственность за то, как используете клиент и выбранные серверы.</p>
<h4>4. Конфигурации пользователя</h4>
<p>URL и параметры подключения задаёте вы. EvilFox не гарантирует доступность, качество или законность сторонних серверов. Проверьте условия вашего провайдера конфигурации.</p>
<h4>5. Нет гарантий</h4>
<p>Приложение предоставляется «как есть» и «по мере доступности», без явных или подразумеваемых гарантий (включая товарную пригодность и пригодность для конкретной цели), в пределах, допускаемых законом.</p>
<h4>6. Ограничение ответственности</h4>
<p>В максимальной степени, допускаемой законом, EvilFox не несёт ответственности за косвенные, случайные, особые убытки, упущенную выгоду, потерю данных или перебои связи, связанные с использованием клиента или сторонних серверов. Обязательные права потребителей (в т.ч. в ЕС) сохраняются.</p>
<h4>7. Обновления</h4>
<p>Сборка из Microsoft Store обновляется через Store. Другие (sideload) сборки могут предлагать обновление через ленту на git.de4ima.uk. Мы можем менять функции в новых версиях.</p>
<h4>8. Прекращение</h4>
<p>Вы можете прекратить использование, удалив приложение. Мы можем прекратить поддержку отдельных сборок. При нарушении Условий право на использование прекращается.</p>
<h4>9. Применимое право</h4>
<p>Эти Условия толкуются по законам страны вашего проживания, если иное не требуется императивными нормами. Для потребителей в ЕС сохраняются обязательные права по праву страны проживания.</p>
<h4>10. Контакты</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · <code>https://t.me/evilfox100Bot</code></p>
</div>
<div class="legal-body" data-lang-block="en" hidden>
<p><strong>Publisher:</strong> EvilFox. <strong>Effective date:</strong> August 2, 2026.</p>
<h4>1. Acceptance</h4>
<p>By installing or using EvilFox, you agree to these Terms. If you do not agree, do not use the app.</p>
<h4>2. License</h4>
<p>You receive a limited, non-exclusive, non-transferable license to use the EvilFox client on your devices for personal or internal use, subject to these Terms and applicable law (including Microsoft Store rules if installed from the Store).</p>
<h4>3. Acceptable use</h4>
<p>Use EvilFox only for lawful purposes. You must not violate the laws of your country or third-party rights, or attempt to disrupt the app or others systems. You are responsible for how you use the client and the servers you choose.</p>
<h4>4. User-supplied configurations</h4>
<p>You provide connection URLs and parameters. EvilFox does not guarantee availability, quality, or legality of third-party servers. Review your configuration providers terms.</p>
<h4>5. No warranty</h4>
<p>The app is provided “as is” and “as available,” without warranties of any kind (express or implied, including merchantability and fitness for a particular purpose), to the fullest extent permitted by law.</p>
<h4>6. Limitation of liability</h4>
<p>To the maximum extent permitted by law, EvilFox is not liable for indirect, incidental, special, or consequential damages, lost profits, data loss, or connectivity interruptions arising from use of the client or third-party servers. Mandatory consumer rights (including in the EU) remain unaffected.</p>
<h4>7. Updates</h4>
<p>Microsoft Store builds update through the Store. Other (sideload) builds may offer updates via the feed on git.de4ima.uk. Features may change in new versions.</p>
<h4>8. Termination</h4>
<p>You may stop using the app by uninstalling it. We may discontinue support for particular builds. If you breach these Terms, your right to use the app ends.</p>
<h4>9. Governing law</h4>
<p>These Terms are interpreted under the laws of your country of residence unless mandatory rules require otherwise. EU consumers keep mandatory rights under the law of their country of residence.</p>
<h4>10. Contact</h4>
<p>EvilFox · <code>https://evilfox.win/</code> · <code>https://t.me/evilfox100Bot</code></p>
</div>
</details>
<div class="about-card">
<div class="logo" aria-hidden="true"><span class="fox">🦊</span></div>
<h2>EvilFox</h2>
<p id="aboutVer">версия…</p>
<p style="margin-top:6px" data-i18n="about_stack">VPN/прокси-клиент: Naive · Hysteria 2 · AWG · Xray</p>
</div>
<div class="tools" style="margin-top:0">
<button class="action secondary" id="aboutUpdBtn" type="button" data-i18n="check_update">Проверить обновление</button>
</div>
<section class="shop" aria-label="Получить конфигурацию" data-i18n-aria="shop_aria">
<h3 data-i18n="need_config">Нужна конфигурация?</h3>
<p data-i18n="need_config_p">URL-ссылку для подключения можно получить на evilfox.win. Техподдержка — в Telegram.</p>
<button class="action primary" id="shopBtn" type="button" data-i18n="go_site">Перейти на сайт</button>
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section>
<!-- Полезные ссылки (сайт + поддержка). Хосты — в allowlist openURL. -->
<section class="svc" aria-label="Полезные ссылки" data-i18n-aria="links_aria">
<h4 data-i18n="useful_links">🌐 Полезные ссылки</h4>
<div class="svc-grid" id="svcGrid"></div>
</section>
<button class="shop-link" id="quitBtn" type="button" hidden style="display:none;margin:12px auto 0;width:auto" data-i18n="quit">Выйти</button>
</section>
</div>
<p class="meta" id="meta">Загрузка…</p>
</div>
</div>
<div class="modal-backdrop" id="modal">
<div class="modal">
<h2 data-i18n="new_profile">Новый профиль</h2>
<div class="stack">
<div>
<label class="field" for="newName" data-i18n="new_name">Название</label>
<input id="newName" type="text" placeholder="Server EU" />
</div>
<div>
<label class="field" for="newProxy" data-i18n="new_link">Ссылка</label>
<input id="newProxy" type="text" placeholder="vless:// · vmess:// · trojan:// · hy2 · naive · awg" />
</div>
</div>
<div class="actions">
<button class="action secondary" id="cancelNew" type="button" data-i18n="cancel">Отмена</button>
<button class="action primary" id="createNew" type="button" data-i18n="create">Создать</button>
</div>
</div>
</div>
<script>
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives).
(function () {
const methods = [
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingServer","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave",
"setMode","setKillSwitch","getLogs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
async function call(name, args) {
const r = await fetch("/api/" + name, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ args: args || [] })
});
const j = await r.json();
if (j && j.error) throw j.error;
return j ? j.result : null;
}
methods.forEach((name) => {
window[name] = function () {
return call(name, Array.prototype.slice.call(arguments));
};
});
})();
const $ = (id) => document.getElementById(id);
const meta = $("meta");
// meta initial text set by applyTranslations / paint
const btn = $("toggleBtn");
const coreBtn = $("coreBtn");
const saveBtn = $("saveBtn");
const addBtn = $("addBtn"); // optional — header + removed
const delBtn = $("delBtn"); // optional — header × removed
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
const profile = $("profile");
const statusText = $("statusText");
const powerLabel = $("powerLabel");
const killSwitch = $("killSwitch");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const updCheckBtn = $("updCheckBtn");
const aboutUpdBtn = $("aboutUpdBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const skipUpdateBtn = $("skipUpdateBtn");
const verLabel = $("verLabel");
const badgeVer = $("badgeVer");
const aboutVer = $("aboutVer");
const storeUpdateNote = $("storeUpdateNote");
const themeToggle = $("themeToggle");
const themeSwitch = $("themeSwitch");
const modeProxyBtn = $("modeProxyBtn");
const modeVpnBtn = $("modeVpnBtn");
const modeSection = $("modeSection");
const vpnChip = $("vpnChip");
const logView = $("logView");
const logsRefreshBtn = $("logsRefreshBtn");
const logsClearBtn = $("logsClearBtn");
const THEME_KEY = "navis.theme";
const LANG_KEY = "navis.lang";
const SKIP_UPDATE_KEY = "navis.skipUpdateVersion";
let storePackaged = false;
let currentMode = "proxy";
let currentPage = "guard";
let currentLang = "ru";
const I18N = {
ru: {
nav_guard: "Защита", nav_profiles: "Мои серверы", nav_settings: "Настройки",
nav_stats: "Статистика", nav_help: "Помощь",
theme_toggle_title: "Тема", theme_toggle_aria: "Переключить тему",
theme_light: "Светлая тема", theme_dark: "Тёмная тема",
language: "Язык / Language",
update_available: "Доступно обновление", update_btn: "Обновить", skip_update: "Пропустить эту версию",
power_aria: "Подключиться или отключиться",
vpn_chip: "VPN · весь трафик",
cur_server_title: "Открыть список серверов",
prov_badge: "⭐ Рекомендуемый",
prov_sub: "Оптимальные серверы рядом с вами · защита без настройки",
offer_text: "🎁 3 дня бесплатного доступа",
page_profiles: "Мои серверы", add_config_aria: "Добавить конфигурацию",
add_config_title: "🔗 Добавить конфигурацию", add_config_btn: "Добавить конфигурацию",
add_config_hint: "Вставьте URL-ссылку для подключения", where_url: "Где взять URL-ссылку?",
servers_title: "🌍 Серверы", ping_btn: "Пинг", best_btn: "Выбрать лучший",
best_btn_title: "Выбрать сервер с наименьшим пингом",
account_info: "Информация об аккаунте", expires_label: "Действует до",
traffic_label: "Трафик", devices_label: "Устройства",
server_props: "Свойства сервера", prop_server: "Сервер", prop_ping: "Пинг",
prop_proto: "Протокол", prop_status: "Статус", save_profile: "Сохранить профиль",
page_settings: "Настройки", traffic_mode: "Режим трафика",
mode_proxy: "Режим прокси", mode_vpn: "Режим VPN",
mode_hint: "Прокси — трафик идёт через локальный прокси (и системный прокси Windows). VPN — весь трафик, включая игры и приложения, через TUN-адаптер; нужны права администратора и wintun.dll рядом с EvilFox.exe. Меняется в отключённом состоянии.",
sysproxy: "Системный прокси", sysproxy_sub: "В режиме VPN не используется — TUN и так захватывает всё",
dark_theme: "Тёмная тема",
auto_best: "🚀 Авто-лучший сервер",
auto_best_sub: "При подключении автоматически выбирается сервер с наименьшим пингом",
kill_switch: "🔒 Защита от утечек (Kill Switch)",
kill_switch_sub: "При обрыве VPN блокировать весь интернет-трафик до ручного отключения или переподключения. Полноценно работает в режиме VPN; в режиме прокси защищены только программы, использующие системный прокси.",
obfs_none: "нет", ph_empty_bbr: "пусто = BBR", ph_password: "пароль", ph_optional: "опционально",
svc_cores: "Сервис · cores, обновления", check_update: "Проверить обновление",
install_cores: "Установить cores", store_updates: "Обновления этой сборки — через Microsoft Store.",
adv_settings: "Расширенные настройки", proxy_params: "Параметры прокси",
proxy_params_hint: "Локальные адреса прокси на этом компьютере — нужны только для ручной настройки отдельных программ.",
page_stats: "Статистика", stats_today: "Статистика за сегодня",
stat_used: "Использовано", stat_online: "Время в сети", stat_sessions: "Сессий",
stats_week: "Трафик за 7 дней", no_data: "Пока нет данных", no_data_short: "нет данных",
conn_log: "Журнал подключения", refresh: "Обновить", clear_screen: "Очистить экран",
page_help: "Помощь",
faq_how_q: "Как подключиться?",
faq_how_a: "Добавьте конфигурацию на вкладке «Мои серверы», затем на вкладке «Защита» нажмите большую круглую кнопку. Приложение само выберет оптимальный сервер.",
faq_url_q: "Где взять URL-ссылку?",
faq_url_a: "URL-ссылку для подключения можно получить на сайте evilfox.win (кнопка ниже) или у вашего провайдера. Полученную ссылку вставьте на вкладке «Мои серверы».",
faq_fail_q: "Не подключается — что делать?",
faq_fail_a: "Проверьте, что конфигурация добавлена и срок её действия не истёк («Мои серверы» → «Информация об аккаунте»). Попробуйте другой сервер (кнопка «Выбрать лучший»). Подробности — в журнале на вкладке «Статистика».",
privacy_title: "Политика конфиденциальности",
terms_title: "Условия использования",
about_stack: "VPN/прокси-клиент: Naive · Hysteria 2 · AWG · Xray",
shop_aria: "Получить конфигурацию", need_config: "Нужна конфигурация?",
need_config_p: "URL-ссылку для подключения можно получить на evilfox.win. Техподдержка — в Telegram.",
go_site: "Перейти на сайт", links_aria: "Полезные ссылки", useful_links: "🌐 Полезные ссылки",
quit: "Выйти", loading: "Загрузка…",
new_profile: "Новый профиль", new_name: "Название", new_link: "Ссылка",
cancel: "Отмена", create: "Создать",
region_unknown: "регион неизвестен",
meta_quit: "Выход…", ks_on: "Kill Switch включён: при обрыве VPN трафик будет заблокирован",
ks_off: "Kill Switch выключен",
unit_sec: "сек", unit_min: "мин", unit_h: "ч", unit_gb: "ГБ", unit_tb: "ТБ", unit_mb: "МБ",
proto_generic: "протокол",
title_optimal: "{region} — оптимальный сервер",
title_server_optimal: "Сервер {n} — оптимальный",
title_server_n: "{region} — сервер {n}", title_server: "Сервер {n}",
server_generic: "Сервер", no_server: "Нет сервера",
no_server_hint: "добавьте конфигурацию на вкладке «Мои серверы»",
pick_other_server: "нажмите, чтобы выбрать другой сервер",
ping_not_measured: "пинг не измерен",
servers_one: "сервер", servers_few: "сервера", servers_many: "серверов",
no_servers_empty: "Нет серверов — добавьте конфигурацию выше",
badge_active: "АКТИВЕН", proto_title: "Протокол {label}",
ping_this_server: "Проверить пинг этого сервера", ping_fail: "Не удалось измерить пинг",
connecting_to: "Подключение к «{name}»…", connected_to: "Защищённое соединение: {name}",
traffic_avail: "Доступно {left}", traffic_avail_of: "Доступно {left} из {total}",
pct_used: "{pct}% использовано", traffic_used_unlim: "Использовано {used} · Безлимит",
expired_on: "Срок доступа истёк {date}", burns_today: "⏳ Сгорает сегодня (до 23:59)",
active_until: "Активен до {date}", expired: "Истёк", today: "Сегодня",
days_left: "осталось {n} дн.", of_pair: "{a} из {b}", unlim: "Безлимит",
version_label: "Версия {v}", update_available_v: "Доступно обновление {v}",
update_notes_fallback: "У вас {v}. Обновление только по кнопке.",
status_blocked: "⛔ Соединение прервано — трафик заблокирован",
status_on: "🟢 Защита активна", status_off: "🔴 Отключено",
power_connect: "ПОДКЛЮЧИТЬСЯ", power_disconnect: "ОТКЛЮЧИТЬСЯ",
btn_connect: "Подключить", btn_disconnect: "Отключить",
config_added: "🟢 Конфигурация добавлена", config_missing: "🔴 Конфигурация не добавлена",
proxy_line: "Прокси",
hint_ks: "Kill Switch активен: интернет заблокирован до отключения или переподключения",
detail_ks: "⛔ Kill Switch: соединение прервано, трафик заблокирован. Нажмите «Отключиться» или переподключитесь.",
hint_encrypted: "Трафик зашифрован", detail_cores: "Сначала установите cores в Настройках («Сервис»)",
hint_cores: "Нужны cores для выбранного протокола",
hint_vpn: "Режим VPN: защита всего трафика, включая игры и приложения",
hint_idle: "Нажмите, чтобы включить защиту",
prov_connected: "✅ Подключено", prov_connect: "🔥 Подключиться",
unavailable: "недоступен", not_measured: "не измерен",
prop_active: "🟢 Активен", prop_inactive: "⚪ Не активен",
meta_ping_auto: "Пинг и автоподключение…", meta_ping_best: "Пинг и выбор лучшего…",
meta_best: "Лучший: {name} · {ms} ms", meta_connected_short: "подключено",
meta_disconnecting: "Отключение…", meta_disconnected: "Отключено",
meta_connecting: "Подключение…", meta_connected: "Защищённое соединение установлено",
meta_saved: "Профиль сохранён", meta_cores_dl: "Скачивание official cores…",
meta_cores_ok: "Cores готовы: {path}",
confirm_delete: "Удалить сервер «{name}»?", meta_deleted: "Профиль удалён",
err_need_name: "Укажите название профиля", meta_created: "Профиль создан",
meta_opening_site: "Открываю evilfox.win…", meta_opened: "Открыто в браузере",
meta_open_fail: "Не удалось открыть ссылку",
meta_opening_named: "Открываю {name}…",
svc_site: "Сайт", svc_support: "Поддержка: Telegram",
meta_need_url: "Вставьте URL-ссылку для подключения",
meta_adding_config: "Добавление конфигурации…",
meta_picking_best: "подбираю оптимальный сервер…",
import_ok: "Конфигурация добавлена · серверов: {n}",
import_skipped: "пропущено {n}", import_ok_raw: "Конфигурация добавлена: {r}",
meta_mode_vpn: "Режим VPN включён: весь трафик пойдёт через туннель после подключения",
meta_mode_proxy: "Режим прокси включён",
log_empty: "Пусто — подключитесь, чтобы увидеть журнал.",
log_fail: "Не удалось получить логи",
meta_pinging: "Пинг серверов…",
meta_ping_result: "Пинг: {ok}/{total} доступны",
meta_checking_upd: "Проверка обновлений…",
meta_upd_avail: "Доступна версия {v}", meta_upd_err: "Обновление: {e}",
meta_upd_current: "У вас актуальная версия {v}",
meta_upd_install: "Скачивание и установка обновления…", meta_restart: "Перезапуск…",
meta_upd_skipped: "Версия {v} пропущена. Следующую предложим."
},
en: {
nav_guard: "Protection", nav_profiles: "My servers", nav_settings: "Settings",
nav_stats: "Statistics", nav_help: "Help",
theme_toggle_title: "Theme", theme_toggle_aria: "Toggle theme",
theme_light: "Light theme", theme_dark: "Dark theme",
language: "Language / Язык",
update_available: "Update available", update_btn: "Update", skip_update: "Skip this version",
power_aria: "Connect or disconnect",
vpn_chip: "VPN · all traffic",
cur_server_title: "Open server list",
prov_badge: "⭐ Recommended",
prov_sub: "Optimal servers near you · protection with no setup",
offer_text: "🎁 3 days free access",
page_profiles: "My servers", add_config_aria: "Add configuration",
add_config_title: "🔗 Add configuration", add_config_btn: "Add configuration",
add_config_hint: "Paste a connection URL", where_url: "Where do I get a URL?",
servers_title: "🌍 Servers", ping_btn: "Ping", best_btn: "Pick best",
best_btn_title: "Select the server with the lowest ping",
account_info: "Account info", expires_label: "Valid until",
traffic_label: "Traffic", devices_label: "Devices",
server_props: "Server properties", prop_server: "Server", prop_ping: "Ping",
prop_proto: "Protocol", prop_status: "Status", save_profile: "Save profile",
page_settings: "Settings", traffic_mode: "Traffic mode",
mode_proxy: "Proxy mode", mode_vpn: "VPN mode",
mode_hint: "Proxy — traffic goes through a local proxy (and the Windows system proxy). VPN — all traffic, including games and apps, via a TUN adapter; requires administrator rights and wintun.dll next to EvilFox.exe. Change only while disconnected.",
sysproxy: "System proxy", sysproxy_sub: "Not used in VPN mode — TUN already captures everything",
dark_theme: "Dark theme",
auto_best: "🚀 Auto best server",
auto_best_sub: "On connect, automatically pick the server with the lowest ping",
kill_switch: "🔒 Leak protection (Kill Switch)",
kill_switch_sub: "If the VPN drops, block all internet traffic until you disconnect or reconnect. Fully effective in VPN mode; in proxy mode only apps that use the system proxy are protected.",
obfs_none: "none", ph_empty_bbr: "empty = BBR", ph_password: "password", ph_optional: "optional",
svc_cores: "Service · cores, updates", check_update: "Check for updates",
install_cores: "Install cores", store_updates: "Updates for this build come from the Microsoft Store.",
adv_settings: "Advanced settings", proxy_params: "Proxy parameters",
proxy_params_hint: "Local proxy addresses on this computer — only needed for manual setup of individual apps.",
page_stats: "Statistics", stats_today: "Today's stats",
stat_used: "Used", stat_online: "Time online", stat_sessions: "Sessions",
stats_week: "Traffic last 7 days", no_data: "No data yet", no_data_short: "no data",
conn_log: "Connection log", refresh: "Refresh", clear_screen: "Clear screen",
page_help: "Help",
faq_how_q: "How do I connect?",
faq_how_a: "Add a configuration on the My servers tab, then on Protection press the large round button. The app will pick an optimal server.",
faq_url_q: "Where do I get a URL?",
faq_url_a: "You can get a connection URL at evilfox.win (button below) or from your provider. Paste it on the My servers tab.",
faq_fail_q: "Won't connect — what should I do?",
faq_fail_a: "Check that a configuration is added and has not expired (My servers → Account info). Try another server (Pick best). Details are in the log on the Statistics tab.",
privacy_title: "Privacy Policy",
terms_title: "Terms of Use",
about_stack: "VPN/proxy client: Naive · Hysteria 2 · AWG · Xray",
shop_aria: "Get configuration", need_config: "Need a configuration?",
need_config_p: "You can get a connection URL at evilfox.win. Support is on Telegram.",
go_site: "Go to website", links_aria: "Useful links", useful_links: "🌐 Useful links",
quit: "Quit", loading: "Loading…",
new_profile: "New profile", new_name: "Name", new_link: "Link",
cancel: "Cancel", create: "Create",
region_unknown: "unknown region",
meta_quit: "Quitting…", ks_on: "Kill Switch on: if VPN drops, traffic will be blocked",
ks_off: "Kill Switch off",
unit_sec: "sec", unit_min: "min", unit_h: "h", unit_gb: "GB", unit_tb: "TB", unit_mb: "MB",
proto_generic: "protocol",
title_optimal: "{region} — optimal server",
title_server_optimal: "Server {n} — optimal",
title_server_n: "{region} — server {n}", title_server: "Server {n}",
server_generic: "Server", no_server: "No server",
no_server_hint: "add a configuration on the My servers tab",
pick_other_server: "tap to choose another server",
ping_not_measured: "ping not measured",
servers_one: "server", servers_few: "servers", servers_many: "servers",
no_servers_empty: "No servers — add a configuration above",
badge_active: "ACTIVE", proto_title: "Protocol {label}",
ping_this_server: "Ping this server", ping_fail: "Could not measure ping",
connecting_to: "Connecting to “{name}”…", connected_to: "Secure connection: {name}",
traffic_avail: "Available {left}", traffic_avail_of: "Available {left} of {total}",
pct_used: "{pct}% used", traffic_used_unlim: "Used {used} · Unlimited",
expired_on: "Access expired {date}", burns_today: "⏳ Expires today (by 23:59)",
active_until: "Active until {date}", expired: "Expired", today: "Today",
days_left: "{n} days left", of_pair: "{a} of {b}", unlim: "Unlimited",
version_label: "Version {v}", update_available_v: "Update available {v}",
update_notes_fallback: "You have {v}. Update only via the button.",
status_blocked: "⛔ Connection interrupted — traffic blocked",
status_on: "🟢 Protection on", status_off: "🔴 Disconnected",
power_connect: "CONNECT", power_disconnect: "DISCONNECT",
btn_connect: "Connect", btn_disconnect: "Disconnect",
config_added: "🟢 Configuration added", config_missing: "🔴 No configuration",
proxy_line: "Proxy",
hint_ks: "Kill Switch active: internet blocked until disconnect or reconnect",
detail_ks: "⛔ Kill Switch: connection interrupted, traffic blocked. Press Disconnect or reconnect.",
hint_encrypted: "Traffic encrypted", detail_cores: "Install cores first in Settings (Service)",
hint_cores: "Cores required for the selected protocol",
hint_vpn: "VPN mode: protect all traffic, including games and apps",
hint_idle: "Press to enable protection",
prov_connected: "✅ Connected", prov_connect: "🔥 Connect",
unavailable: "unreachable", not_measured: "not measured",
prop_active: "🟢 Active", prop_inactive: "⚪ Inactive",
meta_ping_auto: "Pinging and auto-connecting…", meta_ping_best: "Pinging and picking best…",
meta_best: "Best: {name} · {ms} ms", meta_connected_short: "connected",
meta_disconnecting: "Disconnecting…", meta_disconnected: "Disconnected",
meta_connecting: "Connecting…", meta_connected: "Secure connection established",
meta_saved: "Profile saved", meta_cores_dl: "Downloading official cores…",
meta_cores_ok: "Cores ready: {path}",
confirm_delete: "Delete server “{name}”?", meta_deleted: "Profile deleted",
err_need_name: "Enter a profile name", meta_created: "Profile created",
meta_opening_site: "Opening evilfox.win…", meta_opened: "Opened in browser",
meta_open_fail: "Could not open link",
meta_opening_named: "Opening {name}…",
svc_site: "Website", svc_support: "Support: Telegram",
meta_need_url: "Paste a connection URL",
meta_adding_config: "Adding configuration…",
meta_picking_best: "picking optimal server…",
import_ok: "Configuration added · servers: {n}",
import_skipped: "skipped {n}", import_ok_raw: "Configuration added: {r}",
meta_mode_vpn: "VPN mode on: all traffic will go through the tunnel after connect",
meta_mode_proxy: "Proxy mode on",
log_empty: "Empty — connect to see the log.",
log_fail: "Could not load logs",
meta_pinging: "Pinging servers…",
meta_ping_result: "Ping: {ok}/{total} reachable",
meta_checking_upd: "Checking for updates…",
meta_upd_avail: "Version {v} available", meta_upd_err: "Update: {e}",
meta_upd_current: "You have the latest version {v}",
meta_upd_install: "Downloading and installing update…", meta_restart: "Restarting…",
meta_upd_skipped: "Version {v} skipped. We'll offer the next one."
}
};
function t(key, vars) {
const dict = I18N[currentLang] || I18N.ru;
let s = (dict && dict[key] !== undefined) ? dict[key] : ((I18N.ru && I18N.ru[key]) || key);
if (vars) {
Object.keys(vars).forEach((k) => {
s = String(s).split("{" + k + "}").join(String(vars[k]));
});
}
return s;
}
function readLang() {
try {
const v = localStorage.getItem(LANG_KEY);
if (v === "en" || v === "ru") return v;
} catch (_) {}
return "ru";
}
function syncLangSeg() {
document.querySelectorAll("#langSeg button[data-lang], #langSegSettings button[data-lang]").forEach((b) => {
b.classList.toggle("active", b.getAttribute("data-lang") === currentLang);
});
}
function applyTranslations() {
document.documentElement.setAttribute("lang", currentLang === "en" ? "en" : "ru");
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
if (key) el.textContent = t(key);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
const key = el.getAttribute("data-i18n-placeholder");
if (key) el.setAttribute("placeholder", t(key));
});
document.querySelectorAll("[data-i18n-title]").forEach((el) => {
const key = el.getAttribute("data-i18n-title");
if (key) el.title = t(key);
});
document.querySelectorAll("[data-i18n-aria]").forEach((el) => {
const key = el.getAttribute("data-i18n-aria");
if (key) el.setAttribute("aria-label", t(key));
});
document.querySelectorAll("[data-lang-block]").forEach((el) => {
const block = el.getAttribute("data-lang-block");
el.hidden = block !== currentLang;
});
if ($("provGift")) $("provGift").textContent = t("offer_text");
if (meta && !meta.textContent) meta.textContent = t("loading");
else if (meta && (meta.textContent === "Загрузка…" || meta.textContent === "Loading…")) meta.textContent = t("loading");
if ($("statOnline") && ($("statOnline").textContent === "0 мин" || $("statOnline").textContent === "0 min")) {
$("statOnline").textContent = "0 " + t("unit_min");
}
if (logView) {
const logPlaceholderRu = "Пусто — подключитесь, чтобы увидеть журнал.";
const logPlaceholderEn = "Empty — connect to see the log.";
if (!logView.textContent.trim() ||
logView.textContent === logPlaceholderRu ||
logView.textContent === logPlaceholderEn) {
logView.textContent = t("log_empty");
}
}
syncLangSeg();
if (typeof applyTheme === "function") {
applyTheme(document.documentElement.getAttribute("data-theme") || readTheme());
}
}
function setLang(lang) {
currentLang = (lang === "en") ? "en" : "ru";
try { localStorage.setItem(LANG_KEY, currentLang); } catch (_) {}
applyTranslations();
if (typeof renderRecommendedServices === "function") renderRecommendedServices();
// Re-paint live status / server labels from Go state (uses t() + countryName).
if (typeof refresh === "function") {
refresh().catch(() => {});
} else if (typeof rebuildFriendlyNames === "function") {
rebuildFriendlyNames();
if (typeof renderServerList === "function") renderServerList(profile && profile.value);
if (typeof renderCurServer === "function") renderCurServer(profile && profile.value);
if (typeof renderProvConnect === "function") renderProvConnect();
if (typeof renderProfProps === "function") renderProfProps();
if (typeof renderStats === "function") renderStats();
}
}
currentLang = readLang();
function pluralServers(n) {
if (currentLang === "en") return n === 1 ? t("servers_one") : t("servers_many");
return pluralRu(n, t("servers_one"), t("servers_few"), t("servers_many"));
}
// Русские названия стран для человекочитаемых имён серверов (ISO alpha-2).
const COUNTRY_RU = {
AT: "Австрия", AU: "Австралия", AE: "ОАЭ", AR: "Аргентина", BE: "Бельгия",
BG: "Болгария", BR: "Бразилия", CA: "Канада", CH: "Швейцария", CZ: "Чехия",
DE: "Германия", DK: "Дания", EE: "Эстония", ES: "Испания", FI: "Финляндия",
FR: "Франция", GB: "Великобритания", GR: "Греция", HK: "Гонконг", HU: "Венгрия",
IE: "Ирландия", IL: "Израиль", IN: "Индия", IT: "Италия", JP: "Япония",
KR: "Южная Корея", KZ: "Казахстан", LT: "Литва", LU: "Люксембург", LV: "Латвия",
MD: "Молдова", MX: "Мексика", NL: "Нидерланды", NO: "Норвегия", PL: "Польша",
PT: "Португалия", RO: "Румыния", RS: "Сербия", RU: "Россия", SE: "Швеция",
SG: "Сингапур", SK: "Словакия", TR: "Турция", UA: "Украина", US: "США", ZA: "ЮАР"
};
// Опечатки / варианты имён стран → ISO (зеркало Go geoflag; длинные сначала).
const COUNTRY_NAME_ALIASES = [
["румьиния", "RO"], ["румяння", "RO"], ["румьиня", "RO"],
["румыния", "RO"], ["румыня", "RO"], ["руминия", "RO"], ["румыниа", "RO"],
["romania", "RO"], ["bucharest", "RO"], ["бухарест", "RO"]
];
const COUNTRY_EN = {
AT: "Austria", AU: "Australia", AE: "UAE", AR: "Argentina", BE: "Belgium",
BG: "Bulgaria", BR: "Brazil", CA: "Canada", CH: "Switzerland", CZ: "Czechia",
DE: "Germany", DK: "Denmark", EE: "Estonia", ES: "Spain", FI: "Finland",
FR: "France", GB: "United Kingdom", GR: "Greece", HK: "Hong Kong", HU: "Hungary",
IE: "Ireland", IL: "Israel", IN: "India", IT: "Italy", JP: "Japan",
KR: "South Korea", KZ: "Kazakhstan", LT: "Lithuania", LU: "Luxembourg", LV: "Latvia",
MD: "Moldova", MX: "Mexico", NL: "Netherlands", NO: "Norway", PL: "Poland",
PT: "Portugal", RO: "Romania", RS: "Serbia", RU: "Russia", SE: "Sweden",
SG: "Singapore", SK: "Slovakia", TR: "Turkey", UA: "Ukraine", US: "USA", ZA: "South Africa"
};
function countryRu(cc) {
const c = String(cc || "").toUpperCase();
return COUNTRY_RU[c] || "";
}
function countryName(cc) {
const c = String(cc || "").toUpperCase();
if (currentLang === "en") return COUNTRY_EN[c] || COUNTRY_RU[c] || "";
return COUNTRY_RU[c] || "";
}
function isoFromNameText(text) {
const lower = String(text || "").toLowerCase();
if (!lower) return "";
for (let i = 0; i < COUNTRY_NAME_ALIASES.length; i++) {
if (lower.indexOf(COUNTRY_NAME_ALIASES[i][0]) >= 0) return COUNTRY_NAME_ALIASES[i][1];
}
// Прямое совпадение канонического русского / английского названия.
const keys = Object.keys(COUNTRY_RU);
for (let i = 0; i < keys.length; i++) {
const ru = COUNTRY_RU[keys[i]].toLowerCase();
if (ru && lower.indexOf(ru) >= 0) return keys[i];
const en = (COUNTRY_EN[keys[i]] || "").toLowerCase();
if (en && lower.indexOf(en) >= 0) return keys[i];
}
return "";
}
// ==== SVG-флаги ====
// Windows не умеет эмодзи-флаги: Segoe UI Emoji рисует пары региональных
// индикаторов буквами («BE», «LV»), поэтому рисуем настоящие SVG-флаги.
// Формат описателя: h — горизонтальные полосы (цвет или [цвет, вес]),
// v — вертикальные полосы, n — скандинавский крест [фон, крест, внутренний],
// s — произвольный SVG-фрагмент, e — элементы поверх полос.
function star5(cx, cy, r, fill) {
let d = "";
for (let i = 0; i < 10; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 5;
const rr = i % 2 ? r * 0.4 : r;
d += (i ? "L" : "M") + (cx + rr * Math.cos(a)).toFixed(1) + " " + (cy + rr * Math.sin(a)).toFixed(1);
}
return '<path d="' + d + 'Z" fill="' + fill + '"/>';
}
// Упрощённый Юнион Джек (полный флаг и малый кантон для AU/NZ).
const UJ = '<rect width="60" height="40" fill="#012169"/><path d="M0 0 60 40M60 0 0 40" stroke="#FFF" stroke-width="8"/><path d="M0 0 60 40M60 0 0 40" stroke="#C8102E" stroke-width="3.5"/><path d="M30 0V40M0 20H60" stroke="#FFF" stroke-width="13"/><path d="M30 0V40M0 20H60" stroke="#C8102E" stroke-width="7"/>';
const UJC = '<path d="M0 0 30 20M30 0 0 20" stroke="#FFF" stroke-width="4"/><path d="M15 0V20M0 10H30" stroke="#FFF" stroke-width="6"/><path d="M15 0V20M0 10H30" stroke="#C8102E" stroke-width="3"/>';
const FLAGS = {
AT: { h: ["#ED2939", "#FFF", "#ED2939"] },
BE: { v: ["#000", "#FDDA24", "#EF3340"] },
BG: { h: ["#FFF", "#00966E", "#D62612"] },
CH: { s: '<rect width="60" height="40" fill="#DA291C"/><rect x="26" y="8" width="8" height="24" fill="#FFF"/><rect x="18" y="16" width="24" height="8" fill="#FFF"/>' },
CZ: { h: ["#FFF", "#D7141A"], e: '<path d="M0 0 30 20 0 40Z" fill="#11457E"/>' },
DE: { h: ["#000", "#DD0000", "#FFCE00"] },
DK: { n: ["#C8102E", "#FFF"] },
EE: { h: ["#0072CE", "#000", "#FFF"] },
ES: { h: [["#AA151B", 1], ["#F1BF00", 2], ["#AA151B", 1]], e: '<rect x="12" y="16" width="6" height="8" rx="1.2" fill="#AA151B"/>' },
FI: { n: ["#FFF", "#002F6C"] },
FR: { v: ["#002395", "#FFF", "#ED2939"] },
GB: { s: UJ },
GR: { h: ["#0D5EAF", "#FFF", "#0D5EAF", "#FFF", "#0D5EAF", "#FFF", "#0D5EAF", "#FFF", "#0D5EAF"], e: '<rect width="22" height="22.5" fill="#0D5EAF"/><rect x="8.8" width="4.4" height="22.5" fill="#FFF"/><rect y="9" width="22" height="4.4" fill="#FFF"/>' },
HU: { h: ["#CE2939", "#FFF", "#477050"] },
IE: { v: ["#169B62", "#FFF", "#FF883E"] },
IT: { v: ["#008C45", "#F4F9F0", "#CD212A"] },
LT: { h: ["#FDB913", "#006A44", "#C1272D"] },
LU: { h: ["#ED2939", "#FFF", "#00A3E0"] },
LV: { h: [["#9E3039", 2], ["#FFF", 1], ["#9E3039", 2]] },
MD: { v: ["#003DA5", "#FFD100", "#C8102E"], e: '<circle cx="30" cy="20" r="4" fill="#B07F55"/>' },
NL: { h: ["#AE1C28", "#FFF", "#21468B"] },
NO: { n: ["#BA0C2F", "#FFF", "#00205B"] },
PL: { h: ["#FFF", "#DC143C"] },
PT: { v: [["#046A38", 2], ["#DA291C", 3]], e: '<circle cx="24" cy="20" r="5" fill="#FFE900" stroke="#DA291C" stroke-width="1.5"/>' },
RO: { v: ["#002B7F", "#FCD116", "#CE1126"] },
RS: { h: ["#C6363C", "#0C4076", "#FFF"] },
SE: { n: ["#006AA7", "#FECC00"] },
SI: { h: ["#FFF", "#005DA4", "#ED1C24"], e: '<path d="M14 8l4.5 7.5L14 22l-4.5-6.5Z" fill="#005DA4" stroke="#FFF" stroke-width="1"/>' },
SK: { h: ["#FFF", "#0B4EA2", "#EE1C25"], e: '<path d="M14 11l7 2.5v7c0 4.5-3.5 7-7 9-3.5-2-7-4.5-7-9v-7Z" fill="#EE1C25" stroke="#FFF" stroke-width="1.4"/>' },
TR: { s: '<rect width="60" height="40" fill="#E30A17"/><circle cx="23" cy="20" r="10" fill="#FFF"/><circle cx="25.5" cy="20" r="8" fill="#E30A17"/>' + star5(37, 20, 5, "#FFF") },
UA: { h: ["#0057B7", "#FFD700"] },
US: {
h: ["#B22234", "#FFF", "#B22234", "#FFF", "#B22234", "#FFF", "#B22234"],
e: '<rect width="26" height="17.2" fill="#3C3B6E"/>' + [[4, 4], [10, 4], [16, 4], [22, 4], [7, 8.6], [13, 8.6], [19, 8.6], [4, 13.2], [10, 13.2], [16, 13.2], [22, 13.2]].map(function (p) { return '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="1.3" fill="#FFF"/>'; }).join("")
},
CA: { v: [["#D80621", 1], ["#FFF", 2], ["#D80621", 1]], e: '<path d="M30 9l2.2 4.4 4-1.8-1.3 5.4 4.4.9-3.1 3.5 3.6 2.7-5.4-.4.4 5.3-4.8-2.7-4.8 2.7.4-5.3-5.4.4 3.6-2.7-3.1-3.5 4.4-.9-1.3-5.4 4 1.8Z" fill="#D80621"/>' },
JP: { s: '<rect width="60" height="40" fill="#FFF"/><circle cx="30" cy="20" r="10" fill="#BC002D"/>' },
SG: { h: ["#EF3340", "#FFF"], e: '<circle cx="13" cy="10" r="5.5" fill="#FFF"/><circle cx="15.5" cy="10" r="5" fill="#EF3340"/>' },
HK: { s: '<rect width="60" height="40" fill="#DE2910"/>' + [0, 72, 144, 216, 288].map(function (a) { return '<ellipse cx="30" cy="13" rx="2.6" ry="7" fill="#FFF" transform="rotate(' + a + ' 30 20)"/>'; }).join("") },
AU: { s: '<rect width="60" height="40" fill="#012169"/>' + UJC + star5(15, 30, 4, "#FFF") + star5(45, 8, 3, "#FFF") + star5(38, 20, 3, "#FFF") + star5(52, 16, 3, "#FFF") + star5(45, 32, 3, "#FFF") },
NZ: { s: '<rect width="60" height="40" fill="#012169"/>' + UJC + star5(45, 8, 3, "#C8102E") + star5(38, 19, 3, "#C8102E") + star5(52, 19, 3, "#C8102E") + star5(45, 31, 3, "#C8102E") },
IS: { n: ["#02529C", "#FFF", "#DC1E35"] },
AL: { s: '<rect width="60" height="40" fill="#E41E20"/><path d="M30 8c-3 4-8 4.5-12 2.5 2 3.5 5.5 5.5 9 5.5-3 2.5-6.5 3.5-10 3 3 3.5 7.5 4.5 11 2.5v7l2 4 2-4v-7c3.5 2 8 1 11-2.5-3.5.5-7-.5-10-3 3.5 0 7-2 9-5.5-4 2-9 1.5-12-2.5Z" fill="#000"/>' },
MK: { s: '<rect width="60" height="40" fill="#D20000"/><path d="M30 20 0 0M30 20 60 0M30 20 0 40M30 20 60 40M30 20 0 20M30 20 60 20M30 20 30 0M30 20 30 40" stroke="#FFE600" stroke-width="4.5"/><circle cx="30" cy="20" r="7" fill="#FFE600" stroke="#D20000" stroke-width="1.5"/>' },
ME: { s: '<rect width="60" height="40" fill="#C40308"/><rect x="2" y="2" width="56" height="36" fill="none" stroke="#D3AE3B" stroke-width="3"/><circle cx="30" cy="20" r="6" fill="#D3AE3B"/>' },
BA: { s: '<rect width="60" height="40" fill="#002F6C"/><path d="M21 0h26L21 40Z" fill="#FECB00"/>' + star5(16, 6, 2.6, "#FFF") + star5(12, 16, 2.6, "#FFF") + star5(8, 26, 2.6, "#FFF") + star5(4, 36, 2.6, "#FFF") },
HR: { h: ["#C8102E", "#FFF", "#012169"], e: '<rect x="24" y="12" width="12" height="12" fill="#FFF" stroke="#C8102E" stroke-width="1"/><rect x="24" y="12" width="4" height="4" fill="#C8102E"/><rect x="32" y="12" width="4" height="4" fill="#C8102E"/><rect x="28" y="16" width="4" height="4" fill="#C8102E"/><rect x="24" y="20" width="4" height="4" fill="#C8102E"/><rect x="32" y="20" width="4" height="4" fill="#C8102E"/>' },
CY: { s: '<rect width="60" height="40" fill="#FFF"/><ellipse cx="30" cy="16" rx="13" ry="5" fill="#D57800"/><path d="M23 26c4.5 2.5 9.5 2.5 14 0" stroke="#4E5B31" stroke-width="2.2" fill="none"/>' },
MT: { s: '<rect width="30" height="40" fill="#FFF"/><rect x="30" width="30" height="40" fill="#CF142B"/><path d="M7 5h4v4h4v4h-4v4H7v-4H3V9h4Z" fill="#9CA3AF"/>' },
AM: { h: ["#D90012", "#0033A0", "#F2A800"] },
AZ: { h: ["#0092BC", "#E4002B", "#00AF66"], e: '<circle cx="26" cy="20" r="5" fill="#FFF"/><circle cx="28" cy="20" r="4.2" fill="#E4002B"/>' + star5(34, 20, 2.6, "#FFF") },
GE: { s: '<rect width="60" height="40" fill="#FFF"/><path d="M26 0h8v16h26v8H34v16h-8V24H0v-8h26Z" fill="#F04923"/><path d="M13 6v6M10 9h6M47 6v6M44 9h6M13 28v6M10 31h6M47 28v6M44 31h6" stroke="#F04923" stroke-width="2.4"/>' },
KZ: { s: '<rect width="60" height="40" fill="#00AFCA"/><circle cx="30" cy="17" r="7" fill="#FEC50C"/><path d="M17 29c8.5 3.5 17.5 3.5 26 0" stroke="#FEC50C" stroke-width="2.6" fill="none"/>' },
KG: { s: '<rect width="60" height="40" fill="#E8112D"/><circle cx="30" cy="20" r="8.5" fill="#FFEF00"/><path d="M24.5 21.5c1.5-4 9.5-4 11 0" stroke="#E8112D" stroke-width="2" fill="none"/>' },
UZ: { h: [["#0099B5", 10], ["#CE1126", 1.4], ["#FFF", 7.2], ["#CE1126", 1.4], ["#1EB53A", 10]], e: '<circle cx="10" cy="6.5" r="3.6" fill="#FFF"/><circle cx="11.6" cy="6.2" r="3.1" fill="#0099B5"/>' },
TM: { s: '<rect width="60" height="40" fill="#00843D"/><rect x="6" width="9" height="40" fill="#C8102E"/><circle cx="34" cy="12" r="5" fill="#FFF"/><circle cx="36" cy="11.2" r="4.3" fill="#00843D"/>' + star5(44, 10, 2.4, "#FFF") },
TJ: { h: [["#CC0000", 2], ["#FFF", 3], ["#006600", 2]], e: '<circle cx="30" cy="20" r="3" fill="#F8C300"/>' },
RU: { h: ["#FFF", "#0039A6", "#D52B1E"] },
BY: { s: '<rect width="60" height="26.7" fill="#CE1720"/><rect y="26.7" width="60" height="13.3" fill="#007C30"/><rect width="8" height="40" fill="#FFF"/><path d="M4 2l2.5 3L4 8 1.5 5Zm0 10l2.5 3L4 18l-2.5-3Zm0 10l2.5 3L4 28l-2.5-3Zm0 10l2.5 3L4 38l-2.5-3Z" fill="#CE1720"/>' },
IL: { s: '<rect width="60" height="40" fill="#FFF"/><rect y="5" width="60" height="5" fill="#0038B8"/><rect y="30" width="60" height="5" fill="#0038B8"/><path d="M30 12.5l6.5 11.2h-13Zm0 15l-6.5-11.2h13Z" fill="none" stroke="#0038B8" stroke-width="1.8"/>' },
AE: { s: '<rect width="60" height="13.4" fill="#00732F"/><rect y="13.4" width="60" height="13.3" fill="#FFF"/><rect y="26.6" width="60" height="13.4" fill="#000"/><rect width="16" height="40" fill="#FF0000"/>' },
SA: { s: '<rect width="60" height="40" fill="#006C35"/><rect x="12" y="11" width="36" height="9" rx="2" fill="#FFF" opacity=".9"/><rect x="12" y="26" width="30" height="3" rx="1.5" fill="#FFF"/>' },
IN: { h: ["#FF9933", "#FFF", "#138808"], e: '<circle cx="30" cy="20" r="4.5" fill="none" stroke="#000080" stroke-width="1.4"/><circle cx="30" cy="20" r="1" fill="#000080"/>' },
BR: { s: '<rect width="60" height="40" fill="#009739"/><path d="M30 5 55 20 30 35 5 20Z" fill="#FEDD00"/><circle cx="30" cy="20" r="8" fill="#012169"/><path d="M22.5 18.5c5-1.5 10-1 15 3" stroke="#FFF" stroke-width="1.6" fill="none"/>' },
MX: { v: ["#006341", "#FFF", "#CE1126"], e: '<circle cx="30" cy="20" r="4" fill="#8C6A2F"/>' },
AR: { h: ["#74ACDF", "#FFF", "#74ACDF"], e: '<circle cx="30" cy="20" r="4" fill="#F6B40E"/>' },
CL: { s: '<rect width="60" height="20" fill="#FFF"/><rect y="20" width="60" height="20" fill="#DA291C"/><rect width="20" height="20" fill="#0032A0"/>' + star5(10, 10, 5.5, "#FFF") },
KR: { s: '<rect width="60" height="40" fill="#FFF"/><circle cx="30" cy="20" r="9" fill="#CD2E3A"/><path d="M21 20a9 9 0 0 0 18 0 4.5 4.5 0 0 0-9 0 4.5 4.5 0 0 1-9 0Z" fill="#0047A0"/><path d="M9 9l6 4.5M8 11.5l6 4.5M45 26l6 4.5M44 28.5l6 4.5" stroke="#000" stroke-width="1.6"/>' },
TW: { s: '<rect width="60" height="40" fill="#FE0000"/><rect width="30" height="20" fill="#000095"/><circle cx="15" cy="10" r="6" fill="#FFF"/><circle cx="15" cy="10" r="3.5" fill="#000095"/><circle cx="15" cy="10" r="2.5" fill="#FFF"/>' },
TH: { h: [["#A51931", 1], ["#F4F5F8", 1], ["#2D2A4A", 2], ["#F4F5F8", 1], ["#A51931", 1]] },
VN: { s: '<rect width="60" height="40" fill="#DA251D"/>' + star5(30, 20, 9, "#FFCD00") },
ID: { h: ["#E70011", "#FFF"] },
MY: { h: ["#CC0001", "#FFF", "#CC0001", "#FFF", "#CC0001", "#FFF", "#CC0001"], e: '<rect width="30" height="22.9" fill="#010066"/><circle cx="11" cy="11" r="6" fill="#FFCC00"/><circle cx="13.5" cy="11" r="5.2" fill="#010066"/>' + star5(21, 11, 4, "#FFCC00") },
PH: { s: '<rect width="60" height="20" fill="#0038A8"/><rect y="20" width="60" height="20" fill="#CE1126"/><path d="M0 0 24 20 0 40Z" fill="#FFF"/><circle cx="8" cy="20" r="3.5" fill="#FCD116"/>' },
EG: { h: ["#CE1126", "#FFF", "#000"], e: '<circle cx="30" cy="20" r="3.5" fill="#C09300"/>' },
ZA: { s: '<rect width="60" height="16" fill="#E03C31"/><rect y="16" width="60" height="8" fill="#007749"/><rect y="24" width="60" height="16" fill="#001489"/><rect y="13" width="60" height="3" fill="#FFF"/><rect y="24" width="60" height="3" fill="#FFF"/><path d="M0 0 28 20 0 40Z" fill="#007749"/><path d="M0 5 21 20 0 35Z" fill="#FFB81C"/><path d="M0 10 14 20 0 30Z" fill="#000"/>' },
NG: { v: ["#008751", "#FFF", "#008751"] },
MA: { s: '<rect width="60" height="40" fill="#C1272D"/>' + star5(30, 20, 8, "#006233") }
};
// Собрать SVG флага по ISO2-коду; "" — если страна не поддержана.
function flagSVG(cc) {
const d = FLAGS[cc];
if (!d) return "";
let b = "";
const stripes = (arr, horiz) => {
const total = arr.reduce((s, x) => s + (Array.isArray(x) ? x[1] : 1), 0);
let pos = 0;
arr.forEach((x) => {
const col = Array.isArray(x) ? x[0] : x;
const len = ((Array.isArray(x) ? x[1] : 1) / total) * (horiz ? 40 : 60);
// +0.3 — перекрытие полос, чтобы не было волосяных зазоров при масштабировании.
b += horiz
? '<rect x="0" y="' + pos.toFixed(2) + '" width="60" height="' + (len + 0.3).toFixed(2) + '" fill="' + col + '"/>'
: '<rect x="' + pos.toFixed(2) + '" y="0" width="' + (len + 0.3).toFixed(2) + '" height="40" fill="' + col + '"/>';
pos += len;
});
};
if (d.s) b += d.s;
if (d.h) stripes(d.h, true);
if (d.v) stripes(d.v, false);
if (d.n) {
// Скандинавский крест, смещённый к древку.
const t = d.n[2] ? 12 : 8;
b += '<rect width="60" height="40" fill="' + d.n[0] + '"/>';
b += '<rect x="' + (22 - t / 2) + '" width="' + t + '" height="40" fill="' + d.n[1] + '"/><rect y="' + (20 - t / 2) + '" width="60" height="' + t + '" fill="' + d.n[1] + '"/>';
if (d.n[2]) b += '<rect x="19.5" width="5" height="40" fill="' + d.n[2] + '"/><rect y="17.5" width="60" height="5" fill="' + d.n[2] + '"/>';
}
if (d.e) b += d.e;
return '<svg class="fl" viewBox="0 0 60 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' + b + '</svg>';
}
// ISO2 страны профиля: country от бэкенда, иначе имя/флаг, иначе алиасы опечаток.
function profileISO(p) {
if (!p) return "";
const c = String(p.country || "").toUpperCase();
if (/^[A-Z]{2}$/.test(c)) return c;
const rs = Array.from(String(p.flag || ""));
if (rs.length === 2) {
const a = rs[0].codePointAt(0) - 0x1f1e6, b = rs[1].codePointAt(0) - 0x1f1e6;
if (a >= 0 && a < 26 && b >= 0 && b < 26) return String.fromCharCode(65 + a, 65 + b);
}
return isoFromNameText(p.name) || isoFromNameText(p.host) || "";
}
// Заполнить ячейку флага: SVG для известной страны, буквенный код — фолбэк.
function setFlagEl(el, p) {
const cc = profileISO(p);
const svg = flagSVG(cc);
el.classList.remove("unknown");
if (svg) {
el.innerHTML = svg;
} else {
el.textContent = cc || "·";
el.classList.add("unknown");
}
el.title = cc ? (countryName(cc) || cc) : t("region_unknown");
}
function pluralRu(n, one, few, many) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
// ==== Navigation ====
document.querySelectorAll(".nav-btn").forEach((b) => {
b.addEventListener("click", () => showPage(b.dataset.page));
});
function showPage(name) {
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") {
renderStats();
refreshLogs().catch(() => {});
}
}
$("curServer").addEventListener("click", () => showPage("profiles"));
// ==== Theme ====
function readTheme() {
try {
const t = localStorage.getItem(THEME_KEY);
if (t === "light" || t === "dark") return t;
} catch (_) {}
return "light";
}
// Param must not be named `t` — that shadows the i18n helper and throws on title update,
// aborting script init so theme/lang click listeners never bind (4.2.5 regression).
function applyTheme(next) {
const theme = next === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
if (themeToggle) {
themeToggle.title = theme === "dark" ? t("theme_light") : t("theme_dark");
themeToggle.setAttribute("aria-label", themeToggle.title);
}
if (themeSwitch) themeSwitch.checked = theme === "dark";
}
applyTheme(document.documentElement.getAttribute("data-theme") || readTheme());
// Event delegation on stable parents (survives future re-renders; not disabled by busy).
const sideFoot = document.querySelector(".side-foot");
if (sideFoot) {
sideFoot.addEventListener("click", (e) => {
const langBtn = e.target.closest("button[data-lang]");
if (langBtn && sideFoot.contains(langBtn)) {
setLang(langBtn.getAttribute("data-lang"));
return;
}
const themeBtn = e.target.closest("#themeToggle, .theme-toggle");
if (themeBtn && sideFoot.contains(themeBtn)) {
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
applyTheme(cur === "dark" ? "light" : "dark");
}
});
}
if (themeSwitch) {
themeSwitch.addEventListener("change", () => applyTheme(themeSwitch.checked ? "dark" : "light"));
}
const langSegSettings = $("langSegSettings");
if (langSegSettings) {
langSegSettings.addEventListener("click", (e) => {
const langBtn = e.target.closest("button[data-lang]");
if (langBtn && langSegSettings.contains(langBtn)) {
setLang(langBtn.getAttribute("data-lang"));
}
});
}
applyTranslations();
function skippedVersion() {
try { return localStorage.getItem(SKIP_UPDATE_KEY) || ""; } catch (_) { return ""; }
}
function setSkippedVersion(v) {
try {
if (v) localStorage.setItem(SKIP_UPDATE_KEY, String(v));
else localStorage.removeItem(SKIP_UPDATE_KEY);
} catch (_) {}
}
const quitBtn = $("quitBtn");
if (window.__navisHttp && quitBtn) {
quitBtn.hidden = false;
quitBtn.style.display = "block";
quitBtn.addEventListener("click", () => {
withBusy(async () => {
setMeta(t("meta_quit"));
try { await quit(); } catch (_) {}
});
});
}
const subUrl = $("subUrl");
const subBtn = $("subBtn");
const hero = $("hero");
const protoChip = $("protoChip");
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
// Текст оффера — правьте здесь (показывается на карточке EvilFox на вкладке «Защита»).
// Правила Store: никаких цен и упоминаний оплаты внутри приложения.
function offerText() { return t("offer_text"); }
// Полезные ссылки (Помощь). Хост каждой ссылки — в allowlist openURL
// (internal/apphost/app.go → allowedLinkHosts).
const RECOMMENDED_SERVICES = [
{ icon: "🌐", nameKey: "svc_site", url: "https://evilfox.win/" },
{ icon: "✈️", nameKey: "svc_support", url: "https://t.me/evilfox100Bot" }
];
const AUTO_BEST_KEY = "navis.autoBest";
// Оффер на карточке EvilFox рендерим из константы, чтобы правка была в одном месте.
if ($("provGift")) $("provGift").textContent = offerText();
try {
const saved = localStorage.getItem(AUTO_BEST_KEY);
autoBest.checked = saved === "1";
} catch (_) {}
autoBest.addEventListener("change", () => {
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 ? t("ks_on") : t("ks_off"), "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) + " " + t("unit_sec");
const min = Math.floor(sec / 60);
if (min < 60) return min + " " + t("unit_min");
return Math.floor(min / 60) + " " + t("unit_h") + " " + (min % 60) + " " + t("unit_min");
}
function dayLabels() { return currentLang === "en" ? ["Su","Mo","Tu","We","Th","Fr","Sa"] : ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]; }
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: dayLabels()[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 = t("no_data");
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) : t("no_data_short");
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,
bbr_profile: $("hy2Bbr").value,
bandwidth_up: $("hy2Up").value.trim(),
bandwidth_down: $("hy2Down").value.trim(),
obfs: $("hy2Obfs").value,
obfs_password: $("hy2ObfsPass").value.trim(),
sni: $("hy2Sni").value.trim(),
insecure: !!$("hy2Insecure").checked,
pin_sha256: $("hy2Pin").value.trim(),
fast_open: !!$("hy2Fast").checked,
lazy: !!$("hy2Lazy").checked,
hop_interval: $("hy2Hop").value.trim()
};
}
function fillHy2(h) {
if (!h) return;
if (h.congestion) $("hy2Congestion").value = h.congestion;
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
$("hy2Up").value = h.bandwidth_up || "";
$("hy2Down").value = h.bandwidth_down || "";
$("hy2Obfs").value = h.obfs || "";
$("hy2ObfsPass").value = h.obfs_password || "";
$("hy2Sni").value = h.sni || "";
$("hy2Pin").value = h.pin_sha256 || "";
$("hy2Hop").value = h.hop_interval || "";
$("hy2Insecure").checked = !!h.insecure;
$("hy2Fast").checked = !!h.fast_open;
$("hy2Lazy").checked = !!h.lazy;
}
async function persistHy2IfNeeded(protocol) {
const p = (protocol || "").toLowerCase();
const link = ((proxy && proxy.value) || "").trim().toLowerCase();
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
await saveHy2(readHy2());
}
}
let busy = false;
let connected = false;
let ksActive = false; // Kill Switch сработал: трафик заблокирован намеренно
let formHydrated = false;
let dirty = false;
let profiles = [];
let pingMap = {};
let metaHoldUntil = 0;
function markDirty() { dirty = true; }
[
proxy, nameInput, sysproxy, subUrl,
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
].forEach((el) => {
if (!el) return;
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
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");
if (kind === "ok" || kind === "err") metaHoldUntil = Date.now() + 8000;
}
function detectProtoLabel(state, active) {
const p = (state.protocol || active.protocol || "").toLowerCase();
const link = (typeof state.proxy === "string" ? state.proxy : (active.proxy || "")).trim().toLowerCase();
if (p) return p;
if (link.startsWith("vless://")) return "vless";
if (link.startsWith("vmess://")) return "vmess";
if (link.startsWith("trojan://")) return "trojan";
if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) return "hysteria2";
if (link.startsWith("awg://") || link.includes("[interface]")) return "awg";
if (link.startsWith("naive") || link.startsWith("https://") || link.startsWith("quic://")) return "naive";
return t("proto_generic");
}
function msClass(ms, ok) {
if (!ok) return "bad";
if (ms < 80) return "good";
if (ms < 180) return "ok";
if (ms < 350) return "mid";
return "bad";
}
function rememberPings(pings) {
pingMap = {};
(pings || []).forEach((p) => { pingMap[p.name] = p; });
rebuildFriendlyNames();
}
// Человекочитаемые имена серверов («Чехия — оптимальный сервер», «Германия — сервер 2»).
// Профили с одним физическим хостом (IP или hostname) группируются в одну строку;
// протоколы (VLESS / Trojan / Hysteria2…) показываются чипами, а не «сервер 2/3».
// Реальный host:port — только в свойствах сервера и в журнале.
// ISO всегда через profileISO → countryRu, никогда сырое имя панели.
let friendlyMap = {};
function isLiteralIP(host) {
const h = String(host || "").replace(/^\[|\]$/g, "");
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(h)) return true;
if (h.includes(":") && /^[0-9a-fA-F:.]+$/.test(h)) return true;
return false;
}
// Хост без схемы/userinfo/порта — ключ группировки «один физический сервер».
function normalizeEndpointHost(raw) {
let s = String(raw || "").trim();
if (!s) return "";
const lower = s.toLowerCase();
if (lower.includes("[interface]")) {
for (const line of s.split(/\r?\n/)) {
const t = line.trim();
if (/^endpoint\s*[=:]/i.test(t)) {
s = t.replace(/^endpoint\s*[=:]\s*/i, "").trim();
break;
}
}
}
const scheme = s.indexOf("://");
if (scheme >= 0) s = s.slice(scheme + 3);
const at = s.lastIndexOf("@");
if (at >= 0) s = s.slice(at + 1);
const cut = s.search(/[/?#]/);
if (cut >= 0) s = s.slice(0, cut);
s = s.trim();
let host = s;
if (s.startsWith("[")) {
const end = s.indexOf("]");
if (end >= 0) host = s.slice(1, end);
} else {
const col = s.lastIndexOf(":");
// Один «:» → host:port; несколько → скорее IPv6 без скобок.
if (col > 0 && s.indexOf(":") === col) host = s.slice(0, col);
}
return host.replace(/^\[|\]$/g, "").toLowerCase();
}
// Ключ группы: ip:<addr> если известен IP (пинг или литерал в URI), иначе host:<hostname>.
// Порт в ключ не входит — варианты протоколов на разных портах одного хоста = один сервер.
function profileEndpointKey(p) {
if (!p) return "";
const pr = pingMap[p.name];
if (pr && pr.host) {
const ph = String(pr.host).replace(/^\[|\]$/g, "").toLowerCase();
if (isLiteralIP(ph)) return "ip:" + ph;
}
const raw = (p.host && String(p.host).trim()) || (p.proxy && String(p.proxy).trim()) || "";
const host = normalizeEndpointHost(raw);
if (!host) return "id:" + String(p.name || "");
if (isLiteralIP(host)) return "ip:" + host;
return "host:" + host;
}
function protoPretty(proto) {
const p = String(proto || "").toLowerCase();
const map = {
vless: "VLESS", vmess: "VMess", trojan: "Trojan",
hysteria2: "Hysteria2", hy2: "Hysteria2",
naive: "Naive", awg: "AWG"
};
if (map[p]) return map[p];
return p ? (p.charAt(0).toUpperCase() + p.slice(1)) : "?";
}
function uniqueProtos(members) {
const seen = {};
const out = [];
(members || []).forEach((m) => {
const raw = String(m.protocol || "").toLowerCase() || "?";
const label = protoPretty(raw);
if (seen[label]) return;
seen[label] = true;
out.push({ raw: raw, label: label, name: m.name });
});
out.sort((a, b) => a.label.localeCompare(b.label));
return out;
}
function groupBestPing(members) {
let best = null;
(members || []).forEach((m) => {
const pr = pingMap[m.name];
if (!pr) return;
if (pr.ok) {
if (!best || !best.ok || pr.ms < best.ms) best = pr;
} else if (!best) {
best = pr;
}
});
return best;
}
function pickGroupProfile(members, preferredName) {
const list = members || [];
if (!list.length) return null;
if (preferredName && list.some((m) => m.name === preferredName)) {
return list.find((m) => m.name === preferredName);
}
let best = list[0], bestMs = Infinity;
list.forEach((m) => {
const pr = pingMap[m.name];
if (pr && pr.ok && pr.ms < bestMs) { bestMs = pr.ms; best = m; }
});
return best;
}
function rebuildFriendlyNames() {
friendlyMap = {};
const byCountry = {};
profiles.forEach((p) => {
const iso = profileISO(p) || "__none__";
const ek = profileEndpointKey(p);
if (!byCountry[iso]) byCountry[iso] = {};
(byCountry[iso][ek] = byCountry[iso][ek] || []).push(p);
});
Object.keys(byCountry).forEach((iso) => {
const hostMap = byCountry[iso];
const hostGroups = Object.keys(hostMap).map((ek) => {
const list = hostMap[ek].slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
let bestMs = Infinity;
list.forEach((p) => {
const pr = pingMap[p.name];
if (pr && pr.ok && pr.ms < bestMs) bestMs = pr.ms;
});
return { ek: ek, list: list, bestMs: bestMs };
});
// Стабильная нумерация по имени; «оптимальный» — лучший пинг среди РАЗНЫХ хостов.
hostGroups.sort((a, b) => String(a.list[0].name).localeCompare(String(b.list[0].name)));
let bestIdx = 0, bestMsVal = Infinity;
hostGroups.forEach((g, i) => {
if (g.bestMs < bestMsVal) { bestMsVal = g.bestMs; bestIdx = i; }
});
const region = iso === "__none__" ? "" : countryName(iso);
hostGroups.forEach((g, i) => {
const n = i + 1;
let title;
if (i === bestIdx) {
title = region ? t("title_optimal", { region: region }) : t("title_server_optimal", { n: n });
} else {
title = region ? t("title_server_n", { region: region, n: n }) : t("title_server", { n: n });
}
const protos = uniqueProtos(g.list);
const groupNames = g.list.map((x) => x.name);
g.list.forEach((p) => {
friendlyMap[p.name] = {
title: title,
region: region,
iso: iso === "__none__" ? "" : iso,
endpointKey: g.ek,
protocols: protos,
groupNames: groupNames
};
});
});
});
}
function friendlyTitle(name) {
const f = friendlyMap[name];
if (f && f.title) return f.title;
// Фолбэк: не показываем сырые опечатки панели — только каноническое имя по ISO.
const p = profiles.find((x) => x.name === name);
const iso = profileISO(p) || isoFromNameText(name);
const region = countryName(iso);
if (region) return region;
return t("server_generic");
}
function fillProfiles(list, active) {
profiles = list || [];
rebuildFriendlyNames();
const cur = profile.value;
profile.innerHTML = "";
profiles.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = p.name;
profile.appendChild(opt);
});
const want = active || cur || (profiles[0] && profiles[0].name);
if (want) profile.value = want;
renderServerList(want);
renderCurServer(want);
}
// Группы «один хост = один сервер» для списка; сортировка по лучшему пингу группы.
function orderedServerGroups() {
const seen = {};
const groups = [];
profiles.forEach((p) => {
const f = friendlyMap[p.name];
const ek = (f && f.endpointKey) || profileEndpointKey(p);
const iso = (f && f.iso) || profileISO(p) || "__none__";
const id = iso + "|" + ek;
if (seen[id]) return;
seen[id] = true;
const names = (f && f.groupNames) || [p.name];
const members = names.map((n) => profiles.find((x) => x.name === n)).filter(Boolean);
if (!members.length) return;
groups.push({
id: id,
endpointKey: ek,
iso: iso === "__none__" ? "" : iso,
members: members,
title: friendlyTitle(members[0].name),
protocols: (f && f.protocols) || uniqueProtos(members),
ping: groupBestPing(members)
});
});
groups.sort((a, b) => {
const pa = a.ping, pb = b.ping;
const oa = pa && pa.ok, ob = pb && pb.ok;
if (oa !== ob) return oa ? -1 : 1;
if (oa && ob && pa.ms !== pb.ms) return pa.ms - pb.ms;
return String(a.title).localeCompare(String(b.title));
});
return groups;
}
function renderCurServer(activeName) {
const p = profiles.find((x) => x.name === (activeName || profile.value));
if (!p) {
setFlagEl($("curFlag"), null);
$("curName").textContent = t("no_server");
$("curHost").textContent = t("no_server_hint");
$("curProto").textContent = "—";
$("curPing").textContent = "…";
$("curPing").className = "ms";
return;
}
setFlagEl($("curFlag"), p);
$("curName").textContent = friendlyTitle(p.name);
$("curHost").textContent = t("pick_other_server");
$("curProto").textContent = protoPretty(p.protocol);
const f = friendlyMap[p.name];
const members = (f && f.groupNames)
? f.groupNames.map((n) => profiles.find((x) => x.name === n)).filter(Boolean)
: [p];
const pr = groupBestPing(members) || pingMap[p.name];
const el = $("curPing");
if (pr && pr.ok) {
el.className = "ms " + msClass(pr.ms, true);
el.textContent = pr.ms + " ms";
el.title = "";
} else if (pr && pr.error) {
el.className = "ms bad";
el.textContent = "—";
el.title = pr.error;
} else {
// Пинг ещё не измерялся — показываем «—», а не пустоту.
el.className = "ms";
el.textContent = "—";
el.title = t("ping_not_measured");
}
}
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedServerGroups();
// Количество серверов — только здесь, мелкой приглушённой подписью (группы, не профили).
const srvCount = $("srvCount");
if (srvCount) {
srvCount.textContent = list.length
? "· " + list.length + " " + pluralServers(list.length)
: "";
}
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = t("no_servers_empty");
serverList.appendChild(empty);
return;
}
list.forEach((g) => {
const selected = pickGroupProfile(g.members, active);
const isActive = g.members.some((m) => m.name === active);
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (isActive ? " active" : "");
row.dataset.name = selected ? selected.name : g.members[0].name;
const left = document.createElement("div");
left.className = "left";
const flag = document.createElement("span");
flag.className = "flag";
setFlagEl(flag, selected || g.members[0]);
const metaCol = document.createElement("div");
metaCol.className = "meta-col";
const nameRow = document.createElement("div");
nameRow.className = "name-row";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = g.title;
nameRow.appendChild(nameEl);
if (isActive) {
const badge = document.createElement("span");
badge.className = "badge-active";
badge.textContent = t("badge_active");
nameRow.appendChild(badge);
}
metaCol.appendChild(nameRow);
if (g.protocols && g.protocols.length) {
const protos = document.createElement("div");
protos.className = "protos";
g.protocols.forEach((pr) => {
// span, не button — строка сервера уже <button>, вложенные button недопустимы.
const tag = document.createElement("span");
tag.className = "proto-tag" + (isActive && active === pr.name ? " selected" : "");
tag.textContent = pr.label;
tag.title = t("proto_title", { label: pr.label });
tag.setAttribute("role", "button");
tag.tabIndex = 0;
tag.addEventListener("click", (e) => {
e.stopPropagation();
onServerClick(pr.name);
});
tag.addEventListener("dblclick", (e) => {
e.stopPropagation();
onServerConnect(pr.name);
});
tag.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onServerClick(pr.name);
}
});
protos.appendChild(tag);
});
metaCol.appendChild(protos);
}
left.appendChild(flag);
left.appendChild(metaCol);
const right = document.createElement("div");
right.className = "right";
const ms = document.createElement("span");
const pr = g.ping;
if (pr && pr.ok) {
ms.className = "ms " + msClass(pr.ms, true);
ms.textContent = pr.ms + " ms";
} else if (pr && pr.error) {
ms.className = "ms bad";
ms.textContent = "—";
ms.title = pr.error;
} else {
ms.className = "ms";
ms.textContent = "—";
ms.title = t("ping_not_measured");
}
right.appendChild(ms);
// [📊] — пинг всех протоколов группы (лучший показывается в строке).
const pingOne = document.createElement("button");
pingOne.type = "button";
pingOne.className = "row-ping";
pingOne.textContent = "📊";
pingOne.title = t("ping_this_server");
pingOne.addEventListener("click", (e) => {
e.stopPropagation();
runPingGroup(g.members, pingOne, ms);
});
right.appendChild(pingOne);
row.appendChild(left);
row.appendChild(right);
const rowName = selected ? selected.name : g.members[0].name;
row.addEventListener("click", () => onServerClick(rowName));
row.addEventListener("dblclick", () => onServerConnect(rowName));
serverList.appendChild(row);
});
}
// Пинг всех профилей группы (один хост, разные протоколы) по кнопке [📊].
async function runPingGroup(members, btnEl, msEl) {
if (btnEl.disabled) return;
btnEl.disabled = true;
const prevText = msEl.textContent;
msEl.textContent = "…";
try {
for (const m of members || []) {
const res = await pingServer(m.name);
if (res) pingMap[m.name] = res;
}
rebuildFriendlyNames();
renderServerList(profile.value);
renderCurServer(profile.value);
} catch (e) {
msEl.textContent = prevText;
setMeta(t("ping_fail") + ": " + String(e), "err");
btnEl.disabled = false;
}
}
async function onServerClick(name) {
if (busy) return;
if (connected) {
if (name !== profile.value) await onServerConnect(name);
return;
}
await withBusy(async () => {
await selectProfile(name);
dirty = false;
formHydrated = false;
});
}
async function onServerConnect(name) {
if (busy) return;
await withBusy(async () => {
setMeta(t("connecting_to", { name: friendlyTitle(name) }));
await connectProfile(name);
setMeta(t("connected_to", { name: friendlyTitle(name) }), "ok");
});
}
function fmtTraffic(bytes) {
if (!bytes || bytes <= 0) return "0 " + t("unit_gb");
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1024) {
const tb = gb / 1024;
return (tb >= 100 ? Math.round(tb) : tb.toFixed(1)) + " " + t("unit_tb");
}
if (gb >= 100) return Math.round(gb) + " " + t("unit_gb");
if (gb >= 1) return gb.toFixed(1) + " " + t("unit_gb");
const mb = bytes / (1024 * 1024);
return (mb >= 1 ? Math.round(mb) : 1) + " " + t("unit_mb");
}
function fmtDate(unix) {
const d = new Date(unix * 1000);
try {
return d.toLocaleDateString(currentLang === "en" ? "en-US" : "ru-RU", { day: "numeric", month: "long", year: "numeric" });
} catch (_) {
return d.toISOString().slice(0, 10);
}
}
// 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) {
// Не показываем фейковый переключатель там, где VPN-режима нет (macOS).
modeSection.hidden = !state.vpn_mode_supported;
}
if (modeProxyBtn) modeProxyBtn.classList.toggle("active", currentMode === "proxy");
if (modeVpnBtn) modeVpnBtn.classList.toggle("active", currentMode === "vpn");
if (vpnChip) vpnChip.hidden = !state.vpn_active;
}
// Прогресс-бар трафика на «Защите» и «Статистике».
// Заголовок — «📊 Доступно X ГБ», полоса и подпись — процент ИЗРАСХОДОВАННОГО
// («Z% использовано»), чтобы цифры и полоса были в одной системе координат.
function renderTraffic(info) {
const hasTraffic = !!(info && (info.total || info.download || info.upload));
[["trafficCard", "trafficLabel", "trafficPct", "trafficFill", "trafficExp"],
["trafficCard2", "trafficLabel2", "trafficPct2", "trafficFill2", "trafficExp2"]].forEach((ids) => {
const card = $(ids[0]);
if (!card) return;
if (!hasTraffic) { card.hidden = true; return; }
card.hidden = false;
const used = (info.upload || 0) + (info.download || 0);
const label = $(ids[1]), pct = $(ids[2]), fill = $(ids[3]), exp = $(ids[4]);
if (info.total > 0) {
const left = Math.max(0, info.total - used);
const usedPct = Math.min(100, Math.round(used / info.total * 100));
// «из Y ГБ» показываем только когда лимит уже начали расходовать.
label.textContent = used > 0
? t("traffic_avail_of", { left: fmtTraffic(left), total: fmtTraffic(info.total) })
: t("traffic_avail", { left: fmtTraffic(left) });
pct.textContent = t("pct_used", { pct: usedPct });
fill.style.width = usedPct + "%";
fill.classList.toggle("warn", usedPct >= 85);
} else {
label.textContent = t("traffic_used_unlim", { used: fmtTraffic(used) });
pct.textContent = "";
fill.style.width = "100%";
fill.classList.remove("warn");
}
if (exp) {
// Реальная дата окончания; «сгорает сегодня» — только когда
// календарный день окончания совпадает с сегодняшним.
let expText = "";
if (info.expire) {
const expDate = new Date(info.expire * 1000);
const now = new Date();
if (expDate.getTime() < now.getTime() && !isSameCalendarDay(expDate, now)) {
expText = t("expired_on", { date: fmtDateShort(info.expire) });
} else if (isSameCalendarDay(expDate, now)) {
expText = t("burns_today");
} else {
expText = t("active_until", { date: fmtDateShort(info.expire) });
}
}
exp.hidden = !expText;
exp.textContent = expText;
}
});
}
function renderSubInfo(info) {
renderTraffic(info);
trackTraffic(info);
const card = $("subInfoCard");
if (!card) return;
const expireRow = $("subExpireRow"), trafficRow = $("subTrafficRow"), devicesRow = $("subDevicesRow");
const hasExpire = !!(info && info.expire);
const hasTraffic = !!(info && (info.total || info.download || info.upload));
const hasDevices = !!(info && info.devices_known);
if (!info || (!hasExpire && !hasTraffic && !hasDevices)) {
card.hidden = true;
return;
}
card.hidden = false;
expireRow.hidden = !hasExpire;
if (hasExpire) {
const d = new Date(info.expire * 1000);
const text = fmtDate(info.expire);
const daysLeft = Math.floor((d.getTime() - Date.now()) / 86400000);
const v = $("subExpireVal");
if (daysLeft < 0) {
v.textContent = text + " · " + t("expired");
v.classList.add("warn");
} else if (daysLeft === 0) {
v.textContent = text + " · " + t("today");
v.classList.add("warn");
} else if (daysLeft <= 14) {
v.textContent = text + " · " + t("days_left", { n: daysLeft });
v.classList.toggle("warn", daysLeft <= 3);
} else {
v.textContent = text;
v.classList.remove("warn");
}
}
trafficRow.hidden = !hasTraffic;
if (hasTraffic) {
const used = (info.upload || 0) + (info.download || 0);
$("subTrafficVal").textContent = info.total > 0
? t("of_pair", { a: fmtTraffic(used), b: fmtTraffic(info.total) })
: (fmtTraffic(used) + " · " + t("unlim"));
}
devicesRow.hidden = !hasDevices;
if (hasDevices) {
const n = info.devices || 0;
$("subDevicesVal").textContent = info.device_limit > 0 ? t("of_pair", { a: n, b: info.device_limit }) : String(n);
}
}
function renderUpdate(u, version) {
// Версия — только в «Помощь»; в сайдбаре бренд без номера.
if (verLabel) verLabel.textContent = "EvilFox";
if (aboutVer) aboutVer.textContent = t("version_label", { v: version || "?" });
if (badgeVer) {
badgeVer.hidden = true;
badgeVer.textContent = "";
}
if (storePackaged || (u && u.store_managed)) {
updateBanner.classList.remove("show");
if (updCheckBtn) updCheckBtn.hidden = true;
if (aboutUpdBtn) aboutUpdBtn.hidden = true;
if (storeUpdateNote) storeUpdateNote.hidden = false;
return;
}
if (updCheckBtn) updCheckBtn.hidden = false;
if (aboutUpdBtn) aboutUpdBtn.hidden = false;
if (storeUpdateNote) storeUpdateNote.hidden = true;
if (!u) {
updateBanner.classList.remove("show");
return;
}
const latest = (u.latest || "").toString();
const skip = skippedVersion();
if (u.available && latest && skip === latest) {
updateBanner.classList.remove("show");
return;
}
if (u.available) {
updateBanner.classList.add("show");
$("updateTitle").textContent = t("update_available_v", { v: latest });
$("updateNotes").textContent = u.notes || t("update_notes_fallback", { v: u.current || version || "?" });
updateBanner.dataset.latest = latest;
} else {
updateBanner.classList.remove("show");
}
}
function paint(state, opts) {
const syncForm = opts && opts.syncForm;
storePackaged = !!(state && state.store_packaged);
connected = !!state.connected;
ksActive = !!state.kill_switch_active;
trackConnection(connected);
hero.classList.toggle("on", connected);
btn.classList.toggle("on", connected || ksActive);
if (ksActive) {
statusText.textContent = t("status_blocked");
if (powerLabel) powerLabel.textContent = t("power_disconnect");
} else {
statusText.textContent = connected ? t("status_on") : t("status_off");
if (powerLabel) powerLabel.textContent = connected ? t("power_disconnect") : t("power_connect");
}
btn.title = (connected || ksActive) ? t("btn_disconnect") : t("btn_connect");
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 ? t("config_added") : t("config_missing");
}
const lock = connected || busy;
if (proxy) proxy.disabled = lock;
if (nameInput) nameInput.disabled = lock;
sysproxy.disabled = lock;
profile.disabled = lock;
if (addBtn) addBtn.disabled = lock;
if (delBtn) delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
if (saveBtn) saveBtn.disabled = lock;
if (coreBtn) coreBtn.disabled = busy;
pingBtn.disabled = busy;
bestBtn.disabled = busy;
updCheckBtn.disabled = busy;
if (aboutUpdBtn) aboutUpdBtn.disabled = busy;
updateBtn.disabled = busy;
subBtn.disabled = busy;
subUrl.disabled = busy;
if (modeProxyBtn) modeProxyBtn.disabled = busy || connected;
if (modeVpnBtn) modeVpnBtn.disabled = busy || connected;
btn.disabled = busy;
btn.classList.toggle("busy", busy);
rememberPings(state.pings || []);
fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderUpdate(state.update, state.version);
renderSubInfo(state.sub_info);
renderMode(state);
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
if (nameInput) nameInput.value = active.name || state.active_profile || "";
if (proxy) proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
fillHy2(state.hy2);
formHydrated = true;
if (syncForm) dirty = false;
}
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
const label = detectProtoLabel(state, active);
// Чип протокола в hero убран (дублировал карточку активного сервера) — guard на случай возврата.
if (protoChip) {
protoChip.textContent = label;
protoChip.classList.toggle("active", !!state.connected || label !== t("proto_generic"));
}
const hy2 = (label === "hysteria2" || label === "hy2");
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
// Параметры прокси — только когда подключены (без пустого «появится после…»).
const advHttp = $("advHttp"), advSocks = $("advSocks"), logsProxyLine = $("logsProxyLine");
const proxyParamsBlock = $("proxyParamsBlock");
const hasProxyParams = !!(state.http_proxy || state.socks_proxy);
if (proxyParamsBlock) proxyParamsBlock.hidden = !hasProxyParams;
if (advHttp) advHttp.textContent = state.http_proxy || "—";
if (advSocks) advSocks.textContent = state.socks_proxy || "—";
if (logsProxyLine) {
const parts = [];
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
logsProxyLine.hidden = !parts.length;
logsProxyLine.textContent = parts.length ? (t("proxy_line") + ": " + parts.join(" · ")) : "";
}
renderProvConnect();
renderProfProps();
// Нижняя полоса статуса — только для важных сообщений; постоянного
// «Готово к подключению» больше нет (статус виден на большой кнопке).
let detail = "", detailKind = "";
if (ksActive) {
heroHint.textContent = t("hint_ks");
detail = t("detail_ks");
detailKind = "err";
} else if (connected) {
heroHint.textContent = t("hint_encrypted");
} else if (state.core_ready === false) {
detail = t("detail_cores");
detailKind = "err";
heroHint.textContent = t("hint_cores");
} else {
heroHint.textContent = currentMode === "vpn" ? t("hint_vpn") : t("hint_idle");
}
if (!busy && Date.now() > metaHoldUntil) {
setMeta(detail, detailKind);
metaHoldUntil = 0;
}
renderStats();
}
// Кнопка «Подключиться» на карточке EvilFox (вкладка «Защита»).
function renderProvConnect() {
const b = $("provConnectBtn");
if (!b) return;
if (connected) {
b.textContent = t("prov_connected");
b.disabled = true;
} else {
b.textContent = t("prov_connect");
b.disabled = busy;
}
}
// Свойства выбранного сервера: имя / пинг / протокол / статус.
function renderProfProps() {
const p = profiles.find((x) => x.name === profile.value);
const nameEl = $("propName"), pingEl = $("propPing"), protoEl = $("propProto"), stEl = $("propStatus");
if (!nameEl) return;
if (!p) {
nameEl.textContent = "—";
if (pingEl) pingEl.textContent = "—";
if (protoEl) protoEl.textContent = "—";
if (stEl) stEl.textContent = "—";
return;
}
nameEl.textContent = friendlyTitle(p.name);
const pr = pingMap[p.name];
if (pingEl) {
if (pr && pr.ok) pingEl.textContent = pr.ms + " ms";
else if (pr && pr.error) pingEl.textContent = t("unavailable");
else pingEl.textContent = t("not_measured");
}
if (protoEl) {
protoEl.textContent = p.protocol ? protoPretty(p.protocol) : "—";
}
if (stEl) stEl.textContent = connected ? t("prop_active") : t("prop_inactive");
}
async function refresh(opts) {
const state = await getState();
paint(state, opts);
return state;
}
async function withBusy(fn) {
if (busy) return;
busy = true;
paintButtonsLocked(true);
try {
await fn();
} finally {
busy = false;
try {
await refresh({ syncForm: true });
} catch (e) {
setMeta(String(e), "err");
paintButtonsLocked(false);
}
}
}
function paintButtonsLocked(locked) {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, aboutUpdBtn,
updateBtn, skipUpdateBtn, subBtn, subUrl, modeProxyBtn, modeVpnBtn, logsRefreshBtn,
$("provConnectBtn")].forEach((b) => {
if (b) b.disabled = locked;
});
btn.classList.toggle("busy", locked);
}
async function runBest(autoConnect) {
setMeta(autoConnect ? t("meta_ping_auto") : t("meta_ping_best"));
const res = await pingBest(!!autoConnect);
rememberPings(res.pings || []);
renderServerList(res.best_name || profile.value);
renderCurServer(res.best_name || profile.value);
if (res.best_name) {
setMeta(t("meta_best", { name: friendlyTitle(res.best_name), ms: res.best_ms }) + (res.connected ? " · " + t("meta_connected_short") : ""), "ok");
}
return res;
}
profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value);
dirty = false;
formHydrated = false;
}));
btn.addEventListener("click", () => withBusy(async () => {
try {
if (connected || ksActive) {
// ksActive: отключение снимает блокировку Kill Switch и
// восстанавливает обычную сеть.
setMeta(t("meta_disconnecting"));
await disconnect();
setMeta(t("meta_disconnected"), "ok");
} else if (autoBest.checked && profiles.length > 1) {
// «Авто-лучший сервер»: пинг всех и подключение к самому быстрому.
await runBest(true);
} else {
setMeta(t("meta_connecting"));
// Профиль уже выбран; скрытые поля name/proxy гидратируются из state.
// Не перезаписываем proxy пустой строкой, если форма ещё не заполнена.
const n = (nameInput && nameInput.value.trim()) || profile.value;
const px = (proxy && proxy.value.trim()) || "";
if (n && px) {
await saveProfile(n, px, !!sysproxy.checked);
await persistHy2IfNeeded();
}
await connect();
setMeta(t("meta_connected"), "ok");
}
} catch (e) { setMeta(String(e), "err"); }
}));
if (saveBtn) saveBtn.addEventListener("click", () => withBusy(async () => {
try {
const n = (nameInput && nameInput.value.trim()) || profile.value;
const px = (proxy && proxy.value.trim()) || "";
await saveProfile(n, px, !!sysproxy.checked);
await persistHy2IfNeeded();
setMeta(t("meta_saved"), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
if (coreBtn) coreBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta(t("meta_cores_dl"));
const path = await installCore();
setMeta(t("meta_cores_ok", { path: path }), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
if (delBtn) delBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm(t("confirm_delete", { name: friendlyTitle(profile.value) }))) return;
await deleteProfile(profile.value);
formHydrated = false;
dirty = false;
setMeta(t("meta_deleted"), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
if (addBtn) addBtn.addEventListener("click", () => {
$("newName").value = "";
$("newProxy").value = "";
modal.classList.add("open");
$("newName").focus();
});
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.remove("open");
});
$("createNew").addEventListener("click", () => withBusy(async () => {
try {
const n = $("newName").value.trim();
const p = $("newProxy").value.trim();
if (!n) throw t("err_need_name");
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
formHydrated = false;
dirty = false;
setMeta(t("meta_created"), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
async function openShop(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
setMeta(t("meta_opening_site"));
await openURL(SHOP_URL);
setMeta(t("meta_opened"), "ok");
} catch (err) {
setMeta(t("meta_open_fail") + ": " + String(err), "err");
}
}
shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop);
// «Рекомендуемые сервисы» → «Полезные ссылки» (см. RECOMMENDED_SERVICES).
function renderRecommendedServices() {
const grid = $("svcGrid");
if (!grid) return;
grid.innerHTML = "";
RECOMMENDED_SERVICES.forEach((s) => {
const b = document.createElement("button");
b.type = "button";
b.className = "svc-link";
b.title = s.url;
const ic = document.createElement("span");
ic.className = "ic";
ic.textContent = s.icon || "•";
const nm = document.createElement("span");
nm.textContent = t(s.nameKey);
b.append(ic, nm);
b.addEventListener("click", async () => {
try {
setMeta(t("meta_opening_named", { name: t(s.nameKey) }));
await openURL(s.url);
setMeta(t("meta_opened"), "ok");
} catch (err) {
setMeta(t("meta_open_fail") + ": " + String(err), "err");
}
});
grid.appendChild(b);
});
}
renderRecommendedServices();
async function runImportSubscription() {
const url = subUrl.value.trim();
if (!url) {
setMeta(t("meta_need_url"), "err");
return;
}
setMeta(t("meta_adding_config"));
const r = await importSubscription(url);
formHydrated = false;
dirty = false;
subUrl.value = "";
setMeta(importSummary(r) + " · " + t("meta_picking_best"), "ok");
await runBest(!!autoBest.checked);
}
function importSummary(r) {
if (r && typeof r === "object") {
let s = t("import_ok", { n: r.imported || 0 });
if (r.skipped > 0) s += " (" + t("import_skipped", { n: r.skipped }) + ")";
return s;
}
return t("import_ok_raw", { r: r });
}
// «Где взять URL-ссылку?» — открывает evilfox.win в браузере.
const buyKeyLink = $("buyKeyLink");
if (buyKeyLink) buyKeyLink.addEventListener("click", openShop);
// ==== Вкладка «Защита»: карточка EvilFox ====
const provConnectBtn = $("provConnectBtn");
if (provConnectBtn) provConnectBtn.addEventListener("click", () => {
if (!connected && !busy) btn.click();
});
subBtn.addEventListener("click", () => withBusy(async () => {
try {
await runImportSubscription();
} catch (e) { setMeta(String(e), "err"); }
}));
subUrl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
subBtn.click();
}
});
async function switchMode(mode) {
if (mode === currentMode) return;
await withBusy(async () => {
try {
await setMode(mode);
setMeta(mode === "vpn" ? t("meta_mode_vpn") : t("meta_mode_proxy"), "ok");
} catch (e) { setMeta(String(e), "err"); }
});
}
if (modeProxyBtn) modeProxyBtn.addEventListener("click", () => switchMode("proxy"));
if (modeVpnBtn) modeVpnBtn.addEventListener("click", () => switchMode("vpn"));
async function refreshLogs() {
try {
const text = await getLogs();
logView.textContent = (text && text.trim()) ? text : t("log_empty");
logView.scrollTop = logView.scrollHeight;
} catch (e) {
logView.textContent = t("log_fail") + ": " + String(e);
}
}
if (logsRefreshBtn) logsRefreshBtn.addEventListener("click", () => refreshLogs());
if (logsClearBtn) logsClearBtn.addEventListener("click", () => { logView.textContent = ""; });
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta(t("meta_pinging"));
const rows = await pingServers();
rememberPings(rows);
renderServerList(profile.value);
renderCurServer(profile.value);
const ok = (rows || []).filter((r) => r.ok).length;
setMeta(t("meta_ping_result", { ok: ok, total: (rows || []).length }), ok ? "ok" : "err");
} catch (e) { setMeta(String(e), "err"); }
}));
bestBtn.addEventListener("click", () => withBusy(async () => {
try {
await runBest(!!autoBest.checked);
} catch (e) { setMeta(String(e), "err"); }
}));
async function runUpdateCheck() {
try {
setMeta(t("meta_checking_upd"));
const st = await checkUpdate();
renderUpdate(st, st.current);
if (st.available) setMeta(t("meta_upd_avail", { v: st.latest }), "ok");
else if (st.error) setMeta(t("meta_upd_err", { e: st.error }), "err");
else setMeta(t("meta_upd_current", { v: st.current }), "ok");
} catch (e) { setMeta(String(e), "err"); }
}
updCheckBtn.addEventListener("click", () => withBusy(runUpdateCheck));
if (aboutUpdBtn) aboutUpdBtn.addEventListener("click", () => withBusy(runUpdateCheck));
function openHelpLegal(id) {
const el = $(id);
if (!el) return;
el.open = true;
try { el.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {
try { el.scrollIntoView(true); } catch (__) {}
}
}
if ($("jumpPrivacy")) $("jumpPrivacy").addEventListener("click", () => openHelpLegal("helpPrivacy"));
if ($("jumpTerms")) $("jumpTerms").addEventListener("click", () => openHelpLegal("helpTerms"));
updateBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta(t("meta_upd_install"));
const msg = await applyUpdate();
setMeta(String(msg || t("meta_restart")), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
skipUpdateBtn.addEventListener("click", () => {
const latest = updateBanner.dataset.latest || "";
if (latest) setSkippedVersion(latest);
updateBanner.classList.remove("show");
setMeta(t("meta_upd_skipped", { v: latest || "?" }), "ok");
});
(async () => {
try {
await refresh({ syncForm: true });
if (profiles.length > 1 && autoBest.checked && !connected) {
await withBusy(async () => { await runBest(true); });
}
} catch (e) {
setMeta(String(e), "err");
}
})();
setInterval(() => {
if (busy || modal.classList.contains("open")) return;
refresh().catch(() => {});
if (currentPage === "logs") refreshLogs().catch(() => {});
}, 2500);
</script>
</body>
</html>