Release 1.3.1: fix hy2 UDP ping and shop link open.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-28 07:33:26 +03:00
co-authored by Cursor
parent 5d2cb6d76a
commit f7fded5b40
11 changed files with 163 additions and 29 deletions
+31 -3
View File
@@ -360,17 +360,45 @@ func (a *app) autoCheckUpdate() {
func openURL(raw string) error { func openURL(raw string) error {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
switch raw { if raw == "" {
case "https://evilfox.win/", "https://evilfox.win", "http://evilfox.win/", "http://evilfox.win": return fmt.Errorf("пустая ссылка")
}
lower := strings.ToLower(raw)
switch {
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
raw = "https://evilfox.win/" raw = "https://evilfox.win/"
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
// allow shop deep-links on evilfox.win only
default: default:
return fmt.Errorf("разрешена только ссылка evilfox.win") return fmt.Errorf("разрешена только ссылка evilfox.win")
} }
cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", raw) if err := shellOpen(raw); err == nil {
return nil
}
// Fallbacks when ShellExecute is blocked by policy.
cmd := exec.Command("cmd", "/c", "start", "", raw)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
if err := cmd.Start(); err == nil {
return nil
}
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", raw)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start() return cmd.Start()
} }
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func applyWindowIcon(hwnd unsafe.Pointer) { func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath() ico := findIconPath()
if ico == "" || hwnd == nil { if ico == "" || hwnd == nil {
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.3.0", "version": "1.3.1",
"notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей", "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win",
"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": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063", "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e",
"mandatory": false "mandatory": false
} }
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.3.0", "version": "1.3.1",
"notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей", "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win",
"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": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063", "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e",
"mandatory": false "mandatory": false
} }
+14 -3
View File
@@ -205,12 +205,18 @@
} }
.shop-link { .shop-link {
display: block; display: block;
width: 100%;
margin-top: 8px; margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
text-align: center; text-align: center;
font: inherit;
font-size: .78rem; font-size: .78rem;
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
cursor: pointer;
} }
.shop-link:hover { text-decoration: underline; } .shop-link:hover { text-decoration: underline; }
@@ -414,7 +420,7 @@
<h3>Безопасное подключение</h3> <h3>Безопасное подключение</h3>
<p>На любом устройстве — защита данных, стабильность и приватность. Демо-ключ от 30₽.</p> <p>На любом устройстве — защита данных, стабильность и приватность. Демо-ключ от 30₽.</p>
<button class="action primary" id="shopBtn" type="button">Купить на evilfox.win</button> <button class="action primary" id="shopBtn" type="button">Купить на evilfox.win</button>
<a class="shop-link" id="shopLink" href="https://evilfox.win/" rel="noopener">https://evilfox.win/</a> <button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section> </section>
<p class="ver" id="verLabel">Navis</p> <p class="ver" id="verLabel">Navis</p>
</main> </main>
@@ -722,11 +728,16 @@
})); }));
async function openShop(e) { async function openShop(e) {
if (e) e.preventDefault(); if (e) {
e.preventDefault();
e.stopPropagation();
}
try { try {
setMeta("Открываю evilfox.win…");
await openURL(SHOP_URL); await openURL(SHOP_URL);
setMeta("Открыто в браузере", "ok");
} catch (err) { } catch (err) {
setMeta(String(err), "err"); setMeta("Не удалось открыть ссылку: " + String(err), "err");
} }
} }
shopBtn.addEventListener("click", openShop); shopBtn.addEventListener("click", openShop);
+70 -8
View File
@@ -12,7 +12,7 @@ import (
"vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/hysteria2"
) )
// Result is a TCP reachability measurement to a proxy host. // Result is a reachability measurement to a proxy host.
type Result struct { type Result struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
@@ -22,12 +22,13 @@ type Result struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// PingProxyURI measures TCP 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)
} }
// 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".
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 {
res := Result{Name: name} res := Result{Name: name}
if strings.TrimSpace(proxyURI) == "" { if strings.TrimSpace(proxyURI) == "" {
@@ -35,8 +36,9 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res return res
} }
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
var host, port string var host, port string
if proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) { if hy2 {
h, p, err := hysteria2.HostPort(proxyURI) h, p, err := hysteria2.HostPort(proxyURI)
if err != nil { if err != nil {
res.Error = err.Error() res.Error = err.Error()
@@ -49,7 +51,6 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
res.Error = err.Error() res.Error = err.Error()
return res return res
} }
// Parse host from normalized URI without importing net/url dance twice
rest := normalized rest := normalized
if i := strings.Index(rest, "://"); i >= 0 { if i := strings.Index(rest, "://"); i >= 0 {
rest = rest[i+3:] rest = rest[i+3:]
@@ -75,15 +76,76 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res return res
} }
d := net.Dialer{Timeout: 4 * time.Second}
start := time.Now() start := time.Now()
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) var err error
if hy2 {
err = probeUDP(ctx, host, port)
} else {
err = probeTCP(ctx, host, port)
}
if err != nil { if err != nil {
res.Error = fmt.Sprintf("недоступен: %v", err) res.Error = friendlyDialError(err, hy2)
return res return res
} }
_ = conn.Close()
res.Ms = time.Since(start).Milliseconds() res.Ms = time.Since(start).Milliseconds()
res.OK = true res.OK = true
return res return res
} }
func probeTCP(ctx context.Context, host, port string) error {
d := net.Dialer{Timeout: 4 * time.Second}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil {
return err
}
_ = conn.Close()
return nil
}
func probeUDP(ctx context.Context, host, port string) error {
d := net.Dialer{Timeout: 4 * 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)
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
}
buf := make([]byte, 64)
_, err = conn.Read(buf)
if err == nil {
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
}
func friendlyDialError(err error, hy2 bool) string {
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "refused"):
if hy2 {
return "UDP порт недоступен (connection refused)"
}
return "порт закрыт (connection refused)"
case strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out"):
return "таймаут"
case strings.Contains(lower, "no such host"):
return "DNS: хост не найден"
default:
return fmt.Sprintf("недоступен: %v", err)
}
}
+33
View File
@@ -0,0 +1,33 @@
package netcheck
import (
"context"
"strings"
"testing"
"time"
"vpnclient/internal/config"
)
func TestFriendlyDialError(t *testing.T) {
err := &netError{s: "dial tcp 1.2.3.4:22514: connectex: No connection could be made because the target machine actively refused it."}
got := friendlyDialError(err, true)
if !strings.Contains(got, "UDP") {
t.Fatalf("got %q", got)
}
}
type netError struct{ s string }
func (e *netError) Error() string { return e.s }
func (e *netError) Timeout() bool { return false }
func (e *netError) Temporary() bool { return false }
func TestPingProfileEmpty(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r := PingProfile(ctx, "x", config.ProtocolHysteria2, "")
if r.OK || r.Error == "" {
t.Fatalf("%+v", r)
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("User-Agent", "Navis/1.3.0") req.Header.Set("User-Agent", "Navis/1.3.1")
client := &http.Client{Timeout: 45 * time.Second} client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
+1 -1
View File
@@ -18,7 +18,7 @@ import (
) )
// CurrentVersion is the shipped client version. // CurrentVersion is the shipped client version.
const CurrentVersion = "1.3.0" const CurrentVersion = "1.3.1"
// 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"
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.3.0", "version": "1.3.1",
"notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей", "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win",
"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": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063", "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e",
"mandatory": false "mandatory": false
} }
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"FixedFileInfo": { "FixedFileInfo": {
"FileVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 }, "FileVersion": { "Major": 1, "Minor": 3, "Patch": 1, "Build": 0 },
"ProductVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 }, "ProductVersion": { "Major": 1, "Minor": 3, "Patch": 1, "Build": 0 },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
"FileFlags": "00", "FileFlags": "00",
"FileOS": "40004", "FileOS": "40004",
@@ -11,11 +11,11 @@
"StringFileInfo": { "StringFileInfo": {
"CompanyName": "Navis", "CompanyName": "Navis",
"FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows", "FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows",
"FileVersion": "1.3.0.0", "FileVersion": "1.3.1.0",
"InternalName": "Navis", "InternalName": "Navis",
"OriginalFilename": "Navis.exe", "OriginalFilename": "Navis.exe",
"ProductName": "Navis", "ProductName": "Navis",
"ProductVersion": "1.3.0.0" "ProductVersion": "1.3.1.0"
}, },
"VarFileInfo": { "VarFileInfo": {
"Translation": { "Translation": {