diff --git a/.gitignore b/.gitignore
index 59e96fc..ef8fe86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,4 +25,10 @@ android/app/src/main/assets/cores/**/hev-socks5-tunnel
*.apk
!dist/navis-release/android/*.apk
+# Local backups (pre-release archives)
+backups/
+
+# Local Go toolchain
+.tools/
+
diff --git a/README.md b/README.md
index 724fec3..e9d61a5 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,12 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
+В 3.8.0:
+- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
+- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
+- SHA-256 проверка при установке cores (naive / hy2 / xray);
+- snapshot конфига в UI (`Config().Clone`).
+
В 2.7.3:
- восстановление системного прокси после аварийного завершения;
- auth-токен для локального macOS `/api`;
diff --git a/build-macos.bat b/build-macos.bat
index cf95aba..151b17a 100644
--- a/build-macos.bat
+++ b/build-macos.bat
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
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 2.7.3 -build 2.7.3.3 -arch arm64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.0 -build 3.8.0.1 -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 2.7.3 -build 2.7.3.3 -arch amd64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.0 -build 3.8.0.1 -arch amd64
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 2.7.3 -build 2.7.3.3 -arch universal
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.0 -build 3.8.0.1 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go
index 2bb902d..cb985f6 100644
--- a/cmd/vpnapp/main_windows.go
+++ b/cmd/vpnapp/main_windows.go
@@ -4,60 +4,23 @@ package main
import (
"bytes"
- "context"
"fmt"
"log"
"os"
"path/filepath"
- "strings"
- "sync"
"time"
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
+ "vpnclient/internal/apphost"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
- "vpnclient/internal/netcheck"
- "vpnclient/internal/protocols/awg"
- "vpnclient/internal/protocols/hysteria2"
- "vpnclient/internal/protocols/naive"
- "vpnclient/internal/protocols/xray"
"vpnclient/internal/update"
)
-type app struct {
- mu sync.Mutex
- mgr *core.Manager
- cfgPath string
- logBuf *bytes.Buffer
- updateStatus update.Status
- pings []netcheck.Result
- webview webview2.WebView
-}
-
-type uiState struct {
- Connected bool `json:"connected"`
- Profile string `json:"profile,omitempty"`
- ActiveProfile string `json:"active_profile,omitempty"`
- Protocol string `json:"protocol,omitempty"`
- HTTPProxy string `json:"http_proxy,omitempty"`
- SOCKSProxy string `json:"socks_proxy,omitempty"`
- SystemProxy bool `json:"system_proxy"`
- Proxy string `json:"proxy"`
- CoreReady bool `json:"core_ready"`
- CorePath string `json:"core_path,omitempty"`
- ConfigPath string `json:"config_path"`
- Profiles []config.ProfileInfo `json:"profiles"`
- Version string `json:"version"`
- Update update.Status `json:"update"`
- Pings []netcheck.Result `json:"pings"`
- Subscription string `json:"subscription_url"`
- Hy2 core.Hy2Options `json:"hy2"`
-}
-
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
@@ -87,27 +50,25 @@ func main() {
fatalDialog("%v", err)
}
- a := &app{
- mgr: mgr,
- cfgPath: cfgPath,
- logBuf: logBuf,
- updateStatus: update.Status{
- Current: update.DisplayVersion(),
- ManifestURL: update.DefaultManifestURL,
- },
+ a := apphost.New(mgr, cfgPath, logBuf)
+ a.OpenURL = shellOpen
+ a.OnAfterUpdate = func() {
+ // Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
+ go func() {
+ time.Sleep(300 * time.Millisecond)
+ os.Exit(0)
+ }()
}
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
_ = os.MkdirAll(dataPath, 0o755)
- // Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
-
w := webview2.NewWithOptions(webview2.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
- Title: "Navis 2",
+ Title: "Navis",
Width: 500,
Height: 900,
Center: true,
@@ -117,33 +78,32 @@ func main() {
if w == nil {
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
}
- a.webview = w
defer func() {
- _ = a.mgr.Disconnect()
+ _ = mgr.Disconnect()
w.Destroy()
}()
applyWindowIcon(w.Window())
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)
- mustBind(w, "deleteProfile", a.deleteProfile)
- 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)
- mustBind(w, "importSubscription", a.importSubscription)
+ 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)
+ mustBind(w, "deleteProfile", a.DeleteProfile)
+ mustBind(w, "installCore", a.InstallCore)
+ mustBind(w, "openURL", a.OpenShopURL)
+ 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)
+ mustBind(w, "importSubscription", a.ImportSubscription)
- go a.autoCheckUpdate()
+ go a.AutoCheckUpdate()
w.SetHtml(appui.IndexHTML)
w.Run()
@@ -155,340 +115,6 @@ func mustBind(w webview2.WebView, name string, fn interface{}) {
}
}
-func (a *app) getState() (uiState, error) {
- a.mu.Lock()
- defer a.mu.Unlock()
-
- st := a.mgr.Status()
- cfg := a.mgr.Config()
- proxy := ""
- active := cfg.Active
- if p, err := cfg.ActiveProfile(); err == nil {
- proxy = p.Proxy
- active = p.Name
- }
- corePath := ""
- coreReady := false
- if p, err := cfg.ActiveProfile(); err == nil {
- switch p.Protocol {
- case config.ProtocolHysteria2:
- if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- case config.ProtocolAWG:
- if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
- if path, err := xray.ResolveBinary(a.mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- default:
- if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- }
- } else if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- out := uiState{
- Connected: st.Connected,
- Profile: st.Profile,
- ActiveProfile: active,
- Protocol: string(st.Protocol),
- HTTPProxy: st.HTTPProxy,
- SOCKSProxy: st.SOCKSProxy,
- SystemProxy: cfg.SystemProxy,
- Proxy: proxy,
- CoreReady: coreReady,
- CorePath: corePath,
- ConfigPath: a.cfgPath,
- Profiles: config.RedactProfileList(cfg.ListProfiles()),
- Version: update.DisplayVersion(),
- Update: a.updateStatus,
- Pings: append([]netcheck.Result(nil), a.pings...),
- Subscription: cfg.SubscriptionURL,
- Hy2: a.mgr.ActiveHy2Options(),
- }
- if out.Protocol == "" {
- if p, err := cfg.ActiveProfile(); err == nil {
- out.Protocol = string(p.Protocol)
- }
- }
- if st.Connected {
- out.SystemProxy = st.SystemProxy
- }
- return out, nil
-}
-
-func (a *app) saveProfile(name, proxy string, systemProxy bool) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- name = strings.TrimSpace(name)
- if name == "" {
- return fmt.Errorf("укажите название профиля")
- }
- a.mgr.SetSystemProxy(systemProxy)
- return a.mgr.SaveActiveProfile(name, proxy)
-}
-
-func (a *app) createProfile(name, proxy string, systemProxy bool) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- name = strings.TrimSpace(name)
- if name == "" {
- return fmt.Errorf("укажите название профиля")
- }
- a.mgr.SetSystemProxy(systemProxy)
- return a.mgr.SaveProfile(name, proxy)
-}
-
-func (a *app) selectProfile(name string) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- return a.mgr.SetActiveProfile(name)
-}
-
-func (a *app) deleteProfile(name string) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- return a.mgr.DeleteProfile(name)
-}
-
-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()
- name = strings.TrimSpace(name)
- st := a.mgr.Status()
- if st.Connected {
- if name == "" || st.Profile == name {
- a.mu.Unlock()
- return nil
- }
- a.mu.Unlock()
- if err := a.mgr.Disconnect(); err != nil {
- return err
- }
- a.mu.Lock()
- }
- if name != "" {
- if err := a.mgr.SetActiveProfile(name); err != nil {
- a.mu.Unlock()
- return err
- }
- }
-
- if p, err := a.mgr.Config().ActiveProfile(); err != nil {
- a.mu.Unlock()
- return err
- } else if strings.TrimSpace(p.Proxy) == "" {
- a.mu.Unlock()
- return fmt.Errorf("сначала вставьте ссылку сервера")
- }
- a.mu.Unlock()
-
- // Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
- if _, err := a.mgr.EnsureCore(""); err != nil {
- return err
- }
- ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
- defer cancel()
- return a.mgr.Connect(ctx, "")
-}
-
-func (a *app) disconnect() error {
- // Do not hold a.mu across engine teardown — getState polling would freeze the UI.
- return a.mgr.Disconnect()
-}
-
-func (a *app) installCore() (string, error) {
- paths, err := a.mgr.EnsureAllCores()
- if err != nil {
- return "", err
- }
- parts := make([]string, 0, len(paths))
- for k, v := range paths {
- parts = append(parts, k+": "+v)
- }
- return strings.Join(parts, " | "), nil
-}
-
-func (a *app) saveHy2(opts core.Hy2Options) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- return a.mgr.SaveHy2Options(opts)
-}
-
-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()
- st := a.mgr.Status()
- alreadyBest := st.Connected && st.Profile == best.Name
- if st.Connected && !alreadyBest {
- a.mu.Unlock()
- if err := a.mgr.Disconnect(); err != nil {
- return out, err
- }
- a.mu.Lock()
- }
- if !alreadyBest {
- if err := a.mgr.SetActiveProfile(best.Name); err != nil {
- a.mu.Unlock()
- return out, err
- }
- }
- out.Selected = true
- if !autoConnect {
- a.mu.Unlock()
- return out, nil
- }
- if alreadyBest {
- a.mu.Unlock()
- out.Connected = true
- return out, nil
- }
- if p, err := a.mgr.Config().ActiveProfile(); err != nil {
- a.mu.Unlock()
- return out, err
- } else if strings.TrimSpace(p.Proxy) == "" {
- a.mu.Unlock()
- return out, fmt.Errorf("пустая ссылка у лучшего сервера")
- }
- a.mu.Unlock()
-
- 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()
-
- targets := make([]netcheck.Target, 0, len(list))
- for _, p := range list {
- 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()
- return out, nil
-}
-
-func (a *app) checkUpdate() (update.Status, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
- defer cancel()
- st, err := update.Check(ctx, update.DefaultManifestURL)
- a.mu.Lock()
- if err != nil {
- st.Current = update.DisplayVersion()
- st.ManifestURL = update.DefaultManifestURL
- st.Error = err.Error()
- }
- a.updateStatus = st
- a.mu.Unlock()
- return st, nil
-}
-
-func (a *app) applyUpdate() (string, error) {
- // Explicit user action only — never called from autoCheckUpdate.
- st, err := a.checkUpdate()
- if err != nil {
- return "", err
- }
- if !st.Available {
- return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
- defer cancel()
- latest, err := update.Apply(ctx, update.DefaultManifestURL)
- if err != nil {
- return "", err
- }
- _ = a.mgr.Disconnect()
- // Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate (can hang).
- go func() {
- time.Sleep(300 * time.Millisecond)
- os.Exit(0)
- }()
- return "Устанавливается v" + latest + "… Navis перезапустится.", nil
-}
-
-func (a *app) autoCheckUpdate() {
- // Only refresh update status for the UI banner — never auto-install.
- // (Previously auto-apply caused an infinite update loop when the feed version
- // stayed ahead of the shipped CurrentVersion in the downloaded binary.)
- time.Sleep(3 * time.Second)
- _, _ = a.checkUpdate()
-}
-
-func openURL(raw string) error {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- 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/"
- case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
- // allow shop deep-links on evilfox.win only
- default:
- return fmt.Errorf("разрешена только ссылка evilfox.win")
- }
- return shellOpen(raw)
-}
-
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis
index f1941e7..2575ff8 100755
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-cli b/dist/navis-release/darwin-arm64/Navis-cli
index 8c098ad..a706f31 100755
Binary files a/dist/navis-release/darwin-arm64/Navis-cli and b/dist/navis-release/darwin-arm64/Navis-cli differ
diff --git a/dist/navis-release/darwin-arm64/Navis.app.zip b/dist/navis-release/darwin-arm64/Navis.app.zip
index 654d0d4..12a5d31 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 8206042..0e5909e 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/darwin-arm64/Navis.version b/dist/navis-release/darwin-arm64/Navis.version
index 33d26cd..4c28a6f 100644
--- a/dist/navis-release/darwin-arm64/Navis.version
+++ b/dist/navis-release/darwin-arm64/Navis.version
@@ -1 +1 @@
-2.7.3+3
+3.8.0+1
diff --git a/dist/navis-release/darwin-arm64/VERSION b/dist/navis-release/darwin-arm64/VERSION
index 66fa5e9..846fe72 100644
--- a/dist/navis-release/darwin-arm64/VERSION
+++ b/dist/navis-release/darwin-arm64/VERSION
@@ -1 +1 @@
-2.7.3.3
+3.8.0.1
diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json
index 0eb13f5..d586bb9 100644
--- a/dist/navis-release/update.json
+++ b/dist/navis-release/update.json
@@ -1,6 +1,6 @@
{
- "version": "2.7.3",
- "notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
+ "version": "3.8.0",
+ "notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,7 +16,7 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
+ "sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
diff --git a/dist/update.json b/dist/update.json
index 0eb13f5..d586bb9 100644
--- a/dist/update.json
+++ b/dist/update.json
@@ -1,6 +1,6 @@
{
- "version": "2.7.3",
- "notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
+ "version": "3.8.0",
+ "notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,7 +16,7 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
+ "sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
diff --git a/internal/appui/index.html b/internal/appui/index.html
index f7d1f97..2c74ee1 100644
--- a/internal/appui/index.html
+++ b/internal/appui/index.html
@@ -848,7 +848,7 @@
Navis
- 2.7.3
+ 3.8.0
Быстрый клиент · Naive · Hy2 · AWG · Xray
diff --git a/internal/config/config.go b/internal/config/config.go
index 14ec109..22869db 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -144,6 +144,26 @@ func lastIndex(s, substr string) int {
return -1
}
+// Clone returns a deep copy of the config (JSON round-trip).
+func (c *Config) Clone() *Config {
+ if c == nil {
+ return &Config{}
+ }
+ data, err := json.Marshal(c)
+ if err != nil {
+ out := *c
+ out.Profiles = append([]Profile(nil), c.Profiles...)
+ return &out
+ }
+ var out Config
+ if err := json.Unmarshal(data, &out); err != nil {
+ cp := *c
+ cp.Profiles = append([]Profile(nil), c.Profiles...)
+ return &cp
+ }
+ return &out
+}
+
// Load reads and validates a config file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
diff --git a/internal/core/manager.go b/internal/core/manager.go
index fc79004..4060e37 100644
--- a/internal/core/manager.go
+++ b/internal/core/manager.go
@@ -33,6 +33,8 @@ type Manager struct {
profile *config.Profile
stderr io.Writer
binDir string
+ // lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
+ lastStop sync.WaitGroup
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -68,13 +70,25 @@ func (m *Manager) RecoverSystemProxy() {
sysproxy.ClearSentinel(m.cfgPath)
}
+func (m *Manager) waitEngineStopped() {
+ m.lastStop.Wait()
+}
+
func (m *Manager) Connect(ctx context.Context, profileName string) error {
+ // Wait outside the lock so a slow Disconnect can finish freeing :1080/:1081.
+ m.waitEngineStopped()
+
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("already connected; disconnect first")
}
+ if m.engine != nil {
+ // Defunct reference (stop finished or engine died) — drop before start.
+ m.engine = nil
+ m.profile = nil
+ }
if profileName != "" {
m.cfg.Active = profileName
@@ -122,6 +136,9 @@ func (m *Manager) Disconnect() error {
engine := m.engine
m.engine = nil
m.profile = nil
+ if engine != nil {
+ m.lastStop.Add(1)
+ }
m.mu.Unlock()
var first error
@@ -143,14 +160,17 @@ func (m *Manager) Disconnect() error {
}
if engine != nil {
done := make(chan error, 1)
- go func() { done <- engine.Stop() }()
+ go func() {
+ defer m.lastStop.Done()
+ done <- engine.Stop()
+ }()
select {
case err := <-done:
if err != nil && first == nil {
first = err
}
case <-time.After(3 * time.Second):
- // Engine Stop can block on userspace relays; don't freeze the UI.
+ // Stop continues in background; Connect will wait on lastStop.
if first == nil {
first = fmt.Errorf("отключение заняло слишком много времени")
}
@@ -236,10 +256,14 @@ func (m *Manager) BinDir() string { return m.binDir }
func (m *Manager) ConfigPath() string { return m.cfgPath }
+// Config returns a deep snapshot for UI reads. Mutations must go through Manager methods.
func (m *Manager) Config() *config.Config {
m.mu.Lock()
defer m.mu.Unlock()
- return m.cfg
+ if m.cfg == nil {
+ return &config.Config{}
+ }
+ return m.cfg.Clone()
}
func (m *Manager) SetSystemProxy(enabled bool) {
diff --git a/internal/core/manager_lifecycle_test.go b/internal/core/manager_lifecycle_test.go
new file mode 100644
index 0000000..6d5422c
--- /dev/null
+++ b/internal/core/manager_lifecycle_test.go
@@ -0,0 +1,127 @@
+package core
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "vpnclient/internal/config"
+)
+
+type fakeEngine struct {
+ mu sync.Mutex
+ running bool
+ stopDelay time.Duration
+ stops atomic.Int32
+}
+
+func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
+func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.running = true
+ return nil
+}
+func (e *fakeEngine) Stop() error {
+ e.stops.Add(1)
+ if e.stopDelay > 0 {
+ time.Sleep(e.stopDelay)
+ }
+ e.mu.Lock()
+ e.running = false
+ e.mu.Unlock()
+ return nil
+}
+func (e *fakeEngine) Running() bool {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return e.running
+}
+func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
+func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
+
+func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
+ cfg := &config.Config{
+ Active: "t",
+ SystemProxy: false,
+ BinDir: t.TempDir(),
+ Profiles: []config.Profile{{
+ Name: "t",
+ Protocol: config.ProtocolNaive,
+ Proxy: "https://u:p@example.com",
+ Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
+ }},
+ }
+ m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
+ m.mu.Lock()
+ m.engine = eng
+ m.profile = &cfg.Profiles[0]
+ m.mu.Unlock()
+
+ start := time.Now()
+ _ = m.Disconnect()
+ // Connect must wait for Stop to finish even if Disconnect returned early...
+ // our Disconnect waits up to 3s, so with 200ms it should complete.
+ if time.Since(start) < 150*time.Millisecond {
+ t.Fatalf("disconnect returned too fast")
+ }
+ if eng.Running() {
+ t.Fatal("engine still running")
+ }
+
+ // Simulate slow stop that outlives Disconnect timeout.
+ eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
+ m.mu.Lock()
+ m.engine = eng2
+ m.profile = &cfg.Profiles[0]
+ m.mu.Unlock()
+
+ // Force short path: call the wait mechanism like Connect does.
+ m.mu.Lock()
+ engine := m.engine
+ m.engine = nil
+ m.profile = nil
+ m.lastStop.Add(1)
+ m.mu.Unlock()
+ go func() {
+ defer m.lastStop.Done()
+ _ = engine.Stop()
+ }()
+
+ waitStart := time.Now()
+ m.waitEngineStopped()
+ if time.Since(waitStart) < 400*time.Millisecond {
+ t.Fatalf("waitEngineStopped returned before stop finished")
+ }
+ if eng2.Running() {
+ t.Fatal("engine2 still running after wait")
+ }
+}
+
+func TestConfigReturnsClone(t *testing.T) {
+ cfg := &config.Config{
+ Active: "a",
+ Profiles: []config.Profile{{
+ Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
+ }},
+ }
+ m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ snap := m.Config()
+ snap.Active = "mutated"
+ snap.Profiles[0].Proxy = "leaked"
+ if m.cfg.Active != "a" {
+ t.Fatal("Config() leaked mutable Active")
+ }
+ if m.cfg.Profiles[0].Proxy != "https://x" {
+ t.Fatal("Config() leaked mutable Profile")
+ }
+}
diff --git a/internal/coredl/download.go b/internal/coredl/download.go
new file mode 100644
index 0000000..44ecd4d
--- /dev/null
+++ b/internal/coredl/download.go
@@ -0,0 +1,214 @@
+package coredl
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+)
+
+// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
+type ReleaseAsset struct {
+ Name string
+ BrowserDownloadURL string
+ SHA256 string // lowercase hex, empty if unknown
+}
+
+type ghRelease struct {
+ TagName string `json:"tag_name"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Digest string `json:"digest"`
+ } `json:"assets"`
+}
+
+// FetchLatestAssets loads assets for a repo's latest GitHub release.
+func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
+ client := &http.Client{Timeout: 60 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
+ if err != nil {
+ return "", nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("User-Agent", "navis-vpnclient")
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", nil, fmt.Errorf("github api: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
+ }
+ var rel ghRelease
+ if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
+ return "", nil, err
+ }
+ out := make([]ReleaseAsset, 0, len(rel.Assets))
+ for _, a := range rel.Assets {
+ out = append(out, ReleaseAsset{
+ Name: a.Name,
+ BrowserDownloadURL: a.BrowserDownloadURL,
+ SHA256: parseDigestSHA256(a.Digest),
+ })
+ }
+ return rel.TagName, out, nil
+}
+
+func parseDigestSHA256(digest string) string {
+ digest = strings.TrimSpace(strings.ToLower(digest))
+ if digest == "" {
+ return ""
+ }
+ if strings.HasPrefix(digest, "sha256:") {
+ return strings.TrimPrefix(digest, "sha256:")
+ }
+ return ""
+}
+
+// DownloadFile writes url to path (size-capped).
+func DownloadFile(path, url string, maxBytes int64) error {
+ if maxBytes <= 0 {
+ maxBytes = 120 << 20
+ }
+ client := &http.Client{Timeout: 15 * time.Minute}
+ resp, err := client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("download %s: %s", url, resp.Status)
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
+ return err
+}
+
+// FileSHA256 returns lowercase hex digest of path.
+func FileSHA256(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ h := sha256.New()
+ if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
+func VerifySHA256(path, want string) error {
+ want = strings.TrimSpace(strings.ToLower(want))
+ if want == "" {
+ return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
+ }
+ got, err := FileSHA256(path)
+ if err != nil {
+ return err
+ }
+ if got != want {
+ return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
+ }
+ return nil
+}
+
+// RequireSHA returns sha or an error if the asset has none.
+func RequireSHA(a ReleaseAsset) (string, error) {
+ if a.SHA256 != "" {
+ return a.SHA256, nil
+ }
+ return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
+}
+
+// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
+func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
+ if a.SHA256 != "" {
+ return a.SHA256, nil
+ }
+ companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
+ for _, name := range companions {
+ for _, o := range all {
+ if !strings.EqualFold(o.Name, name) {
+ continue
+ }
+ sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
+ if err != nil {
+ return "", err
+ }
+ if sum != "" {
+ return sum, nil
+ }
+ }
+ }
+ return RequireSHA(a)
+}
+
+func fetchChecksumFile(url, assetName string) (string, error) {
+ client := &http.Client{Timeout: 60 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("checksum download: %s", resp.Status)
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", err
+ }
+ text := string(body)
+ lowerName := strings.ToLower(assetName)
+ lines := strings.Split(text, "\n")
+ var shaLine string
+ for _, line := range lines {
+ l := strings.TrimSpace(strings.ToLower(line))
+ if strings.HasPrefix(l, "sha256:") {
+ shaLine = strings.TrimSpace(line[len("sha256:"):])
+ // keep scanning — prefer line that also mentions the asset
+ if strings.Contains(l, lowerName) {
+ return strings.ToLower(strings.Fields(shaLine)[0]), nil
+ }
+ }
+ fields := strings.Fields(line)
+ if len(fields) >= 2 {
+ sum := strings.ToLower(fields[0])
+ file := strings.TrimPrefix(fields[len(fields)-1], "*")
+ if len(sum) == 64 && strings.EqualFold(file, assetName) {
+ return sum, nil
+ }
+ }
+ }
+ if shaLine != "" {
+ parts := strings.Fields(shaLine)
+ if len(parts) > 0 && len(parts[0]) == 64 {
+ return strings.ToLower(parts[0]), nil
+ }
+ }
+ return "", nil
+}
+
+// DownloadAndVerify downloads url to path and checks sha256.
+func DownloadAndVerify(path, url, wantSHA256 string) error {
+ if err := DownloadFile(path, url, 0); err != nil {
+ return err
+ }
+ if err := VerifySHA256(path, wantSHA256); err != nil {
+ _ = os.Remove(path)
+ return err
+ }
+ return nil
+}
diff --git a/internal/coredl/download_test.go b/internal/coredl/download_test.go
new file mode 100644
index 0000000..bec06c2
--- /dev/null
+++ b/internal/coredl/download_test.go
@@ -0,0 +1,12 @@
+package coredl
+
+import "testing"
+
+func TestParseDigestAndVerify(t *testing.T) {
+ if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
+ t.Fatalf("parse: %q", got)
+ }
+ if parseDigestSHA256("md5:x") != "" {
+ t.Fatal("expected empty for non-sha256")
+ }
+}
diff --git a/internal/protocols/hysteria2/binary.go b/internal/protocols/hysteria2/binary.go
index 910c0db..79b4983 100644
--- a/internal/protocols/hysteria2/binary.go
+++ b/internal/protocols/hysteria2/binary.go
@@ -1,16 +1,14 @@
package hysteria2
import (
- "encoding/json"
"fmt"
- "io"
- "net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
- "time"
+
+ "vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
@@ -72,7 +70,11 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
- name, url, err := findReleaseAsset(want)
+ asset, all, err := findReleaseAsset(want)
+ if err != nil {
+ return "", err
+ }
+ sum, err := coredl.ResolveSHA(asset, all)
if err != nil {
return "", err
}
@@ -81,8 +83,8 @@ func EnsureBinary(binDir string) (string, error) {
destName = "hysteria.exe"
}
dest := filepath.Join(binDir, destName)
- tmp := filepath.Join(binDir, name+".download")
- if err := downloadFile(tmp, url); err != nil {
+ tmp := filepath.Join(binDir, asset.Name+".download")
+ if err := coredl.DownloadAndVerify(tmp, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
_ = os.Remove(dest)
@@ -114,73 +116,27 @@ func releaseAssetName() (string, error) {
}
}
-type ghRelease struct {
- TagName string `json:"tag_name"`
- Assets []struct {
- Name string `json:"name"`
- BrowserDownloadURL string `json:"browser_download_url"`
- } `json:"assets"`
-}
-
-func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
- client := &http.Client{Timeout: 60 * time.Second}
- req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
+func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
+ _, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
- return "", "", err
+ return coredl.ReleaseAsset{}, nil, err
}
- req.Header.Set("Accept", "application/vnd.github+json")
- req.Header.Set("User-Agent", "navis-vpnclient")
- resp, err := client.Do(req)
- if err != nil {
- return "", "", fmt.Errorf("github api: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
- return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
- }
- var rel ghRelease
- if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
- return "", "", err
- }
- // Prefer exact non-avx name; fall back to avx variant on Windows.
- var fallback string
- var fallbackURL string
- for _, a := range rel.Assets {
+ var fallback coredl.ReleaseAsset
+ for _, a := range assets {
if a.Name == exactName {
- return a.Name, a.BrowserDownloadURL, nil
+ return a, assets, nil
}
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
- fallback, fallbackURL = a.Name, a.BrowserDownloadURL
+ fallback = a
}
}
- if fallback != "" {
- return fallback, fallbackURL, nil
+ if fallback.Name != "" {
+ return fallback, assets, nil
}
- // Prefix match for versioned names
- for _, a := range rel.Assets {
+ for _, a := range assets {
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
- return a.Name, a.BrowserDownloadURL, nil
+ return a, assets, nil
}
}
- return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
-}
-
-func downloadFile(path, url string) error {
- client := &http.Client{Timeout: 10 * time.Minute}
- resp, err := client.Get(url)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("download %s: %s", url, resp.Status)
- }
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer f.Close()
- _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
- return err
+ return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in hysteria release", exactName)
}
diff --git a/internal/protocols/naive/binary.go b/internal/protocols/naive/binary.go
index fc77702..3cde003 100644
--- a/internal/protocols/naive/binary.go
+++ b/internal/protocols/naive/binary.go
@@ -2,16 +2,15 @@ package naive
import (
"archive/zip"
- "encoding/json"
"fmt"
"io"
- "net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
- "time"
+
+ "vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
@@ -51,12 +50,16 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
- assetName, url, err := findReleaseAsset(pattern)
+ asset, all, err := findReleaseAsset(pattern)
if err != nil {
return "", err
}
- archivePath := filepath.Join(binDir, assetName)
- if err := downloadFile(archivePath, url); err != nil {
+ sum, err := coredl.ResolveSHA(asset, all)
+ if err != nil {
+ return "", err
+ }
+ archivePath := filepath.Join(binDir, asset.Name+".download")
+ if err := coredl.DownloadAndVerify(archivePath, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
defer os.Remove(archivePath)
@@ -88,52 +91,26 @@ func assetNameForPlatform() (string, error) {
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
return "naiveproxy-*-linux-x64.zip", nil
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
- // Newer releases use mac-x64-x64; older used mac-x64.
return "naiveproxy-*-mac-x64", nil
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
- // Newer releases use mac-arm64-arm64; older used mac-arm64.
return "naiveproxy-*-mac-arm64", nil
default:
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
}
}
-type ghRelease struct {
- TagName string `json:"tag_name"`
- Assets []struct {
- Name string `json:"name"`
- BrowserDownloadURL string `json:"browser_download_url"`
- } `json:"assets"`
-}
-
-func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
- client := &http.Client{Timeout: 60 * time.Second}
- req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
+func findReleaseAsset(pattern string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
+ _, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
- return "", "", err
- }
- req.Header.Set("Accept", "application/vnd.github+json")
- req.Header.Set("User-Agent", "vpnclient-go")
- resp, err := client.Do(req)
- if err != nil {
- return "", "", fmt.Errorf("github api: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
- return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
- }
- var rel ghRelease
- if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
- return "", "", err
+ return coredl.ReleaseAsset{}, nil, err
}
prefix := strings.Split(pattern, "*")[0]
needle := ""
if parts := strings.SplitN(pattern, "*", 2); len(parts) == 2 {
needle = parts[1]
}
- var bestName, bestURL string
- for _, a := range rel.Assets {
+ var best coredl.ReleaseAsset
+ for _, a := range assets {
name := a.Name
if strings.Contains(name, "plugin") {
continue
@@ -144,41 +121,22 @@ func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
if needle != "" && !strings.Contains(name, needle) {
continue
}
- // Prefer desktop archives over openwrt/apk.
if strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".tar.xz") || strings.HasSuffix(name, ".txz") {
- return name, a.BrowserDownloadURL, nil
+ return a, assets, nil
}
- if bestName == "" {
- bestName, bestURL = name, a.BrowserDownloadURL
+ if best.Name == "" {
+ best = a
}
}
- if bestName != "" {
- return bestName, bestURL, nil
+ if best.Name != "" {
+ return best, assets, nil
}
- return "", "", fmt.Errorf("no asset matching %s in release %s", pattern, rel.TagName)
-}
-
-func downloadFile(path, url string) error {
- client := &http.Client{Timeout: 10 * time.Minute}
- resp, err := client.Get(url)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("download %s: %s", url, resp.Status)
- }
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer f.Close()
- _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
- return err
+ return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset matching %s in naive release", pattern)
}
func extractNaiveArchive(archivePath, destDir string) error {
lower := strings.ToLower(archivePath)
+ lower = strings.TrimSuffix(lower, ".download")
switch {
case strings.HasSuffix(lower, ".zip"):
return unzipNaive(archivePath, destDir)
diff --git a/internal/protocols/xray/binary.go b/internal/protocols/xray/binary.go
index 1124552..0ca58d8 100644
--- a/internal/protocols/xray/binary.go
+++ b/internal/protocols/xray/binary.go
@@ -2,16 +2,15 @@ package xray
import (
"archive/zip"
- "encoding/json"
"fmt"
"io"
- "net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
- "time"
+
+ "vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
@@ -56,13 +55,17 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
- name, url, err := findReleaseAsset(want)
+ asset, all, err := findReleaseAsset(want)
if err != nil {
return "", err
}
- zipPath := filepath.Join(binDir, name+".download")
+ sum, err := coredl.ResolveSHA(asset, all)
+ if err != nil {
+ return "", err
+ }
+ zipPath := filepath.Join(binDir, asset.Name+".download")
_ = os.Remove(zipPath)
- if err := downloadFile(zipPath, url); err != nil {
+ if err := coredl.DownloadAndVerify(zipPath, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
defer os.Remove(zipPath)
@@ -73,7 +76,6 @@ func EnsureBinary(binDir string) (string, error) {
}
dest := filepath.Join(binDir, destName)
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
- // Some zips nest the binary; try case-insensitive match.
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
return "", fmt.Errorf("%v; %w", err, err2)
}
@@ -104,65 +106,17 @@ func releaseZipName() (string, error) {
}
}
-type ghRelease struct {
- TagName string `json:"tag_name"`
- Assets []struct {
- Name string `json:"name"`
- BrowserDownloadURL string `json:"browser_download_url"`
- } `json:"assets"`
-}
-
-func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
- client := &http.Client{Timeout: 60 * time.Second}
- req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
+func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
+ _, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
- return "", "", err
+ return coredl.ReleaseAsset{}, nil, err
}
- req.Header.Set("Accept", "application/vnd.github+json")
- req.Header.Set("User-Agent", "navis-vpnclient")
- resp, err := client.Do(req)
- if err != nil {
- return "", "", fmt.Errorf("github api: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
- return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
- }
- var rel ghRelease
- if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
- return "", "", err
- }
- for _, a := range rel.Assets {
- if a.Name == exactName {
- return a.Name, a.BrowserDownloadURL, nil
+ for _, a := range assets {
+ if a.Name == exactName || strings.EqualFold(a.Name, exactName) {
+ return a, assets, nil
}
}
- for _, a := range rel.Assets {
- if strings.EqualFold(a.Name, exactName) {
- return a.Name, a.BrowserDownloadURL, nil
- }
- }
- return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
-}
-
-func downloadFile(path, url string) error {
- client := &http.Client{Timeout: 15 * time.Minute}
- resp, err := client.Get(url)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("download %s: %s", url, resp.Status)
- }
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer f.Close()
- _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
- return err
+ return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in xray release", exactName)
}
func extractNamedFromZip(zipPath, wantName, dest string) error {
diff --git a/internal/sysproxy/windows.go b/internal/sysproxy/windows.go
index c43b153..7e6074f 100644
--- a/internal/sysproxy/windows.go
+++ b/internal/sysproxy/windows.go
@@ -5,6 +5,7 @@ package sysproxy
import (
"fmt"
"strings"
+ "sync"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
@@ -16,6 +17,7 @@ const (
)
type windowsController struct {
+ mu sync.Mutex
enabled bool
hadProxy bool
oldEnable uint64
@@ -27,9 +29,15 @@ func newPlatform() Controller {
return &windowsController{}
}
-func (c *windowsController) Enabled() bool { return c.enabled }
+func (c *windowsController) Enabled() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.enabled
+}
func (c *windowsController) Enable(httpHostPort string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
if httpHostPort == "" {
return fmt.Errorf("sysproxy: empty http proxy address")
}
@@ -92,6 +100,8 @@ func (c *windowsController) Enable(httpHostPort string) error {
}
func (c *windowsController) Disable() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
if !c.enabled {
return nil
}
@@ -99,6 +109,8 @@ func (c *windowsController) Disable() error {
}
func (c *windowsController) ForceDisable() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
return c.disableLocked(false)
}
diff --git a/internal/update/update.go b/internal/update/update.go
index e19bc38..862938f 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -18,11 +18,11 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
-const CurrentVersion = "2.7.3"
+const CurrentVersion = "3.8.0"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
-const BuildNumber = 3
+const BuildNumber = 1
// 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/scripts/build-macos-arm64.sh b/scripts/build-macos-arm64.sh
index ab6f06b..55a1e35 100755
--- a/scripts/build-macos-arm64.sh
+++ b/scripts/build-macos-arm64.sh
@@ -59,7 +59,7 @@ build = "${BUILD}"
full = "${FULL}"
binp = Path('dist/navis-release/darwin-arm64/Navis')
h = hashlib.sha256(binp.read_bytes()).hexdigest()
-notes = f"Navis {ver}+{build}: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке)."
+notes = f"Navis {ver}+{build}: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot."
for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
if not p.exists():
continue
diff --git a/server/update.json b/server/update.json
index 0eb13f5..d586bb9 100644
--- a/server/update.json
+++ b/server/update.json
@@ -1,6 +1,6 @@
{
- "version": "2.7.3",
- "notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
+ "version": "3.8.0",
+ "notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,7 +16,7 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
+ "sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
diff --git a/versioninfo.json b/versioninfo.json
index 10d3c24..6d46594 100644
--- a/versioninfo.json
+++ b/versioninfo.json
@@ -1,7 +1,7 @@
{
"FixedFileInfo": {
- "FileVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
- "ProductVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
+ "FileVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 },
+ "ProductVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 },
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "40004",
@@ -10,13 +10,13 @@
},
"StringFileInfo": {
"CompanyName": "EvilFox",
- "FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
- "FileVersion": "2.7.3.3",
+ "FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
+ "FileVersion": "3.8.0.1",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
- "ProductVersion": "2.7.3.3",
+ "ProductVersion": "3.8.0.1",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {