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:
+173
-14
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user