diff --git a/README.md b/README.md index 715c678..2a16c77 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,10 @@ https://evilfox.win/ Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси. +В 2.2.0: +- компактный список нод с пингом (как в Happ); +- параллельный пинг, кнопка «Лучший», автоподключение к серверу с минимальной задержкой после подписки. + В 2.1.0: - вторичные кнопки перекрашены (не белые), чтобы не путать с основным действием. diff --git a/build-macos.bat b/build-macos.bat index 33ba068..37ba0d6 100644 --- a/build-macos.bat +++ b/build-macos.bat @@ -22,9 +22,9 @@ set CGO_ENABLED= go build -o "tools\packmac\packmac.exe" .\tools\packmac if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 1.4.1 -arch arm64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 2.2.0 -arch arm64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 1.4.1 -arch amd64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 2.2.0 -arch amd64 if errorlevel 1 exit /b 1 echo Built: diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index d06fa2e..37c394d 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -109,7 +109,7 @@ func main() { WindowOptions: webview2.WindowOptions{ Title: "Navis 2", Width: 500, - Height: 820, + Height: 900, Center: true, IconId: 1, }, @@ -124,11 +124,12 @@ func main() { }() applyWindowIcon(w.Window()) - w.SetSize(500, 820, webview2.HintNone) + w.SetSize(500, 900, webview2.HintNone) mustBind(w, "getState", a.getState) mustBind(w, "connect", a.connect) mustBind(w, "disconnect", a.disconnect) + mustBind(w, "connectProfile", a.connectProfile) mustBind(w, "saveProfile", a.saveProfile) mustBind(w, "createProfile", a.createProfile) mustBind(w, "selectProfile", a.selectProfile) @@ -136,6 +137,7 @@ func main() { mustBind(w, "installCore", a.installCore) mustBind(w, "openURL", openURL) mustBind(w, "pingServers", a.pingServers) + mustBind(w, "pingBest", a.pingBest) mustBind(w, "checkUpdate", a.checkUpdate) mustBind(w, "applyUpdate", a.applyUpdate) mustBind(w, "saveHy2", a.saveHy2) @@ -254,9 +256,31 @@ func (a *app) deleteProfile(name string) error { } func (a *app) connect() error { + return a.connectProfile("") +} + +// connectProfile switches to the named profile (if set) and connects. +// If already connected to another profile, disconnects first. +func (a *app) connectProfile(name string) error { a.mu.Lock() defer a.mu.Unlock() + name = strings.TrimSpace(name) + st := a.mgr.Status() + if st.Connected { + if name == "" || st.Profile == name { + return nil + } + if err := a.mgr.Disconnect(); err != nil { + return err + } + } + if name != "" { + if err := a.mgr.SetActiveProfile(name); err != nil { + return err + } + } + if p, err := a.mgr.Config().ActiveProfile(); err != nil { return err } else if strings.TrimSpace(p.Proxy) == "" { @@ -301,24 +325,88 @@ func (a *app) importSubscription(rawURL string) (int, error) { return a.mgr.ImportSubscription(rawURL) } +type pingBestResult struct { + Pings []netcheck.Result `json:"pings"` + BestName string `json:"best_name,omitempty"` + BestMs int64 `json:"best_ms,omitempty"` + Selected bool `json:"selected"` + Connected bool `json:"connected"` +} + func (a *app) pingServers() ([]netcheck.Result, error) { + res, err := a.runPings() + return res, err +} + +// pingBest measures all nodes, activates the fastest OK one, and optionally connects. +func (a *app) pingBest(autoConnect bool) (pingBestResult, error) { + pings, err := a.runPings() + out := pingBestResult{Pings: pings} + if err != nil { + return out, err + } + best, ok := netcheck.BestOK(pings) + if !ok { + return out, fmt.Errorf("нет доступных серверов") + } + out.BestName = best.Name + out.BestMs = best.Ms + + a.mu.Lock() + defer a.mu.Unlock() + st := a.mgr.Status() + alreadyBest := st.Connected && st.Profile == best.Name + if st.Connected && !alreadyBest { + if err := a.mgr.Disconnect(); err != nil { + return out, err + } + } + if !alreadyBest { + if err := a.mgr.SetActiveProfile(best.Name); err != nil { + return out, err + } + } + out.Selected = true + if !autoConnect { + return out, nil + } + if alreadyBest { + out.Connected = true + return out, nil + } + if p, err := a.mgr.Config().ActiveProfile(); err != nil { + return out, err + } else if strings.TrimSpace(p.Proxy) == "" { + return out, fmt.Errorf("пустая ссылка у лучшего сервера") + } + if _, err := a.mgr.EnsureCore(""); err != nil { + return out, err + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := a.mgr.Connect(ctx, ""); err != nil { + return out, err + } + out.Connected = true + return out, nil +} + +func (a *app) runPings() ([]netcheck.Result, error) { a.mu.Lock() list := a.mgr.Profiles() a.mu.Unlock() - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - - out := make([]netcheck.Result, 0, len(list)) + targets := make([]netcheck.Target, 0, len(list)) for _, p := range list { - proxy := p.Proxy - if proxy == "" { - out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"}) - continue - } - out = append(out, netcheck.PingProfile(ctx, p.Name, config.Protocol(p.Protocol), proxy)) + targets = append(targets, netcheck.Target{ + Name: p.Name, + Protocol: config.Protocol(p.Protocol), + Proxy: p.Proxy, + }) } - + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + out := netcheck.PingAll(ctx, targets) a.mu.Lock() a.pings = out a.mu.Unlock() diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index d0a6f74..7c13344 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/darwin-amd64/Navis b/dist/navis-release/darwin-amd64/Navis index 5cbc2ac..905635d 100644 Binary files a/dist/navis-release/darwin-amd64/Navis and b/dist/navis-release/darwin-amd64/Navis differ diff --git a/dist/navis-release/darwin-amd64/Navis.app.zip b/dist/navis-release/darwin-amd64/Navis.app.zip index 4fb9631..f9371e0 100644 Binary files a/dist/navis-release/darwin-amd64/Navis.app.zip and b/dist/navis-release/darwin-amd64/Navis.app.zip differ diff --git a/dist/navis-release/darwin-amd64/Navis.dmg b/dist/navis-release/darwin-amd64/Navis.dmg index b58806c..5315be5 100644 Binary files a/dist/navis-release/darwin-amd64/Navis.dmg and b/dist/navis-release/darwin-amd64/Navis.dmg differ diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis index f9d10f8..b88bbc3 100644 Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ diff --git a/dist/navis-release/darwin-arm64/Navis.app.zip b/dist/navis-release/darwin-arm64/Navis.app.zip index 59c8cd6..9d0cdb5 100644 Binary files a/dist/navis-release/darwin-arm64/Navis.app.zip and b/dist/navis-release/darwin-arm64/Navis.app.zip differ diff --git a/dist/navis-release/darwin-arm64/Navis.dmg b/dist/navis-release/darwin-arm64/Navis.dmg index f8288ea..90779f5 100644 Binary files a/dist/navis-release/darwin-arm64/Navis.dmg and b/dist/navis-release/darwin-arm64/Navis.dmg differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 3b83984..05df1b6 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,28 +1,28 @@ { - "version": "2.1.0", - "notes": "Вторичные кнопки не белые — лучше отличаются от «Подключить»", + "version": "2.2.0", + "notes": "Компактный список нод с пингом (как Happ), автовыбор и подключение к серверу с лучшим пингом", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", + "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", + "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "e10c3eab6453837a08d2a606351f052afde6a7882708fe4e85abba9a97dd753d", + "sha256": "777564f1605209deb67b440a8f94667443498df38cef0a34a0ab8ab9922dab44", "os": "darwin", "arch": "arm64" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "818d5554d57e4a5bd195583681c5d9f59cdef112e571d32e3ff61f1ad7aca22a", + "sha256": "5d0b0e3a2edebdb26f78eb39a7a67c6d7d7fb46beaa737869a0fec1150da3935", "os": "darwin", "arch": "amd64" } diff --git a/dist/update.json b/dist/update.json index 3b83984..05df1b6 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,28 +1,28 @@ { - "version": "2.1.0", - "notes": "Вторичные кнопки не белые — лучше отличаются от «Подключить»", + "version": "2.2.0", + "notes": "Компактный список нод с пингом (как Happ), автовыбор и подключение к серверу с лучшим пингом", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", + "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", + "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "e10c3eab6453837a08d2a606351f052afde6a7882708fe4e85abba9a97dd753d", + "sha256": "777564f1605209deb67b440a8f94667443498df38cef0a34a0ab8ab9922dab44", "os": "darwin", "arch": "arm64" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "818d5554d57e4a5bd195583681c5d9f59cdef112e571d32e3ff61f1ad7aca22a", + "sha256": "5d0b0e3a2edebdb26f78eb39a7a67c6d7d7fb46beaa737869a0fec1150da3935", "os": "darwin", "arch": "amd64" } diff --git a/internal/appui/index.html b/internal/appui/index.html index 1ff8876..9d47ad5 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -446,6 +446,143 @@ } .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 rgba(8, 90, 68, 0.22); + background: linear-gradient(180deg, #d2efe4, #bfe6d6); + 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: linear-gradient(180deg, #2f6f5f, #25584b); + color: #f2fff9; + 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(42vh, 360px); + overflow: auto; + display: grid; + gap: 3px; + margin-bottom: 10px; + padding: 4px; + border-radius: 14px; + border: 1px solid var(--line); + background: rgba(255,255,255,.4); + } + .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: rgba(255,255,255,.72); + 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(13,138,102,.25); + background: #fff; + } + .server-row.active { + border-color: rgba(13,138,102,.4); + background: linear-gradient(135deg, rgba(216,243,233,.95), #fff); + box-shadow: 0 4px 14px rgba(13,138,102,.1); + } + .server-row .left { 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: #0a7a3e; } + .server-row .ms.mid { color: #b8860b; } + .server-row .ms.bad { color: var(--danger); } + .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: 10px 0 0; font-size: .82rem; @@ -588,7 +725,7 @@

