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": {