Release 3.8.2.11: idle rev-cache, port-safe Connect, single-instance tray UX.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

Faster unchanged polls; orphan-core cleanup on listen ports; clearer busy-port errors; cores only from binDir; incremental server list; CSP without Google Fonts; single-instance activates existing window; close-to-tray without kill-others or balloon.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 17:27:47 +03:00
co-authored by Cursor
parent fed1ace33e
commit 57d719f02f
45 changed files with 876 additions and 285 deletions
+66 -45
View File
@@ -44,6 +44,9 @@ type App struct {
ctx context.Context
cancel context.CancelFunc
events *eventBroker
revMu sync.Mutex
cachedRev string
cachedConn bool
}
type UIState struct {
@@ -111,17 +114,35 @@ func (a *App) GetState() (UIState, error) {
// GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since
// matches the current fingerprint (cheap idle HTTP polls).
func (a *App) GetStateSince(since string) (UIState, error) {
if since != "" {
a.revMu.Lock()
cachedRev, cachedConn := a.cachedRev, a.cachedConn
a.revMu.Unlock()
if cachedRev != "" && cachedRev == since && a.Mgr.Status().Connected == cachedConn {
return UIState{Rev: cachedRev, Unchanged: true}, nil
}
}
out, err := a.getState(false)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
a.revMu.Lock()
a.cachedRev = out.Rev
a.cachedConn = out.Connected
a.revMu.Unlock()
if since != "" && since == out.Rev {
return UIState{Rev: out.Rev, Unchanged: true}, nil
}
return out, nil
}
func (a *App) invalidateRevCache() {
a.revMu.Lock()
a.cachedRev = ""
a.revMu.Unlock()
}
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) {
out, err := a.getState(true)
@@ -238,24 +259,34 @@ func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveActiveProfile(name, proxy)
err := a.Mgr.SaveActiveProfile(name, proxy)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveProfile(name, proxy)
err := a.Mgr.SaveProfile(name, proxy)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) SelectProfile(name string) error {
@@ -271,8 +302,12 @@ func (a *App) SelectProfile(name string) error {
func (a *App) DeleteProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.DeleteProfile(name)
err := a.Mgr.DeleteProfile(name)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) Connect() error { return a.ConnectProfile("") }
@@ -368,17 +403,21 @@ func (a *App) ProbeTunnel() error {
}
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
return a.Mgr.SavePrefs(core.Prefs{
err := a.Mgr.SavePrefs(core.Prefs{
ConnectOnLaunch: connectOnLaunch,
AutoReconnect: autoReconnect,
SystemProxy: systemProxy,
})
if err == nil {
a.NotifyState("prefs")
}
return err
}
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync.
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and core warmup.
func (a *App) StartBackground() {
go a.watchdogLoop()
go a.dockIconLoop()
dockicon.SetConnected(false)
go a.warmActiveCore()
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
@@ -408,27 +447,7 @@ func (a *App) warmActiveCore() {
// Soft fail — Connect will surface the error if the user tries.
return
}
}
func (a *App) dockIconLoop() {
dockicon.SetConnected(false)
var last bool
first := true
t := time.NewTicker(400 * time.Millisecond)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
up := a.Mgr.Status().Connected
if first || up != last {
first = false
last = up
dockicon.SetConnected(up)
}
}
}
a.NotifyState("cores")
}
func (a *App) watchdogLoop() {
@@ -443,6 +462,8 @@ func (a *App) watchdogLoop() {
continue
}
prefs := a.Mgr.Prefs()
dockicon.SetConnected(false)
a.NotifyState("disconnected")
if !prefs.AutoReconnect {
continue
}
@@ -465,17 +486,26 @@ func (a *App) InstallCore() (string, error) {
for k, v := range paths {
parts = append(parts, k+": "+v)
}
a.NotifyState("cores")
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)
err := a.Mgr.SaveHy2Options(opts)
a.mu.Unlock()
if err == nil {
a.NotifyState("hy2")
}
return err
}
func (a *App) ImportSubscription(rawURL string) (int, error) {
return a.Mgr.ImportSubscription(rawURL)
n, err := a.Mgr.ImportSubscription(rawURL)
if err == nil {
a.NotifyState("subscription")
}
return n, err
}
func (a *App) PingServers() ([]netcheck.Result, error) {
@@ -540,24 +570,14 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
}
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,
})
}
targets := a.Mgr.PingTargets()
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()
a.NotifyState("pings")
return out, nil
}
@@ -573,6 +593,7 @@ func (a *App) CheckUpdate() (update.Status, error) {
}
a.UpdateStatus = st
a.mu.Unlock()
a.NotifyState("update")
return st, nil
}