Files
navi/internal/appui/index.html
T

3240 lines
138 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EvilFox</title>
<script>
(function () {
try {
var k = "navis.theme";
var t = localStorage.getItem(k);
if (t !== "light" && t !== "dark") {
t = "light";
}
document.documentElement.setAttribute("data-theme", t);
} catch (_) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
/* Dark-first slate + cyan accent; sidebar layout (Hiddify/Nord style) */
:root {
color-scheme: light;
--ink: #0f172a;
--text: var(--ink);
--muted: #64748b;
--line: rgba(15, 23, 42, 0.10);
--accent: #0891b2;
--accent-deep: #0e7490;
--accent-soft: #cffafe;
--danger: #dc2626;
--ok: #0d9488;
--ms-good: #059669;
--ms-mid: #d97706;
--surface: rgba(255, 255, 255, 0.88);
--surface-2: rgba(248, 250, 252, 0.92);
--input-bg: #ffffff;
--row-bg: rgba(255, 255, 255, 0.7);
--hero-bg: linear-gradient(155deg, #f8fafc 0%, #ecfeff 55%, #f1f5f9 100%);
--hero-bg-on: linear-gradient(155deg, #ecfeff 0%, #e0f2fe 50%, #f8fafc 100%);
--chip-bg: rgba(255, 255, 255, 0.85);
--server-list-bg: rgba(241, 245, 249, 0.65);
--server-row-bg: rgba(255, 255, 255, 0.92);
--server-row-hover: #fff;
--server-row-active: linear-gradient(135deg, #ecfeff, #fff);
--meta-bg: rgba(241, 245, 249, 0.8);
--meta-ok-bg: rgba(204, 251, 241, 0.75);
--meta-err-bg: rgba(254, 226, 226, 0.85);
--shop-bg: linear-gradient(135deg, rgba(8,145,178,.08), rgba(255,255,255,.7));
--shop-border: rgba(8, 145, 178, 0.18);
--modal-bg: #f8fafc;
--modal-scrim: rgba(15, 23, 42, 0.45);
--update-bg: linear-gradient(135deg, rgba(8,145,178,.12), rgba(255,255,255,.85));
--update-border: rgba(8, 145, 178, 0.28);
--icon-btn-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--icon-btn-border: rgba(15, 23, 42, 0.12);
--icon-btn-hover: linear-gradient(180deg, #f1f5f9, #e2e8f0);
--mini-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #f0fdfa;
--switch-off: #cbd5e1;
--switch-knob: #fff;
--dot-off: #94a3b8;
--shadow: 0 20px 50px rgba(15, 23, 42, 0.10);
--radius: 18px;
--ease: cubic-bezier(.22,.8,.24,1);
--page-bg:
radial-gradient(700px 380px at 0% -5%, #a5f3fc 0%, transparent 50%),
radial-gradient(560px 320px at 100% 0%, #bae6fd 0%, transparent 45%),
linear-gradient(165deg, #f1f5f9 0%, #f8fafc 50%, #eef2ff 100%);
--theme-toggle-bg: rgba(255, 255, 255, 0.75);
--side-bg: rgba(255, 255, 255, 0.65);
--side-active: rgba(8, 145, 178, 0.12);
--side-active-fg: #0e7490;
/* Кнопка-выключатель: акцентная в выключенном состоянии («нажми меня»),
красноватая — когда подключено (действие = отключить). */
--power-off: linear-gradient(160deg, #0891b2, #22d3ee);
--power-on: linear-gradient(160deg, #b91c1c, #ef4444);
--log-bg: rgba(15, 23, 42, 0.04);
}
html[data-theme="dark"] {
color-scheme: dark;
--ink: #e8eef7;
--text: var(--ink);
--muted: #8b9bb4;
--line: rgba(148, 163, 184, 0.14);
--accent: #22d3ee;
--accent-deep: #06b6d4;
--accent-soft: rgba(34, 211, 238, 0.12);
--danger: #f87171;
--ok: #2dd4bf;
--ms-good: #34d399;
--ms-mid: #fbbf24;
--surface: rgba(15, 20, 30, 0.92);
--surface-2: rgba(22, 30, 44, 0.88);
--input-bg: rgba(10, 14, 22, 0.95);
--row-bg: rgba(20, 28, 40, 0.8);
--hero-bg: linear-gradient(155deg, #121826 0%, #0f172a 55%, #111827 100%);
--hero-bg-on: linear-gradient(155deg, #0c2a32 0%, #132033 50%, #121826 100%);
--chip-bg: rgba(30, 41, 59, 0.9);
--server-list-bg: rgba(8, 12, 20, 0.55);
--server-row-bg: rgba(22, 30, 44, 0.9);
--server-row-hover: rgba(30, 41, 59, 0.95);
--server-row-active: linear-gradient(135deg, rgba(8,47,55,.95), rgba(22,30,44,.95));
--meta-bg: rgba(10, 14, 22, 0.7);
--meta-ok-bg: rgba(6, 78, 72, 0.45);
--meta-err-bg: rgba(69, 26, 26, 0.55);
--shop-bg: linear-gradient(135deg, rgba(34,211,238,.08), rgba(15,20,30,.85));
--shop-border: rgba(34, 211, 238, 0.18);
--modal-bg: #121826;
--modal-scrim: rgba(2, 6, 14, 0.72);
--update-bg: linear-gradient(135deg, rgba(34,211,238,.12), rgba(15,20,30,.9));
--update-border: rgba(34, 211, 238, 0.28);
--icon-btn-bg: linear-gradient(180deg, #1e293b, #162032);
--icon-btn-border: rgba(34, 211, 238, 0.22);
--icon-btn-hover: linear-gradient(180deg, #243247, #1a2536);
--mini-bg: linear-gradient(180deg, #1e293b, #162032);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #ecfeff;
--switch-off: #334155;
--switch-knob: #e2e8f0;
--dot-off: #64748b;
--shadow: 0 22px 55px rgba(0, 0, 0, 0.5);
--page-bg:
radial-gradient(680px 380px at 5% -8%, rgba(8, 90, 110, 0.35) 0%, transparent 55%),
radial-gradient(520px 300px at 100% 0%, rgba(30, 58, 95, 0.32) 0%, transparent 48%),
linear-gradient(165deg, #070b12 0%, #0c1220 48%, #0a1018 100%);
--theme-toggle-bg: rgba(30, 41, 59, 0.85);
--side-bg: rgba(12, 18, 30, 0.72);
--side-active: rgba(34, 211, 238, 0.14);
--side-active-fg: #22d3ee;
--power-off: linear-gradient(160deg, #0e7490, #06b6d4);
--power-on: linear-gradient(160deg, #b91c1c, #ef4444);
--log-bg: rgba(2, 6, 14, 0.6);
}
* { box-sizing: border-box; }
/* Атрибут hidden должен побеждать классовые display:grid/flex (иначе пустые карточки видны). */
[hidden] { display: none !important; }
html, body {
margin: 0;
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;
}
/* Inactive pages must not paint (WebView2 overflow ghosts from hidden siblings). */
.page { display: none !important; max-width: 640px; margin: 0 auto; }
.page.active { display: block !important; animation: rise .3s var(--ease) both; }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.page-title {
font-family: Outfit, sans-serif;
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -.02em;
margin: 0 0 14px;
}
.update-banner {
display: none;
margin: 0 20px 0;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--update-border);
background: var(--update-bg);
animation: rise .4s var(--ease) both;
}
.update-banner.show { display: block; margin-top: 14px; }
.update-banner strong {
font-family: Outfit, sans-serif;
display: block;
margin-bottom: 4px;
font-size: .95rem;
}
.update-banner p { margin: 0 0 10px; color: var(--muted); font-size: .84rem; }
.update-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.update-actions .action { flex: 1 1 auto; min-width: 140px; }
/* ==== Home / power ==== */
/* Sticky so the big connect control never scrolls under the provider list. */
.hero {
position: sticky;
top: 0;
z-index: 6;
overflow: visible;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--hero-bg);
padding: 26px 16px 20px;
margin-bottom: 14px;
text-align: center;
transition: border-color .25s, box-shadow .25s, background .25s;
min-height: 220px;
box-sizing: border-box;
}
.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: 148px;
height: 148px;
min-width: 148px;
min-height: 148px;
border-radius: 50%;
border: 0;
cursor: pointer;
background: var(--power-off);
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
margin: 4px auto 14px;
transition: background .25s, color .25s, transform .12s, box-shadow .25s;
box-shadow: 0 14px 34px rgba(8, 145, 178, .3);
position: relative;
flex-shrink: 0;
}
.power-btn:hover { transform: translateY(-2px); }
.power-btn:active { transform: none; }
.power-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
.power-btn svg { width: 40px; height: 40px; }
.power-btn .power-label {
font-family: Outfit, sans-serif;
font-size: .64rem;
font-weight: 800;
letter-spacing: .09em;
line-height: 1;
}
.power-btn.on {
background: var(--power-on);
color: #fff5f5;
box-shadow: 0 16px 44px rgba(220, 38, 38, .4);
}
.power-btn.on::after {
content: "";
position: absolute;
inset: -9px;
border-radius: 50%;
border: 2px solid rgba(248, 113, 113, .5);
animation: ring 2s ease-out infinite;
}
@keyframes ring {
0% { transform: scale(.96); opacity: .9; }
100% { transform: scale(1.12); opacity: 0; }
}
/* Состояние «подключение…» — вращающаяся дуга вокруг кнопки */
.power-btn.busy::after {
content: "";
position: absolute;
inset: -9px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status-line {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-weight: 800;
font-size: 1.02rem;
font-family: Outfit, sans-serif;
}
.dot {
width: 11px; height: 11px; border-radius: 50%;
background: var(--dot-off);
transition: background .2s;
}
.dot.on {
background: var(--ok);
animation: pulse 1.8s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(13,138,102,.4); }
70% { box-shadow: 0 0 0 12px rgba(13,138,102,0); }
100% { box-shadow: 0 0 0 0 rgba(13,138,102,0); }
}
.chips-line {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.proto-chip {
font-size: .72rem;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--muted);
background: var(--chip-bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.proto-chip.active {
color: var(--accent-deep);
background: var(--accent-soft);
border-color: rgba(13,138,102,.22);
}
.mode-chip {
font-size: .72rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
border-radius: 999px;
padding: 5px 10px;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(8,145,178,.25);
}
.hero-hint {
margin: 10px 0 0;
font-size: .8rem;
color: var(--muted);
min-height: 1.2em;
line-height: 1.35;
}
/* Current server card (home, bottom) */
.cur-server {
display: grid;
grid-template-columns: auto 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; align-items: center; gap: 8px; flex: 0 0 auto; }
/* Протокол — крошечная буква-иконка с подсказкой (не повторяем «vless» под каждой строкой). */
.proto-badge {
flex: 0 0 auto;
width: 18px;
height: 18px;
display: grid;
place-items: center;
border-radius: 6px;
font-size: .6rem;
font-weight: 800;
color: var(--muted);
background: var(--meta-bg);
border: 1px solid var(--line);
cursor: default;
}
/* Проверка скорости одного сервера — маленькая кнопка [📊] в строке. */
.row-ping {
appearance: none;
flex: 0 0 auto;
width: 26px;
height: 26px;
padding: 0;
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid var(--line);
background: transparent;
font-size: .78rem;
cursor: pointer;
transition: border-color .15s;
}
.row-ping:hover { border-color: rgba(8,145,178,.45); }
.row-ping:disabled { opacity: .5; cursor: default; }
.server-row .name-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* Пилюля «АКТИВЕН» вместо строчного текста «активный сервер» */
.badge-active {
flex: 0 0 auto;
font-size: .58rem;
font-weight: 800;
letter-spacing: .06em;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(8,145,178,.35);
border-radius: 999px;
padding: 2px 7px;
line-height: 1.2;
}
html[data-theme="dark"] .badge-active { color: var(--accent); }
.server-row .ms {
font-size: .78rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.server-row .ms::before {
content: "";
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
margin-right: 5px;
vertical-align: 1px;
background: var(--dot-off);
}
.server-row .ms.good::before, .server-row .ms.ok::before { background: var(--ms-good); }
.server-row .ms.mid::before { background: var(--ms-mid); }
.server-row .ms.bad::before { background: var(--danger); }
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); }
.remna-box {
margin-top: 4px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--surface-2);
display: grid;
gap: 8px;
}
.remna-title {
font-family: Outfit, sans-serif;
font-weight: 700;
font-size: .92rem;
letter-spacing: -.02em;
}
.remna-hint { margin: 0; font-size: .75rem; color: var(--muted); line-height: 1.4; }
.server-empty { padding: 14px 10px; text-align: center; color: var(--muted); font-size: .82rem; }
.profile-edit summary {
cursor: pointer;
list-style: none;
font-size: .78rem;
font-weight: 700;
color: var(--muted);
padding: 4px 0;
}
.profile-edit summary::-webkit-details-marker { display: none; }
.meta {
margin: 8px 20px 12px;
font-size: .82rem;
color: var(--muted);
line-height: 1.45;
min-height: 2.4em;
padding: 8px 12px;
border-radius: 12px;
background: var(--meta-bg);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
flex: 0 0 auto;
}
.meta.ok { color: var(--ok); background: var(--meta-ok-bg); border-color: rgba(13,138,102,.18); }
.meta.err { color: var(--danger); background: var(--meta-err-bg); border-color: rgba(192,54,44,.16); }
/* ==== Статистика: карточки за сегодня + недельный график ==== */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.stat-card {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--row-bg);
padding: 12px 10px;
text-align: center;
}
.stat-val {
font-family: Outfit, sans-serif;
font-weight: 800;
font-size: 1.02rem;
letter-spacing: -.01em;
font-variant-numeric: tabular-nums;
}
.stat-k { font-size: .68rem; color: var(--muted); margin-top: 3px; }
.week-chart {
display: flex;
align-items: stretch;
gap: 6px;
height: 118px;
padding: 10px 12px 8px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--row-bg);
margin-bottom: 12px;
}
.week-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 4px;
min-width: 0;
}
.week-bar {
width: 100%;
max-width: 34px;
border-radius: 6px 6px 2px 2px;
background: linear-gradient(180deg, var(--accent), var(--accent-deep));
min-height: 2px;
}
.week-day { font-size: .62rem; color: var(--muted); white-space: nowrap; }
.week-empty { width: 100%; align-self: center; text-align: center; color: var(--muted); font-size: .8rem; }
/* Logs */
.log-view {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .72rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
background: var(--log-bg);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
max-height: 62vh;
overflow: auto;
margin: 0 0 10px;
min-height: 200px;
color: var(--ink);
}
.shop {
margin-top: 12px;
padding: 13px 14px 12px;
border-radius: 18px;
border: 1px solid var(--shop-border);
background: var(--shop-bg);
}
.shop h3 { font-family: Outfit, sans-serif; font-size: 1rem; letter-spacing: -0.02em; margin: 0 0 5px; }
.shop p { margin: 0 0 10px; color: var(--muted); font-size: .82rem; line-height: 1.4; }
.shop .action { width: 100%; }
.shop-link {
display: block;
width: 100%;
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
text-align: center;
font: inherit;
font-size: .76rem;
color: var(--accent);
font-weight: 600;
cursor: pointer;
box-shadow: none;
}
.shop-link:hover { text-decoration: underline; }
/* «Рекомендуемые сервисы» — маленький ненавязчивый блок в углу «О программе» */
.svc {
margin-top: 12px;
padding: 10px 12px 11px;
border-radius: 14px;
border: 1px dashed var(--line);
background: transparent;
}
.svc h4 {
margin: 0 0 7px;
font-family: Outfit, sans-serif;
font-size: .66rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.svc-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.svc-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: .74rem;
font-weight: 600;
cursor: pointer;
box-shadow: none;
transition: color .15s, border-color .15s;
}
.svc-link:hover { color: var(--accent-deep); border-color: rgba(13,138,102,.45); }
.svc-link .ic { font-size: .85rem; line-height: 1; }
/* ==== Traffic progress (главный экран + Статистика) ==== */
.traffic {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--row-bg);
padding: 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;
}
/* «📊 ДОСТУПНО X ГБ» — заголовок блока трафика капсом */
.traffic-row > span:first-child { text-transform: uppercase; letter-spacing: .03em; font-size: .8rem; }
.traffic-row .pct { color: var(--muted); font-weight: 600; font-size: .78rem; }
.traffic-bar {
height: 9px;
border-radius: 999px;
background: var(--meta-bg);
overflow: hidden;
}
.traffic-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-deep), var(--accent));
transition: width .4s var(--ease);
}
.traffic-fill.warn { background: linear-gradient(90deg, #d97706, #f59e0b); }
.traffic-sub { font-size: .74rem; color: var(--muted); }
/* ==== Провайдеры (вкладка «Защита») ==== */
.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 {
align-self: flex-start;
font-size: .64rem;
font-weight: 800;
letter-spacing: .03em;
color: #7c5b00;
background: linear-gradient(180deg, #fde68a, #fbbf24);
border: 1px solid rgba(124, 91, 0, .25);
border-radius: 999px;
padding: 3px 9px;
box-shadow: 0 4px 10px rgba(251, 191, 36, .35);
}
/* Приглушённая подпись «Сторонний сервис» на карточках конкурентов */
.prov-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; }
/* Статус конфигурации: 🟢 добавлена / 🔴 не добавлена (сам URL не показываем). */
.key-status { margin: 0; font-size: .8rem; font-weight: 700; }
.key-link {
border: 0;
background: transparent;
padding: 0;
font: inherit;
font-size: .76rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
text-align: center;
}
.key-link:hover { text-decoration: underline; }
/* Технические строки (параметры прокси, адрес сервера) */
.adv-line {
margin: 4px 0 0;
font-size: .74rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.host-line {
margin: 6px 0 2px;
font-size: .72rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all;
}
.about-card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
padding: 18px 16px;
text-align: center;
margin-bottom: 12px;
}
.about-card .logo { margin: 0 auto 10px; width: 56px; height: 56px; border-radius: 16px; }
.about-card .logo svg { width: 34px; height: 34px; }
.about-card .logo .fox { font-size: 1.9rem; }
.about-card h2 { font-family: Outfit, sans-serif; margin: 0 0 4px; letter-spacing: -.03em; }
.about-card p { margin: 0; color: var(--muted); font-size: .84rem; line-height: 1.45; }
.modal-backdrop {
position: fixed; inset: 0;
background: var(--modal-scrim);
backdrop-filter: blur(4px);
display: none;
place-items: center;
padding: 18px;
z-index: 20;
}
.modal-backdrop.open { display: grid; }
.modal {
width: min(400px, 100%);
background: var(--modal-bg);
border-radius: 22px;
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 18px;
animation: rise .35s var(--ease) both;
}
.modal h2 { font-family: Outfit, sans-serif; margin: 0 0 12px; font-size: 1.2rem; letter-spacing: -.02em; }
.modal .actions { margin-top: 12px; grid-template-columns: 1fr 1fr; }
@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" hidden></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" aria-hidden="true">
<path d="M12 3v8"/>
<path d="M6.3 6.6a8 8 0 1 0 11.4 0"/>
</svg>
<span class="power-label" id="powerLabel">ПОДКЛЮЧИТЬСЯ</span>
</button>
<div class="status-line">
<span id="statusText">🔴 Отключено</span>
</div>
<!-- Протокол показываем на карточке активного сервера ниже — в статусной строке не дублируем. -->
<div class="chips-line">
<span class="mode-chip" id="vpnChip" hidden>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" id="provSection">
<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 дня бесплатного доступа</span>
<button class="action primary" id="provConnectBtn" type="button">🔥 Подключиться</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>
</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>
</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>
</div>
</div>
</div>
</section>
<!-- ============ Мои серверы ============ -->
<section class="page" data-page="profiles">
<h2 class="page-title">Мои серверы</h2>
<section class="key-box" aria-label="Добавить конфигурацию">
<h3>🔗 Добавить конфигурацию</h3>
<p class="key-status" id="subStatus">🔴 Конфигурация не добавлена</p>
<input id="subUrl" type="text" placeholder="https://" spellcheck="false" />
<button class="action primary" id="subBtn" type="button">Добавить конфигурацию</button>
<p class="key-hint">Вставьте URL-ссылку для подключения</p>
<button class="key-link" id="buyKeyLink" type="button">Где взять URL-ссылку?</button>
</section>
<div class="server-toolbar">
<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" title="Выбрать сервер с наименьшим пингом">Выбрать лучший</button>
</div>
</div>
<div class="server-list" id="serverList"></div>
<select id="profile" hidden></select>
<section class="subinfo" id="subInfoCard" hidden>
<div class="subinfo-title">Информация об аккаунте</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" id="profProps">
<div class="subinfo-row"><span class="k">Сервер</span><span class="v" id="propName"></span></div>
<div class="subinfo-row"><span class="k">Пинг</span><span class="v" id="propPing"></span></div>
<div class="subinfo-row"><span class="k">Протокол</span><span class="v" id="propProto"></span></div>
<div class="subinfo-row"><span class="k">Статус</span><span class="v" id="propStatus"></span></div>
</div>
<!-- Скрытые поля — нужны для connect/save, в UI не показываем. -->
<input id="name" type="text" hidden />
<textarea id="proxy" hidden rows="1"></textarea>
<button class="action secondary" id="saveBtn" type="button" hidden>Сохранить профиль</button>
</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>
<div class="row">
<span class="row-text">🔒 Защита от утечек (Kill Switch)
<span class="row-sub">При обрыве VPN блокировать весь интернет-трафик до ручного отключения или переподключения. Полноценно работает в режиме VPN; в режиме прокси защищены только программы, использующие системный прокси.</span>
</span>
<label class="switch">
<input id="killSwitch" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<details class="panel hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / obfuscation</summary>
<div class="grid2">
<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" id="proxyParamsBlock" hidden>
<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"><span>Статистика за сегодня</span></div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-val" id="statUsed"></div>
<div class="stat-k">Использовано</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statOnline">0 мин</div>
<div class="stat-k">Время в сети</div>
</div>
<div class="stat-card">
<div class="stat-val" id="statSessions">0</div>
<div class="stat-k">Сессий</div>
</div>
</div>
<div class="section-title"><span>Трафик за 7 дней</span></div>
<div class="week-chart" id="weekChart">
<div class="week-empty">Пока нет данных</div>
</div>
<div class="section-title" style="margin-top:4px"><span>Журнал подключения</span></div>
<p class="adv-line" id="logsProxyLine" hidden style="margin:0 0 8px"></p>
<pre class="log-view" id="logView">Пусто — подключитесь, чтобы увидеть журнал.</pre>
<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>Где взять URL-ссылку?</summary>
<p class="remna-hint" style="margin-top:6px">URL-ссылку для подключения можно получить на сайте evilfox.win (кнопка ниже) или у вашего провайдера. Полученную ссылку вставьте на вкладке «Мои серверы».</p>
</details>
<details class="panel">
<summary>Не подключается — что делать?</summary>
<p class="remna-hint" style="margin-top:6px">Проверьте, что конфигурация добавлена и срок её действия не истёк («Мои серверы» → «Информация об аккаунте»). Попробуйте другой сервер (кнопка «Выбрать лучший»). Подробности — в журнале на вкладке «Статистика».</p>
</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>URL-ссылку для подключения можно получить на evilfox.win. Техподдержка — в Telegram.</p>
<button class="action primary" id="shopBtn" type="button">Перейти на сайт</button>
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section>
<!-- Полезные ссылки (сайт + поддержка). Хосты — в allowlist openURL. -->
<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","pingServer","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave",
"setMode","setKillSwitch","getLogs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
async function call(name, args) {
const r = await fetch("/api/" + name, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ args: args || [] })
});
const j = await r.json();
if (j && j.error) throw j.error;
return j ? j.result : null;
}
methods.forEach((name) => {
window[name] = function () {
return call(name, Array.prototype.slice.call(arguments));
};
});
})();
const $ = (id) => document.getElementById(id);
const meta = $("meta");
const btn = $("toggleBtn");
const coreBtn = $("coreBtn");
const saveBtn = $("saveBtn");
const addBtn = $("addBtn"); // optional — header + removed
const delBtn = $("delBtn"); // optional — header × removed
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
const profile = $("profile");
const statusText = $("statusText");
const powerLabel = $("powerLabel");
const killSwitch = $("killSwitch");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const updCheckBtn = $("updCheckBtn");
const aboutUpdBtn = $("aboutUpdBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const skipUpdateBtn = $("skipUpdateBtn");
const verLabel = $("verLabel");
const badgeVer = $("badgeVer");
const aboutVer = $("aboutVer");
const storeUpdateNote = $("storeUpdateNote");
const themeToggle = $("themeToggle");
const themeSwitch = $("themeSwitch");
const modeProxyBtn = $("modeProxyBtn");
const modeVpnBtn = $("modeVpnBtn");
const modeSection = $("modeSection");
const vpnChip = $("vpnChip");
const logView = $("logView");
const logsRefreshBtn = $("logsRefreshBtn");
const logsClearBtn = $("logsClearBtn");
const THEME_KEY = "navis.theme";
const 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: "ЮАР"
};
// Опечатки / варианты имён стран → ISO (зеркало Go geoflag; длинные сначала).
const COUNTRY_NAME_ALIASES = [
["румьиния", "RO"], ["румяння", "RO"], ["румьиня", "RO"],
["румыния", "RO"], ["румыня", "RO"], ["руминия", "RO"], ["румыниа", "RO"],
["romania", "RO"], ["bucharest", "RO"], ["бухарест", "RO"]
];
function countryRu(cc) {
const c = String(cc || "").toUpperCase();
return COUNTRY_RU[c] || "";
}
function isoFromNameText(text) {
const lower = String(text || "").toLowerCase();
if (!lower) return "";
for (let i = 0; i < COUNTRY_NAME_ALIASES.length; i++) {
if (lower.indexOf(COUNTRY_NAME_ALIASES[i][0]) >= 0) return COUNTRY_NAME_ALIASES[i][1];
}
// Прямое совпадение канонического русского названия.
const keys = Object.keys(COUNTRY_RU);
for (let i = 0; i < keys.length; i++) {
const ru = COUNTRY_RU[keys[i]].toLowerCase();
if (ru && lower.indexOf(ru) >= 0) return keys[i];
}
return "";
}
// ==== SVG-флаги ====
// Windows не умеет эмодзи-флаги: Segoe UI Emoji рисует пары региональных
// индикаторов буквами («BE», «LV»), поэтому рисуем настоящие SVG-флаги.
// Формат описателя: h — горизонтальные полосы (цвет или [цвет, вес]),
// v — вертикальные полосы, n — скандинавский крест [фон, крест, внутренний],
// s — произвольный SVG-фрагмент, e — элементы поверх полос.
function star5(cx, cy, r, fill) {
let d = "";
for (let i = 0; i < 10; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 5;
const rr = i % 2 ? r * 0.4 : r;
d += (i ? "L" : "M") + (cx + rr * Math.cos(a)).toFixed(1) + " " + (cy + rr * Math.sin(a)).toFixed(1);
}
return '<path d="' + d + 'Z" fill="' + fill + '"/>';
}
// Упрощённый Юнион Джек (полный флаг и малый кантон для AU/NZ).
const UJ = '<rect width="60" height="40" fill="#012169"/><path d="M0 0 60 40M60 0 0 40" stroke="#FFF" stroke-width="8"/><path d="M0 0 60 40M60 0 0 40" stroke="#C8102E" stroke-width="3.5"/><path d="M30 0V40M0 20H60" stroke="#FFF" stroke-width="13"/><path d="M30 0V40M0 20H60" stroke="#C8102E" stroke-width="7"/>';
const UJC = '<path d="M0 0 30 20M30 0 0 20" stroke="#FFF" stroke-width="4"/><path d="M15 0V20M0 10H30" stroke="#FFF" stroke-width="6"/><path d="M15 0V20M0 10H30" stroke="#C8102E" stroke-width="3"/>';
const FLAGS = {
AT: { h: ["#ED2939", "#FFF", "#ED2939"] },
BE: { v: ["#000", "#FDDA24", "#EF3340"] },
BG: { h: ["#FFF", "#00966E", "#D62612"] },
CH: { s: '<rect width="60" height="40" fill="#DA291C"/><rect x="26" y="8" width="8" height="24" fill="#FFF"/><rect x="18" y="16" width="24" height="8" fill="#FFF"/>' },
CZ: { h: ["#FFF", "#D7141A"], e: '<path d="M0 0 30 20 0 40Z" fill="#11457E"/>' },
DE: { h: ["#000", "#DD0000", "#FFCE00"] },
DK: { n: ["#C8102E", "#FFF"] },
EE: { h: ["#0072CE", "#000", "#FFF"] },
ES: { h: [["#AA151B", 1], ["#F1BF00", 2], ["#AA151B", 1]], e: '<rect x="12" y="16" width="6" height="8" rx="1.2" fill="#AA151B"/>' },
FI: { n: ["#FFF", "#002F6C"] },
FR: { v: ["#002395", "#FFF", "#ED2939"] },
GB: { s: UJ },
GR: { h: ["#0D5EAF", "#FFF", "#0D5EAF", "#FFF", "#0D5EAF", "#FFF", "#0D5EAF", "#FFF", "#0D5EAF"], e: '<rect width="22" height="22.5" fill="#0D5EAF"/><rect x="8.8" width="4.4" height="22.5" fill="#FFF"/><rect y="9" width="22" height="4.4" fill="#FFF"/>' },
HU: { h: ["#CE2939", "#FFF", "#477050"] },
IE: { v: ["#169B62", "#FFF", "#FF883E"] },
IT: { v: ["#008C45", "#F4F9F0", "#CD212A"] },
LT: { h: ["#FDB913", "#006A44", "#C1272D"] },
LU: { h: ["#ED2939", "#FFF", "#00A3E0"] },
LV: { h: [["#9E3039", 2], ["#FFF", 1], ["#9E3039", 2]] },
MD: { v: ["#003DA5", "#FFD100", "#C8102E"], e: '<circle cx="30" cy="20" r="4" fill="#B07F55"/>' },
NL: { h: ["#AE1C28", "#FFF", "#21468B"] },
NO: { n: ["#BA0C2F", "#FFF", "#00205B"] },
PL: { h: ["#FFF", "#DC143C"] },
PT: { v: [["#046A38", 2], ["#DA291C", 3]], e: '<circle cx="24" cy="20" r="5" fill="#FFE900" stroke="#DA291C" stroke-width="1.5"/>' },
RO: { v: ["#002B7F", "#FCD116", "#CE1126"] },
RS: { h: ["#C6363C", "#0C4076", "#FFF"] },
SE: { n: ["#006AA7", "#FECC00"] },
SI: { h: ["#FFF", "#005DA4", "#ED1C24"], e: '<path d="M14 8l4.5 7.5L14 22l-4.5-6.5Z" fill="#005DA4" stroke="#FFF" stroke-width="1"/>' },
SK: { h: ["#FFF", "#0B4EA2", "#EE1C25"], e: '<path d="M14 11l7 2.5v7c0 4.5-3.5 7-7 9-3.5-2-7-4.5-7-9v-7Z" fill="#EE1C25" stroke="#FFF" stroke-width="1.4"/>' },
TR: { s: '<rect width="60" height="40" fill="#E30A17"/><circle cx="23" cy="20" r="10" fill="#FFF"/><circle cx="25.5" cy="20" r="8" fill="#E30A17"/>' + star5(37, 20, 5, "#FFF") },
UA: { h: ["#0057B7", "#FFD700"] },
US: {
h: ["#B22234", "#FFF", "#B22234", "#FFF", "#B22234", "#FFF", "#B22234"],
e: '<rect width="26" height="17.2" fill="#3C3B6E"/>' + [[4, 4], [10, 4], [16, 4], [22, 4], [7, 8.6], [13, 8.6], [19, 8.6], [4, 13.2], [10, 13.2], [16, 13.2], [22, 13.2]].map(function (p) { return '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="1.3" fill="#FFF"/>'; }).join("")
},
CA: { v: [["#D80621", 1], ["#FFF", 2], ["#D80621", 1]], e: '<path d="M30 9l2.2 4.4 4-1.8-1.3 5.4 4.4.9-3.1 3.5 3.6 2.7-5.4-.4.4 5.3-4.8-2.7-4.8 2.7.4-5.3-5.4.4 3.6-2.7-3.1-3.5 4.4-.9-1.3-5.4 4 1.8Z" fill="#D80621"/>' },
JP: { s: '<rect width="60" height="40" fill="#FFF"/><circle cx="30" cy="20" r="10" fill="#BC002D"/>' },
SG: { h: ["#EF3340", "#FFF"], e: '<circle cx="13" cy="10" r="5.5" fill="#FFF"/><circle cx="15.5" cy="10" r="5" fill="#EF3340"/>' },
HK: { s: '<rect width="60" height="40" fill="#DE2910"/>' + [0, 72, 144, 216, 288].map(function (a) { return '<ellipse cx="30" cy="13" rx="2.6" ry="7" fill="#FFF" transform="rotate(' + a + ' 30 20)"/>'; }).join("") },
AU: { s: '<rect width="60" height="40" fill="#012169"/>' + UJC + star5(15, 30, 4, "#FFF") + star5(45, 8, 3, "#FFF") + star5(38, 20, 3, "#FFF") + star5(52, 16, 3, "#FFF") + star5(45, 32, 3, "#FFF") },
NZ: { s: '<rect width="60" height="40" fill="#012169"/>' + UJC + star5(45, 8, 3, "#C8102E") + star5(38, 19, 3, "#C8102E") + star5(52, 19, 3, "#C8102E") + star5(45, 31, 3, "#C8102E") },
IS: { n: ["#02529C", "#FFF", "#DC1E35"] },
AL: { s: '<rect width="60" height="40" fill="#E41E20"/><path d="M30 8c-3 4-8 4.5-12 2.5 2 3.5 5.5 5.5 9 5.5-3 2.5-6.5 3.5-10 3 3 3.5 7.5 4.5 11 2.5v7l2 4 2-4v-7c3.5 2 8 1 11-2.5-3.5.5-7-.5-10-3 3.5 0 7-2 9-5.5-4 2-9 1.5-12-2.5Z" fill="#000"/>' },
MK: { s: '<rect width="60" height="40" fill="#D20000"/><path d="M30 20 0 0M30 20 60 0M30 20 0 40M30 20 60 40M30 20 0 20M30 20 60 20M30 20 30 0M30 20 30 40" stroke="#FFE600" stroke-width="4.5"/><circle cx="30" cy="20" r="7" fill="#FFE600" stroke="#D20000" stroke-width="1.5"/>' },
ME: { s: '<rect width="60" height="40" fill="#C40308"/><rect x="2" y="2" width="56" height="36" fill="none" stroke="#D3AE3B" stroke-width="3"/><circle cx="30" cy="20" r="6" fill="#D3AE3B"/>' },
BA: { s: '<rect width="60" height="40" fill="#002F6C"/><path d="M21 0h26L21 40Z" fill="#FECB00"/>' + star5(16, 6, 2.6, "#FFF") + star5(12, 16, 2.6, "#FFF") + star5(8, 26, 2.6, "#FFF") + star5(4, 36, 2.6, "#FFF") },
HR: { h: ["#C8102E", "#FFF", "#012169"], e: '<rect x="24" y="12" width="12" height="12" fill="#FFF" stroke="#C8102E" stroke-width="1"/><rect x="24" y="12" width="4" height="4" fill="#C8102E"/><rect x="32" y="12" width="4" height="4" fill="#C8102E"/><rect x="28" y="16" width="4" height="4" fill="#C8102E"/><rect x="24" y="20" width="4" height="4" fill="#C8102E"/><rect x="32" y="20" width="4" height="4" fill="#C8102E"/>' },
CY: { s: '<rect width="60" height="40" fill="#FFF"/><ellipse cx="30" cy="16" rx="13" ry="5" fill="#D57800"/><path d="M23 26c4.5 2.5 9.5 2.5 14 0" stroke="#4E5B31" stroke-width="2.2" fill="none"/>' },
MT: { s: '<rect width="30" height="40" fill="#FFF"/><rect x="30" width="30" height="40" fill="#CF142B"/><path d="M7 5h4v4h4v4h-4v4H7v-4H3V9h4Z" fill="#9CA3AF"/>' },
AM: { h: ["#D90012", "#0033A0", "#F2A800"] },
AZ: { h: ["#0092BC", "#E4002B", "#00AF66"], e: '<circle cx="26" cy="20" r="5" fill="#FFF"/><circle cx="28" cy="20" r="4.2" fill="#E4002B"/>' + star5(34, 20, 2.6, "#FFF") },
GE: { s: '<rect width="60" height="40" fill="#FFF"/><path d="M26 0h8v16h26v8H34v16h-8V24H0v-8h26Z" fill="#F04923"/><path d="M13 6v6M10 9h6M47 6v6M44 9h6M13 28v6M10 31h6M47 28v6M44 31h6" stroke="#F04923" stroke-width="2.4"/>' },
KZ: { s: '<rect width="60" height="40" fill="#00AFCA"/><circle cx="30" cy="17" r="7" fill="#FEC50C"/><path d="M17 29c8.5 3.5 17.5 3.5 26 0" stroke="#FEC50C" stroke-width="2.6" fill="none"/>' },
KG: { s: '<rect width="60" height="40" fill="#E8112D"/><circle cx="30" cy="20" r="8.5" fill="#FFEF00"/><path d="M24.5 21.5c1.5-4 9.5-4 11 0" stroke="#E8112D" stroke-width="2" fill="none"/>' },
UZ: { h: [["#0099B5", 10], ["#CE1126", 1.4], ["#FFF", 7.2], ["#CE1126", 1.4], ["#1EB53A", 10]], e: '<circle cx="10" cy="6.5" r="3.6" fill="#FFF"/><circle cx="11.6" cy="6.2" r="3.1" fill="#0099B5"/>' },
TM: { s: '<rect width="60" height="40" fill="#00843D"/><rect x="6" width="9" height="40" fill="#C8102E"/><circle cx="34" cy="12" r="5" fill="#FFF"/><circle cx="36" cy="11.2" r="4.3" fill="#00843D"/>' + star5(44, 10, 2.4, "#FFF") },
TJ: { h: [["#CC0000", 2], ["#FFF", 3], ["#006600", 2]], e: '<circle cx="30" cy="20" r="3" fill="#F8C300"/>' },
RU: { h: ["#FFF", "#0039A6", "#D52B1E"] },
BY: { s: '<rect width="60" height="26.7" fill="#CE1720"/><rect y="26.7" width="60" height="13.3" fill="#007C30"/><rect width="8" height="40" fill="#FFF"/><path d="M4 2l2.5 3L4 8 1.5 5Zm0 10l2.5 3L4 18l-2.5-3Zm0 10l2.5 3L4 28l-2.5-3Zm0 10l2.5 3L4 38l-2.5-3Z" fill="#CE1720"/>' },
IL: { s: '<rect width="60" height="40" fill="#FFF"/><rect y="5" width="60" height="5" fill="#0038B8"/><rect y="30" width="60" height="5" fill="#0038B8"/><path d="M30 12.5l6.5 11.2h-13Zm0 15l-6.5-11.2h13Z" fill="none" stroke="#0038B8" stroke-width="1.8"/>' },
AE: { s: '<rect width="60" height="13.4" fill="#00732F"/><rect y="13.4" width="60" height="13.3" fill="#FFF"/><rect y="26.6" width="60" height="13.4" fill="#000"/><rect width="16" height="40" fill="#FF0000"/>' },
SA: { s: '<rect width="60" height="40" fill="#006C35"/><rect x="12" y="11" width="36" height="9" rx="2" fill="#FFF" opacity=".9"/><rect x="12" y="26" width="30" height="3" rx="1.5" fill="#FFF"/>' },
IN: { h: ["#FF9933", "#FFF", "#138808"], e: '<circle cx="30" cy="20" r="4.5" fill="none" stroke="#000080" stroke-width="1.4"/><circle cx="30" cy="20" r="1" fill="#000080"/>' },
BR: { s: '<rect width="60" height="40" fill="#009739"/><path d="M30 5 55 20 30 35 5 20Z" fill="#FEDD00"/><circle cx="30" cy="20" r="8" fill="#012169"/><path d="M22.5 18.5c5-1.5 10-1 15 3" stroke="#FFF" stroke-width="1.6" fill="none"/>' },
MX: { v: ["#006341", "#FFF", "#CE1126"], e: '<circle cx="30" cy="20" r="4" fill="#8C6A2F"/>' },
AR: { h: ["#74ACDF", "#FFF", "#74ACDF"], e: '<circle cx="30" cy="20" r="4" fill="#F6B40E"/>' },
CL: { s: '<rect width="60" height="20" fill="#FFF"/><rect y="20" width="60" height="20" fill="#DA291C"/><rect width="20" height="20" fill="#0032A0"/>' + star5(10, 10, 5.5, "#FFF") },
KR: { s: '<rect width="60" height="40" fill="#FFF"/><circle cx="30" cy="20" r="9" fill="#CD2E3A"/><path d="M21 20a9 9 0 0 0 18 0 4.5 4.5 0 0 0-9 0 4.5 4.5 0 0 1-9 0Z" fill="#0047A0"/><path d="M9 9l6 4.5M8 11.5l6 4.5M45 26l6 4.5M44 28.5l6 4.5" stroke="#000" stroke-width="1.6"/>' },
TW: { s: '<rect width="60" height="40" fill="#FE0000"/><rect width="30" height="20" fill="#000095"/><circle cx="15" cy="10" r="6" fill="#FFF"/><circle cx="15" cy="10" r="3.5" fill="#000095"/><circle cx="15" cy="10" r="2.5" fill="#FFF"/>' },
TH: { h: [["#A51931", 1], ["#F4F5F8", 1], ["#2D2A4A", 2], ["#F4F5F8", 1], ["#A51931", 1]] },
VN: { s: '<rect width="60" height="40" fill="#DA251D"/>' + star5(30, 20, 9, "#FFCD00") },
ID: { h: ["#E70011", "#FFF"] },
MY: { h: ["#CC0001", "#FFF", "#CC0001", "#FFF", "#CC0001", "#FFF", "#CC0001"], e: '<rect width="30" height="22.9" fill="#010066"/><circle cx="11" cy="11" r="6" fill="#FFCC00"/><circle cx="13.5" cy="11" r="5.2" fill="#010066"/>' + star5(21, 11, 4, "#FFCC00") },
PH: { s: '<rect width="60" height="20" fill="#0038A8"/><rect y="20" width="60" height="20" fill="#CE1126"/><path d="M0 0 24 20 0 40Z" fill="#FFF"/><circle cx="8" cy="20" r="3.5" fill="#FCD116"/>' },
EG: { h: ["#CE1126", "#FFF", "#000"], e: '<circle cx="30" cy="20" r="3.5" fill="#C09300"/>' },
ZA: { s: '<rect width="60" height="16" fill="#E03C31"/><rect y="16" width="60" height="8" fill="#007749"/><rect y="24" width="60" height="16" fill="#001489"/><rect y="13" width="60" height="3" fill="#FFF"/><rect y="24" width="60" height="3" fill="#FFF"/><path d="M0 0 28 20 0 40Z" fill="#007749"/><path d="M0 5 21 20 0 35Z" fill="#FFB81C"/><path d="M0 10 14 20 0 30Z" fill="#000"/>' },
NG: { v: ["#008751", "#FFF", "#008751"] },
MA: { s: '<rect width="60" height="40" fill="#C1272D"/>' + star5(30, 20, 8, "#006233") }
};
// Собрать SVG флага по ISO2-коду; "" — если страна не поддержана.
function flagSVG(cc) {
const d = FLAGS[cc];
if (!d) return "";
let b = "";
const stripes = (arr, horiz) => {
const total = arr.reduce((s, x) => s + (Array.isArray(x) ? x[1] : 1), 0);
let pos = 0;
arr.forEach((x) => {
const col = Array.isArray(x) ? x[0] : x;
const len = ((Array.isArray(x) ? x[1] : 1) / total) * (horiz ? 40 : 60);
// +0.3 — перекрытие полос, чтобы не было волосяных зазоров при масштабировании.
b += horiz
? '<rect x="0" y="' + pos.toFixed(2) + '" width="60" height="' + (len + 0.3).toFixed(2) + '" fill="' + col + '"/>'
: '<rect x="' + pos.toFixed(2) + '" y="0" width="' + (len + 0.3).toFixed(2) + '" height="40" fill="' + col + '"/>';
pos += len;
});
};
if (d.s) b += d.s;
if (d.h) stripes(d.h, true);
if (d.v) stripes(d.v, false);
if (d.n) {
// Скандинавский крест, смещённый к древку.
const t = d.n[2] ? 12 : 8;
b += '<rect width="60" height="40" fill="' + d.n[0] + '"/>';
b += '<rect x="' + (22 - t / 2) + '" width="' + t + '" height="40" fill="' + d.n[1] + '"/><rect y="' + (20 - t / 2) + '" width="60" height="' + t + '" fill="' + d.n[1] + '"/>';
if (d.n[2]) b += '<rect x="19.5" width="5" height="40" fill="' + d.n[2] + '"/><rect y="17.5" width="60" height="5" fill="' + d.n[2] + '"/>';
}
if (d.e) b += d.e;
return '<svg class="fl" viewBox="0 0 60 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' + b + '</svg>';
}
// ISO2 страны профиля: country от бэкенда, иначе имя/флаг, иначе алиасы опечаток.
function profileISO(p) {
if (!p) return "";
const c = String(p.country || "").toUpperCase();
if (/^[A-Z]{2}$/.test(c)) return c;
const rs = Array.from(String(p.flag || ""));
if (rs.length === 2) {
const a = rs[0].codePointAt(0) - 0x1f1e6, b = rs[1].codePointAt(0) - 0x1f1e6;
if (a >= 0 && a < 26 && b >= 0 && b < 26) return String.fromCharCode(65 + a, 65 + b);
}
return isoFromNameText(p.name) || isoFromNameText(p.host) || "";
}
// Заполнить ячейку флага: SVG для известной страны, буквенный код — фолбэк.
function setFlagEl(el, p) {
const cc = profileISO(p);
const svg = flagSVG(cc);
el.classList.remove("unknown");
if (svg) {
el.innerHTML = svg;
} else {
el.textContent = cc || "·";
el.classList.add("unknown");
}
el.title = cc ? (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") {
renderStats();
refreshLogs().catch(() => {});
}
}
$("curServer").addEventListener("click", () => showPage("profiles"));
// ==== Theme ====
function readTheme() {
try {
const t = localStorage.getItem(THEME_KEY);
if (t === "light" || t === "dark") return t;
} catch (_) {}
return "light";
}
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 на вкладке «Защита»).
// Правила Store: никаких цен и упоминаний оплаты внутри приложения.
const OFFER_TEXT = "🎁 3 дня бесплатного доступа";
// Полезные ссылки (Помощь). Хост каждой ссылки — в allowlist openURL
// (internal/apphost/app.go → allowedLinkHosts).
const RECOMMENDED_SERVICES = [
{ icon: "🌐", name: "Сайт", url: "https://evilfox.win/" },
{ icon: "✈️", name: "Поддержка: Telegram", url: "https://t.me/evilfox100Bot" }
];
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 (_) {}
});
// Kill Switch — настройка живёт в config.json на стороне Go.
let killSwitchSyncing = false;
if (killSwitch) killSwitch.addEventListener("change", async () => {
if (killSwitchSyncing) return;
try {
await setKillSwitch(!!killSwitch.checked);
setMeta(killSwitch.checked
? "Kill Switch включён: при обрыве VPN трафик будет заблокирован"
: "Kill Switch выключен", "ok");
} catch (e) {
killSwitch.checked = !killSwitch.checked;
setMeta(String(e), "err");
}
});
// ==== Локальная статистика (localStorage) ====
// Источники данных:
// • трафик — дельты счётчика «использовано» из sub_info (заголовок
// Subscription-Userinfo / Remnawave API), накапливаются по дням;
// • время в сети и число сессий — отслеживание подключений в этом UI.
const STATS_DAILY_KEY = "navis.stats.daily"; // {"YYYY-MM-DD": {t: bytes, o: sec, s: sessions}}
const STATS_LAST_USED_KEY = "navis.stats.lastUsed";
let onlineFlushMs = 0; // отметка последнего накопления «времени в сети»
let wasConnected = false;
function dayKey(d) {
const x = d || new Date();
return x.getFullYear() + "-" + String(x.getMonth() + 1).padStart(2, "0") + "-" + String(x.getDate()).padStart(2, "0");
}
function loadDaily() {
try {
const o = JSON.parse(localStorage.getItem(STATS_DAILY_KEY) || "{}");
return (o && typeof o === "object") ? o : {};
} catch (_) { return {}; }
}
function saveDaily(o) {
// Храним только последние 14 дней.
const keys = Object.keys(o).sort();
while (keys.length > 14) delete o[keys.shift()];
try { localStorage.setItem(STATS_DAILY_KEY, JSON.stringify(o)); } catch (_) {}
}
function bumpToday(field, delta) {
if (!delta || delta <= 0) return;
const o = loadDaily();
const k = dayKey();
const d = o[k] || {};
d[field] = (d[field] || 0) + delta;
o[k] = d;
saveDaily(o);
}
// Дельта трафика из sub_info: счётчик «использовано» растёт → копим в сегодня.
function trackTraffic(info) {
if (!info || (!info.total && !info.download && !info.upload)) return;
const used = (info.upload || 0) + (info.download || 0);
let last = -1;
try { last = parseInt(localStorage.getItem(STATS_LAST_USED_KEY), 10); } catch (_) {}
if (Number.isFinite(last) && last >= 0 && used > last) {
bumpToday("t", used - last);
}
try { localStorage.setItem(STATS_LAST_USED_KEY, String(used)); } catch (_) {}
}
// Подключение/отключение: сессии и время в сети.
function trackConnection(connectedNow) {
const now = Date.now();
if (connectedNow && !wasConnected) {
bumpToday("s", 1);
onlineFlushMs = now;
} else if (connectedNow && wasConnected && onlineFlushMs) {
// Периодический сброс, чтобы время не терялось при закрытии приложения.
const sec = Math.floor((now - onlineFlushMs) / 1000);
if (sec >= 1) { bumpToday("o", sec); onlineFlushMs = now; }
} else if (!connectedNow && wasConnected && onlineFlushMs) {
bumpToday("o", Math.floor((now - onlineFlushMs) / 1000));
onlineFlushMs = 0;
}
wasConnected = connectedNow;
}
function fmtOnline(sec) {
if (!sec || sec < 60) return (sec || 0) + " сек";
const min = Math.floor(sec / 60);
if (min < 60) return min + " мин";
return Math.floor(min / 60) + " ч " + (min % 60) + " мин";
}
const DAY_RU = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
function renderStats() {
const daily = loadDaily();
const today = daily[dayKey()] || {};
const usedEl = $("statUsed"), onlineEl = $("statOnline"), sessEl = $("statSessions");
if (usedEl) usedEl.textContent = today.t > 0 ? fmtTraffic(today.t) : "—";
if (onlineEl) onlineEl.textContent = fmtOnline((today.o || 0) + (wasConnected && onlineFlushMs ? Math.floor((Date.now() - onlineFlushMs) / 1000) : 0));
if (sessEl) sessEl.textContent = String(today.s || 0);
const chart = $("weekChart");
if (!chart) return;
const days = [];
let maxT = 0;
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const rec = daily[dayKey(d)] || {};
days.push({ label: DAY_RU[d.getDay()], t: rec.t || 0 });
if ((rec.t || 0) > maxT) maxT = rec.t;
}
chart.innerHTML = "";
if (!maxT) {
const empty = document.createElement("div");
empty.className = "week-empty";
empty.textContent = "Пока нет данных";
chart.appendChild(empty);
return;
}
days.forEach((d) => {
const col = document.createElement("div");
col.className = "week-col";
const bar = document.createElement("div");
bar.className = "week-bar";
bar.style.height = Math.max(2, Math.round(d.t / maxT * 100)) + "%";
bar.title = d.t ? fmtTraffic(d.t) : "нет данных";
const label = document.createElement("div");
label.className = "week-day";
label.textContent = d.label;
col.appendChild(bar);
col.appendChild(label);
chart.appendChild(col);
});
}
function readHy2() {
return {
congestion: $("hy2Congestion").value,
bbr_profile: $("hy2Bbr").value,
bandwidth_up: $("hy2Up").value.trim(),
bandwidth_down: $("hy2Down").value.trim(),
obfs: $("hy2Obfs").value,
obfs_password: $("hy2ObfsPass").value.trim(),
sni: $("hy2Sni").value.trim(),
insecure: !!$("hy2Insecure").checked,
pin_sha256: $("hy2Pin").value.trim(),
fast_open: !!$("hy2Fast").checked,
lazy: !!$("hy2Lazy").checked,
hop_interval: $("hy2Hop").value.trim()
};
}
function fillHy2(h) {
if (!h) return;
if (h.congestion) $("hy2Congestion").value = h.congestion;
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
$("hy2Up").value = h.bandwidth_up || "";
$("hy2Down").value = h.bandwidth_down || "";
$("hy2Obfs").value = h.obfs || "";
$("hy2ObfsPass").value = h.obfs_password || "";
$("hy2Sni").value = h.sni || "";
$("hy2Pin").value = h.pin_sha256 || "";
$("hy2Hop").value = h.hop_interval || "";
$("hy2Insecure").checked = !!h.insecure;
$("hy2Fast").checked = !!h.fast_open;
$("hy2Lazy").checked = !!h.lazy;
}
async function persistHy2IfNeeded(protocol) {
const p = (protocol || "").toLowerCase();
const link = ((proxy && proxy.value) || "").trim().toLowerCase();
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
await saveHy2(readHy2());
}
}
let busy = false;
let connected = false;
let ksActive = false; // Kill Switch сработал: трафик заблокирован намеренно
let formHydrated = false;
let dirty = false;
let profiles = [];
let pingMap = {};
let metaHoldUntil = 0;
function markDirty() { dirty = true; }
[
proxy, nameInput, sysproxy, subUrl,
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
].forEach((el) => {
if (!el) return;
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
function setMeta(text, kind) {
meta.textContent = text || "";
// Пустая строка — нижняя полоса скрыта целиком (нет «Готово к подключению»).
meta.hidden = !text;
meta.classList.remove("ok", "err");
if (kind === "ok") meta.classList.add("ok");
if (kind === "err") meta.classList.add("err");
if (kind === "ok" || kind === "err") metaHoldUntil = Date.now() + 8000;
}
function detectProtoLabel(state, active) {
const p = (state.protocol || active.protocol || "").toLowerCase();
const link = (typeof state.proxy === "string" ? state.proxy : (active.proxy || "")).trim().toLowerCase();
if (p) return p;
if (link.startsWith("vless://")) return "vless";
if (link.startsWith("vmess://")) return "vmess";
if (link.startsWith("trojan://")) return "trojan";
if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) return "hysteria2";
if (link.startsWith("awg://") || link.includes("[interface]")) return "awg";
if (link.startsWith("naive") || link.startsWith("https://") || link.startsWith("quic://")) return "naive";
return "протокол";
}
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 показываем только в свойствах сервера и в журнале.
// ISO всегда через profileISO (бэкенд + JS-алиасы опечаток) → countryRu, никогда сырое имя панели.
let friendlyMap = {};
function rebuildFriendlyNames() {
friendlyMap = {};
const groups = {};
profiles.forEach((p) => {
const key = profileISO(p) || "__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, iso: key === "__none__" ? "" : key };
});
});
}
function friendlyTitle(name) {
const f = friendlyMap[name];
if (f && f.title) return f.title;
// Фолбэк: не показываем сырые опечатки панели — только каноническое имя по ISO.
const p = profiles.find((x) => x.name === name);
const iso = profileISO(p) || isoFromNameText(name);
const region = countryRu(iso);
if (region) return region;
return "Сервер";
}
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);
}
metaCol.appendChild(nameRow);
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);
// [📊] — проверить пинг только этого сервера.
const pingOne = document.createElement("button");
pingOne.type = "button";
pingOne.className = "row-ping";
pingOne.textContent = "📊";
pingOne.title = "Проверить пинг этого сервера";
pingOne.addEventListener("click", (e) => {
e.stopPropagation();
runPingOne(p.name, pingOne, ms);
});
right.appendChild(pingOne);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
});
}
// Пинг одного сервера по кнопке [📊] в строке.
async function runPingOne(name, btnEl, msEl) {
if (btnEl.disabled) return;
btnEl.disabled = true;
const prevText = msEl.textContent;
msEl.textContent = "…";
try {
const res = await pingServer(name);
if (res) pingMap[name] = res;
rebuildFriendlyNames();
renderServerList(profile.value);
renderCurServer(profile.value);
} catch (e) {
msEl.textContent = prevText;
setMeta("Не удалось измерить пинг: " + String(e), "err");
btnEl.disabled = false;
}
}
async function onServerClick(name) {
if (busy) return;
if (connected) {
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);
}
}
// DD.MM.YYYY для строки «Активен до …».
function fmtDateShort(unix) {
const d = new Date(unix * 1000);
return String(d.getDate()).padStart(2, "0") + "." + String(d.getMonth() + 1).padStart(2, "0") + "." + d.getFullYear();
}
function isSameCalendarDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function renderMode(state) {
currentMode = state.mode === "vpn" ? "vpn" : "proxy";
if (modeSection) {
// Не показываем фейковый переключатель там, где VPN-режима нет (macOS).
modeSection.hidden = !state.vpn_mode_supported;
}
if (modeProxyBtn) modeProxyBtn.classList.toggle("active", currentMode === "proxy");
if (modeVpnBtn) modeVpnBtn.classList.toggle("active", currentMode === "vpn");
if (vpnChip) vpnChip.hidden = !state.vpn_active;
}
// Прогресс-бар трафика на «Защите» и «Статистике».
// Заголовок — «📊 Доступно X ГБ», полоса и подпись — процент ИЗРАСХОДОВАННОГО
// («Z% использовано»), чтобы цифры и полоса были в одной системе координат.
function renderTraffic(info) {
const hasTraffic = !!(info && (info.total || info.download || info.upload));
[["trafficCard", "trafficLabel", "trafficPct", "trafficFill", "trafficExp"],
["trafficCard2", "trafficLabel2", "trafficPct2", "trafficFill2", "trafficExp2"]].forEach((ids) => {
const card = $(ids[0]);
if (!card) return;
if (!hasTraffic) { card.hidden = true; return; }
card.hidden = false;
const used = (info.upload || 0) + (info.download || 0);
const label = $(ids[1]), pct = $(ids[2]), fill = $(ids[3]), exp = $(ids[4]);
if (info.total > 0) {
const left = Math.max(0, info.total - used);
const usedPct = Math.min(100, Math.round(used / info.total * 100));
// «из Y ГБ» показываем только когда лимит уже начали расходовать.
label.textContent = used > 0
? "Доступно " + 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 expDate = new Date(info.expire * 1000);
const now = new Date();
if (expDate.getTime() < now.getTime() && !isSameCalendarDay(expDate, now)) {
expText = "Срок доступа истёк " + fmtDateShort(info.expire);
} else if (isSameCalendarDay(expDate, now)) {
expText = "⏳ Сгорает сегодня (до 23:59)";
} else {
expText = "Активен до " + fmtDateShort(info.expire);
}
}
exp.hidden = !expText;
exp.textContent = expText;
}
});
}
function renderSubInfo(info) {
renderTraffic(info);
trackTraffic(info);
const card = $("subInfoCard");
if (!card) return;
const expireRow = $("subExpireRow"), trafficRow = $("subTrafficRow"), devicesRow = $("subDevicesRow");
const hasExpire = !!(info && info.expire);
const hasTraffic = !!(info && (info.total || info.download || info.upload));
const hasDevices = !!(info && info.devices_known);
if (!info || (!hasExpire && !hasTraffic && !hasDevices)) {
card.hidden = true;
return;
}
card.hidden = false;
expireRow.hidden = !hasExpire;
if (hasExpire) {
const d = new Date(info.expire * 1000);
const text = fmtDate(info.expire);
const daysLeft = Math.floor((d.getTime() - Date.now()) / 86400000);
const v = $("subExpireVal");
if (daysLeft < 0) {
v.textContent = text + " · Истёк";
v.classList.add("warn");
} else if (daysLeft === 0) {
v.textContent = text + " · Сегодня";
v.classList.add("warn");
} else if (daysLeft <= 14) {
v.textContent = text + " · осталось " + daysLeft + " дн.";
v.classList.toggle("warn", daysLeft <= 3);
} else {
v.textContent = text;
v.classList.remove("warn");
}
}
trafficRow.hidden = !hasTraffic;
if (hasTraffic) {
const used = (info.upload || 0) + (info.download || 0);
$("subTrafficVal").textContent = info.total > 0
? (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) {
// Версия — только в «Помощь»; в сайдбаре бренд без номера.
if (verLabel) verLabel.textContent = "EvilFox";
if (aboutVer) aboutVer.textContent = "Версия " + (version || "?");
if (badgeVer) {
badgeVer.hidden = true;
badgeVer.textContent = "";
}
if (storePackaged || (u && u.store_managed)) {
updateBanner.classList.remove("show");
if (updCheckBtn) updCheckBtn.hidden = true;
if (aboutUpdBtn) aboutUpdBtn.hidden = true;
if (storeUpdateNote) storeUpdateNote.hidden = false;
return;
}
if (updCheckBtn) updCheckBtn.hidden = false;
if (aboutUpdBtn) aboutUpdBtn.hidden = false;
if (storeUpdateNote) storeUpdateNote.hidden = true;
if (!u) {
updateBanner.classList.remove("show");
return;
}
const latest = (u.latest || "").toString();
const skip = skippedVersion();
if (u.available && latest && skip === latest) {
updateBanner.classList.remove("show");
return;
}
if (u.available) {
updateBanner.classList.add("show");
$("updateTitle").textContent = "Доступно обновление " + 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;
ksActive = !!state.kill_switch_active;
trackConnection(connected);
hero.classList.toggle("on", connected);
btn.classList.toggle("on", connected || ksActive);
if (ksActive) {
statusText.textContent = "⛔ Соединение прервано — трафик заблокирован";
if (powerLabel) powerLabel.textContent = "ОТКЛЮЧИТЬСЯ";
} else {
statusText.textContent = connected ? "🟢 Защита активна" : "🔴 Отключено";
if (powerLabel) powerLabel.textContent = connected ? "ОТКЛЮЧИТЬСЯ" : "ПОДКЛЮЧИТЬСЯ";
}
btn.title = (connected || ksActive) ? "Отключить" : "Подключить";
if (killSwitch && document.activeElement !== killSwitch) {
killSwitchSyncing = true;
killSwitch.checked = !!state.kill_switch;
killSwitchSyncing = false;
}
// Статус конфигурации на «Мои серверы»: сам URL не показываем (он
// хранится в config.json и нужен для обновления списка серверов).
const subStatus = $("subStatus");
if (subStatus) {
const hasSub = !!(state.subscription_url && String(state.subscription_url).trim());
subStatus.textContent = hasSub ? "🟢 Конфигурация добавлена" : "🔴 Конфигурация не добавлена";
}
const lock = connected || busy;
if (proxy) proxy.disabled = lock;
if (nameInput) nameInput.disabled = lock;
sysproxy.disabled = lock;
profile.disabled = lock;
if (addBtn) addBtn.disabled = lock;
if (delBtn) delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
if (saveBtn) saveBtn.disabled = lock;
if (coreBtn) coreBtn.disabled = busy;
pingBtn.disabled = busy;
bestBtn.disabled = busy;
updCheckBtn.disabled = busy;
if (aboutUpdBtn) aboutUpdBtn.disabled = busy;
updateBtn.disabled = busy;
subBtn.disabled = busy;
subUrl.disabled = busy;
if (modeProxyBtn) modeProxyBtn.disabled = busy || connected;
if (modeVpnBtn) modeVpnBtn.disabled = busy || connected;
btn.disabled = busy;
btn.classList.toggle("busy", busy);
rememberPings(state.pings || []);
fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderUpdate(state.update, state.version);
renderSubInfo(state.sub_info);
renderMode(state);
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
if (nameInput) nameInput.value = active.name || state.active_profile || "";
if (proxy) proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
fillHy2(state.hy2);
formHydrated = true;
if (syncForm) dirty = false;
}
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
const label = detectProtoLabel(state, active);
// Чип протокола в hero убран (дублировал карточку активного сервера) — guard на случай возврата.
if (protoChip) {
protoChip.textContent = label;
protoChip.classList.toggle("active", !!state.connected || label !== "протокол");
}
const hy2 = (label === "hysteria2" || label === "hy2");
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
// Параметры прокси — только когда подключены (без пустого «появится после…»).
const advHttp = $("advHttp"), advSocks = $("advSocks"), logsProxyLine = $("logsProxyLine");
const proxyParamsBlock = $("proxyParamsBlock");
const hasProxyParams = !!(state.http_proxy || state.socks_proxy);
if (proxyParamsBlock) proxyParamsBlock.hidden = !hasProxyParams;
if (advHttp) advHttp.textContent = state.http_proxy || "—";
if (advSocks) advSocks.textContent = state.socks_proxy || "—";
if (logsProxyLine) {
const parts = [];
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
logsProxyLine.hidden = !parts.length;
logsProxyLine.textContent = parts.length ? ("Прокси: " + parts.join(" · ")) : "";
}
renderProvConnect();
renderProfProps();
// Нижняя полоса статуса — только для важных сообщений; постоянного
// «Готово к подключению» больше нет (статус виден на большой кнопке).
let detail = "", detailKind = "";
if (ksActive) {
heroHint.textContent = "Kill Switch активен: интернет заблокирован до отключения или переподключения";
detail = "⛔ Kill Switch: соединение прервано, трафик заблокирован. Нажмите «Отключиться» или переподключитесь.";
detailKind = "err";
} else if (connected) {
heroHint.textContent = "Трафик зашифрован";
} else if (state.core_ready === false) {
detail = "Сначала установите cores в Настройках («Сервис»)";
detailKind = "err";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
heroHint.textContent = currentMode === "vpn"
? "Режим VPN: защита всего трафика, включая игры и приложения"
: "Нажмите, чтобы включить защиту";
}
if (!busy && Date.now() > metaHoldUntil) {
setMeta(detail, detailKind);
metaHoldUntil = 0;
}
renderStats();
}
// Кнопка «Подключиться» на карточке EvilFox (вкладка «Защита»).
function renderProvConnect() {
const b = $("provConnectBtn");
if (!b) return;
if (connected) {
b.textContent = "✅ Подключено";
b.disabled = true;
} else {
b.textContent = "🔥 Подключиться";
b.disabled = busy;
}
}
// Свойства выбранного сервера: имя / пинг / протокол / статус.
function renderProfProps() {
const p = profiles.find((x) => x.name === profile.value);
const nameEl = $("propName"), pingEl = $("propPing"), protoEl = $("propProto"), stEl = $("propStatus");
if (!nameEl) return;
if (!p) {
nameEl.textContent = "—";
if (pingEl) pingEl.textContent = "—";
if (protoEl) protoEl.textContent = "—";
if (stEl) stEl.textContent = "—";
return;
}
nameEl.textContent = friendlyTitle(p.name);
const pr = pingMap[p.name];
if (pingEl) {
if (pr && pr.ok) pingEl.textContent = pr.ms + " ms";
else if (pr && pr.error) pingEl.textContent = "недоступен";
else pingEl.textContent = "не измерен";
}
if (protoEl) {
const proto = String(p.protocol || "—");
protoEl.textContent = proto.charAt(0).toUpperCase() + proto.slice(1);
}
if (stEl) stEl.textContent = connected ? "🟢 Активен" : "⚪ Не активен";
}
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 || ksActive) {
// ksActive: отключение снимает блокировку Kill Switch и
// восстанавливает обычную сеть.
setMeta("Отключение…");
await disconnect();
setMeta("Отключено", "ok");
} else if (autoBest.checked && profiles.length > 1) {
// «Авто-лучший сервер»: пинг всех и подключение к самому быстрому.
await runBest(true);
} else {
setMeta("Подключение…");
// Профиль уже выбран; скрытые поля name/proxy гидратируются из state.
// Не перезаписываем proxy пустой строкой, если форма ещё не заполнена.
const n = (nameInput && nameInput.value.trim()) || profile.value;
const px = (proxy && proxy.value.trim()) || "";
if (n && px) {
await saveProfile(n, px, !!sysproxy.checked);
await persistHy2IfNeeded();
}
await connect();
setMeta("Защищённое соединение установлено", "ok");
}
} catch (e) { setMeta(String(e), "err"); }
}));
if (saveBtn) saveBtn.addEventListener("click", () => withBusy(async () => {
try {
const n = (nameInput && nameInput.value.trim()) || profile.value;
const px = (proxy && proxy.value.trim()) || "";
await saveProfile(n, px, !!sysproxy.checked);
await persistHy2IfNeeded();
setMeta("Профиль сохранён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
if (coreBtn) coreBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание official cores…");
const path = await installCore();
setMeta("Cores готовы: " + path, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
if (delBtn) 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"); }
}));
if (addBtn) addBtn.addEventListener("click", () => {
$("newName").value = "";
$("newProxy").value = "";
modal.classList.add("open");
$("newName").focus();
});
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.remove("open");
});
$("createNew").addEventListener("click", () => withBusy(async () => {
try {
const n = $("newName").value.trim();
const p = $("newProxy").value.trim();
if (!n) throw "Укажите название профиля";
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("Вставьте URL-ссылку для подключения", "err");
return;
}
setMeta("Добавление конфигурации…");
const r = await importSubscription(url);
formHydrated = false;
dirty = false;
subUrl.value = "";
setMeta(importSummary(r) + " · подбираю оптимальный сервер…", "ok");
await runBest(!!autoBest.checked);
}
function importSummary(r) {
if (r && typeof r === "object") {
let s = "Конфигурация добавлена · серверов: " + (r.imported || 0);
if (r.skipped > 0) s += " (пропущено " + r.skipped + ")";
return s;
}
return "Конфигурация добавлена: " + r;
}
// «Где взять URL-ссылку?» — открывает 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();
});
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>