Navis

- 2.1 + 2.2

Быстрый клиент · Naive · Hy2 · AWG · Xray

@@ -609,31 +746,43 @@
-
Сервер
- -
- - - +
+
Серверы
+
+ + + + +
+ +
+ -
-
- - -
-
- - -
-
- -
- - +
+ Редактировать выбранный / подписка +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
-
+
@@ -644,10 +793,6 @@
-
- -
-
Hysteria 2 · BBR / obfuscation
@@ -705,15 +850,11 @@
- Сервис · пинг, cores, обновления + Сервис · cores, обновления
- +
-
- -
-

Загрузка…

@@ -765,10 +906,12 @@ const shopBtn = $("shopBtn"); const shopLink = $("shopLink"); const pingBtn = $("pingBtn"); + const bestBtn = $("bestBtn"); + const autoBest = $("autoBest"); + const serverList = $("serverList"); const updCheckBtn = $("updCheckBtn"); const updateBanner = $("updateBanner"); const updateBtn = $("updateBtn"); - const pingList = $("pingList"); const verLabel = $("verLabel"); const subUrl = $("subUrl"); const subBtn = $("subBtn"); @@ -776,6 +919,16 @@ const protoChip = $("protoChip"); const heroHint = $("heroHint"); const SHOP_URL = "https://evilfox.win/"; + const AUTO_BEST_KEY = "navis.autoBest"; + + try { + const saved = localStorage.getItem(AUTO_BEST_KEY); + if (saved === "0") autoBest.checked = false; + if (saved === "1") autoBest.checked = true; + } catch (_) {} + autoBest.addEventListener("change", () => { + try { localStorage.setItem(AUTO_BEST_KEY, autoBest.checked ? "1" : "0"); } catch (_) {} + }); function readHy2() { return { @@ -823,6 +976,7 @@ let formHydrated = false; let dirty = false; let profiles = []; + let pingMap = {}; let metaHoldUntil = 0; function markDirty() { dirty = true; } @@ -858,6 +1012,19 @@ 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; @@ -865,31 +1032,103 @@ profiles.forEach((p) => { const opt = document.createElement("option"); opt.value = p.name; - const proto = p.protocol ? String(p.protocol).toUpperCase() : ""; - opt.textContent = (proto ? (proto + " · ") : "") + (p.host ? (p.name + " · " + p.host) : 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); } - function renderPings(pings) { - pingList.innerHTML = ""; - (pings || []).forEach((p) => { - const row = document.createElement("div"); - row.className = "ping-item"; - const left = document.createElement("span"); - left.textContent = p.host ? (p.name + " · " + p.host) : p.name; - const right = document.createElement("span"); - right.className = "ms " + (p.ok ? "ok" : "bad"); - right.textContent = p.ok ? (p.ms + " ms") : (p.error || "ошибка"); + 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 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 nameEl = document.createElement("div"); + nameEl.className = "name"; + nameEl.textContent = p.name; + const sub = document.createElement("div"); + sub.className = "sub"; + sub.textContent = p.host || "нет хоста"; + left.appendChild(nameEl); + left.appendChild(sub); + + 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); - pingList.appendChild(row); + row.addEventListener("click", () => onServerClick(p.name)); + row.addEventListener("dblclick", () => onServerConnect(p.name)); + serverList.appendChild(row); }); - if ((pings || []).length && $("toolsBox") && !$("toolsBox").open) { - $("toolsBox").open = true; + } + + 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 renderUpdate(u, version) { @@ -928,14 +1167,15 @@ saveBtn.disabled = lock; coreBtn.disabled = busy; pingBtn.disabled = busy; + bestBtn.disabled = busy; updCheckBtn.disabled = busy; updateBtn.disabled = busy; subBtn.disabled = busy; subUrl.disabled = busy; btn.disabled = busy; + rememberPings(state.pings || []); fillProfiles(state.profiles || [], state.active_profile || state.profile); - renderPings(state.pings || []); renderUpdate(state.update, state.version); if (typeof state.subscription_url === "string" && !dirty) { subUrl.value = state.subscription_url; @@ -969,8 +1209,9 @@ detail = "Сначала установите cores в разделе «Сервис»"; heroHint.textContent = "Нужны cores для выбранного протокола"; } else { - detail = "Готово к подключению"; - heroHint.textContent = "Выберите профиль и нажмите Подключить"; + const n = (state.profiles || []).length; + detail = n > 1 ? ("Серверов: " + n + " · нажмите Пинг или Лучший") : "Готово к подключению"; + heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить"; } if (!busy && Date.now() > metaHoldUntil) { setMeta(detail, state.core_ready === false ? "err" : ""); @@ -1002,11 +1243,22 @@ } function paintButtonsLocked(locked) { - [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn, subUrl].forEach((b) => { + [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, subBtn, subUrl].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); + 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; @@ -1104,7 +1356,8 @@ const n = await importSubscription(url); formHydrated = false; dirty = false; - setMeta("Импортировано профилей: " + n, "ok"); + setMeta("Импортировано: " + n + " · измеряю пинг…", "ok"); + await runBest(!!autoBest.checked); } subBtn.addEventListener("click", () => withBusy(async () => { @@ -1121,14 +1374,21 @@ pingBtn.addEventListener("click", () => withBusy(async () => { try { - setMeta("Проверка серверов…"); + setMeta("Пинг серверов…"); const rows = await pingServers(); - renderPings(rows); + rememberPings(rows); + renderServerList(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"); } + })); + updCheckBtn.addEventListener("click", () => withBusy(async () => { try { setMeta("Проверка обновлений…"); @@ -1148,7 +1408,16 @@ } catch (e) { setMeta(String(e), "err"); } })); - refresh({ syncForm: true }).catch((e) => setMeta(String(e), "err")); + (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")) refresh().catch(() => {}); }, 2500); diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go index 65e3543..0279762 100644 --- a/internal/netcheck/ping.go +++ b/internal/netcheck/ping.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "net" + "sort" "strings" + "sync" "time" "vpnclient/internal/config" @@ -24,11 +26,78 @@ type Result struct { Error string `json:"error,omitempty"` } +// Target describes one profile to ping. +type Target struct { + Name string + Protocol config.Protocol + Proxy string +} + // PingProxyURI measures connect time to the host in a share/proxy URI. func PingProxyURI(ctx context.Context, name, proxyURI string) Result { return PingProfile(ctx, name, "", proxyURI) } +// PingAll pings targets in parallel (bounded concurrency) and sorts OK results by latency. +func PingAll(ctx context.Context, targets []Target) []Result { + out := make([]Result, len(targets)) + if len(targets) == 0 { + return out + } + workers := 12 + if len(targets) < workers { + workers = len(targets) + } + sem := make(chan struct{}, workers) + var wg sync.WaitGroup + for i, t := range targets { + wg.Add(1) + go func(i int, t Target) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + if strings.TrimSpace(t.Proxy) == "" { + out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"} + return + } + out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy) + }(i, t) + } + wg.Wait() + + sorted := append([]Result(nil), out...) + sort.SliceStable(sorted, func(i, j int) bool { + a, b := sorted[i], sorted[j] + if a.OK != b.OK { + return a.OK + } + if !a.OK { + return a.Name < b.Name + } + if a.Ms != b.Ms { + return a.Ms < b.Ms + } + return a.Name < b.Name + }) + return sorted +} + +// BestOK returns the lowest-latency successful result, if any. +func BestOK(results []Result) (Result, bool) { + var best Result + found := false + for _, r := range results { + if !r.OK { + continue + } + if !found || r.Ms < best.Ms { + best = r + found = true + } + } + return best, found +} + // PingProfile pings using protocol hints when available. // Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused". func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result { @@ -111,7 +180,7 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU } func probeTCP(ctx context.Context, host, port string) error { - d := net.Dialer{Timeout: 4 * time.Second} + d := net.Dialer{Timeout: 3 * time.Second} conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) if err != nil { return err @@ -121,20 +190,19 @@ func probeTCP(ctx context.Context, host, port string) error { } func probeUDP(ctx context.Context, host, port string) error { - d := net.Dialer{Timeout: 4 * time.Second} + d := net.Dialer{Timeout: 3 * time.Second} conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port)) if err != nil { return err } defer conn.Close() - deadline := time.Now().Add(1500 * time.Millisecond) + deadline := time.Now().Add(1200 * time.Millisecond) if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { deadline = dl } _ = conn.SetDeadline(deadline) - // Any datagram is enough to exercise the UDP path; QUIC rarely answers a bare probe. if _, err := conn.Write([]byte{0}); err != nil { return err } @@ -144,7 +212,6 @@ func probeUDP(ctx context.Context, host, port string) error { return nil } if ne, ok := err.(net.Error); ok && ne.Timeout() { - // No ICMP unreachable → port is typically open or filtered; treat as reachable for UI ping. return nil } return err diff --git a/internal/update/update.go b/internal/update/update.go index 3237095..a86f42a 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -17,7 +17,7 @@ import ( ) // CurrentVersion is the shipped client version. -const CurrentVersion = "2.1.0" +const CurrentVersion = "2.2.0" // DefaultManifestURL is the update feed (hosted in the project git repo). const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json" diff --git a/server/update.json b/server/update.json index 3b83984..05df1b6 100644 --- a/server/update.json +++ b/server/update.json @@ -1,28 +1,28 @@ { - "version": "2.1.0", - "notes": "Вторичные кнопки не белые — лучше отличаются от «Подключить»", + "version": "2.2.0", + "notes": "Компактный список нод с пингом (как Happ), автовыбор и подключение к серверу с лучшим пингом", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", + "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", + "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "e10c3eab6453837a08d2a606351f052afde6a7882708fe4e85abba9a97dd753d", + "sha256": "777564f1605209deb67b440a8f94667443498df38cef0a34a0ab8ab9922dab44", "os": "darwin", "arch": "arm64" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "818d5554d57e4a5bd195583681c5d9f59cdef112e571d32e3ff61f1ad7aca22a", + "sha256": "5d0b0e3a2edebdb26f78eb39a7a67c6d7d7fb46beaa737869a0fec1150da3935", "os": "darwin", "arch": "amd64" } diff --git a/versioninfo.json b/versioninfo.json index ed0fbab..9ea57a9 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 2, "Minor": 1, "Patch": 0, "Build": 0 }, - "ProductVersion": { "Major": 2, "Minor": 1, "Patch": 0, "Build": 0 }, + "FileVersion": { "Major": 2, "Minor": 2, "Patch": 0, "Build": 0 }, + "ProductVersion": { "Major": 2, "Minor": 2, "Patch": 0, "Build": 0 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -11,12 +11,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "2.1.0.0", + "FileVersion": "2.2.0.0", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "2.1.0.0", + "ProductVersion": "2.2.0.0", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {