2 Commits
Author SHA1 Message Date
M4andCursor d1570b23d3 Release 3.8.2.3: cheaper poll I/O, batched macOS sysproxy, no Connect Clone.
Trust corebin cache without Stat; batch networksetup; ActiveProxyURI instead of Config.Clone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 03:02:19 +03:00
M4andCursor 838f9e340b Release 3.8.2.2: macOS Dock icon turns sea-wave N when connected.
Swap application icon on connect/disconnect via AppKit; ship arm64 build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:52:55 +03:00
27 changed files with 296 additions and 102 deletions
+3 -1
View File
@@ -247,7 +247,9 @@ https://evilfox.win/
- при неожиданном падении ядра снимается системный прокси и sentinel; - при неожиданном падении ядра снимается системный прокси и sentinel;
- логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение; - логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение;
- подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP; - подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP;
- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии. - трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии;
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
- 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone.
В 3.8.1: В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; - меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 3080201 versionCode = 3080203
versionName = "3.8.2+1" versionName = "3.8.2+3"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+3 -3
View File
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.1 -arch arm64 tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.3 -arch arm64
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.1 -arch amd64 tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.3 -arch amd64
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.1 -arch universal tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.3 -arch universal
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI: echo Built Mac GUI + CLI:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3.8.2+1 3.8.2+3
+1 -1
View File
@@ -1 +1 @@
3.8.2.1 3.8.2.3
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.", "notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
@@ -16,13 +16,13 @@
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a", "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip", "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd", "zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f" "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.", "notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
@@ -16,13 +16,13 @@
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a", "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip", "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd", "zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f" "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
+32 -6
View File
@@ -18,6 +18,7 @@ import (
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/core" "vpnclient/internal/core"
"vpnclient/internal/corebin" "vpnclient/internal/corebin"
"vpnclient/internal/dockicon"
"vpnclient/internal/logbuf" "vpnclient/internal/logbuf"
"vpnclient/internal/netcheck" "vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/awg"
@@ -237,10 +238,12 @@ func (a *App) ConnectProfile(name string) error {
return err return err
} }
} }
if p, err := a.Mgr.Config().ActiveProfile(); err != nil { proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock() a.mu.Unlock()
return err return err
} else if strings.TrimSpace(p.Proxy) == "" { }
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock() a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера") return fmt.Errorf("сначала вставьте ссылку сервера")
} }
@@ -264,18 +267,23 @@ func (a *App) ConnectProfile(name string) error {
perr := a.Mgr.Probe(pctx, "") perr := a.Mgr.Probe(pctx, "")
pcancel() pcancel()
if perr == nil { if perr == nil {
dockicon.SetConnected(true)
return nil return nil
} }
last = fmt.Errorf("туннель не прошёл проверку: %w", perr) last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
a.Mgr.SetLastError(last.Error()) a.Mgr.SetLastError(last.Error())
_ = a.Mgr.Disconnect() _ = a.Mgr.Disconnect()
dockicon.SetConnected(false)
time.Sleep(400 * time.Millisecond) time.Sleep(400 * time.Millisecond)
} }
dockicon.SetConnected(false)
return last return last
} }
func (a *App) Disconnect() error { func (a *App) Disconnect() error {
return a.Mgr.Disconnect() err := a.Mgr.Disconnect()
dockicon.SetConnected(false)
return err
} }
func (a *App) GetLogs() string { func (a *App) GetLogs() string {
@@ -303,9 +311,10 @@ func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error
}) })
} }
// StartBackground runs reconnect-on-crash and optional connect-on-launch. // StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync.
func (a *App) StartBackground() { func (a *App) StartBackground() {
go a.watchdogLoop() go a.watchdogLoop()
go a.dockIconLoop()
prefs := a.Mgr.Prefs() prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch { if prefs.ConnectOnLaunch {
go func() { go func() {
@@ -318,6 +327,21 @@ func (a *App) StartBackground() {
} }
} }
func (a *App) dockIconLoop() {
dockicon.SetConnected(false)
var last bool
first := true
for {
time.Sleep(400 * time.Millisecond)
up := a.Mgr.Status().Connected
if first || up != last {
first = false
last = up
dockicon.SetConnected(up)
}
}
}
func (a *App) watchdogLoop() { func (a *App) watchdogLoop() {
for { for {
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
@@ -398,10 +422,12 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
out.Connected = true out.Connected = true
return out, nil return out, nil
} }
if p, err := a.Mgr.Config().ActiveProfile(); err != nil { proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock() a.mu.Unlock()
return out, err return out, err
} else if strings.TrimSpace(p.Proxy) == "" { }
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock() a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера") return out, fmt.Errorf("пустая ссылка у лучшего сервера")
} }
+10 -2
View File
@@ -325,7 +325,7 @@ func WriteExample(path string) error {
return Save(path, Example()) return Save(path, Example())
} }
// Save writes config as indented JSON. // Save writes config as indented JSON (atomic replace).
func Save(path string, cfg Config) error { func Save(path string, cfg Config) error {
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return err return err
@@ -341,7 +341,15 @@ func Save(path string, cfg Config) error {
return err return err
} }
} }
return os.WriteFile(path, data, 0o600) tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
} }
// SetActiveProxy updates the active profile proxy URI. // SetActiveProxy updates the active profile proxy URI.
+19 -2
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"strings" "strings"
"sync"
) )
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns). // ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
@@ -14,6 +15,22 @@ type ProfileInfo struct {
Active bool `json:"active"` Active bool `json:"active"`
} }
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
var hostCache sync.Map // proxy URI/conf → host string
func cachedProxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
if v, ok := hostCache.Load(proxy); ok {
return v.(string)
}
h := proxyHost(proxy)
hostCache.Store(proxy, h)
return h
}
// ListProfiles returns UI-friendly profile summaries. // ListProfiles returns UI-friendly profile summaries.
func (c *Config) ListProfiles() []ProfileInfo { func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles)) out := make([]ProfileInfo, 0, len(c.Profiles))
@@ -22,7 +39,7 @@ func (c *Config) ListProfiles() []ProfileInfo {
Name: p.Name, Name: p.Name,
Protocol: string(p.Protocol), Protocol: string(p.Protocol),
Proxy: p.Proxy, Proxy: p.Proxy,
Host: proxyHost(p.Proxy), Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active, Active: p.Name == c.Active,
}) })
} }
@@ -36,7 +53,7 @@ func (c *Config) ListProfilesForPoll() []ProfileInfo {
out = append(out, ProfileInfo{ out = append(out, ProfileInfo{
Name: p.Name, Name: p.Name,
Protocol: string(p.Protocol), Protocol: string(p.Protocol),
Host: proxyHost(p.Proxy), Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active, Active: p.Name == c.Active,
}) })
} }
+14
View File
@@ -350,6 +350,20 @@ func (m *Manager) Config() *config.Config {
return m.cfg.Clone() return m.cfg.Clone()
} }
// ActiveProxyURI returns the active profile proxy without cloning the whole config.
func (m *Manager) ActiveProxyURI() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return "", fmt.Errorf("no config")
}
p, err := m.cfg.ActiveProfile()
if err != nil {
return "", err
}
return p.Proxy, nil
}
// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies). // UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
type UIPoll struct { type UIPoll struct {
Status Status Status Status
+10 -8
View File
@@ -1,7 +1,7 @@
package corebin package corebin
import ( import (
"os" "strings"
"sync" "sync"
) )
@@ -15,6 +15,11 @@ func CacheKey(binDir, proto string) string {
return binDir + "\x00" + proto return binDir + "\x00" + proto
} }
// VirtualPath reports paths that are not on-disk binaries (e.g. embedded AWG).
func VirtualPath(path string) bool {
return strings.HasPrefix(path, "embedded-")
}
// Get returns a cached absolute path if present. // Get returns a cached absolute path if present.
func Get(key string) (string, bool) { func Get(key string) (string, bool) {
mu.Lock() mu.Lock()
@@ -33,23 +38,20 @@ func Set(key, path string) {
mu.Unlock() mu.Unlock()
} }
// Invalidate clears all cached paths (call after EnsureCore / binDir change). // Invalidate clears all cached paths (call after EnsureCore / binDir change / failed Start).
func Invalidate() { func Invalidate() {
mu.Lock() mu.Lock()
cache = map[string]string{} cache = map[string]string{}
mu.Unlock() mu.Unlock()
} }
// Resolve caches the result of fn under key. Stale paths (missing on disk) are refreshed. // Resolve caches the result of fn under key.
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
// Embedded/virtual paths never touch the filesystem.
func Resolve(key string, fn func() (string, error)) (string, error) { func Resolve(key string, fn func() (string, error)) (string, error) {
if path, ok := Get(key); ok { if path, ok := Get(key); ok {
if _, err := os.Stat(path); err == nil {
return path, nil return path, nil
} }
mu.Lock()
delete(cache, key)
mu.Unlock()
}
path, err := fn() path, err := fn()
if err != nil { if err != nil {
return "", err return "", err
+28 -14
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
) )
func TestResolveCacheAndStale(t *testing.T) { func TestResolveTrustsCacheWithoutStat(t *testing.T) {
Invalidate() Invalidate()
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "core") path := filepath.Join(dir, "core")
@@ -20,26 +20,40 @@ func TestResolveCacheAndStale(t *testing.T) {
return path, nil return path, nil
} }
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 { if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("first: path=%q calls=%d err=%v", p, calls, err) t.Fatalf("first: %q %v calls=%d", p, err, calls)
} }
_ = os.Remove(path) // missing on disk — cache still trusted until Invalidate
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 { if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("cached: path=%q calls=%d err=%v", p, calls, err) t.Fatalf("cached without Stat: %q %v calls=%d", p, err, calls)
} }
_ = os.Remove(path) Invalidate()
if _, err := Resolve(key, fn); err != nil { if _, err := Resolve(key, fn); err == nil && calls != 2 {
// fn returns removed path; Resolve still succeeds but next Stat misses // fn returns deleted path; Resolve still succeeds
t.Fatalf("unexpected err after delete before recreate: %v", err)
} }
if calls != 2 { if calls != 2 {
t.Fatalf("expected refresh call, got %d", calls) t.Fatalf("after Invalidate expected refresh, calls=%d", calls)
} }
if err := os.WriteFile(path, []byte("y"), 0o755); err != nil {
t.Fatal(err)
} }
if p, err := Resolve(key, fn); err != nil || p != path {
t.Fatalf("after recreate: %q %v", p, err) func TestVirtualEmbeddedAWG(t *testing.T) {
Invalidate()
key := CacheKey("/bin", "awg")
calls := 0
p, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p != "embedded-amneziawg-go" || calls != 1 {
t.Fatalf("%q %v calls=%d", p, err, calls)
} }
if ProtoKey("vless") != "xray" || ProtoKey("naive") != "naive" { if !VirtualPath(p) {
t.Fatal("ProtoKey") t.Fatal("expected virtual")
}
p2, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p2 != p || calls != 1 {
t.Fatalf("second: %q calls=%d", p2, calls)
} }
} }
+106
View File
@@ -0,0 +1,106 @@
//go:build darwin
package dockicon
import (
_ "embed"
"log"
"sync"
"unsafe"
"github.com/ebitengine/purego"
"github.com/ebitengine/purego/objc"
)
//go:embed icon_idle.png
var iconIdlePNG []byte
//go:embed icon_connected.png
var iconConnectedPNG []byte
var (
once sync.Once
ready bool
mu sync.Mutex
last *bool
selSet objc.SEL
selPerf objc.SEL
selShared objc.SEL
selData objc.SEL
selAlloc objc.SEL
selInit objc.SEL
selRetain objc.SEL
clsApp objc.Class
clsData objc.Class
clsImage objc.Class
)
func initAppKit() {
once.Do(func() {
if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: Foundation: %v", err)
return
}
if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: AppKit: %v", err)
return
}
clsApp = objc.GetClass("NSApplication")
clsData = objc.GetClass("NSData")
clsImage = objc.GetClass("NSImage")
if clsApp == 0 || clsData == 0 || clsImage == 0 {
log.Printf("dockicon: missing AppKit classes")
return
}
selShared = objc.RegisterName("sharedApplication")
selSet = objc.RegisterName("setApplicationIconImage:")
selPerf = objc.RegisterName("performSelectorOnMainThread:withObject:waitUntilDone:")
selData = objc.RegisterName("dataWithBytes:length:")
selAlloc = objc.RegisterName("alloc")
selInit = objc.RegisterName("initWithData:")
selRetain = objc.RegisterName("retain")
ready = true
})
}
// SetConnected swaps the Dock icon: sea-wave N when connected, original when idle.
func SetConnected(connected bool) {
initAppKit()
if !ready {
return
}
mu.Lock()
if last != nil && *last == connected {
mu.Unlock()
return
}
v := connected
last = &v
mu.Unlock()
png := iconIdlePNG
if connected {
png = iconConnectedPNG
}
if len(png) == 0 {
return
}
data := objc.ID(clsData).Send(selData, unsafe.Pointer(&png[0]), len(png))
if data == 0 {
return
}
img := objc.ID(clsImage).Send(selAlloc)
img = img.Send(selInit, data)
if img == 0 {
return
}
img.Send(selRetain) // keep alive across async main-thread call
app := objc.ID(clsApp).Send(selShared)
if app == 0 {
return
}
// AppKit must run on the main thread (glaze owns it).
app.Send(selPerf, selSet, img, false)
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !darwin
package dockicon
// SetConnected is a no-op outside macOS (Dock icons are a Darwin feature).
func SetConnected(connected bool) {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+34 -34
View File
@@ -47,32 +47,25 @@ func (c *darwinController) Enable(httpHostPort string) error {
return fmt.Errorf("sysproxy: no network services") return fmt.Errorf("sysproxy: no network services")
} }
// One bash process for all networksetup calls (was 6 execs × N services).
// setwebproxy / setsecurewebproxy / setsocksfirewallproxy also turn the proxy on.
var b strings.Builder
b.WriteString("set -e\n")
for _, svc := range services { for _, svc := range services {
if err := runNetworkSetup("-setwebproxy", svc, host, port); err != nil { q := shellQuote(svc)
return err fmt.Fprintf(&b, "networksetup -setwebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
fmt.Fprintf(&b, "networksetup -setsecurewebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxy %s %s %s\n", q, shellQuote(host), shellQuote(socks))
} }
if err := runNetworkSetup("-setsecurewebproxy", svc, host, port); err != nil { if err := runBash(b.String()); err != nil {
return err return err
} }
if err := runNetworkSetup("-setsocksfirewallproxy", svc, host, socks); err != nil {
return err
}
if err := runNetworkSetup("-setwebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsecurewebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsocksfirewallproxystate", svc, "on"); err != nil {
return err
}
}
c.enabled = true c.enabled = true
c.host = host c.host = host
c.httpPort = port c.httpPort = port
c.socksPort = socks c.socksPort = socks
c.services = services c.services = append([]string(nil), services...)
return nil return nil
} }
@@ -88,7 +81,8 @@ func (c *darwinController) Disable() error {
func (c *darwinController) ForceDisable() error { func (c *darwinController) ForceDisable() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.disableServices(nil) svcs := c.services
return c.disableServices(svcs)
} }
func (c *darwinController) disableServices(services []string) error { func (c *darwinController) disableServices(services []string) error {
@@ -99,21 +93,23 @@ func (c *darwinController) disableServices(services []string) error {
return err return err
} }
} }
var first error if len(services) == 0 {
for _, svc := range services {
for _, args := range [][]string{
{"-setwebproxystate", svc, "off"},
{"-setsecurewebproxystate", svc, "off"},
{"-setsocksfirewallproxystate", svc, "off"},
} {
if err := runNetworkSetup(args...); err != nil && first == nil {
first = err
}
}
}
c.enabled = false c.enabled = false
c.services = nil c.services = nil
return first return nil
}
var b strings.Builder
b.WriteString("set +e\n") // best-effort: turn off what we can
for _, svc := range services {
q := shellQuote(svc)
fmt.Fprintf(&b, "networksetup -setwebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsecurewebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxystate %s off\n", q)
}
err := runBash(b.String())
c.enabled = false
c.services = nil
return err
} }
func (c *darwinController) Enabled() bool { func (c *darwinController) Enabled() bool {
@@ -142,11 +138,15 @@ func listNetworkServices() ([]string, error) {
return services, sc.Err() return services, sc.Err()
} }
func runNetworkSetup(args ...string) error { func runBash(script string) error {
cmd := exec.Command("networksetup", args...) cmd := exec.Command("/bin/bash", "-c", script)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("networksetup %v: %w (%s)", args, err, strings.TrimSpace(string(out))) return fmt.Errorf("networksetup batch: %w (%s)", err, strings.TrimSpace(string(out)))
} }
return nil return nil
} }
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part, // BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build. // macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 1 const BuildNumber = 3
// DefaultManifestURL is the update feed (hosted in the project git repo). // DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json" const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
+2 -3
View File
@@ -77,9 +77,8 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest()
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else "" zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else "" dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
notes = ( notes = (
f"Navis {ver}+{build}: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; " f"Navis {ver}+{build}: меньше I/O на poll (без Stat ядер / кэш host); "
f"автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; " f"macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении."
f"CI и строгая подпись релиза."
) )
paths = [ paths = [
Path('dist/update.json'), Path('dist/update.json'),
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.", "notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
@@ -16,13 +16,13 @@
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a", "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip", "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd", "zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f" "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
+4 -4
View File
@@ -4,13 +4,13 @@
"Major": 3, "Major": 3,
"Minor": 8, "Minor": 8,
"Patch": 2, "Patch": 2,
"Build": 1 "Build": 3
}, },
"ProductVersion": { "ProductVersion": {
"Major": 3, "Major": 3,
"Minor": 8, "Minor": 8,
"Patch": 2, "Patch": 2,
"Build": 1 "Build": 3
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
"FileFlags": "00", "FileFlags": "00",
@@ -21,12 +21,12 @@
"StringFileInfo": { "StringFileInfo": {
"CompanyName": "EvilFox", "CompanyName": "EvilFox",
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", "FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "3.8.2.1", "FileVersion": "3.8.2.3",
"InternalName": "Navis", "InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox", "LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe", "OriginalFilename": "Navis.exe",
"ProductName": "Navis", "ProductName": "Navis",
"ProductVersion": "3.8.2.1", "ProductVersion": "3.8.2.3",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/" "Comments": "Open-source VPN/proxy client. https://evilfox.win/"
}, },
"VarFileInfo": { "VarFileInfo": {