Release 3.8.2.6: perceived-speed UX — search, tray hide, core warm, SSE.
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:
+39
-6
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,6 +548,28 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.server-toolbar .section-title { margin: 0; }
|
||||
.server-search-wrap {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.server-search-wrap input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(8, 90, 68, 0.18);
|
||||
background: var(--field-bg, rgba(255,255,255,.55));
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 12px;
|
||||
font: inherit;
|
||||
font-size: .88rem;
|
||||
outline: none;
|
||||
}
|
||||
.server-search-wrap input:focus {
|
||||
border-color: rgba(37, 88, 75, 0.45);
|
||||
}
|
||||
[data-theme="dark"] .server-search-wrap input {
|
||||
background: rgba(0,0,0,.22);
|
||||
border-color: rgba(180, 220, 200, 0.14);
|
||||
}
|
||||
.server-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
+71
-3
@@ -51,6 +51,7 @@
|
||||
const bestBtn = $("bestBtn");
|
||||
const autoBest = $("autoBest");
|
||||
const serverList = $("serverList");
|
||||
const serverSearch = $("serverSearch");
|
||||
const updCheckBtn = $("updCheckBtn");
|
||||
const updateBanner = $("updateBanner");
|
||||
const updateBtn = $("updateBtn");
|
||||
@@ -251,17 +252,35 @@
|
||||
return list;
|
||||
}
|
||||
|
||||
function filteredProfiles() {
|
||||
const q = (serverSearch && serverSearch.value || "").trim().toLowerCase();
|
||||
const list = orderedProfiles();
|
||||
if (!q) return list;
|
||||
return list.filter((p) => {
|
||||
const hay = [p.name, p.host, p.protocol].map((x) => String(x || "").toLowerCase()).join(" ");
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function renderServerList(activeName) {
|
||||
const active = activeName || profile.value;
|
||||
serverList.innerHTML = "";
|
||||
const list = orderedProfiles();
|
||||
if (!list.length) {
|
||||
const all = orderedProfiles();
|
||||
const list = filteredProfiles();
|
||||
if (!all.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "server-empty";
|
||||
empty.textContent = "Нет серверов — добавьте профиль или подписку";
|
||||
serverList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
if (!list.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "server-empty";
|
||||
empty.textContent = "Ничего не найдено";
|
||||
serverList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
list.forEach((p) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
@@ -707,11 +726,60 @@
|
||||
setMeta(String(e), "err");
|
||||
}
|
||||
})();
|
||||
if (serverSearch) {
|
||||
serverSearch.addEventListener("input", () => renderServerList(profile.value));
|
||||
}
|
||||
|
||||
function typingInField(el) {
|
||||
if (!el) return false;
|
||||
const tag = (el.tagName || "").toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable;
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("open")) {
|
||||
modal.classList.remove("open");
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (typingInField(document.activeElement)) return;
|
||||
if ((e.key === "/" || (e.key === "f" && (e.ctrlKey || e.metaKey))) && serverSearch) {
|
||||
e.preventDefault();
|
||||
serverSearch.focus();
|
||||
serverSearch.select();
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Enter" || e.key === " ") && !busy) {
|
||||
e.preventDefault();
|
||||
btn.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Safety-net poll; primary updates come from SSE /api/events when available.
|
||||
setInterval(() => {
|
||||
if (document.hidden || busy || modal.classList.contains("open")) return;
|
||||
refresh().catch(() => {});
|
||||
}, 4000);
|
||||
}, 12000);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !busy) refresh({ force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
(function startEventStream() {
|
||||
if (!window.__navisHttp || typeof EventSource === "undefined") return;
|
||||
const tok = window.__NAVIS_TOKEN__ || "";
|
||||
const url = "/api/events" + (tok ? ("?t=" + encodeURIComponent(tok)) : "");
|
||||
let es;
|
||||
const connectES = () => {
|
||||
try { if (es) es.close(); } catch (_) {}
|
||||
es = new EventSource(url);
|
||||
es.onmessage = () => {
|
||||
if (!busy) refresh({ force: true }).catch(() => {});
|
||||
};
|
||||
es.onerror = () => {
|
||||
try { es.close(); } catch (_) {}
|
||||
setTimeout(connectES, 2500);
|
||||
};
|
||||
};
|
||||
connectES();
|
||||
})();
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@
|
||||
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-search-wrap">
|
||||
<input id="serverSearch" type="search" placeholder="Поиск по имени, хосту, протоколу…" spellcheck="false" autocomplete="off" />
|
||||
</div>
|
||||
<label class="auto-best">
|
||||
<input id="autoBest" type="checkbox" />
|
||||
Авто: пинг + подключение к лучшему
|
||||
|
||||
@@ -4,6 +4,7 @@ package trayhost
|
||||
type Hooks struct {
|
||||
Connect func()
|
||||
Disconnect func()
|
||||
Show func()
|
||||
Quit func()
|
||||
IsUp func() bool
|
||||
}
|
||||
|
||||
@@ -47,9 +47,11 @@ const (
|
||||
tpmRight = 0x0020
|
||||
tpmBottom = 0x0020
|
||||
idiApplication = 32512
|
||||
idOpen = 1000
|
||||
idConnect = 1001
|
||||
idDisconnect = 1002
|
||||
idQuit = 1003
|
||||
wmLButtonDblClk = 0x0203
|
||||
)
|
||||
|
||||
type notifyIconData struct {
|
||||
@@ -107,12 +109,21 @@ func runTray(appName string, h Hooks) {
|
||||
var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr {
|
||||
switch msgU {
|
||||
case wmTray:
|
||||
if lParam == wmRButtonUp || lParam == wmLButtonUp {
|
||||
switch lParam {
|
||||
case wmLButtonDblClk:
|
||||
if h.Show != nil {
|
||||
go h.Show()
|
||||
}
|
||||
case wmRButtonUp, wmLButtonUp:
|
||||
showMenu(hwnd, h)
|
||||
}
|
||||
return 0
|
||||
case wmCommand:
|
||||
switch int(wParam & 0xffff) {
|
||||
case idOpen:
|
||||
if h.Show != nil {
|
||||
go h.Show()
|
||||
}
|
||||
case idConnect:
|
||||
if h.Connect != nil {
|
||||
go h.Connect()
|
||||
@@ -178,6 +189,10 @@ func showMenu(hwnd windows.Handle, h Hooks) {
|
||||
p, _ := windows.UTF16PtrFromString(text)
|
||||
procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p)))
|
||||
}
|
||||
if h.Show != nil {
|
||||
add(idOpen, "Открыть")
|
||||
procAppendMenu.Call(menu, mfSeparator, 0, 0)
|
||||
}
|
||||
up := h.IsUp != nil && h.IsUp()
|
||||
if up {
|
||||
add(idDisconnect, "Отключить")
|
||||
|
||||
@@ -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 = 5
|
||||
const BuildNumber = 6
|
||||
|
||||
// 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