Release 3.8.2.1: reliability, UI probe/logs, CI, signing gates.

Clear sysproxy on core death; probe+reconnect; subscription prune;
Best ignores soft UDP; Windows tray; Android protocol gate; CI workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-07-30 02:44:48 +03:00
co-authored by Cursor
parent 6b6c13c933
commit 621c847cb3
39 changed files with 913 additions and 143 deletions
+173 -14
View File
@@ -24,7 +24,7 @@ import (
"vpnclient/internal/sysproxy"
)
// Manager orchestrates protocol engines and Windows system proxy.
// Manager orchestrates protocol engines and OS system proxy.
type Manager struct {
mu sync.Mutex
cfgPath string
@@ -36,6 +36,10 @@ type Manager struct {
binDir string
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
lastStop sync.WaitGroup
lastError string
unexpectedDrop bool
watchGeneration uint64
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -113,15 +117,16 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
}
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
_ = engine.Stop()
m.mu.Unlock()
return fmt.Errorf("already connected; disconnect first")
}
if wantSysProxy {
httpProxy, ok := engine.LocalHTTPProxy()
if !ok {
m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
}
@@ -129,6 +134,7 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
if errors.Is(err, sysproxy.ErrUnsupported) {
fmt.Fprintf(stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
} else {
m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("enable system proxy: %w", err)
}
@@ -139,15 +145,81 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
m.engine = engine
m.profile = &profileCopy
m.lastError = ""
m.unexpectedDrop = false
m.watchGeneration++
gen := m.watchGeneration
m.mu.Unlock()
go m.watchEngine(gen, engine)
return nil
}
// LastError returns the last connection / core failure message.
func (m *Manager) LastError() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastError
}
// SetLastError stores a UI-facing error string.
func (m *Manager) SetLastError(msg string) {
m.mu.Lock()
m.lastError = strings.TrimSpace(msg)
m.mu.Unlock()
}
// TakeUnexpectedDrop reports and clears a crash-driven disconnect.
func (m *Manager) TakeUnexpectedDrop() bool {
m.mu.Lock()
defer m.mu.Unlock()
if !m.unexpectedDrop {
return false
}
m.unexpectedDrop = false
return true
}
func (m *Manager) watchEngine(gen uint64, eng Engine) {
if eng == nil {
return
}
for {
time.Sleep(800 * time.Millisecond)
m.mu.Lock()
if m.watchGeneration != gen || m.engine != eng {
m.mu.Unlock()
return
}
if eng.Running() {
m.mu.Unlock()
continue
}
// Core died without Disconnect().
m.engine = nil
m.profile = nil
m.lastError = "процесс ядра завершился неожиданно"
m.unexpectedDrop = true
sys := m.sys
cfgPath := m.cfgPath
m.mu.Unlock()
if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(cfgPath)) {
_ = sys.ForceDisable()
sysproxy.ClearSentinel(cfgPath)
fmt.Fprintf(m.stderr, "core exited: system proxy cleared\n")
}
return
}
}
func (m *Manager) Disconnect() error {
m.mu.Lock()
sys := m.sys
engine := m.engine
m.engine = nil
m.profile = nil
m.watchGeneration++ // stop watcher
m.unexpectedDrop = false
if engine != nil {
m.lastStop.Add(1)
}
@@ -280,15 +352,18 @@ func (m *Manager) Config() *config.Config {
// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
type UIPoll struct {
Status Status
Active string
SystemProxy bool
Subscription string
ActiveProxy string
ActiveProtocol config.Protocol
Profiles []config.ProfileInfo
Hy2 Hy2Options
BinDir string
Status Status
Active string
SystemProxy bool
Subscription string
ActiveProxy string
ActiveProtocol config.Protocol
Profiles []config.ProfileInfo
Hy2 Hy2Options
BinDir string
LastError string
ConnectOnLaunch bool
AutoReconnect bool
}
// PollUI gathers status + profile list under one lock without Config().Clone().
@@ -302,11 +377,14 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll {
SystemProxy: false,
Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
LastError: m.lastError,
}
if m.cfg != nil {
out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy
out.Subscription = m.cfg.SubscriptionURL
out.ConnectOnLaunch = m.cfg.ConnectOnLaunch
out.AutoReconnect = m.cfg.AutoReconnect
if includeSecrets {
out.Profiles = m.cfg.ListProfiles()
} else {
@@ -354,6 +432,38 @@ func (m *Manager) SetSystemProxy(enabled bool) {
m.cfg.SystemProxy = enabled
}
// Prefs are GUI toggles persisted in config.json.
type Prefs struct {
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
SystemProxy bool `json:"system_proxy"`
}
func (m *Manager) Prefs() Prefs {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return Prefs{}
}
return Prefs{
ConnectOnLaunch: m.cfg.ConnectOnLaunch,
AutoReconnect: m.cfg.AutoReconnect,
SystemProxy: m.cfg.SystemProxy,
}
}
func (m *Manager) SavePrefs(p Prefs) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return fmt.Errorf("no config")
}
m.cfg.ConnectOnLaunch = p.ConnectOnLaunch
m.cfg.AutoReconnect = p.AutoReconnect
m.cfg.SystemProxy = p.SystemProxy
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) UpdateProxyURI(proxy string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -576,20 +686,69 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return 0, fmt.Errorf("сначала отключитесь")
}
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
newNames := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, it := range items {
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
name := strings.TrimSpace(it.Name)
if name == "" {
continue
}
_ = m.cfg.UpsertProfileKeepActive(name, it.URI, it.Protocol)
for i := range m.cfg.Profiles {
if m.cfg.Profiles[i].Name == it.Name {
if m.cfg.Profiles[i].Name == name {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
break
}
}
if _, ok := seen[name]; !ok {
seen[name] = struct{}{}
newNames = append(newNames, name)
}
}
prev := map[string]struct{}{}
for _, n := range m.cfg.SubscriptionNames {
prev[n] = struct{}{}
}
// Prune profiles that came from the previous subscription sync but are gone now.
if len(prev) > 0 {
kept := make([]config.Profile, 0, len(m.cfg.Profiles))
for _, p := range m.cfg.Profiles {
if _, wasSub := prev[p.Name]; wasSub {
if _, still := seen[p.Name]; !still {
continue
}
}
kept = append(kept, p)
}
if len(kept) == 0 {
// Safety: never wipe all profiles.
kept = append([]config.Profile(nil), m.cfg.Profiles...)
} else {
m.cfg.Profiles = kept
}
activeOK := false
for _, p := range m.cfg.Profiles {
if p.Name == m.cfg.Active {
activeOK = true
break
}
}
if !activeOK && len(m.cfg.Profiles) > 0 {
m.cfg.Active = m.cfg.Profiles[0].Name
}
}
m.cfg.SubscriptionNames = newNames
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
return len(items), nil
return len(newNames), nil
}
func (m *Manager) SaveConfig() error {