diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 152d09b..a58df64 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 407753d..7abc4a3 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -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" } diff --git a/dist/navis-release/windows/EvilFox.exe b/dist/navis-release/windows/EvilFox.exe index 373f5dd..5f15617 100644 Binary files a/dist/navis-release/windows/EvilFox.exe and b/dist/navis-release/windows/EvilFox.exe differ diff --git a/dist/navis-release/windows/Navis.exe b/dist/navis-release/windows/Navis.exe index 373f5dd..5f15617 100644 Binary files a/dist/navis-release/windows/Navis.exe and b/dist/navis-release/windows/Navis.exe differ diff --git a/dist/navis-release/windows/evilfox-cli.exe b/dist/navis-release/windows/evilfox-cli.exe index 42ad652..c0bae2b 100644 Binary files a/dist/navis-release/windows/evilfox-cli.exe and b/dist/navis-release/windows/evilfox-cli.exe differ diff --git a/dist/navis-release/windows/vpnclient.exe b/dist/navis-release/windows/vpnclient.exe index 42ad652..c0bae2b 100644 Binary files a/dist/navis-release/windows/vpnclient.exe and b/dist/navis-release/windows/vpnclient.exe differ diff --git a/dist/update.json b/dist/update.json index 407753d..7abc4a3 100644 --- a/dist/update.json +++ b/dist/update.json @@ -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" } diff --git a/internal/appui/index.html b/internal/appui/index.html index ac571ab..0339cbd 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -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: если известен IP (пинг или литерал в URI), иначе host:. + // Порт в ключ не входит — варианты протоколов на разных портах одного хоста = один сервер. + 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 — строка сервера уже