Files
navi/internal/appui/index.html
T

2226 lines
82 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EvilFox</title>
<script>
(function () {
try {
var k = "navis.theme";
var t = localStorage.getItem(k);
if (t !== "light" && t !== "dark") {
t = "dark";
}
document.documentElement.setAttribute("data-theme", t);
} catch (_) {
document.documentElement.setAttribute("data-theme", "dark");
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
/* Dark-first slate + cyan accent; sidebar layout (Hiddify/Nord style) */
:root {
color-scheme: light;
--ink: #0f172a;
--text: var(--ink);
--muted: #64748b;
--line: rgba(15, 23, 42, 0.10);
--accent: #0891b2;
--accent-deep: #0e7490;
--accent-soft: #cffafe;
--danger: #dc2626;
--ok: #0d9488;
--ms-good: #059669;
--ms-mid: #d97706;
--surface: rgba(255, 255, 255, 0.88);
--surface-2: rgba(248, 250, 252, 0.92);
--input-bg: #ffffff;
--row-bg: rgba(255, 255, 255, 0.7);
--hero-bg: linear-gradient(155deg, #f8fafc 0%, #ecfeff 55%, #f1f5f9 100%);
--hero-bg-on: linear-gradient(155deg, #ecfeff 0%, #e0f2fe 50%, #f8fafc 100%);
--chip-bg: rgba(255, 255, 255, 0.85);
--server-list-bg: rgba(241, 245, 249, 0.65);
--server-row-bg: rgba(255, 255, 255, 0.92);
--server-row-hover: #fff;
--server-row-active: linear-gradient(135deg, #ecfeff, #fff);
--meta-bg: rgba(241, 245, 249, 0.8);
--meta-ok-bg: rgba(204, 251, 241, 0.75);
--meta-err-bg: rgba(254, 226, 226, 0.85);
--shop-bg: linear-gradient(135deg, rgba(8,145,178,.08), rgba(255,255,255,.7));
--shop-border: rgba(8, 145, 178, 0.18);
--modal-bg: #f8fafc;
--modal-scrim: rgba(15, 23, 42, 0.45);
--update-bg: linear-gradient(135deg, rgba(8,145,178,.12), rgba(255,255,255,.85));
--update-border: rgba(8, 145, 178, 0.28);
--icon-btn-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--icon-btn-border: rgba(15, 23, 42, 0.12);
--icon-btn-hover: linear-gradient(180deg, #f1f5f9, #e2e8f0);
--mini-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #f0fdfa;
--switch-off: #cbd5e1;
--switch-knob: #fff;
--dot-off: #94a3b8;
--shadow: 0 20px 50px rgba(15, 23, 42, 0.10);
--radius: 18px;
--ease: cubic-bezier(.22,.8,.24,1);
--page-bg:
radial-gradient(700px 380px at 0% -5%, #a5f3fc 0%, transparent 50%),
radial-gradient(560px 320px at 100% 0%, #bae6fd 0%, transparent 45%),
linear-gradient(165deg, #f1f5f9 0%, #f8fafc 50%, #eef2ff 100%);
--theme-toggle-bg: rgba(255, 255, 255, 0.75);
--side-bg: rgba(255, 255, 255, 0.65);
--side-active: rgba(8, 145, 178, 0.12);
--side-active-fg: #0e7490;
--power-off: linear-gradient(160deg, #e2e8f0, #cbd5e1);
--power-on: linear-gradient(160deg, #0d9488, #0891b2);
--log-bg: rgba(15, 23, 42, 0.04);
}
html[data-theme="dark"] {
color-scheme: dark;
--ink: #e8eef7;
--text: var(--ink);
--muted: #8b9bb4;
--line: rgba(148, 163, 184, 0.14);
--accent: #22d3ee;
--accent-deep: #06b6d4;
--accent-soft: rgba(34, 211, 238, 0.12);
--danger: #f87171;
--ok: #2dd4bf;
--ms-good: #34d399;
--ms-mid: #fbbf24;
--surface: rgba(15, 20, 30, 0.92);
--surface-2: rgba(22, 30, 44, 0.88);
--input-bg: rgba(10, 14, 22, 0.95);
--row-bg: rgba(20, 28, 40, 0.8);
--hero-bg: linear-gradient(155deg, #121826 0%, #0f172a 55%, #111827 100%);
--hero-bg-on: linear-gradient(155deg, #0c2a32 0%, #132033 50%, #121826 100%);
--chip-bg: rgba(30, 41, 59, 0.9);
--server-list-bg: rgba(8, 12, 20, 0.55);
--server-row-bg: rgba(22, 30, 44, 0.9);
--server-row-hover: rgba(30, 41, 59, 0.95);
--server-row-active: linear-gradient(135deg, rgba(8,47,55,.95), rgba(22,30,44,.95));
--meta-bg: rgba(10, 14, 22, 0.7);
--meta-ok-bg: rgba(6, 78, 72, 0.45);
--meta-err-bg: rgba(69, 26, 26, 0.55);
--shop-bg: linear-gradient(135deg, rgba(34,211,238,.08), rgba(15,20,30,.85));
--shop-border: rgba(34, 211, 238, 0.18);
--modal-bg: #121826;
--modal-scrim: rgba(2, 6, 14, 0.72);
--update-bg: linear-gradient(135deg, rgba(34,211,238,.12), rgba(15,20,30,.9));
--update-border: rgba(34, 211, 238, 0.28);
--icon-btn-bg: linear-gradient(180deg, #1e293b, #162032);
--icon-btn-border: rgba(34, 211, 238, 0.22);
--icon-btn-hover: linear-gradient(180deg, #243247, #1a2536);
--mini-bg: linear-gradient(180deg, #1e293b, #162032);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #ecfeff;
--switch-off: #334155;
--switch-knob: #e2e8f0;
--dot-off: #64748b;
--shadow: 0 22px 55px rgba(0, 0, 0, 0.5);
--page-bg:
radial-gradient(680px 380px at 5% -8%, rgba(8, 90, 110, 0.35) 0%, transparent 55%),
radial-gradient(520px 300px at 100% 0%, rgba(30, 58, 95, 0.32) 0%, transparent 48%),
linear-gradient(165deg, #070b12 0%, #0c1220 48%, #0a1018 100%);
--theme-toggle-bg: rgba(30, 41, 59, 0.85);
--side-bg: rgba(12, 18, 30, 0.72);
--side-active: rgba(34, 211, 238, 0.14);
--side-active-fg: #22d3ee;
--power-off: linear-gradient(160deg, #243247, #16202f);
--power-on: linear-gradient(160deg, #0d9488, #0891b2);
--log-bg: rgba(2, 6, 14, 0.6);
}
* { box-sizing: border-box; }
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; }
.brand {
font-family: Outfit, sans-serif;
font-size: 1.35rem;
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
margin: 0;
}
.badge-ver {
font-size: .62rem;
font-weight: 700;
letter-spacing: .04em;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(8,145,178,.22);
border-radius: 7px;
padding: 2px 6px;
}
.nav-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 700;
font-size: .9rem;
padding: 11px 14px;
border-radius: 13px;
cursor: pointer;
transition: background .15s, color .15s;
text-align: left;
}
.nav-btn svg { width: 20px; height: 20px; flex: 0 0 auto; }
.nav-btn:hover { background: var(--side-active); }
.nav-btn.active {
background: var(--side-active);
color: var(--side-active-fg);
}
.side .spacer { flex: 1; }
.side-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 0;
border-top: 1px solid var(--line);
}
.side-foot .ver {
font-size: .7rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-toggle {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--theme-toggle-bg);
color: var(--ink);
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s, border-color .15s, transform .12s;
padding: 0;
}
.theme-toggle:hover { border-color: rgba(8, 145, 178, 0.4); }
.theme-toggle svg { width: 18px; height: 18px; display: none; }
html[data-theme="light"] .theme-toggle .icon-moon { display: block; }
html[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
/* ==== Content ==== */
.content {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.pages {
flex: 1;
overflow-y: auto;
padding: 18px 20px 8px;
}
.page { display: none; max-width: 640px; margin: 0 auto; animation: rise .3s var(--ease) both; }
.page.active { display: block; }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.page-title {
font-family: Outfit, sans-serif;
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -.02em;
margin: 0 0 14px;
}
.update-banner {
display: none;
margin: 0 20px 0;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--update-border);
background: var(--update-bg);
animation: rise .4s var(--ease) both;
}
.update-banner.show { display: block; margin-top: 14px; }
.update-banner strong {
font-family: Outfit, sans-serif;
display: block;
margin-bottom: 4px;
font-size: .95rem;
}
.update-banner p { margin: 0 0 10px; color: var(--muted); font-size: .84rem; }
.update-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.update-actions .action { flex: 1 1 auto; min-width: 140px; }
/* ==== Home / power ==== */
.hero {
position: relative;
overflow: hidden;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--hero-bg);
padding: 26px 16px 20px;
margin-bottom: 14px;
text-align: center;
transition: border-color .25s, box-shadow .25s, background .25s;
}
.hero.on {
border-color: rgba(13,138,102,.35);
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
background: var(--hero-bg-on);
}
.power-btn {
width: 132px;
height: 132px;
border-radius: 50%;
border: 0;
cursor: pointer;
background: var(--power-off);
color: var(--muted);
display: grid;
place-items: center;
margin: 4px auto 14px;
transition: background .25s, color .25s, transform .12s, box-shadow .25s;
box-shadow: 0 14px 34px rgba(15, 23, 42, .18);
position: relative;
}
.power-btn:hover { transform: translateY(-2px); }
.power-btn:active { transform: none; }
.power-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
.power-btn svg { width: 46px; height: 46px; }
.power-btn.on {
background: var(--power-on);
color: #f0fdfa;
box-shadow: 0 16px 44px rgba(13, 148, 136, .45);
}
.power-btn.on::after {
content: "";
position: absolute;
inset: -9px;
border-radius: 50%;
border: 2px solid rgba(45, 212, 191, .45);
animation: ring 2s ease-out infinite;
}
@keyframes ring {
0% { transform: scale(.96); opacity: .9; }
100% { transform: scale(1.12); opacity: 0; }
}
.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);
}
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); }
.server-row .meta-col { min-width: 0; }
.server-row .name {
font-weight: 700;
font-size: .84rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .sub {
margin-top: 2px;
font-size: .72rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: 0 0 auto; }
.server-row .proto {
font-size: .62rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--accent-deep);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 6px;
}
.server-row .ms {
font-size: .78rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); }
.remna-box {
margin-top: 4px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--surface-2);
display: grid;
gap: 8px;
}
.remna-title {
font-family: Outfit, sans-serif;
font-weight: 700;
font-size: .92rem;
letter-spacing: -.02em;
}
.remna-hint { margin: 0; font-size: .75rem; color: var(--muted); line-height: 1.4; }
.server-empty { padding: 14px 10px; text-align: center; color: var(--muted); font-size: .82rem; }
.profile-edit summary {
cursor: pointer;
list-style: none;
font-size: .78rem;
font-weight: 700;
color: var(--muted);
padding: 4px 0;
}
.profile-edit summary::-webkit-details-marker { display: none; }
.meta {
margin: 8px 20px 12px;
font-size: .82rem;
color: var(--muted);
line-height: 1.45;
min-height: 2.4em;
padding: 8px 12px;
border-radius: 12px;
background: var(--meta-bg);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
flex: 0 0 auto;
}
.meta.ok { color: var(--ok); background: var(--meta-ok-bg); border-color: rgba(13,138,102,.18); }
.meta.err { color: var(--danger); background: var(--meta-err-bg); border-color: rgba(192,54,44,.16); }
/* Logs */
.log-view {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .72rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
background: var(--log-bg);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
max-height: 62vh;
overflow: auto;
margin: 0 0 10px;
min-height: 200px;
color: var(--ink);
}
.shop {
margin-top: 12px;
padding: 13px 14px 12px;
border-radius: 18px;
border: 1px solid var(--shop-border);
background: var(--shop-bg);
}
.shop h3 { font-family: Outfit, sans-serif; font-size: 1rem; letter-spacing: -0.02em; margin: 0 0 5px; }
.shop p { margin: 0 0 10px; color: var(--muted); font-size: .82rem; line-height: 1.4; }
.shop .action { width: 100%; }
.shop-link {
display: block;
width: 100%;
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
text-align: center;
font: inherit;
font-size: .76rem;
color: var(--accent);
font-weight: 600;
cursor: pointer;
box-shadow: none;
}
.shop-link:hover { text-decoration: underline; }
/* «Рекомендуемые сервисы» — маленький ненавязчивый блок в углу «О программе» */
.svc {
margin-top: 12px;
padding: 10px 12px 11px;
border-radius: 14px;
border: 1px dashed var(--line);
background: transparent;
}
.svc h4 {
margin: 0 0 7px;
font-family: Outfit, sans-serif;
font-size: .66rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.svc-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.svc-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: .74rem;
font-weight: 600;
cursor: pointer;
box-shadow: none;
transition: color .15s, border-color .15s;
}
.svc-link:hover { color: var(--accent-deep); border-color: rgba(13,138,102,.45); }
.svc-link .ic { font-size: .85rem; line-height: 1; }
.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 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">
<svg viewBox="0 0 32 32" fill="none">
<path d="M8 26V6h4.2L20 18.4V6H24v20h-4.2L12 13.6V26H8z" fill="#f4fff9"/>
<path d="M22.5 8.2l3.2-1.1-1.1 3.2-5.6 5.6 1.6 1.6 5.6-5.6 3.2-1.1-1.1 3.2" fill="#b8f0d8" opacity=".9"/>
</svg>
</div>
<h1 class="brand">EvilFox</h1>
<span class="badge-ver" id="badgeVer">4.0.0</span>
</div>
<button class="nav-btn active" type="button" data-page="home">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11.5 12 4l9 7.5"/><path d="M5.5 10v9.5h13V10"/></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="M6 3.5h9l3.5 3.5v13.5h-12.5z"/><path d="M9 10h6M9 13.5h6M9 17h4"/></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="M12 10.8v5.4M12 7.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="home">
<section class="hero" id="hero">
<button class="power-btn" id="toggleBtn" type="button" aria-label="Подключить / отключить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round">
<path d="M12 3v8"/>
<path d="M6.3 6.6a8 8 0 1 0 11.4 0"/>
</svg>
</button>
<div class="status-line">
<span class="dot" id="dot"></span>
<span id="statusText">Отключено</span>
</div>
<div class="chips-line">
<span class="proto-chip" id="protoChip">протокол</span>
<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="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>
</section>
<!-- ============ Профили ============ -->
<section class="page" data-page="profiles">
<h2 class="page-title">Профили</h2>
<div class="server-toolbar">
<div class="section-title"><span>Серверы</span></div>
<div class="server-actions">
<button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="mini accent" id="bestBtn" type="button">Лучший</button>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль" style="width:34px;height:34px;font-size:1rem">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
</div>
</div>
<div class="server-list" id="serverList"></div>
<select id="profile" hidden></select>
<details class="profile-edit" id="profileEdit">
<summary>Редактировать / подписка</summary>
<div class="stack" style="margin-top:8px">
<div>
<label class="field" for="name">Название</label>
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
</div>
<div>
<label class="field" for="proxy">Ссылка / конфиг</label>
<textarea id="proxy" rows="3" placeholder="vless:// · vmess:// · trojan:// · hy2:// · naive+https:// · AWG .conf" spellcheck="false"></textarea>
</div>
<div>
<label class="field" for="subUrl">URL подписки</label>
<div class="sub-bar">
<input id="subUrl" type="text" placeholder="https://…/api/sub/… или обычная подписка" spellcheck="false" />
<button class="icon-btn wide" id="subBtn" type="button" title="Обновить подписку">Обновить</button>
</div>
</div>
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
</details>
</section>
<!-- ============ Настройки ============ -->
<section class="page" data-page="settings">
<h2 class="page-title">Настройки</h2>
<div class="section" id="modeSection">
<div class="section-title"><span>Режим трафика</span></div>
<div class="mode-seg" id="modeSeg">
<button type="button" id="modeProxyBtn">Режим прокси</button>
<button type="button" id="modeVpnBtn">Режим VPN</button>
</div>
<p class="remna-hint" id="modeHint">Прокси — трафик идёт через локальный прокси (и системный прокси Windows). VPN — весь трафик, включая игры и приложения, через TUN-адаптер; нужны права администратора и wintun.dll рядом с EvilFox.exe. Меняется в отключённом состоянии.</p>
</div>
<div class="row">
<span class="row-text">Системный прокси
<span class="row-sub">В режиме VPN не используется — TUN и так захватывает всё</span>
</span>
<label class="switch">
<input id="sysproxy" type="checkbox" checked />
<span class="slider"></span>
</label>
</div>
<div class="row">
<span class="row-text">Тёмная тема</span>
<label class="switch">
<input id="themeSwitch" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<div class="row">
<span class="row-text">Авто-лучший сервер
<span class="row-sub">Пинг всех серверов и автоподключение к лучшему</span>
</span>
<label class="switch">
<input id="autoBest" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<details class="panel hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / obfuscation</summary>
<div class="grid2">
<div>
<label class="field" for="hy2Congestion">Congestion</label>
<select id="hy2Congestion">
<option value="bbr">BBR</option>
<option value="reno">Reno</option>
</select>
</div>
<div>
<label class="field" for="hy2Bbr">BBR profile</label>
<select id="hy2Bbr">
<option value="standard">standard</option>
<option value="conservative">conservative</option>
<option value="aggressive">aggressive</option>
</select>
</div>
<div>
<label class="field" for="hy2Up">Brutal up</label>
<input id="hy2Up" type="text" placeholder="пусто = BBR" />
</div>
<div>
<label class="field" for="hy2Down">Brutal down</label>
<input id="hy2Down" type="text" placeholder="пусто = BBR" />
</div>
<div>
<label class="field" for="hy2Obfs">Obfs</label>
<select id="hy2Obfs">
<option value="">нет</option>
<option value="salamander">salamander</option>
<option value="gecko">gecko</option>
</select>
</div>
<div>
<label class="field" for="hy2ObfsPass">Obfs password</label>
<input id="hy2ObfsPass" type="text" placeholder="пароль" />
</div>
<div>
<label class="field" for="hy2Sni">SNI</label>
<input id="hy2Sni" type="text" placeholder="cdn.example.com" />
</div>
<div>
<label class="field" for="hy2Pin">pinSHA256</label>
<input id="hy2Pin" type="text" placeholder="опционально" />
</div>
<div>
<label class="field" for="hy2Hop">Hop interval</label>
<input id="hy2Hop" type="text" placeholder="30s" />
</div>
</div>
<label class="check"><input id="hy2Insecure" type="checkbox" /> TLS insecure</label>
<label class="check"><input id="hy2Fast" type="checkbox" /> Fast Open</label>
<label class="check"><input id="hy2Lazy" type="checkbox" /> Lazy connect</label>
</details>
<details class="panel" id="toolsBox">
<summary>Сервис · cores, обновления</summary>
<div class="tools">
<button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button>
<button class="action secondary" id="coreBtn" type="button">Установить cores</button>
</div>
<p class="meta" id="storeUpdateNote" hidden style="margin:8px 0 0;min-height:0">Обновления этой сборки — через Microsoft Store.</p>
</details>
</section>
<!-- ============ Логи ============ -->
<section class="page" data-page="logs">
<h2 class="page-title">Логи</h2>
<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>
<div class="about-card">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none">
<path d="M8 26V6h4.2L20 18.4V6H24v20h-4.2L12 13.6V26H8z" fill="#f4fff9"/>
<path d="M22.5 8.2l3.2-1.1-1.1 3.2-5.6 5.6 1.6 1.6 5.6-5.6 3.2-1.1-1.1 3.2" fill="#b8f0d8" opacity=".9"/>
</svg>
</div>
<h2>EvilFox</h2>
<p id="aboutVer">версия…</p>
<p style="margin-top:6px">VPN/прокси-клиент: Naive · Hysteria 2 · AWG · Xray</p>
</div>
<div class="tools" style="margin-top:0">
<button class="action secondary" id="aboutUpdBtn" type="button">Проверить обновление</button>
</div>
<section class="shop" aria-label="Купить подключение">
<h3>Нужен доступ?</h3>
<p>Ключи и тарифы на evilfox.win — демо от 30₽.</p>
<button class="action primary" id="shopBtn" type="button">Открыть магазин</button>
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section>
<!-- Рекомендуемые сервисы: ненавязчивый блок в нижнем углу «О программе».
Ссылки задаются в скрипте — константа RECOMMENDED_SERVICES. -->
<section class="svc" aria-label="Рекомендуемые сервисы">
<h4>Рекомендуемые сервисы</h4>
<div class="svc-grid" id="svcGrid"></div>
</section>
<button class="shop-link" id="quitBtn" type="button" hidden style="display:none;margin:12px auto 0;width:auto">Выйти</button>
</section>
</div>
<p class="meta" id="meta">Загрузка…</p>
</div>
</div>
<div class="modal-backdrop" id="modal">
<div class="modal">
<h2>Новый профиль</h2>
<div class="stack">
<div>
<label class="field" for="newName">Название</label>
<input id="newName" type="text" placeholder="Server EU" />
</div>
<div>
<label class="field" for="newProxy">Ссылка</label>
<input id="newProxy" type="text" placeholder="vless:// · vmess:// · trojan:// · hy2 · naive · awg" />
</div>
</div>
<div class="actions">
<button class="action secondary" id="cancelNew" type="button">Отмена</button>
<button class="action primary" id="createNew" type="button">Создать</button>
</div>
</div>
</div>
<script>
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives).
(function () {
const methods = [
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave",
"setMode","getLogs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
async function call(name, args) {
const r = await fetch("/api/" + name, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ args: args || [] })
});
const j = await r.json();
if (j && j.error) throw j.error;
return j ? j.result : null;
}
methods.forEach((name) => {
window[name] = function () {
return call(name, Array.prototype.slice.call(arguments));
};
});
})();
const $ = (id) => document.getElementById(id);
const meta = $("meta");
const btn = $("toggleBtn");
const coreBtn = $("coreBtn");
const saveBtn = $("saveBtn");
const addBtn = $("addBtn");
const delBtn = $("delBtn");
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
const profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const updCheckBtn = $("updCheckBtn");
const aboutUpdBtn = $("aboutUpdBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const skipUpdateBtn = $("skipUpdateBtn");
const verLabel = $("verLabel");
const badgeVer = $("badgeVer");
const aboutVer = $("aboutVer");
const storeUpdateNote = $("storeUpdateNote");
const themeToggle = $("themeToggle");
const themeSwitch = $("themeSwitch");
const modeProxyBtn = $("modeProxyBtn");
const modeVpnBtn = $("modeVpnBtn");
const modeSection = $("modeSection");
const vpnChip = $("vpnChip");
const logView = $("logView");
const logsRefreshBtn = $("logsRefreshBtn");
const logsClearBtn = $("logsClearBtn");
const THEME_KEY = "navis.theme";
const SKIP_UPDATE_KEY = "navis.skipUpdateVersion";
let storePackaged = false;
let currentMode = "proxy";
let currentPage = "home";
// ==== Navigation ====
document.querySelectorAll(".nav-btn").forEach((b) => {
b.addEventListener("click", () => showPage(b.dataset.page));
});
function showPage(name) {
currentPage = name;
document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
document.querySelectorAll(".page").forEach((p) => p.classList.toggle("active", p.dataset.page === name));
if (name === "logs") refreshLogs().catch(() => {});
}
$("curServer").addEventListener("click", () => showPage("profiles"));
// ==== Theme ====
function readTheme() {
try {
const t = localStorage.getItem(THEME_KEY);
if (t === "light" || t === "dark") return t;
} catch (_) {}
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch (_) {
return "dark";
}
}
function applyTheme(t) {
const theme = t === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
if (themeToggle) {
themeToggle.title = theme === "dark" ? "Светлая тема" : "Тёмная тема";
themeToggle.setAttribute("aria-label", themeToggle.title);
}
if (themeSwitch) themeSwitch.checked = theme === "dark";
}
applyTheme(document.documentElement.getAttribute("data-theme") || readTheme());
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
applyTheme(cur === "dark" ? "light" : "dark");
});
}
if (themeSwitch) {
themeSwitch.addEventListener("change", () => applyTheme(themeSwitch.checked ? "dark" : "light"));
}
function skippedVersion() {
try { return localStorage.getItem(SKIP_UPDATE_KEY) || ""; } catch (_) { return ""; }
}
function setSkippedVersion(v) {
try {
if (v) localStorage.setItem(SKIP_UPDATE_KEY, String(v));
else localStorage.removeItem(SKIP_UPDATE_KEY);
} catch (_) {}
}
const quitBtn = $("quitBtn");
if (window.__navisHttp && quitBtn) {
quitBtn.hidden = false;
quitBtn.style.display = "block";
quitBtn.addEventListener("click", () => {
withBusy(async () => {
setMeta("Выход…");
try { await quit(); } catch (_) {}
});
});
}
const subUrl = $("subUrl");
const subBtn = $("subBtn");
const hero = $("hero");
const protoChip = $("protoChip");
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
// Список рекомендуемых сервисов — замените ссылки/названия здесь.
// Хост каждой ссылки должен быть разрешён в allowlist openURL
// (internal/apphost/app.go → allowedLinkHosts), иначе ссылка не откроется.
const RECOMMENDED_SERVICES = [
{ icon: "🛒", name: "EvilFox Shop", url: "https://evilfox.win/" },
{ icon: "✈️", name: "Telegram", url: "https://t.me/evilfox" },
{ icon: "🖥️", name: "Хостинг de4ima", url: "https://de4ima.uk" },
{ icon: "🦊", name: "Git", url: "https://git.de4ima.uk/Evilfox/navi" }
];
const AUTO_BEST_KEY = "navis.autoBest";
try {
const saved = localStorage.getItem(AUTO_BEST_KEY);
autoBest.checked = saved === "1";
} catch (_) {}
autoBest.addEventListener("change", () => {
try { localStorage.setItem(AUTO_BEST_KEY, autoBest.checked ? "1" : "0"); } catch (_) {}
});
function readHy2() {
return {
congestion: $("hy2Congestion").value,
bbr_profile: $("hy2Bbr").value,
bandwidth_up: $("hy2Up").value.trim(),
bandwidth_down: $("hy2Down").value.trim(),
obfs: $("hy2Obfs").value,
obfs_password: $("hy2ObfsPass").value.trim(),
sni: $("hy2Sni").value.trim(),
insecure: !!$("hy2Insecure").checked,
pin_sha256: $("hy2Pin").value.trim(),
fast_open: !!$("hy2Fast").checked,
lazy: !!$("hy2Lazy").checked,
hop_interval: $("hy2Hop").value.trim()
};
}
function fillHy2(h) {
if (!h) return;
if (h.congestion) $("hy2Congestion").value = h.congestion;
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
$("hy2Up").value = h.bandwidth_up || "";
$("hy2Down").value = h.bandwidth_down || "";
$("hy2Obfs").value = h.obfs || "";
$("hy2ObfsPass").value = h.obfs_password || "";
$("hy2Sni").value = h.sni || "";
$("hy2Pin").value = h.pin_sha256 || "";
$("hy2Hop").value = h.hop_interval || "";
$("hy2Insecure").checked = !!h.insecure;
$("hy2Fast").checked = !!h.fast_open;
$("hy2Lazy").checked = !!h.lazy;
}
async function persistHy2IfNeeded(protocol) {
const p = (protocol || "").toLowerCase();
const link = proxy.value.trim().toLowerCase();
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
await saveHy2(readHy2());
}
}
let busy = false;
let connected = false;
let formHydrated = false;
let dirty = false;
let profiles = [];
let pingMap = {};
let metaHoldUntil = 0;
function markDirty() { dirty = true; }
[
proxy, nameInput, sysproxy, subUrl,
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
].forEach((el) => {
if (!el) return;
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
function setMeta(text, kind) {
meta.textContent = text || "";
meta.classList.remove("ok", "err");
if (kind === "ok") meta.classList.add("ok");
if (kind === "err") meta.classList.add("err");
if (kind === "ok" || kind === "err") metaHoldUntil = Date.now() + 8000;
}
function detectProtoLabel(state, active) {
const p = (state.protocol || active.protocol || "").toLowerCase();
const link = (typeof state.proxy === "string" ? state.proxy : (active.proxy || "")).trim().toLowerCase();
if (p) return p;
if (link.startsWith("vless://")) return "vless";
if (link.startsWith("vmess://")) return "vmess";
if (link.startsWith("trojan://")) return "trojan";
if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) return "hysteria2";
if (link.startsWith("awg://") || link.includes("[interface]")) return "awg";
if (link.startsWith("naive") || link.startsWith("https://") || link.startsWith("quic://")) return "naive";
return "протокол";
}
function msClass(ms, ok) {
if (!ok) return "bad";
if (ms < 80) return "good";
if (ms < 180) return "ok";
if (ms < 350) return "mid";
return "bad";
}
function rememberPings(pings) {
pingMap = {};
(pings || []).forEach((p) => { pingMap[p.name] = p; });
}
function fillProfiles(list, active) {
profiles = list || [];
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) {
$("curFlag").textContent = "·";
$("curName").textContent = "Нет сервера";
$("curHost").textContent = "добавьте профиль или подписку";
$("curProto").textContent = "—";
$("curPing").textContent = "…";
$("curPing").className = "ms";
return;
}
$("curFlag").textContent = p.flag || (p.country || "·");
$("curName").textContent = p.name;
$("curHost").textContent = p.host || "нет хоста";
$("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";
} else if (pr && pr.error) {
el.className = "ms bad";
el.textContent = "—";
el.title = pr.error;
} else {
el.className = "ms";
el.textContent = "…";
}
}
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedProfiles();
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — подождите автовыдачу или добавьте подписку";
serverList.appendChild(empty);
return;
}
list.forEach((p) => {
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (p.name === active ? " active" : "");
row.dataset.name = p.name;
const left = document.createElement("div");
left.className = "left";
const flag = document.createElement("span");
flag.className = "flag" + (p.flag ? "" : " unknown");
flag.textContent = p.flag || (p.country || "·");
flag.title = p.country || "регион неизвестен";
const metaCol = document.createElement("div");
metaCol.className = "meta-col";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = p.name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = p.host || "нет хоста";
metaCol.appendChild(nameEl);
metaCol.appendChild(sub);
left.appendChild(flag);
left.appendChild(metaCol);
const right = document.createElement("div");
right.className = "right";
const proto = document.createElement("span");
proto.className = "proto";
proto.textContent = (p.protocol || "?").toString();
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 = "…";
}
right.appendChild(proto);
right.appendChild(ms);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
});
}
async function onServerClick(name) {
if (busy) return;
if (connected) {
if (name !== profile.value) await onServerConnect(name);
return;
}
await withBusy(async () => {
await selectProfile(name);
dirty = false;
formHydrated = false;
});
}
async function onServerConnect(name) {
if (busy) return;
await withBusy(async () => {
setMeta("Подключение к «" + name + "»…");
await connectProfile(name);
setMeta("Подключено: " + name, "ok");
});
}
function fmtTraffic(bytes) {
if (!bytes || bytes <= 0) return "0 ГБ";
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1024) {
const tb = gb / 1024;
return (tb >= 100 ? Math.round(tb) : tb.toFixed(1)) + " ТБ";
}
if (gb >= 100) return Math.round(gb) + " ГБ";
if (gb >= 1) return gb.toFixed(1) + " ГБ";
const mb = bytes / (1024 * 1024);
return (mb >= 1 ? Math.round(mb) : 1) + " МБ";
}
function fmtDate(unix) {
const d = new Date(unix * 1000);
try {
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
} catch (_) {
return d.toISOString().slice(0, 10);
}
}
function renderMode(state) {
currentMode = state.mode === "vpn" ? "vpn" : "proxy";
if (modeSection) {
// Не показываем фейковый переключатель там, где VPN-режима нет (macOS).
modeSection.hidden = !state.vpn_mode_supported;
}
if (modeProxyBtn) modeProxyBtn.classList.toggle("active", currentMode === "proxy");
if (modeVpnBtn) modeVpnBtn.classList.toggle("active", currentMode === "vpn");
if (vpnChip) vpnChip.hidden = !state.vpn_active;
}
function renderSubInfo(info) {
const card = $("subInfoCard");
if (!card) return;
const expireRow = $("subExpireRow"), trafficRow = $("subTrafficRow"), devicesRow = $("subDevicesRow");
const hasExpire = !!(info && info.expire);
const hasTraffic = !!(info && (info.total || info.download || info.upload));
const hasDevices = !!(info && info.devices_known);
if (!info || (!hasExpire && !hasTraffic && !hasDevices)) {
card.hidden = true;
return;
}
card.hidden = false;
expireRow.hidden = !hasExpire;
if (hasExpire) {
const d = new Date(info.expire * 1000);
const text = fmtDate(info.expire);
const daysLeft = Math.floor((d.getTime() - Date.now()) / 86400000);
const v = $("subExpireVal");
if (daysLeft < 0) {
v.textContent = text + " · истекла";
v.classList.add("warn");
} else {
v.textContent = daysLeft <= 14 ? (text + " · осталось " + daysLeft + " дн.") : text;
v.classList.toggle("warn", daysLeft <= 3);
}
}
trafficRow.hidden = !hasTraffic;
if (hasTraffic) {
const used = (info.upload || 0) + (info.download || 0);
$("subTrafficVal").textContent = info.total > 0
? (fmtTraffic(used) + " из " + fmtTraffic(info.total))
: (fmtTraffic(used) + " · Безлимит");
}
devicesRow.hidden = !hasDevices;
if (hasDevices) {
const n = info.devices || 0;
$("subDevicesVal").textContent = info.device_limit > 0 ? (n + " из " + info.device_limit) : String(n);
}
}
function renderUpdate(u, version) {
const label = "EvilFox v" + (version || "?");
verLabel.textContent = label;
if (aboutVer) aboutVer.textContent = "Версия " + (version || "?");
if (badgeVer && version) {
const short = String(version).split("+")[0];
badgeVer.textContent = short || version;
}
if (storePackaged || (u && u.store_managed)) {
updateBanner.classList.remove("show");
if (updCheckBtn) updCheckBtn.hidden = true;
if (aboutUpdBtn) aboutUpdBtn.hidden = true;
if (storeUpdateNote) storeUpdateNote.hidden = false;
return;
}
if (updCheckBtn) updCheckBtn.hidden = false;
if (aboutUpdBtn) aboutUpdBtn.hidden = false;
if (storeUpdateNote) storeUpdateNote.hidden = true;
if (!u) {
updateBanner.classList.remove("show");
return;
}
const latest = (u.latest || "").toString();
const skip = skippedVersion();
if (u.available && latest && skip === latest) {
updateBanner.classList.remove("show");
return;
}
if (u.available) {
updateBanner.classList.add("show");
$("updateTitle").textContent = "Доступно обновление " + latest;
$("updateNotes").textContent = u.notes || ("У вас " + (u.current || version || "?") + ". Обновление только по кнопке.");
updateBanner.dataset.latest = latest;
} else {
updateBanner.classList.remove("show");
}
}
function paint(state, opts) {
const syncForm = opts && opts.syncForm;
storePackaged = !!(state && state.store_packaged);
connected = !!state.connected;
dot.classList.toggle("on", connected);
hero.classList.toggle("on", connected);
btn.classList.toggle("on", connected);
statusText.textContent = connected
? ("Подключено" + (state.profile ? " · " + state.profile : ""))
: "Отключено";
btn.title = connected ? "Отключить" : "Подключить";
const lock = connected || busy;
proxy.disabled = lock;
nameInput.disabled = lock;
sysproxy.disabled = lock;
profile.disabled = lock;
addBtn.disabled = lock;
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
saveBtn.disabled = lock;
coreBtn.disabled = busy;
pingBtn.disabled = busy;
bestBtn.disabled = busy;
updCheckBtn.disabled = busy;
if (aboutUpdBtn) aboutUpdBtn.disabled = busy;
updateBtn.disabled = busy;
subBtn.disabled = busy;
subUrl.disabled = busy;
if (modeProxyBtn) modeProxyBtn.disabled = busy || connected;
if (modeVpnBtn) modeVpnBtn.disabled = busy || connected;
btn.disabled = busy;
rememberPings(state.pings || []);
fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderUpdate(state.update, state.version);
renderSubInfo(state.sub_info);
renderMode(state);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
nameInput.value = active.name || state.active_profile || "";
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
fillHy2(state.hy2);
formHydrated = true;
if (syncForm) dirty = false;
}
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
const label = detectProtoLabel(state, active);
protoChip.textContent = label;
protoChip.classList.toggle("active", !!state.connected || label !== "протокол");
const hy2 = (label === "hysteria2" || label === "hy2");
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
let detail = "";
if (connected) {
const parts = [];
if (state.vpn_active) parts.push("VPN: весь трафик через туннель");
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
detail = parts.join(" · ") || "Туннель активен";
heroHint.textContent = detail;
} else if (state.core_ready === false) {
detail = "Сначала установите cores в Настройках («Сервис»)";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
const n = (state.profiles || []).length;
detail = n > 1 ? ("Серверов: " + n + " · Профили → Пинг / Лучший") : "Готово к подключению";
heroHint.textContent = currentMode === "vpn"
? "Режим VPN: весь трафик, включая игры и приложения"
: "Режим прокси: браузеры и приложения через прокси";
}
if (!busy && Date.now() > metaHoldUntil) {
setMeta(detail, state.core_ready === false ? "err" : "");
metaHoldUntil = 0;
}
}
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].forEach((b) => {
if (b) b.disabled = 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("Лучший: " + res.best_name + " · " + res.best_ms + " ms" + (res.connected ? " · подключено" : ""), "ok");
}
return res;
}
profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value);
dirty = false;
formHydrated = false;
}));
btn.addEventListener("click", () => withBusy(async () => {
try {
if (connected) {
setMeta("Отключение…");
await disconnect();
setMeta("Отключено", "ok");
} else {
setMeta("Подключение…");
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
await connect();
setMeta("Туннель активен", "ok");
}
} catch (e) { setMeta(String(e), "err"); }
}));
saveBtn.addEventListener("click", () => withBusy(async () => {
try {
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
setMeta("Профиль сохранён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
coreBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание official cores…");
const path = await installCore();
setMeta("Cores готовы: " + path, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
delBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm("Удалить профиль «" + profile.value + "»?")) return;
await deleteProfile(profile.value);
formHydrated = false;
dirty = false;
setMeta("Профиль удалён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
addBtn.addEventListener("click", () => {
$("newName").value = "";
$("newProxy").value = "";
modal.classList.add("open");
$("newName").focus();
});
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.remove("open");
});
$("createNew").addEventListener("click", () => withBusy(async () => {
try {
const n = $("newName").value.trim();
const p = $("newProxy").value.trim();
if (!n) throw "Укажите название профиля";
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
formHydrated = false;
dirty = false;
setMeta("Профиль создан", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
async function openShop(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
setMeta("Открываю evilfox.win…");
await openURL(SHOP_URL);
setMeta("Открыто в браузере", "ok");
} catch (err) {
setMeta("Не удалось открыть ссылку: " + String(err), "err");
}
}
shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop);
// «Рекомендуемые сервисы» — рендер из RECOMMENDED_SERVICES (см. константу выше).
(function renderRecommendedServices() {
const grid = $("svcGrid");
if (!grid) return;
RECOMMENDED_SERVICES.forEach((s) => {
const b = document.createElement("button");
b.type = "button";
b.className = "svc-link";
b.title = s.url;
const ic = document.createElement("span");
ic.className = "ic";
ic.textContent = s.icon || "•";
const nm = document.createElement("span");
nm.textContent = s.name;
b.append(ic, nm);
b.addEventListener("click", async () => {
try {
setMeta("Открываю " + s.name + "…");
await openURL(s.url);
setMeta("Открыто в браузере", "ok");
} catch (err) {
setMeta("Не удалось открыть ссылку: " + String(err), "err");
}
});
grid.appendChild(b);
});
})();
async function runImportSubscription() {
const url = subUrl.value.trim();
if (!url) {
setMeta("Вставьте URL подписки", "err");
return;
}
setMeta("Загрузка подписки…");
const r = await importSubscription(url);
formHydrated = false;
dirty = false;
setMeta(importSummary(r) + " · измеряю пинг…", "ok");
await runBest(!!autoBest.checked);
}
function importSummary(r) {
if (r && typeof r === "object") {
let s = "Импортировано " + (r.imported || 0) + " серверов";
if (r.skipped > 0) s += " (пропущено " + r.skipped + ")";
return s;
}
return "Импортировано: " + r;
}
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>