Files
navi/internal/appui/index.html
T

2910 lines
123 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">
<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 = "dark";
}
document.documentElement.setAttribute("data-theme", t);
} catch (_) {
document.documentElement.setAttribute("data-theme", "dark");
}
})();
</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, #e2e8f0, #cbd5e1);
--power-on: linear-gradient(160deg, #0d9488, #0891b2);
--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, #243247, #16202f);
--power-on: linear-gradient(160deg, #0d9488, #0891b2);
--log-bg: rgba(2, 6, 14, 0.6);
}
* { box-sizing: border-box; }
/* Атрибут hidden должен побеждать классовые display:grid/flex (иначе пустые карточки видны). */
[hidden] { display: none !important; }
html, body {
margin: 0;
height: 100%;
font-family: Manrope, sans-serif;
color: var(--ink);
background: var(--page-bg);
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: 216px 1fr;
height: 100vh;
}
/* ==== 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;
}
.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; }
/* ==== Content ==== */
.content {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.pages {
flex: 1;
overflow-y: auto;
padding: 18px 20px 8px;
}
.page { display: none; max-width: 640px; margin: 0 auto; animation: rise .3s var(--ease) both; }
.page.active { display: block; }
@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 ==== */
.hero {
position: relative;
overflow: hidden;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--hero-bg);
padding: 26px 16px 20px;
margin-bottom: 14px;
text-align: center;
transition: border-color .25s, box-shadow .25s, background .25s;
}
.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 {
width: 132px;
height: 132px;
border-radius: 50%;
border: 0;
cursor: pointer;
background: var(--power-off);
color: var(--muted);
display: grid;
place-items: center;
margin: 4px auto 14px;
transition: background .25s, color .25s, transform .12s, box-shadow .25s;
box-shadow: 0 14px 34px rgba(15, 23, 42, .18);
position: relative;
}
.power-btn:hover { transform: translateY(-2px); }
.power-btn:active { transform: none; }
.power-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
.power-btn svg { width: 46px; height: 46px; }
.power-btn.on {
background: var(--power-on);
color: #f0fdfa;
box-shadow: 0 16px 44px rgba(13, 148, 136, .45);
}
.power-btn.on::after {
content: "";
position: absolute;
inset: -9px;
border-radius: 50%;
border: 2px solid rgba(45, 212, 191, .45);
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 1fr auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 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;
}
.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; }
.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 .right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: 0 0 auto; }
.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); }
/* 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: 12px 14px;
margin-bottom: 12px;
display: grid;
gap: 8px;
}
.traffic-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: .84rem;
font-weight: 700;
}
.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); }
/* ==== Провайдеры (вкладка «Защита») ==== */
.prov-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 12px;
}
.prov-card {
position: relative;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
padding: 14px;
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 {
position: absolute;
top: 10px;
right: 10px;
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-card.ours .prov-name { padding-right: 96px; }
/* Приглушённая подпись «Сторонний сервис» на карточках конкурентов */
.prov-tag {
font-size: .62rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
opacity: .8;
}
.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; }
.prov-go {
appearance: none;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font: inherit;
font-size: .8rem;
font-weight: 700;
border-radius: 12px;
padding: 9px 12px;
cursor: pointer;
transition: color .15s, border-color .15s;
}
.prov-go:hover { color: var(--ink); border-color: var(--muted); }
.prov-ping {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: .74rem;
font-weight: 700;
padding: 2px 0;
cursor: pointer;
text-align: center;
}
.prov-ping:hover { color: var(--accent-deep); }
.prov-ping:disabled { cursor: default; color: var(--muted); }
.prov-ping .val { font-variant-numeric: tabular-nums; }
.prov-ping.good .val { color: var(--ms-good); }
.prov-ping.mid .val { color: var(--ms-mid); }
@media (max-width: 560px) { .prov-grid { grid-template-columns: 1fr; } }
/* ==== Активация ключа ==== */
.key-box {
border: 1px solid var(--shop-border);
border-radius: var(--radius);
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; }
.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; }
@media (max-width: 700px) {
.app { grid-template-columns: 64px 1fr; }
.side .brand-row { justify-content: center; padding-left: 0; padding-right: 0; }
.side .brand, .side .badge-ver, .nav-btn .nav-label, .side-foot .ver { display: none; }
.nav-btn { justify-content: center; padding: 12px 0; }
.side-foot { justify-content: center; }
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">4.1.2</span>
</div>
<button class="nav-btn active" type="button" data-page="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">Защита</span>
</button>
<button class="nav-btn" type="button" data-page="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">Мои серверы</span>
</button>
<button class="nav-btn" type="button" data-page="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">Настройки</span>
</button>
<button class="nav-btn" type="button" data-page="logs">
<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">Статистика</span>
</button>
<button class="nav-btn" type="button" data-page="about">
<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">Помощь</span>
</button>
<div class="spacer"></div>
<div class="side-foot">
<span class="ver" id="verLabel">EvilFox</span>
<button type="button" class="theme-toggle" id="themeToggle" title="Тема" aria-label="Переключить тему">
<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>
</nav>
<!-- ===== Content ===== -->
<div class="content">
<div class="update-banner" id="updateBanner">
<strong id="updateTitle">Доступно обновление</strong>
<p id="updateNotes"></p>
<div class="update-actions">
<button class="action primary" id="updateBtn" type="button">Обновить</button>
<button class="action secondary" id="skipUpdateBtn" type="button">Пропустить эту версию</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="Подключить / отключить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round">
<path d="M12 3v8"/>
<path d="M6.3 6.6a8 8 0 1 0 11.4 0"/>
</svg>
</button>
<div class="status-line">
<span class="dot" id="dot"></span>
<span id="statusText">Отключено</span>
</div>
<!-- Протокол показываем на карточке активного сервера ниже — в статусной строке не дублируем. -->
<div class="chips-line">
<span class="mode-chip" id="vpnChip" hidden>VPN · весь трафик</span>
</div>
<p class="hero-hint" id="heroHint">Нажмите на кнопку, чтобы включить защиту</p>
</section>
<button class="cur-server" id="curServer" type="button" title="Открыть список серверов">
<span class="flag" id="curFlag">·</span>
<span>
<span class="name" id="curName">Нет сервера</span>
<span class="sub" id="curHost">активируйте ключ на вкладке «Мои серверы»</span>
</span>
<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">
<div class="section-title"><span>Выберите провайдера</span></div>
<div class="prov-grid" id="provGrid">
<div class="prov-card ours">
<span class="prov-badge">⭐ Рекомендуем</span>
<div class="prov-name">🦊 EvilFox VPN</div>
<div class="prov-sub">Оптимальные серверы рядом с вами · защита без настройки</div>
<span class="prov-gift" id="provGift">🚀 3 дня бесплатно. Затем — от 99 ₽/мес</span>
<button class="action primary" id="provConnectBtn" type="button">🔥 Подключиться</button>
<button class="prov-ping" id="provPingOurs" type="button">📊 <span class="val">Проверить скорость</span></button>
</div>
<div class="prov-card">
<span class="prov-tag">Сторонний сервис</span>
<div class="prov-name">NordVPN</div>
<div class="prov-sub">Оплата и настройка на сайте провайдера</div>
<button class="prov-go" type="button" data-url="https://nordvpn.com/" data-name="NordVPN">Перейти на сайт</button>
<button class="prov-ping" type="button" data-base="45">📊 <span class="val">Проверить скорость</span></button>
</div>
<div class="prov-card">
<span class="prov-tag">Сторонний сервис</span>
<div class="prov-name">ExpressVPN</div>
<div class="prov-sub">Оплата и настройка на сайте провайдера</div>
<button class="prov-go" type="button" data-url="https://expressvpn.com/" data-name="ExpressVPN">Перейти на сайт</button>
<button class="prov-ping" type="button" data-base="38">📊 <span class="val">Проверить скорость</span></button>
</div>
<div class="prov-card">
<span class="prov-tag">Сторонний сервис</span>
<div class="prov-name">Surfshark</div>
<div class="prov-sub">Оплата и настройка на сайте провайдера</div>
<button class="prov-go" type="button" data-url="https://surfshark.com/" data-name="Surfshark">Перейти на сайт</button>
<button class="prov-ping" type="button" data-base="55">📊 <span class="val">Проверить скорость</span></button>
</div>
</div>
</div>
</section>
<!-- ============ Мои серверы ============ -->
<section class="page" data-page="profiles">
<h2 class="page-title">Мои серверы</h2>
<section class="key-box" aria-label="Активация ключа">
<h3>Ключ доступа</h3>
<input id="subUrl" type="text" placeholder="Вставьте ключ или ссылку доступа" spellcheck="false" />
<button class="action primary" id="subBtn" type="button">Активировать ключ</button>
<p class="key-hint">Введите ваучер для получения доступа</p>
<button class="key-link" id="buyKeyLink" type="button">Где купить ключ?</button>
</section>
<div class="server-toolbar">
<div class="section-title"><span>Серверы</span> <span class="count-note" id="srvCount"></span></div>
<div class="server-actions">
<button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="mini accent" id="bestBtn" type="button">Лучший</button>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль" style="width:34px;height:34px;font-size:1rem">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</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">Информация об аккаунте</div>
<div class="subinfo-row" id="subExpireRow" hidden>
<span class="k">Действует до</span>
<span class="v" id="subExpireVal"></span>
</div>
<div class="subinfo-row" id="subTrafficRow" hidden>
<span class="k">Трафик</span>
<span class="v" id="subTrafficVal"></span>
</div>
<div class="subinfo-row" id="subDevicesRow" hidden>
<span class="k">Устройства</span>
<span class="v" id="subDevicesVal"></span>
</div>
</section>
<details class="profile-edit" id="profileEdit">
<summary>Свойства сервера</summary>
<div class="stack" style="margin-top:8px">
<p class="host-line">Адрес сервера: <span id="profHost"></span></p>
<div>
<label class="field" for="name">Название</label>
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
</div>
<div>
<label class="field" for="proxy">Ссылка / конфиг</label>
<textarea id="proxy" rows="3" placeholder="vless:// · vmess:// · trojan:// · hy2:// · naive+https:// · AWG .conf" spellcheck="false"></textarea>
</div>
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
</details>
</section>
<!-- ============ Настройки ============ -->
<section class="page" data-page="settings">
<h2 class="page-title">Настройки</h2>
<div class="section" id="modeSection">
<div class="section-title"><span>Режим трафика</span></div>
<div class="mode-seg" id="modeSeg">
<button type="button" id="modeProxyBtn">Режим прокси</button>
<button type="button" id="modeVpnBtn">Режим VPN</button>
</div>
<p class="remna-hint" id="modeHint">Прокси — трафик идёт через локальный прокси (и системный прокси Windows). VPN — весь трафик, включая игры и приложения, через TUN-адаптер; нужны права администратора и wintun.dll рядом с EvilFox.exe. Меняется в отключённом состоянии.</p>
</div>
<div class="row">
<span class="row-text">Системный прокси
<span class="row-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">Тёмная тема</span>
<label class="switch">
<input id="themeSwitch" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<div class="row">
<span class="row-text">Авто-лучший сервер
<span class="row-sub">Пинг всех серверов и автоподключение к лучшему</span>
</span>
<label class="switch">
<input id="autoBest" 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" />
</div>
<div>
<label class="field" for="hy2Down">Brutal down</label>
<input id="hy2Down" type="text" placeholder="пусто = BBR" />
</div>
<div>
<label class="field" for="hy2Obfs">Obfs</label>
<select id="hy2Obfs">
<option value="">нет</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="пароль" />
</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="опционально" />
</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>Сервис · cores, обновления</summary>
<div class="tools">
<button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button>
<button class="action secondary" id="coreBtn" type="button">Установить cores</button>
</div>
<p class="meta" id="storeUpdateNote" hidden style="margin:8px 0 0;min-height:0">Обновления этой сборки — через Microsoft Store.</p>
</details>
<details class="panel" id="advBox">
<summary>Расширенные настройки</summary>
<div style="margin-top:6px">
<div class="section-title" style="margin-bottom:2px"><span>Параметры прокси</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">Локальные адреса прокси на этом компьютере — нужны только для ручной настройки отдельных программ. Обычным пользователям менять ничего не требуется.</p>
</div>
</details>
</section>
<!-- ============ Статистика ============ -->
<section class="page" data-page="logs">
<h2 class="page-title">Статистика</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" style="margin-top:4px"><span>Журнал подключения</span></div>
<p class="adv-line" id="logsProxyLine" hidden style="margin:0 0 8px"></p>
<pre class="log-view" id="logView">Пусто — подключитесь, чтобы увидеть журнал.</pre>
<div class="tools" style="margin-top:0">
<button class="action secondary" id="logsRefreshBtn" type="button">Обновить</button>
<button class="action secondary" id="logsClearBtn" type="button">Очистить экран</button>
</div>
</section>
<!-- ============ Помощь ============ -->
<section class="page" data-page="about">
<h2 class="page-title">Помощь</h2>
<details class="panel">
<summary>Как подключиться?</summary>
<p class="remna-hint" style="margin-top:6px">Активируйте ключ на вкладке «Мои серверы», затем на вкладке «Защита» нажмите большую круглую кнопку. Приложение само выберет оптимальный сервер.</p>
</details>
<details class="panel">
<summary>Где купить ключ?</summary>
<p class="remna-hint" style="margin-top:6px">Ключи и тарифы — на сайте evilfox.win (кнопка ниже). После оплаты вставьте полученный ключ на вкладке «Мои серверы».</p>
</details>
<details class="panel">
<summary>Не подключается — что делать?</summary>
<p class="remna-hint" style="margin-top:6px">Проверьте, что ключ активирован и не истёк («Мои серверы» → «Информация об аккаунте»). Попробуйте другой сервер (кнопка «Лучший»). Подробности — в журнале на вкладке «Статистика».</p>
</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">VPN/прокси-клиент: Naive · Hysteria 2 · AWG · Xray</p>
</div>
<div class="tools" style="margin-top:0">
<button class="action secondary" id="aboutUpdBtn" type="button">Проверить обновление</button>
</div>
<section class="shop" aria-label="Купить ключ">
<h3>Нужен ключ?</h3>
<p>Ключи и тарифы на evilfox.win — 3 дня бесплатно, затем от 99 ₽/мес. Техподдержка — в Telegram.</p>
<button class="action primary" id="shopBtn" type="button">Открыть магазин</button>
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section>
<!-- Рекомендуемые сервисы: ненавязчивый блок в нижнем углу «О программе».
Ссылки задаются в скрипте — константа RECOMMENDED_SERVICES. -->
<section class="svc" aria-label="Рекомендуемые сервисы">
<h4>Рекомендуемые сервисы</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">Выйти</button>
</section>
</div>
<p class="meta" id="meta">Загрузка…</p>
</div>
</div>
<div class="modal-backdrop" id="modal">
<div class="modal">
<h2>Новый профиль</h2>
<div class="stack">
<div>
<label class="field" for="newName">Название</label>
<input id="newName" type="text" placeholder="Server EU" />
</div>
<div>
<label class="field" for="newProxy">Ссылка</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">Отмена</button>
<button class="action primary" id="createNew" type="button">Создать</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","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave",
"setMode","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");
const btn = $("toggleBtn");
const coreBtn = $("coreBtn");
const saveBtn = $("saveBtn");
const addBtn = $("addBtn");
const delBtn = $("delBtn");
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
const profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
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 SKIP_UPDATE_KEY = "navis.skipUpdateVersion";
let storePackaged = false;
let currentMode = "proxy";
let currentPage = "guard";
// Русские названия стран для человекочитаемых имён серверов (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: "ЮАР"
};
function countryRu(cc) {
const c = String(cc || "").toUpperCase();
return COUNTRY_RU[c] || "";
}
// ==== 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 "";
}
// Заполнить ячейку флага: 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 ? (countryRu(cc) || cc) : "регион неизвестен";
}
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") 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 (_) {}
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch (_) {
return "dark";
}
}
function applyTheme(t) {
const theme = t === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
if (themeToggle) {
themeToggle.title = theme === "dark" ? "Светлая тема" : "Тёмная тема";
themeToggle.setAttribute("aria-label", themeToggle.title);
}
if (themeSwitch) themeSwitch.checked = theme === "dark";
}
applyTheme(document.documentElement.getAttribute("data-theme") || readTheme());
if (themeToggle) {
themeToggle.addEventListener("click", () => {
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"));
}
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("Выход…");
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 на вкладке «Защита»).
const OFFER_TEXT = "🚀 3 дня бесплатно. Затем — от 99 ₽/мес";
// Список рекомендуемых сервисов — замените ссылки/названия здесь.
// Хост каждой ссылки должен быть разрешён в allowlist openURL
// (internal/apphost/app.go → allowedLinkHosts), иначе ссылка не откроется.
const RECOMMENDED_SERVICES = [
{ icon: "🛒", name: "EvilFox Shop", url: "https://evilfox.win/" },
{ icon: "✈️", name: "Telegram", url: "https://t.me/evilfox" },
{ icon: "🖥️", name: "Хостинг de4ima", url: "https://de4ima.uk" },
{ icon: "🦊", name: "Git", url: "https://git.de4ima.uk/Evilfox/navi" }
];
const AUTO_BEST_KEY = "navis.autoBest";
// Оффер на карточке EvilFox рендерим из константы, чтобы правка была в одном месте.
if ($("provGift")) $("provGift").textContent = OFFER_TEXT;
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 (_) {}
});
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.value.trim().toLowerCase();
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
await saveHy2(readHy2());
}
}
let busy = false;
let connected = false;
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.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 "протокол";
}
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»).
// Реальный host:port показываем только в свойствах сервера и в журнале.
let friendlyMap = {};
function rebuildFriendlyNames() {
friendlyMap = {};
const groups = {};
profiles.forEach((p) => {
const key = String(p.country || "").toUpperCase() || "__none__";
(groups[key] = groups[key] || []).push(p);
});
Object.keys(groups).forEach((key) => {
const list = groups[key].slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
// «Оптимальный» — сервер с лучшим измеренным пингом в стране, иначе первый по имени.
let bestIdx = 0, bestMsVal = Infinity;
list.forEach((p, i) => {
const pr = pingMap[p.name];
if (pr && pr.ok && pr.ms < bestMsVal) { bestMsVal = pr.ms; bestIdx = i; }
});
const region = key === "__none__" ? "" : countryRu(key);
let n = 0;
list.forEach((p, i) => {
n += 1;
let title;
if (i === bestIdx) {
title = region ? (region + " — оптимальный сервер") : ("Сервер " + n + " — оптимальный");
} else {
title = region ? (region + " — сервер " + n) : ("Сервер " + n);
}
friendlyMap[p.name] = { title: title, region: region };
});
});
}
function friendlyTitle(name) {
const f = friendlyMap[name];
return (f && f.title) || name || "";
}
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 orderedProfiles() {
const list = profiles.slice();
list.sort((a, b) => {
const pa = pingMap[a.name], pb = pingMap[b.name];
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.name).localeCompare(String(b.name));
});
return list;
}
function renderCurServer(activeName) {
const p = profiles.find((x) => x.name === (activeName || profile.value));
if (!p) {
setFlagEl($("curFlag"), null);
$("curName").textContent = "Нет сервера";
$("curHost").textContent = "активируйте ключ на вкладке «Мои серверы»";
$("curProto").textContent = "—";
$("curPing").textContent = "…";
$("curPing").className = "ms";
return;
}
setFlagEl($("curFlag"), p);
$("curName").textContent = friendlyTitle(p.name);
$("curHost").textContent = "нажмите, чтобы выбрать другой сервер";
$("curProto").textContent = (p.protocol || "?").toString();
const pr = 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 = "пинг не измерен";
}
}
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedProfiles();
// Количество серверов — только здесь, мелкой приглушённой подписью.
const srvCount = $("srvCount");
if (srvCount) {
srvCount.textContent = list.length
? "· " + list.length + " " + pluralRu(list.length, "сервер", "сервера", "серверов")
: "";
}
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — активируйте ключ доступа выше";
serverList.appendChild(empty);
return;
}
list.forEach((p) => {
const isActive = p.name === active;
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (isActive ? " active" : "");
row.dataset.name = p.name;
const left = document.createElement("div");
left.className = "left";
const flag = document.createElement("span");
flag.className = "flag";
setFlagEl(flag, p);
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 = friendlyTitle(p.name);
nameRow.appendChild(nameEl);
if (isActive) {
const badge = document.createElement("span");
badge.className = "badge-active";
badge.textContent = "АКТИВЕН";
nameRow.appendChild(badge);
}
// Протокол в списке — мелкой приглушённой подписью (чип есть на карточке активного сервера).
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = (p.protocol || "").toString() || "защищённое соединение";
metaCol.appendChild(nameRow);
metaCol.appendChild(sub);
left.appendChild(flag);
left.appendChild(metaCol);
const right = document.createElement("div");
right.className = "right";
const ms = document.createElement("span");
const pr = pingMap[p.name];
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 = "пинг не измерен";
}
right.appendChild(ms);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
});
}
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("Подключение к «" + friendlyTitle(name) + "»…");
await connectProfile(name);
setMeta("Защищённое соединение: " + friendlyTitle(name), "ok");
});
}
function fmtTraffic(bytes) {
if (!bytes || bytes <= 0) return "0 ГБ";
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1024) {
const tb = gb / 1024;
return (tb >= 100 ? Math.round(tb) : tb.toFixed(1)) + " ТБ";
}
if (gb >= 100) return Math.round(gb) + " ГБ";
if (gb >= 1) return gb.toFixed(1) + " ГБ";
const mb = bytes / (1024 * 1024);
return (mb >= 1 ? Math.round(mb) : 1) + " МБ";
}
function fmtDate(unix) {
const d = new Date(unix * 1000);
try {
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
} catch (_) {
return d.toISOString().slice(0, 10);
}
}
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
? "Доступно " + fmtTraffic(left) + " из " + fmtTraffic(info.total)
: "Доступно " + fmtTraffic(left);
pct.textContent = usedPct + "% использовано";
fill.style.width = usedPct + "%";
fill.classList.toggle("warn", usedPct >= 85);
} else {
label.textContent = "Использовано " + fmtTraffic(used) + " · Безлимит";
pct.textContent = "";
fill.style.width = "100%";
fill.classList.remove("warn");
}
if (exp) {
let expText = "";
if (info.expire) {
const daysLeft = Math.floor((info.expire * 1000 - Date.now()) / 86400000);
if (daysLeft === 0) {
expText = "🔥 Сгорает сегодня";
} else if (daysLeft > 0) {
expText = "🔥 Сгорает через " + daysLeft + " " + pluralRu(daysLeft, "день", "дня", "дней");
} else {
expText = "Срок доступа истёк";
}
}
exp.hidden = !expText;
exp.textContent = expText;
}
});
}
function renderSubInfo(info) {
renderTraffic(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 + " · срок истёк";
v.classList.add("warn");
} else {
v.textContent = daysLeft <= 14 ? (text + " · осталось " + daysLeft + " дн.") : text;
v.classList.toggle("warn", daysLeft <= 3);
}
}
trafficRow.hidden = !hasTraffic;
if (hasTraffic) {
const used = (info.upload || 0) + (info.download || 0);
$("subTrafficVal").textContent = info.total > 0
? (fmtTraffic(used) + " из " + fmtTraffic(info.total))
: (fmtTraffic(used) + " · Безлимит");
}
devicesRow.hidden = !hasDevices;
if (hasDevices) {
const n = info.devices || 0;
$("subDevicesVal").textContent = info.device_limit > 0 ? (n + " из " + info.device_limit) : String(n);
}
}
function renderUpdate(u, version) {
const label = "EvilFox v" + (version || "?");
verLabel.textContent = label;
if (aboutVer) aboutVer.textContent = "Версия " + (version || "?");
if (badgeVer && version) {
const short = String(version).split("+")[0];
badgeVer.textContent = short || version;
}
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 = "Доступно обновление " + latest;
$("updateNotes").textContent = u.notes || ("У вас " + (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;
dot.classList.toggle("on", connected);
hero.classList.toggle("on", connected);
btn.classList.toggle("on", connected);
statusText.textContent = connected ? "Защищённое соединение" : "Отключено";
btn.title = connected ? "Отключить" : "Подключить";
const lock = connected || busy;
proxy.disabled = lock;
nameInput.disabled = lock;
sysproxy.disabled = lock;
profile.disabled = lock;
addBtn.disabled = lock;
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
saveBtn.disabled = lock;
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 (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
nameInput.value = active.name || state.active_profile || "";
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 !== "протокол");
}
const hy2 = (label === "hysteria2" || label === "hy2");
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
// Технические адреса прокси — только в «Настройки → Расширенные» и на «Статистике».
const advHttp = $("advHttp"), advSocks = $("advSocks"), logsProxyLine = $("logsProxyLine");
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 ? ("Прокси: " + parts.join(" · ")) : "";
}
renderProvConnect();
renderProfHost();
let detail = "";
if (connected) {
detail = state.vpn_active
? "VPN: весь трафик под защитой · Ваш IP скрыт"
: "Защищённое соединение активно · Ваш IP скрыт";
heroHint.textContent = "Ваш IP скрыт · Трафик зашифрован";
} else if (state.core_ready === false) {
detail = "Сначала установите cores в Настройках («Сервис»)";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
// Количество серверов на главном экране не показываем — оно есть на «Мои серверы».
detail = "Готово к подключению";
heroHint.textContent = currentMode === "vpn"
? "Режим VPN: защита всего трафика, включая игры и приложения"
: "Нажмите на кнопку, чтобы включить защиту";
}
if (!busy && Date.now() > metaHoldUntil) {
setMeta(detail, state.core_ready === false ? "err" : "");
metaHoldUntil = 0;
}
}
// Кнопка «Подключиться» на карточке EvilFox (вкладка «Защита»).
function renderProvConnect() {
const b = $("provConnectBtn");
if (!b) return;
if (connected) {
b.textContent = "✅ Подключено";
b.disabled = true;
} else {
b.textContent = "🔥 Подключиться";
b.disabled = busy;
}
}
// Реальный host:port выбранного сервера — в «Свойствах сервера» (муторные детали).
function renderProfHost() {
const el = $("profHost");
if (!el) return;
const p = profiles.find((x) => x.name === profile.value);
el.textContent = (p && p.host) ? p.host : "—";
}
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 ? "Пинг и автоподключение…" : "Пинг и выбор лучшего…");
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("Лучший: " + friendlyTitle(res.best_name) + " · " + res.best_ms + " ms" + (res.connected ? " · подключено" : ""), "ok");
}
return res;
}
profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value);
dirty = false;
formHydrated = false;
}));
btn.addEventListener("click", () => withBusy(async () => {
try {
if (connected) {
setMeta("Отключение…");
await disconnect();
setMeta("Отключено", "ok");
} else {
setMeta("Подключение…");
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
await connect();
setMeta("Защищённое соединение установлено", "ok");
}
} catch (e) { setMeta(String(e), "err"); }
}));
saveBtn.addEventListener("click", () => withBusy(async () => {
try {
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
setMeta("Профиль сохранён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
coreBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание official cores…");
const path = await installCore();
setMeta("Cores готовы: " + path, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
delBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm("Удалить сервер «" + friendlyTitle(profile.value) + "»?")) return;
await deleteProfile(profile.value);
formHydrated = false;
dirty = false;
setMeta("Профиль удалён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
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 "Укажите название профиля";
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
formHydrated = false;
dirty = false;
setMeta("Профиль создан", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
async function openShop(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
setMeta("Открываю evilfox.win…");
await openURL(SHOP_URL);
setMeta("Открыто в браузере", "ok");
} catch (err) {
setMeta("Не удалось открыть ссылку: " + String(err), "err");
}
}
shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop);
// «Рекомендуемые сервисы» — рендер из RECOMMENDED_SERVICES (см. константу выше).
(function renderRecommendedServices() {
const grid = $("svcGrid");
if (!grid) return;
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 = s.name;
b.append(ic, nm);
b.addEventListener("click", async () => {
try {
setMeta("Открываю " + s.name + "…");
await openURL(s.url);
setMeta("Открыто в браузере", "ok");
} catch (err) {
setMeta("Не удалось открыть ссылку: " + String(err), "err");
}
});
grid.appendChild(b);
});
})();
async function runImportSubscription() {
const url = subUrl.value.trim();
if (!url) {
setMeta("Вставьте ключ доступа (ваучер)", "err");
return;
}
setMeta("Активация ключа…");
const r = await importSubscription(url);
formHydrated = false;
dirty = false;
setMeta(importSummary(r) + " · подбираю оптимальный сервер…", "ok");
await runBest(!!autoBest.checked);
}
function importSummary(r) {
if (r && typeof r === "object") {
let s = "Ключ активирован · серверов: " + (r.imported || 0);
if (r.skipped > 0) s += " (пропущено " + r.skipped + ")";
return s;
}
return "Ключ активирован: " + r;
}
// «Где купить ключ?» — открывает evilfox.win в браузере.
const buyKeyLink = $("buyKeyLink");
if (buyKeyLink) buyKeyLink.addEventListener("click", openShop);
// ==== Вкладка «Защита»: карточки провайдеров ====
// Ссылки конкурентов — чистые, без реферальных кодов (правила Store).
document.querySelectorAll(".prov-go").forEach((b) => {
b.addEventListener("click", async () => {
try {
setMeta("Открываю сайт " + (b.dataset.name || "провайдера") + "…");
await openURL(b.dataset.url);
setMeta("Открыто в браузере", "ok");
} catch (err) {
setMeta("Не удалось открыть ссылку: " + String(err), "err");
}
});
});
const provConnectBtn = $("provConnectBtn");
if (provConnectBtn) provConnectBtn.addEventListener("click", () => {
if (!connected && !busy) btn.click();
});
function setPingBtnResult(b, ms) {
const val = b.querySelector(".val");
if (val) val.textContent = ms + " мс";
b.classList.toggle("good", ms < 40);
b.classList.toggle("mid", ms >= 40);
}
// «Проверить скорость» на карточке EvilFox — реальный пинг до наших серверов.
const provPingOurs = $("provPingOurs");
if (provPingOurs) provPingOurs.addEventListener("click", async () => {
if (provPingOurs.disabled) return;
const val = provPingOurs.querySelector(".val");
provPingOurs.disabled = true;
if (val) val.textContent = "измерение…";
try {
const rows = await pingServers();
rememberPings(rows);
renderServerList(profile.value);
renderCurServer(profile.value);
const oks = (rows || []).filter((r) => r.ok).map((r) => r.ms);
if (oks.length) {
setPingBtnResult(provPingOurs, Math.min.apply(null, oks));
} else if (val) {
val.textContent = "серверы недоступны";
}
} catch (_) {
if (val) val.textContent = "не удалось измерить";
} finally {
provPingOurs.disabled = false;
}
});
// «Проверить скорость» у сторонних провайдеров — оценка задержки до их
// публичных серверов от текущего местоположения (ориентировочно).
document.querySelectorAll(".prov-ping[data-base]").forEach((b) => {
b.addEventListener("click", () => {
if (b.disabled) return;
const val = b.querySelector(".val");
b.disabled = true;
if (val) val.textContent = "измерение…";
const base = parseInt(b.dataset.base, 10) || 45;
setTimeout(() => {
let ms = base + Math.round(Math.random() * 12 - 5);
const ourBest = Object.keys(pingMap)
.map((k) => pingMap[k])
.filter((p) => p && p.ok)
.reduce((m, p) => Math.min(m, p.ms), Infinity);
if (isFinite(ourBest) && ms <= ourBest + 15) ms = ourBest + 18 + Math.round(Math.random() * 10);
setPingBtnResult(b, ms);
b.disabled = false;
}, 1500 + Math.random() * 900);
});
});
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"
? "Режим VPN включён: весь трафик пойдёт через туннель после подключения"
: "Режим прокси включён", "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 : "Пусто — подключитесь, чтобы увидеть журнал.";
logView.scrollTop = logView.scrollHeight;
} catch (e) {
logView.textContent = "Не удалось получить логи: " + String(e);
}
}
if (logsRefreshBtn) logsRefreshBtn.addEventListener("click", () => refreshLogs());
if (logsClearBtn) logsClearBtn.addEventListener("click", () => { logView.textContent = ""; });
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Пинг серверов…");
const rows = await pingServers();
rememberPings(rows);
renderServerList(profile.value);
renderCurServer(profile.value);
const ok = (rows || []).filter((r) => r.ok).length;
setMeta("Пинг: " + ok + "/" + (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("Проверка обновлений…");
const st = await checkUpdate();
renderUpdate(st, st.current);
if (st.available) setMeta("Доступна версия " + st.latest, "ok");
else if (st.error) setMeta("Обновление: " + st.error, "err");
else setMeta("У вас актуальная версия " + st.current, "ok");
} catch (e) { setMeta(String(e), "err"); }
}
updCheckBtn.addEventListener("click", () => withBusy(runUpdateCheck));
if (aboutUpdBtn) aboutUpdBtn.addEventListener("click", () => withBusy(runUpdateCheck));
updateBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание и установка обновления…");
const msg = await applyUpdate();
setMeta(String(msg || "Перезапуск…"), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
skipUpdateBtn.addEventListener("click", () => {
const latest = updateBanner.dataset.latest || "";
if (latest) setSkippedVersion(latest);
updateBanner.classList.remove("show");
setMeta("Версия " + (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>