Author SHA1 Message Date
M4 54b5b87990 Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks. 2026-07-29 21:43:41 +03:00
26 changed files with 534 additions and 619 deletions
+6
View File
@@ -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/
+6
View File
@@ -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`;
+3 -3
View File
@@ -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:
+28 -402
View File
@@ -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 {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2.7.3+3
3.8.0+1
+1 -1
View File
@@ -1 +1 @@
2.7.3.3
3.8.0.1
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -848,7 +848,7 @@
<div class="brand-wrap">
<div class="brand-row">
<h1 class="brand">Navis</h1>
<span class="badge-ver" id="badgeVer">2.7.3</span>
<span class="badge-ver" id="badgeVer">3.8.0</span>
</div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div>
+20
View File
@@ -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)
+27 -3
View File
@@ -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) {
+127
View File
@@ -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")
}
}
+214
View File
@@ -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
}
+12
View File
@@ -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")
}
}
+21 -65
View File
@@ -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)
}
+21 -63
View File
@@ -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)
+16 -62
View File
@@ -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 {
+13 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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",
+5 -5
View File
@@ -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": {