Release 2.2.0: Happ-style multi-node list with ping and auto best connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-29 07:39:46 +03:00
co-authored by Cursor
parent 37e31ae742
commit 8c513f6e08
17 changed files with 527 additions and 99 deletions
+4
View File
@@ -210,6 +210,10 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси. Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
В 2.2.0:
- компактный список нод с пингом (как в Happ);
- параллельный пинг, кнопка «Лучший», автоподключение к серверу с минимальной задержкой после подписки.
В 2.1.0: В 2.1.0:
- вторичные кнопки перекрашены (не белые), чтобы не путать с основным действием. - вторичные кнопки перекрашены (не белые), чтобы не путать с основным действием.
+2 -2
View File
@@ -22,9 +22,9 @@ set CGO_ENABLED=
go build -o "tools\packmac\packmac.exe" .\tools\packmac go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1 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 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 if errorlevel 1 exit /b 1
echo Built: echo Built:
+101 -13
View File
@@ -109,7 +109,7 @@ func main() {
WindowOptions: webview2.WindowOptions{ WindowOptions: webview2.WindowOptions{
Title: "Navis 2", Title: "Navis 2",
Width: 500, Width: 500,
Height: 820, Height: 900,
Center: true, Center: true,
IconId: 1, IconId: 1,
}, },
@@ -124,11 +124,12 @@ func main() {
}() }()
applyWindowIcon(w.Window()) applyWindowIcon(w.Window())
w.SetSize(500, 820, webview2.HintNone) w.SetSize(500, 900, webview2.HintNone)
mustBind(w, "getState", a.getState) mustBind(w, "getState", a.getState)
mustBind(w, "connect", a.connect) mustBind(w, "connect", a.connect)
mustBind(w, "disconnect", a.disconnect) mustBind(w, "disconnect", a.disconnect)
mustBind(w, "connectProfile", a.connectProfile)
mustBind(w, "saveProfile", a.saveProfile) mustBind(w, "saveProfile", a.saveProfile)
mustBind(w, "createProfile", a.createProfile) mustBind(w, "createProfile", a.createProfile)
mustBind(w, "selectProfile", a.selectProfile) mustBind(w, "selectProfile", a.selectProfile)
@@ -136,6 +137,7 @@ func main() {
mustBind(w, "installCore", a.installCore) mustBind(w, "installCore", a.installCore)
mustBind(w, "openURL", openURL) mustBind(w, "openURL", openURL)
mustBind(w, "pingServers", a.pingServers) mustBind(w, "pingServers", a.pingServers)
mustBind(w, "pingBest", a.pingBest)
mustBind(w, "checkUpdate", a.checkUpdate) mustBind(w, "checkUpdate", a.checkUpdate)
mustBind(w, "applyUpdate", a.applyUpdate) mustBind(w, "applyUpdate", a.applyUpdate)
mustBind(w, "saveHy2", a.saveHy2) mustBind(w, "saveHy2", a.saveHy2)
@@ -254,9 +256,31 @@ func (a *app) deleteProfile(name string) error {
} }
func (a *app) connect() 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() a.mu.Lock()
defer a.mu.Unlock() 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 { if p, err := a.mgr.Config().ActiveProfile(); err != nil {
return err return err
} else if strings.TrimSpace(p.Proxy) == "" { } else if strings.TrimSpace(p.Proxy) == "" {
@@ -301,24 +325,88 @@ func (a *app) importSubscription(rawURL string) (int, error) {
return a.mgr.ImportSubscription(rawURL) 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) { 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() a.mu.Lock()
list := a.mgr.Profiles() list := a.mgr.Profiles()
a.mu.Unlock() a.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) targets := make([]netcheck.Target, 0, len(list))
defer cancel()
out := make([]netcheck.Result, 0, len(list))
for _, p := range list { for _, p := range list {
proxy := p.Proxy targets = append(targets, netcheck.Target{
if proxy == "" { Name: p.Name,
out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"}) Protocol: config.Protocol(p.Protocol),
continue Proxy: p.Proxy,
} })
out = append(out, netcheck.PingProfile(ctx, p.Name, config.Protocol(p.Protocol), proxy))
} }
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
out := netcheck.PingAll(ctx, targets)
a.mu.Lock() a.mu.Lock()
a.pings = out a.pings = out
a.mu.Unlock() a.mu.Unlock()
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -1,28 +1,28 @@
{ {
"version": "2.1.0", "version": "2.2.0",
"notes": "Вторичные кнопки не белые — лучше отличаются от «Подключить»", "notes": "Компактный список нод с пингом (как Happ), автовыбор и подключение к серверу с лучшим пингом",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "e10c3eab6453837a08d2a606351f052afde6a7882708fe4e85abba9a97dd753d", "sha256": "777564f1605209deb67b440a8f94667443498df38cef0a34a0ab8ab9922dab44",
"os": "darwin", "os": "darwin",
"arch": "arm64" "arch": "arm64"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "818d5554d57e4a5bd195583681c5d9f59cdef112e571d32e3ff61f1ad7aca22a", "sha256": "5d0b0e3a2edebdb26f78eb39a7a67c6d7d7fb46beaa737869a0fec1150da3935",
"os": "darwin", "os": "darwin",
"arch": "amd64" "arch": "amd64"
} }
+6 -6
View File
@@ -1,28 +1,28 @@
{ {
"version": "2.1.0", "version": "2.2.0",
"notes": "Вторичные кнопки не белые — лучше отличаются от «Подключить»", "notes": "Компактный список нод с пингом (как Happ), автовыбор и подключение к серверу с лучшим пингом",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "e10c3eab6453837a08d2a606351f052afde6a7882708fe4e85abba9a97dd753d", "sha256": "777564f1605209deb67b440a8f94667443498df38cef0a34a0ab8ab9922dab44",
"os": "darwin", "os": "darwin",
"arch": "arm64" "arch": "arm64"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "818d5554d57e4a5bd195583681c5d9f59cdef112e571d32e3ff61f1ad7aca22a", "sha256": "5d0b0e3a2edebdb26f78eb39a7a67c6d7d7fb46beaa737869a0fec1150da3935",
"os": "darwin", "os": "darwin",
"arch": "amd64" "arch": "amd64"
} }
+325 -56
View File
@@ -446,6 +446,143 @@
} }
.tools .action { min-height: 44px; font-size: .9rem; } .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 { .meta {
margin: 10px 0 0; margin: 10px 0 0;
font-size: .82rem; font-size: .82rem;
@@ -588,7 +725,7 @@
<div class="brand-wrap"> <div class="brand-wrap">
<div class="brand-row"> <div class="brand-row">
<h1 class="brand">Navis</h1> <h1 class="brand">Navis</h1>
<span class="badge-ver">2.1</span> <span class="badge-ver">2.2</span>
</div> </div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p> <p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div> </div>
@@ -609,31 +746,43 @@
</section> </section>
<section class="section"> <section class="section">
<div class="section-title"><span>Сервер</span></div> <div class="server-toolbar">
<label class="field" for="profile">Профиль</label> <div class="section-title"><span>Серверы</span></div>
<div class="profile-bar"> <div class="server-actions">
<select id="profile"></select> <button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль">+</button> <button class="mini accent" id="bestBtn" type="button">Лучший</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить профиль">×</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>
<label class="auto-best">
<input id="autoBest" type="checkbox" checked />
Авто: пинг + подключение к лучшему
</label>
<div class="server-list" id="serverList"></div>
<select id="profile" hidden></select>
<div class="stack"> <details class="profile-edit" id="profileEdit">
<div> <summary>Редактировать выбранный / подписка</summary>
<label class="field" for="name">Название</label> <div class="stack" style="margin-top:8px">
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" /> <div>
</div> <label class="field" for="name">Название</label>
<div> <input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
<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">Подписка</label>
<div class="sub-bar">
<input id="subUrl" type="text" placeholder="https://…/sub" spellcheck="false" />
<button class="icon-btn wide" id="subBtn" type="button" title="Обновить подписку">Обновить</button>
</div> </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">Подписка</label>
<div class="sub-bar">
<input id="subUrl" type="text" placeholder="https://…/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> </div>
</div> </details>
</section> </section>
<div class="row"> <div class="row">
@@ -644,10 +793,6 @@
</label> </label>
</div> </div>
<div class="actions">
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
<details class="panel hy2" id="hy2Box"> <details class="panel hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / obfuscation</summary> <summary>Hysteria 2 · BBR / obfuscation</summary>
<div class="grid2"> <div class="grid2">
@@ -705,15 +850,11 @@
</details> </details>
<details class="panel" id="toolsBox"> <details class="panel" id="toolsBox">
<summary>Сервис · пинг, cores, обновления</summary> <summary>Сервис · cores, обновления</summary>
<div class="tools"> <div class="tools">
<button class="action secondary" id="pingBtn" type="button">Пинг серверов</button>
<button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button> <button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button>
<button class="action secondary" id="coreBtn" type="button">Установить cores</button>
</div> </div>
<div style="margin-top:8px">
<button class="action secondary" id="coreBtn" type="button" style="width:100%">Установить cores (naive + hy2 + xray)</button>
</div>
<div class="ping-list" id="pingList"></div>
</details> </details>
<p class="meta" id="meta">Загрузка…</p> <p class="meta" id="meta">Загрузка…</p>
@@ -765,10 +906,12 @@
const shopBtn = $("shopBtn"); const shopBtn = $("shopBtn");
const shopLink = $("shopLink"); const shopLink = $("shopLink");
const pingBtn = $("pingBtn"); const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const updCheckBtn = $("updCheckBtn"); const updCheckBtn = $("updCheckBtn");
const updateBanner = $("updateBanner"); const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn"); const updateBtn = $("updateBtn");
const pingList = $("pingList");
const verLabel = $("verLabel"); const verLabel = $("verLabel");
const subUrl = $("subUrl"); const subUrl = $("subUrl");
const subBtn = $("subBtn"); const subBtn = $("subBtn");
@@ -776,6 +919,16 @@
const protoChip = $("protoChip"); const protoChip = $("protoChip");
const heroHint = $("heroHint"); const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/"; 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() { function readHy2() {
return { return {
@@ -823,6 +976,7 @@
let formHydrated = false; let formHydrated = false;
let dirty = false; let dirty = false;
let profiles = []; let profiles = [];
let pingMap = {};
let metaHoldUntil = 0; let metaHoldUntil = 0;
function markDirty() { dirty = true; } function markDirty() { dirty = true; }
@@ -858,6 +1012,19 @@
return "протокол"; 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) { function fillProfiles(list, active) {
profiles = list || []; profiles = list || [];
const cur = profile.value; const cur = profile.value;
@@ -865,31 +1032,103 @@
profiles.forEach((p) => { profiles.forEach((p) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = p.name; opt.value = p.name;
const proto = p.protocol ? String(p.protocol).toUpperCase() : ""; opt.textContent = p.name;
opt.textContent = (proto ? (proto + " · ") : "") + (p.host ? (p.name + " · " + p.host) : p.name);
profile.appendChild(opt); profile.appendChild(opt);
}); });
const want = active || cur || (profiles[0] && profiles[0].name); const want = active || cur || (profiles[0] && profiles[0].name);
if (want) profile.value = want; if (want) profile.value = want;
renderServerList(want);
} }
function renderPings(pings) { function orderedProfiles() {
pingList.innerHTML = ""; const list = profiles.slice();
(pings || []).forEach((p) => { list.sort((a, b) => {
const row = document.createElement("div"); const pa = pingMap[a.name], pb = pingMap[b.name];
row.className = "ping-item"; const oa = pa && pa.ok, ob = pb && pb.ok;
const left = document.createElement("span"); if (oa !== ob) return oa ? -1 : 1;
left.textContent = p.host ? (p.name + " · " + p.host) : p.name; if (oa && ob && pa.ms !== pb.ms) return pa.ms - pb.ms;
const right = document.createElement("span"); return String(a.name).localeCompare(String(b.name));
right.className = "ms " + (p.ok ? "ok" : "bad"); });
right.textContent = p.ok ? (p.ms + " ms") : (p.error || "ошибка"); 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(left);
row.appendChild(right); 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) { function renderUpdate(u, version) {
@@ -928,14 +1167,15 @@
saveBtn.disabled = lock; saveBtn.disabled = lock;
coreBtn.disabled = busy; coreBtn.disabled = busy;
pingBtn.disabled = busy; pingBtn.disabled = busy;
bestBtn.disabled = busy;
updCheckBtn.disabled = busy; updCheckBtn.disabled = busy;
updateBtn.disabled = busy; updateBtn.disabled = busy;
subBtn.disabled = busy; subBtn.disabled = busy;
subUrl.disabled = busy; subUrl.disabled = busy;
btn.disabled = busy; btn.disabled = busy;
rememberPings(state.pings || []);
fillProfiles(state.profiles || [], state.active_profile || state.profile); fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderPings(state.pings || []);
renderUpdate(state.update, state.version); renderUpdate(state.update, state.version);
if (typeof state.subscription_url === "string" && !dirty) { if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url; subUrl.value = state.subscription_url;
@@ -969,8 +1209,9 @@
detail = "Сначала установите cores в разделе «Сервис»"; detail = "Сначала установите cores в разделе «Сервис»";
heroHint.textContent = "Нужны cores для выбранного протокола"; heroHint.textContent = "Нужны cores для выбранного протокола";
} else { } else {
detail = "Готово к подключению"; const n = (state.profiles || []).length;
heroHint.textContent = "Выберите профиль и нажмите Подключить"; detail = n > 1 ? ("Серверов: " + n + " · нажмите Пинг или Лучший") : "Готово к подключению";
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
} }
if (!busy && Date.now() > metaHoldUntil) { if (!busy && Date.now() > metaHoldUntil) {
setMeta(detail, state.core_ready === false ? "err" : ""); setMeta(detail, state.core_ready === false ? "err" : "");
@@ -1002,11 +1243,22 @@
} }
function paintButtonsLocked(locked) { 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; 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 () => { profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value); await selectProfile(profile.value);
dirty = false; dirty = false;
@@ -1104,7 +1356,8 @@
const n = await importSubscription(url); const n = await importSubscription(url);
formHydrated = false; formHydrated = false;
dirty = false; dirty = false;
setMeta("Импортировано профилей: " + n, "ok"); setMeta("Импортировано: " + n + " · измеряю пинг…", "ok");
await runBest(!!autoBest.checked);
} }
subBtn.addEventListener("click", () => withBusy(async () => { subBtn.addEventListener("click", () => withBusy(async () => {
@@ -1121,14 +1374,21 @@
pingBtn.addEventListener("click", () => withBusy(async () => { pingBtn.addEventListener("click", () => withBusy(async () => {
try { try {
setMeta(роверка серверов…"); setMeta(инг серверов…");
const rows = await pingServers(); const rows = await pingServers();
renderPings(rows); rememberPings(rows);
renderServerList(profile.value);
const ok = (rows || []).filter((r) => r.ok).length; const ok = (rows || []).filter((r) => r.ok).length;
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err"); setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
} catch (e) { setMeta(String(e), "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 () => { updCheckBtn.addEventListener("click", () => withBusy(async () => {
try { try {
setMeta("Проверка обновлений…"); setMeta("Проверка обновлений…");
@@ -1148,7 +1408,16 @@
} catch (e) { setMeta(String(e), "err"); } } 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); setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
</script> </script>
</body> </body>
+72 -5
View File
@@ -4,7 +4,9 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"sort"
"strings" "strings"
"sync"
"time" "time"
"vpnclient/internal/config" "vpnclient/internal/config"
@@ -24,11 +26,78 @@ type Result struct {
Error string `json:"error,omitempty"` 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. // PingProxyURI measures connect time to the host in a share/proxy URI.
func PingProxyURI(ctx context.Context, name, proxyURI string) Result { func PingProxyURI(ctx context.Context, name, proxyURI string) Result {
return PingProfile(ctx, name, "", proxyURI) 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. // PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused". // 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 { 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 { 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)) conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil { if err != nil {
return err return err
@@ -121,20 +190,19 @@ func probeTCP(ctx context.Context, host, port string) error {
} }
func probeUDP(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)) conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
if err != nil { if err != nil {
return err return err
} }
defer conn.Close() 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) { if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
deadline = dl deadline = dl
} }
_ = conn.SetDeadline(deadline) _ = 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 { if _, err := conn.Write([]byte{0}); err != nil {
return err return err
} }
@@ -144,7 +212,6 @@ func probeUDP(ctx context.Context, host, port string) error {
return nil return nil
} }
if ne, ok := err.(net.Error); ok && ne.Timeout() { 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 nil
} }
return err return err
+1 -1
View File
@@ -17,7 +17,7 @@ import (
) )
// CurrentVersion is the shipped client version. // 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). // 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" const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
+6 -6
View File
@@ -1,28 +1,28 @@
{ {
"version": "2.1.0", "version": "2.2.0",
"notes": "Вторичные кнопки не белые — лучше отличаются от «Подключить»", "notes": "Компактный список нод с пингом (как Happ), автовыбор и подключение к серверу с лучшим пингом",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "faebea93c2efae41c7283ae933aae920e9f7484a56f3decfb231200e81ce909b", "sha256": "acd3a57cb52d745ecd0dc24074cfbd0299820d863ce00c2d4ce7753fb7dc73f3",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "e10c3eab6453837a08d2a606351f052afde6a7882708fe4e85abba9a97dd753d", "sha256": "777564f1605209deb67b440a8f94667443498df38cef0a34a0ab8ab9922dab44",
"os": "darwin", "os": "darwin",
"arch": "arm64" "arch": "arm64"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "818d5554d57e4a5bd195583681c5d9f59cdef112e571d32e3ff61f1ad7aca22a", "sha256": "5d0b0e3a2edebdb26f78eb39a7a67c6d7d7fb46beaa737869a0fec1150da3935",
"os": "darwin", "os": "darwin",
"arch": "amd64" "arch": "amd64"
} }
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"FixedFileInfo": { "FixedFileInfo": {
"FileVersion": { "Major": 2, "Minor": 1, "Patch": 0, "Build": 0 }, "FileVersion": { "Major": 2, "Minor": 2, "Patch": 0, "Build": 0 },
"ProductVersion": { "Major": 2, "Minor": 1, "Patch": 0, "Build": 0 }, "ProductVersion": { "Major": 2, "Minor": 2, "Patch": 0, "Build": 0 },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
"FileFlags": "00", "FileFlags": "00",
"FileOS": "40004", "FileOS": "40004",
@@ -11,12 +11,12 @@
"StringFileInfo": { "StringFileInfo": {
"CompanyName": "EvilFox", "CompanyName": "EvilFox",
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", "FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "2.1.0.0", "FileVersion": "2.2.0.0",
"InternalName": "Navis", "InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox", "LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe", "OriginalFilename": "Navis.exe",
"ProductName": "Navis", "ProductName": "Navis",
"ProductVersion": "2.1.0.0", "ProductVersion": "2.2.0.0",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/" "Comments": "Open-source VPN/proxy client. https://evilfox.win/"
}, },
"VarFileInfo": { "VarFileInfo": {