Release 4.2.4+1: group same-host profiles into one server row with protocol chips (VLESS · Trojan · Hysteria2) instead of «сервер 2/3»; оптимальный among distinct hosts. Windows 4.2.4.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-02 09:40:17 +03:00
co-authored by Cursor
parent 0ba662c650
commit 6d52913c32
12 changed files with 282 additions and 64 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 4_023_001
versionName = "4.2.3+1"
versionCode = 4_024_001
versionName = "4.2.4+1"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
+4 -4
View File
@@ -1,11 +1,11 @@
{
"version": "4.2.3",
"notes": "4.2.3: на вкладке «Защита» только карточка EvilFox — убраны NordVPN/ExpressVPN/Surfshark и их ссылки",
"version": "4.2.4",
"notes": "4.2.4: группировка серверов по хосту — один физический сервер с протоколами VLESS · Trojan · Hysteria2 вместо «сервер 2/3»; «оптимальный» среди разных хостов",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "a39c2b3f26d4342409e28d9106d4ced2e63eec9e4a5968de7c61d16b5f72b639",
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
"mandatory": false,
"platforms": {
"android": {
@@ -40,7 +40,7 @@
},
"windows-amd64": {
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "a39c2b3f26d4342409e28d9106d4ced2e63eec9e4a5968de7c61d16b5f72b639",
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
"os": "windows",
"arch": "amd64"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -1,11 +1,11 @@
{
"version": "4.2.3",
"notes": "4.2.3: на вкладке «Защита» только карточка EvilFox — убраны NordVPN/ExpressVPN/Surfshark и их ссылки",
"version": "4.2.4",
"notes": "4.2.4: группировка серверов по хосту — один физический сервер с протоколами VLESS · Trojan · Hysteria2 вместо «сервер 2/3»; «оптимальный» среди разных хостов",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "a39c2b3f26d4342409e28d9106d4ced2e63eec9e4a5968de7c61d16b5f72b639",
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
"mandatory": false,
"platforms": {
"android": {
@@ -40,7 +40,7 @@
},
"windows-amd64": {
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "a39c2b3f26d4342409e28d9106d4ced2e63eec9e4a5968de7c61d16b5f72b639",
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
"os": "windows",
"arch": "amd64"
}
+262 -44
View File
@@ -874,6 +874,33 @@
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .protos {
margin-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.server-row .proto-tag {
display: inline-block;
font-size: .62rem;
font-weight: 700;
letter-spacing: .02em;
color: var(--muted);
background: var(--meta-bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 7px;
cursor: pointer;
line-height: 1.35;
user-select: none;
}
.server-row .proto-tag:hover { border-color: rgba(8,145,178,.45); color: var(--text); }
.server-row .proto-tag.selected {
color: var(--accent-deep);
background: var(--accent-soft);
border-color: rgba(8,145,178,.35);
}
.server-row .right { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
/* Протокол — крошечная буква-иконка с подсказкой (не повторяем «vless» под каждой строкой). */
.proto-badge {
@@ -2281,35 +2308,168 @@
}
// Человекочитаемые имена серверов («Чехия — оптимальный сервер», «Германия — сервер 2»).
// Реальный host:port показываем только в свойствах сервера и в журнале.
// ISO всегда через profileISO (бэкенд + JS-алиасы опечаток) → countryRu, никогда сырое имя панели.
// Профили с одним физическим хостом (IP или hostname) группируются в одну строку;
// протоколы (VLESS / Trojan / Hysteria2…) показываются чипами, а не «сервер 2/3».
// Реальный host:port — только в свойствах сервера и в журнале.
// ISO всегда через profileISO → countryRu, никогда сырое имя панели.
let friendlyMap = {};
function isLiteralIP(host) {
const h = String(host || "").replace(/^\[|\]$/g, "");
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(h)) return true;
if (h.includes(":") && /^[0-9a-fA-F:.]+$/.test(h)) return true;
return false;
}
// Хост без схемы/userinfo/порта — ключ группировки «один физический сервер».
function normalizeEndpointHost(raw) {
let s = String(raw || "").trim();
if (!s) return "";
const lower = s.toLowerCase();
if (lower.includes("[interface]")) {
for (const line of s.split(/\r?\n/)) {
const t = line.trim();
if (/^endpoint\s*[=:]/i.test(t)) {
s = t.replace(/^endpoint\s*[=:]\s*/i, "").trim();
break;
}
}
}
const scheme = s.indexOf("://");
if (scheme >= 0) s = s.slice(scheme + 3);
const at = s.lastIndexOf("@");
if (at >= 0) s = s.slice(at + 1);
const cut = s.search(/[/?#]/);
if (cut >= 0) s = s.slice(0, cut);
s = s.trim();
let host = s;
if (s.startsWith("[")) {
const end = s.indexOf("]");
if (end >= 0) host = s.slice(1, end);
} else {
const col = s.lastIndexOf(":");
// Один «:» → host:port; несколько → скорее IPv6 без скобок.
if (col > 0 && s.indexOf(":") === col) host = s.slice(0, col);
}
return host.replace(/^\[|\]$/g, "").toLowerCase();
}
// Ключ группы: ip:<addr> если известен IP (пинг или литерал в URI), иначе host:<hostname>.
// Порт в ключ не входит — варианты протоколов на разных портах одного хоста = один сервер.
function profileEndpointKey(p) {
if (!p) return "";
const pr = pingMap[p.name];
if (pr && pr.host) {
const ph = String(pr.host).replace(/^\[|\]$/g, "").toLowerCase();
if (isLiteralIP(ph)) return "ip:" + ph;
}
const raw = (p.host && String(p.host).trim()) || (p.proxy && String(p.proxy).trim()) || "";
const host = normalizeEndpointHost(raw);
if (!host) return "id:" + String(p.name || "");
if (isLiteralIP(host)) return "ip:" + host;
return "host:" + host;
}
function protoPretty(proto) {
const p = String(proto || "").toLowerCase();
const map = {
vless: "VLESS", vmess: "VMess", trojan: "Trojan",
hysteria2: "Hysteria2", hy2: "Hysteria2",
naive: "Naive", awg: "AWG"
};
if (map[p]) return map[p];
return p ? (p.charAt(0).toUpperCase() + p.slice(1)) : "?";
}
function uniqueProtos(members) {
const seen = {};
const out = [];
(members || []).forEach((m) => {
const raw = String(m.protocol || "").toLowerCase() || "?";
const label = protoPretty(raw);
if (seen[label]) return;
seen[label] = true;
out.push({ raw: raw, label: label, name: m.name });
});
out.sort((a, b) => a.label.localeCompare(b.label));
return out;
}
function groupBestPing(members) {
let best = null;
(members || []).forEach((m) => {
const pr = pingMap[m.name];
if (!pr) return;
if (pr.ok) {
if (!best || !best.ok || pr.ms < best.ms) best = pr;
} else if (!best) {
best = pr;
}
});
return best;
}
function pickGroupProfile(members, preferredName) {
const list = members || [];
if (!list.length) return null;
if (preferredName && list.some((m) => m.name === preferredName)) {
return list.find((m) => m.name === preferredName);
}
let best = list[0], bestMs = Infinity;
list.forEach((m) => {
const pr = pingMap[m.name];
if (pr && pr.ok && pr.ms < bestMs) { bestMs = pr.ms; best = m; }
});
return best;
}
function rebuildFriendlyNames() {
friendlyMap = {};
const groups = {};
const byCountry = {};
profiles.forEach((p) => {
const key = profileISO(p) || "__none__";
(groups[key] = groups[key] || []).push(p);
const iso = profileISO(p) || "__none__";
const ek = profileEndpointKey(p);
if (!byCountry[iso]) byCountry[iso] = {};
(byCountry[iso][ek] = byCountry[iso][ek] || []).push(p);
});
Object.keys(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; }
Object.keys(byCountry).forEach((iso) => {
const hostMap = byCountry[iso];
const hostGroups = Object.keys(hostMap).map((ek) => {
const list = hostMap[ek].slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
let bestMs = Infinity;
list.forEach((p) => {
const pr = pingMap[p.name];
if (pr && pr.ok && pr.ms < bestMs) bestMs = pr.ms;
});
return { ek: ek, list: list, bestMs: bestMs };
});
const region = key === "__none__" ? "" : countryRu(key);
let n = 0;
list.forEach((p, i) => {
n += 1;
// Стабильная нумерация по имени; «оптимальный» — лучший пинг среди РАЗНЫХ хостов.
hostGroups.sort((a, b) => String(a.list[0].name).localeCompare(String(b.list[0].name)));
let bestIdx = 0, bestMsVal = Infinity;
hostGroups.forEach((g, i) => {
if (g.bestMs < bestMsVal) { bestMsVal = g.bestMs; bestIdx = i; }
});
const region = iso === "__none__" ? "" : countryRu(iso);
hostGroups.forEach((g, i) => {
const n = i + 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 };
const protos = uniqueProtos(g.list);
const groupNames = g.list.map((x) => x.name);
g.list.forEach((p) => {
friendlyMap[p.name] = {
title: title,
region: region,
iso: iso === "__none__" ? "" : iso,
endpointKey: g.ek,
protocols: protos,
groupNames: groupNames
};
});
});
});
}
@@ -2341,16 +2501,38 @@
renderCurServer(want);
}
function orderedProfiles() {
const list = profiles.slice();
list.sort((a, b) => {
const pa = pingMap[a.name], pb = pingMap[b.name];
// Группы «один хост = один сервер» для списка; сортировка по лучшему пингу группы.
function orderedServerGroups() {
const seen = {};
const groups = [];
profiles.forEach((p) => {
const f = friendlyMap[p.name];
const ek = (f && f.endpointKey) || profileEndpointKey(p);
const iso = (f && f.iso) || profileISO(p) || "__none__";
const id = iso + "|" + ek;
if (seen[id]) return;
seen[id] = true;
const names = (f && f.groupNames) || [p.name];
const members = names.map((n) => profiles.find((x) => x.name === n)).filter(Boolean);
if (!members.length) return;
groups.push({
id: id,
endpointKey: ek,
iso: iso === "__none__" ? "" : iso,
members: members,
title: friendlyTitle(members[0].name),
protocols: (f && f.protocols) || uniqueProtos(members),
ping: groupBestPing(members)
});
});
groups.sort((a, b) => {
const pa = a.ping, pb = b.ping;
const oa = pa && pa.ok, ob = pb && pb.ok;
if (oa !== ob) return oa ? -1 : 1;
if (oa && ob && pa.ms !== pb.ms) return pa.ms - pb.ms;
return String(a.name).localeCompare(String(b.name));
return String(a.title).localeCompare(String(b.title));
});
return list;
return groups;
}
function renderCurServer(activeName) {
@@ -2367,8 +2549,12 @@
setFlagEl($("curFlag"), p);
$("curName").textContent = friendlyTitle(p.name);
$("curHost").textContent = "нажмите, чтобы выбрать другой сервер";
$("curProto").textContent = (p.protocol || "?").toString();
const pr = pingMap[p.name];
$("curProto").textContent = protoPretty(p.protocol);
const f = friendlyMap[p.name];
const members = (f && f.groupNames)
? f.groupNames.map((n) => profiles.find((x) => x.name === n)).filter(Boolean)
: [p];
const pr = groupBestPing(members) || pingMap[p.name];
const el = $("curPing");
if (pr && pr.ok) {
el.className = "ms " + msClass(pr.ms, true);
@@ -2389,8 +2575,8 @@
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedProfiles();
// Количество серверов — только здесь, мелкой приглушённой подписью.
const list = orderedServerGroups();
// Количество серверов — только здесь, мелкой приглушённой подписью (группы, не профили).
const srvCount = $("srvCount");
if (srvCount) {
srvCount.textContent = list.length
@@ -2404,25 +2590,26 @@
serverList.appendChild(empty);
return;
}
list.forEach((p) => {
const isActive = p.name === active;
list.forEach((g) => {
const selected = pickGroupProfile(g.members, active);
const isActive = g.members.some((m) => m.name === active);
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (isActive ? " active" : "");
row.dataset.name = p.name;
row.dataset.name = selected ? selected.name : g.members[0].name;
const left = document.createElement("div");
left.className = "left";
const flag = document.createElement("span");
flag.className = "flag";
setFlagEl(flag, p);
setFlagEl(flag, selected || g.members[0]);
const metaCol = document.createElement("div");
metaCol.className = "meta-col";
const nameRow = document.createElement("div");
nameRow.className = "name-row";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = friendlyTitle(p.name);
nameEl.textContent = g.title;
nameRow.appendChild(nameEl);
if (isActive) {
const badge = document.createElement("span");
@@ -2431,14 +2618,43 @@
nameRow.appendChild(badge);
}
metaCol.appendChild(nameRow);
if (g.protocols && g.protocols.length) {
const protos = document.createElement("div");
protos.className = "protos";
g.protocols.forEach((pr) => {
// span, не button — строка сервера уже <button>, вложенные button недопустимы.
const tag = document.createElement("span");
tag.className = "proto-tag" + (isActive && active === pr.name ? " selected" : "");
tag.textContent = pr.label;
tag.title = "Протокол " + pr.label;
tag.setAttribute("role", "button");
tag.tabIndex = 0;
tag.addEventListener("click", (e) => {
e.stopPropagation();
onServerClick(pr.name);
});
tag.addEventListener("dblclick", (e) => {
e.stopPropagation();
onServerConnect(pr.name);
});
tag.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onServerClick(pr.name);
}
});
protos.appendChild(tag);
});
metaCol.appendChild(protos);
}
left.appendChild(flag);
left.appendChild(metaCol);
const right = document.createElement("div");
right.className = "right";
// Протокол — только на карточке активного сервера / в «Свойствах», не в строках списка.
const ms = document.createElement("span");
const pr = pingMap[p.name];
const pr = g.ping;
if (pr && pr.ok) {
ms.className = "ms " + msClass(pr.ms, true);
ms.textContent = pr.ms + " ms";
@@ -2452,7 +2668,7 @@
ms.title = "пинг не измерен";
}
right.appendChild(ms);
// [📊] — проверить пинг только этого сервера.
// [📊] — пинг всех протоколов группы (лучший показывается в строке).
const pingOne = document.createElement("button");
pingOne.type = "button";
pingOne.className = "row-ping";
@@ -2460,27 +2676,30 @@
pingOne.title = "Проверить пинг этого сервера";
pingOne.addEventListener("click", (e) => {
e.stopPropagation();
runPingOne(p.name, pingOne, ms);
runPingGroup(g.members, pingOne, ms);
});
right.appendChild(pingOne);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
const rowName = selected ? selected.name : g.members[0].name;
row.addEventListener("click", () => onServerClick(rowName));
row.addEventListener("dblclick", () => onServerConnect(rowName));
serverList.appendChild(row);
});
}
// Пинг одного сервера по кнопке [📊] в строке.
async function runPingOne(name, btnEl, msEl) {
// Пинг всех профилей группы (один хост, разные протоколы) по кнопке [📊].
async function runPingGroup(members, 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;
for (const m of members || []) {
const res = await pingServer(m.name);
if (res) pingMap[m.name] = res;
}
rebuildFriendlyNames();
renderServerList(profile.value);
renderCurServer(profile.value);
@@ -2845,8 +3064,7 @@
else pingEl.textContent = "не измерен";
}
if (protoEl) {
const proto = String(p.protocol || "—");
protoEl.textContent = proto.charAt(0).toUpperCase() + proto.slice(1);
protoEl.textContent = p.protocol ? protoPretty(p.protocol) : "—";
}
if (stEl) stEl.textContent = connected ? "🟢 Активен" : "⚪ Не активен";
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "4.2.3"
const CurrentVersion = "4.2.4"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
+1 -1
View File
@@ -33,7 +33,7 @@
<Identity
Name="EvilFox.EvilFox"
Publisher="CN=EvilFox"
Version="4.2.3.1"
Version="4.2.4.1"
ProcessorArchitecture="x64" />
<Properties>
+4 -4
View File
@@ -1,11 +1,11 @@
{
"version": "4.2.3",
"notes": "4.2.3: на вкладке «Защита» только карточка EvilFox — убраны NordVPN/ExpressVPN/Surfshark и их ссылки",
"version": "4.2.4",
"notes": "4.2.4: группировка серверов по хосту — один физический сервер с протоколами VLESS · Trojan · Hysteria2 вместо «сервер 2/3»; «оптимальный» среди разных хостов",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "a39c2b3f26d4342409e28d9106d4ced2e63eec9e4a5968de7c61d16b5f72b639",
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
"mandatory": false,
"platforms": {
"android": {
@@ -40,7 +40,7 @@
},
"windows-amd64": {
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "a39c2b3f26d4342409e28d9106d4ced2e63eec9e4a5968de7c61d16b5f72b639",
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
"os": "windows",
"arch": "amd64"
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"FixedFileInfo": {
"FileVersion": { "Major": 4, "Minor": 2, "Patch": 3, "Build": 1 },
"ProductVersion": { "Major": 4, "Minor": 2, "Patch": 3, "Build": 1 },
"FileVersion": { "Major": 4, "Minor": 2, "Patch": 4, "Build": 1 },
"ProductVersion": { "Major": 4, "Minor": 2, "Patch": 4, "Build": 1 },
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "40004",
@@ -11,12 +11,12 @@
"StringFileInfo": {
"CompanyName": "EvilFox",
"FileDescription": "EvilFox — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "4.2.3.1",
"FileVersion": "4.2.4.1",
"InternalName": "EvilFox",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "EvilFox.exe",
"ProductName": "EvilFox",
"ProductVersion": "4.2.3.1",
"ProductVersion": "4.2.4.1",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {