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>
This commit is contained in:
@@ -238,10 +238,12 @@ func (a *App) ConnectProfile(name string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
|
||||
proxy, err := a.Mgr.ActiveProxyURI()
|
||||
if err != nil {
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
||||
}
|
||||
if strings.TrimSpace(proxy) == "" {
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("сначала вставьте ссылку сервера")
|
||||
}
|
||||
@@ -420,10 +422,12 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
|
||||
out.Connected = true
|
||||
return out, nil
|
||||
}
|
||||
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
|
||||
proxy, err := a.Mgr.ActiveProxyURI()
|
||||
if err != nil {
|
||||
a.mu.Unlock()
|
||||
return out, err
|
||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
||||
}
|
||||
if strings.TrimSpace(proxy) == "" {
|
||||
a.mu.Unlock()
|
||||
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ func WriteExample(path string) error {
|
||||
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 {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
@@ -341,7 +341,15 @@ func Save(path string, cfg Config) error {
|
||||
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.
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) ListProfiles() []ProfileInfo {
|
||||
out := make([]ProfileInfo, 0, len(c.Profiles))
|
||||
@@ -22,7 +39,7 @@ func (c *Config) ListProfiles() []ProfileInfo {
|
||||
Name: p.Name,
|
||||
Protocol: string(p.Protocol),
|
||||
Proxy: p.Proxy,
|
||||
Host: proxyHost(p.Proxy),
|
||||
Host: cachedProxyHost(p.Proxy),
|
||||
Active: p.Name == c.Active,
|
||||
})
|
||||
}
|
||||
@@ -36,7 +53,7 @@ func (c *Config) ListProfilesForPoll() []ProfileInfo {
|
||||
out = append(out, ProfileInfo{
|
||||
Name: p.Name,
|
||||
Protocol: string(p.Protocol),
|
||||
Host: proxyHost(p.Proxy),
|
||||
Host: cachedProxyHost(p.Proxy),
|
||||
Active: p.Name == c.Active,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -350,6 +350,20 @@ func (m *Manager) Config() *config.Config {
|
||||
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).
|
||||
type UIPoll struct {
|
||||
Status Status
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package corebin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -15,6 +15,11 @@ func CacheKey(binDir, proto string) string {
|
||||
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.
|
||||
func Get(key string) (string, bool) {
|
||||
mu.Lock()
|
||||
@@ -33,22 +38,19 @@ func Set(key, path string) {
|
||||
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() {
|
||||
mu.Lock()
|
||||
cache = map[string]string{}
|
||||
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) {
|
||||
if path, ok := Get(key); ok {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
mu.Lock()
|
||||
delete(cache, key)
|
||||
mu.Unlock()
|
||||
return path, nil
|
||||
}
|
||||
path, err := fn()
|
||||
if err != nil {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCacheAndStale(t *testing.T) {
|
||||
func TestResolveTrustsCacheWithoutStat(t *testing.T) {
|
||||
Invalidate()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "core")
|
||||
@@ -20,26 +20,40 @@ func TestResolveCacheAndStale(t *testing.T) {
|
||||
return path, nil
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
if _, err := Resolve(key, fn); err != nil {
|
||||
// fn returns removed path; Resolve still succeeds but next Stat misses
|
||||
t.Fatalf("unexpected err after delete before recreate: %v", err)
|
||||
Invalidate()
|
||||
if _, err := Resolve(key, fn); err == nil && calls != 2 {
|
||||
// fn returns deleted path; Resolve still succeeds
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 p, err := Resolve(key, fn); err != nil || p != path {
|
||||
t.Fatalf("after recreate: %q %v", p, err)
|
||||
if !VirtualPath(p) {
|
||||
t.Fatal("expected virtual")
|
||||
}
|
||||
if ProtoKey("vless") != "xray" || ProtoKey("naive") != "naive" {
|
||||
t.Fatal("ProtoKey")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-40
@@ -13,12 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type darwinController struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
host string
|
||||
httpPort string
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
host string
|
||||
httpPort string
|
||||
socksPort string
|
||||
services []string
|
||||
services []string
|
||||
}
|
||||
|
||||
func newPlatform() Controller { return &darwinController{} }
|
||||
@@ -47,32 +47,25 @@ func (c *darwinController) Enable(httpHostPort string) error {
|
||||
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 {
|
||||
if err := runNetworkSetup("-setwebproxy", svc, host, port); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runNetworkSetup("-setsecurewebproxy", svc, host, port); err != nil {
|
||||
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
|
||||
}
|
||||
q := shellQuote(svc)
|
||||
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 := runBash(b.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.enabled = true
|
||||
c.host = host
|
||||
c.httpPort = port
|
||||
c.socksPort = socks
|
||||
c.services = services
|
||||
c.services = append([]string(nil), services...)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,7 +81,8 @@ func (c *darwinController) Disable() error {
|
||||
func (c *darwinController) ForceDisable() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.disableServices(nil)
|
||||
svcs := c.services
|
||||
return c.disableServices(svcs)
|
||||
}
|
||||
|
||||
func (c *darwinController) disableServices(services []string) error {
|
||||
@@ -99,21 +93,23 @@ func (c *darwinController) disableServices(services []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var first error
|
||||
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
|
||||
}
|
||||
}
|
||||
if len(services) == 0 {
|
||||
c.enabled = false
|
||||
c.services = nil
|
||||
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 first
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *darwinController) Enabled() bool {
|
||||
@@ -142,11 +138,15 @@ func listNetworkServices() ([]string, error) {
|
||||
return services, sc.Err()
|
||||
}
|
||||
|
||||
func runNetworkSetup(args ...string) error {
|
||||
cmd := exec.Command("networksetup", args...)
|
||||
func runBash(script string) error {
|
||||
cmd := exec.Command("/bin/bash", "-c", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
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
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
|
||||
|
||||
// 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 = 2
|
||||
const BuildNumber = 3
|
||||
|
||||
// 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"
|
||||
|
||||
Reference in New Issue
Block a user