Release 3.8.2.6: perceived-speed UX — search, tray hide, core warm, SSE.
ci / test (macos-latest) (push) Canceled after 0s
ci / test (ubuntu-latest) (push) Canceled after 0s
ci / test (windows-latest) (push) Canceled after 0s

Server search/hotkeys; Windows close-to-tray; background EnsureCore; SSE status with rare poll fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 16:32:19 +03:00
co-authored by Cursor
parent 2ad376b49e
commit 5d7c96f7fc
23 changed files with 409 additions and 33 deletions
+39 -6
View File
@@ -39,9 +39,11 @@ type App struct {
Pings []netcheck.Result
OpenURL func(string) error
OnAfterUpdate func()
OnQuit func()
APIToken string
ctx context.Context
cancel context.CancelFunc
events *eventBroker
}
type UIState struct {
@@ -81,11 +83,12 @@ type PingBestResult struct {
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
ctx, cancel := context.WithCancel(context.Background())
return &App{
Mgr: mgr,
Mgr: mgr,
CfgPath: cfgPath,
LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
events: newEventBroker(),
UpdateStatus: update.Status{
Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL,
@@ -257,8 +260,13 @@ func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
func (a *App) SelectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.SetActiveProfile(name)
err := a.Mgr.SetActiveProfile(name)
a.mu.Unlock()
if err == nil {
go a.warmActiveCore()
a.NotifyState("profile")
}
return err
}
func (a *App) DeleteProfile(name string) error {
@@ -305,6 +313,7 @@ func (a *App) ConnectProfile(name string) error {
for attempt := 0; attempt < 2; attempt++ {
if _, err := a.Mgr.EnsureCore(""); err != nil {
a.Mgr.SetLastError(err.Error())
a.NotifyState("error")
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
@@ -320,6 +329,7 @@ func (a *App) ConnectProfile(name string) error {
pcancel()
if perr == nil {
dockicon.SetConnected(true)
a.NotifyState("connected")
return nil
}
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
@@ -329,12 +339,14 @@ func (a *App) ConnectProfile(name string) error {
time.Sleep(400 * time.Millisecond)
}
dockicon.SetConnected(false)
a.NotifyState("error")
return last
}
func (a *App) Disconnect() error {
err := a.Mgr.Disconnect()
dockicon.SetConnected(false)
a.NotifyState("disconnected")
return err
}
@@ -367,6 +379,7 @@ func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error
func (a *App) StartBackground() {
go a.watchdogLoop()
go a.dockIconLoop()
go a.warmActiveCore()
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
go func() {
@@ -383,6 +396,20 @@ func (a *App) StartBackground() {
}
}
// warmActiveCore downloads/resolves the binary for the active profile protocol.
// Non-blocking; Connect still EnsureCore if this has not finished yet.
func (a *App) warmActiveCore() {
select {
case <-a.ctx.Done():
return
case <-time.After(350 * time.Millisecond):
}
if _, err := a.Mgr.EnsureCore(""); err != nil {
// Soft fail — Connect will surface the error if the user tries.
return
}
}
func (a *App) dockIconLoop() {
dockicon.SetConnected(false)
var last bool
@@ -620,6 +647,7 @@ func (a *App) Handler() http.Handler {
}
_, _ = io.WriteString(w, html)
})
mux.HandleFunc("/api/events", a.handleEvents)
mux.HandleFunc("/api/", a.handleAPI)
return mux
}
@@ -721,6 +749,11 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
go func() {
time.Sleep(200 * time.Millisecond)
_ = a.Mgr.Disconnect()
a.Shutdown()
if a.OnQuit != nil {
a.OnQuit()
return
}
os.Exit(0)
}()
return "ok", nil
+117
View File
@@ -0,0 +1,117 @@
package apphost
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// eventBroker fans out lightweight UI notifications (SSE).
type eventBroker struct {
mu sync.Mutex
subs map[chan []byte]struct{}
}
func newEventBroker() *eventBroker {
return &eventBroker{subs: make(map[chan []byte]struct{})}
}
func (b *eventBroker) subscribe() chan []byte {
ch := make(chan []byte, 8)
b.mu.Lock()
b.subs[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *eventBroker) unsubscribe(ch chan []byte) {
b.mu.Lock()
if _, ok := b.subs[ch]; ok {
delete(b.subs, ch)
close(ch)
}
b.mu.Unlock()
}
func (b *eventBroker) publish(payload []byte) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
select {
case ch <- payload:
default:
// Drop if a slow client is behind; safety-net poll will catch up.
}
}
}
// NotifyState pushes a small SSE payload so the UI can refresh immediately.
func (a *App) NotifyState(kind string) {
if a == nil || a.events == nil {
return
}
if kind == "" {
kind = "state"
}
b, err := json.Marshal(map[string]string{"type": kind})
if err != nil {
return
}
a.events.publish(b)
}
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
if a.APIToken != "" {
tok := r.Header.Get("X-Navis-Token")
if tok == "" {
tok = r.URL.Query().Get("t")
}
if tok != a.APIToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := a.events.subscribe()
defer a.events.unsubscribe(ch)
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case <-a.ctx.Done():
return
case <-ping.C:
if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
case msg, ok := <-ch:
if !ok {
return
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
return
}
flusher.Flush()
}
}
}