Files
navi/internal/apphost/app.go
T
M4andCursor 2ad376b49e
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:13:59 +03:00

754 lines
19 KiB
Go

package apphost
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/corebin"
"vpnclient/internal/dockicon"
"vpnclient/internal/logbuf"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/update"
)
// App is the GUI controller shared by Windows WebView and macOS HTTP UI.
type App struct {
mu sync.Mutex
Mgr *core.Manager
CfgPath string
LogBuf *logbuf.Buffer
UpdateStatus update.Status
Pings []netcheck.Result
OpenURL func(string) error
OnAfterUpdate func()
APIToken string
ctx context.Context
cancel context.CancelFunc
}
type UIState struct {
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
LastError string `json:"last_error,omitempty"`
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
LogTail string `json:"log_tail,omitempty"`
Rev string `json:"rev,omitempty"`
Unchanged bool `json:"unchanged,omitempty"`
}
type PingBestResult struct {
Pings []netcheck.Result `json:"pings"`
BestName string `json:"best_name,omitempty"`
BestMs int64 `json:"best_ms,omitempty"`
Selected bool `json:"selected"`
Connected bool `json:"connected"`
}
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
ctx, cancel := context.WithCancel(context.Background())
return &App{
Mgr: mgr,
CfgPath: cfgPath,
LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
UpdateStatus: update.Status{
Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL,
},
}
}
// Shutdown cancels background loops (dock icon / watchdog). Safe to call more than once.
func (a *App) Shutdown() {
if a == nil || a.cancel == nil {
return
}
a.cancel()
}
func (a *App) GetState() (UIState, error) {
return a.GetStateSince("")
}
// 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) {
out, err := a.getState(false)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
if since != "" && since == out.Rev {
return UIState{Rev: out.Rev, Unchanged: true}, nil
}
return out, nil
}
// 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)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
return out, nil
}
func (a *App) getState(includeSecrets bool) (UIState, error) {
// Hold App.mu only for fields mutated by update/ping handlers.
a.mu.Lock()
upd := a.UpdateStatus
pings := append([]netcheck.Result(nil), a.Pings...)
cfgPath := a.CfgPath
a.mu.Unlock()
poll := a.Mgr.PollUI(includeSecrets)
st := poll.Status
proxy := poll.ActiveProxy
if !includeSecrets {
proxy = config.RedactProxyURI(proxy)
}
hy2 := poll.Hy2
if !includeSecrets {
hy2.ObfsPassword = ""
}
corePath, coreReady := resolveCoreCached(poll.BinDir, poll.ActiveProtocol)
profiles := poll.Profiles
if includeSecrets {
// Editor needs state.Proxy; list still omits other profiles' URIs.
profiles = config.RedactProfileList(profiles)
}
out := UIState{
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: poll.Active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: poll.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: cfgPath,
Profiles: profiles,
Version: update.DisplayVersion(),
Update: upd,
Pings: pings,
Subscription: poll.Subscription,
Hy2: hy2,
LastError: poll.LastError,
ConnectOnLaunch: poll.ConnectOnLaunch,
AutoReconnect: poll.AutoReconnect,
}
if includeSecrets && a.LogBuf != nil {
out.LogTail = trimLogTail(a.LogBuf.String(), 4000)
}
if out.Protocol == "" {
out.Protocol = string(poll.ActiveProtocol)
}
if st.Connected {
out.SystemProxy = st.SystemProxy
}
return out, nil
}
func fingerprintState(s UIState) string {
var b strings.Builder
b.Grow(256)
fmt.Fprintf(&b, "%t|%s|%s|%s|%t|%s|%t|%t|%t|%s|",
s.Connected, s.Profile, s.ActiveProfile, s.Protocol, s.CoreReady, s.LastError,
s.SystemProxy, s.ConnectOnLaunch, s.AutoReconnect, s.Subscription)
fmt.Fprintf(&b, "%s|%s|%t|", s.Update.Latest, s.Update.Current, s.Update.Available)
for _, p := range s.Profiles {
fmt.Fprintf(&b, "%s,%s,%s,%t;", p.Name, p.Protocol, p.Host, p.Active)
}
for _, p := range s.Pings {
fmt.Fprintf(&b, "%s,%t,%t,%d,%s;", p.Name, p.OK, p.Soft, p.Ms, p.Error)
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:8])
}
func trimLogTail(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return "…\n" + s[len(s)-max:]
}
func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready bool) {
key := corebin.CacheKey(binDir, corebin.ProtoKey(string(proto)))
var err error
switch proto {
case config.ProtocolHysteria2:
path, err = corebin.Resolve(key, func() (string, error) { return hysteria2.ResolveBinary(binDir) })
case config.ProtocolAWG:
path, err = corebin.Resolve(key, func() (string, error) { return awg.ResolveBinary(binDir) })
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
path, err = corebin.Resolve(key, func() (string, error) { return xray.ResolveBinary(binDir) })
default:
path, err = corebin.Resolve(key, func() (string, error) { return naive.ResolveBinary(binDir) })
}
if err != nil {
return "", false
}
return path, true
}
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveActiveProfile(name, proxy)
}
func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveProfile(name, proxy)
}
func (a *App) SelectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.SetActiveProfile(name)
}
func (a *App) DeleteProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.DeleteProfile(name)
}
func (a *App) Connect() error { return a.ConnectProfile("") }
func (a *App) ConnectProfile(name string) error {
a.mu.Lock()
name = strings.TrimSpace(name)
st := a.Mgr.Status()
if st.Connected {
if name == "" || st.Profile == name {
a.mu.Unlock()
return nil
}
a.mu.Unlock()
if err := a.Mgr.Disconnect(); err != nil {
return err
}
a.mu.Lock()
}
if name != "" {
if err := a.Mgr.SetActiveProfile(name); err != nil {
a.mu.Unlock()
return err
}
}
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return err
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера")
}
a.mu.Unlock()
var last error
for attempt := 0; attempt < 2; attempt++ {
if _, err := a.Mgr.EnsureCore(""); err != nil {
a.Mgr.SetLastError(err.Error())
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
err := a.Mgr.Connect(ctx, "")
cancel()
if err != nil {
a.Mgr.SetLastError(err.Error())
last = err
continue
}
pctx, pcancel := context.WithTimeout(context.Background(), 12*time.Second)
perr := a.Mgr.Probe(pctx, "")
pcancel()
if perr == nil {
dockicon.SetConnected(true)
return nil
}
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
a.Mgr.SetLastError(last.Error())
_ = a.Mgr.Disconnect()
dockicon.SetConnected(false)
time.Sleep(400 * time.Millisecond)
}
dockicon.SetConnected(false)
return last
}
func (a *App) Disconnect() error {
err := a.Mgr.Disconnect()
dockicon.SetConnected(false)
return err
}
func (a *App) GetLogs() string {
if a.LogBuf == nil {
return ""
}
return trimLogTail(a.LogBuf.String(), 12000)
}
func (a *App) ProbeTunnel() error {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
err := a.Mgr.Probe(ctx, "")
if err != nil {
a.Mgr.SetLastError(err.Error())
}
return err
}
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
return a.Mgr.SavePrefs(core.Prefs{
ConnectOnLaunch: connectOnLaunch,
AutoReconnect: autoReconnect,
SystemProxy: systemProxy,
})
}
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync.
func (a *App) StartBackground() {
go a.watchdogLoop()
go a.dockIconLoop()
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
go func() {
select {
case <-a.ctx.Done():
return
case <-time.After(1200 * time.Millisecond):
}
if a.Mgr.Status().Connected {
return
}
_ = a.Connect()
}()
}
}
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)
}
}
}
}
func (a *App) watchdogLoop() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
if !a.Mgr.TakeUnexpectedDrop() {
continue
}
prefs := a.Mgr.Prefs()
if !prefs.AutoReconnect {
continue
}
select {
case <-a.ctx.Done():
return
case <-time.After(800 * time.Millisecond):
}
_ = a.Connect()
}
}
}
func (a *App) InstallCore() (string, error) {
paths, err := a.Mgr.EnsureAllCores()
if err != nil {
return "", err
}
parts := make([]string, 0, len(paths))
for k, v := range paths {
parts = append(parts, k+": "+v)
}
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)
}
func (a *App) ImportSubscription(rawURL string) (int, error) {
return a.Mgr.ImportSubscription(rawURL)
}
func (a *App) PingServers() ([]netcheck.Result, error) {
return a.runPings()
}
func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
pings, err := a.runPings()
out := PingBestResult{Pings: pings}
if err != nil {
return out, err
}
best, ok := netcheck.BestOK(pings)
if !ok {
return out, fmt.Errorf("нет доступных серверов")
}
out.BestName = best.Name
out.BestMs = best.Ms
a.mu.Lock()
st := a.Mgr.Status()
alreadyBest := st.Connected && st.Profile == best.Name
if st.Connected && !alreadyBest {
a.mu.Unlock()
if err := a.Mgr.Disconnect(); err != nil {
return out, err
}
a.mu.Lock()
}
if !alreadyBest {
if err := a.Mgr.SetActiveProfile(best.Name); err != nil {
a.mu.Unlock()
return out, err
}
}
out.Selected = true
if !autoConnect {
a.mu.Unlock()
return out, nil
}
if alreadyBest {
a.mu.Unlock()
out.Connected = true
return out, nil
}
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return out, err
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
}
a.mu.Unlock()
if err := a.ConnectProfile(""); err != nil {
return out, err
}
out.Connected = true
return out, nil
}
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,
})
}
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()
return out, nil
}
func (a *App) CheckUpdate() (update.Status, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
st, err := update.Check(ctx, update.DefaultManifestURL)
a.mu.Lock()
if err != nil {
st.Current = update.DisplayVersion()
st.ManifestURL = update.DefaultManifestURL
st.Error = err.Error()
}
a.UpdateStatus = st
a.mu.Unlock()
return st, nil
}
func (a *App) ApplyUpdate() (string, error) {
// Explicit user action only — never called from AutoCheckUpdate.
st, err := a.CheckUpdate()
if err != nil {
return "", err
}
if !st.Available {
return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
latest, err := update.Apply(ctx, update.DefaultManifestURL)
if err != nil {
return "", err
}
_ = a.Mgr.Disconnect()
if a.OnAfterUpdate != nil {
a.OnAfterUpdate()
} else {
go func() {
time.Sleep(300 * time.Millisecond)
os.Exit(0)
}()
}
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
}
func (a *App) AutoCheckUpdate() {
// Only check and populate status for the banner — never auto-apply.
// Auto-apply previously caused endless download→relaunch loops on Mac/Windows.
time.Sleep(3 * time.Second)
_, _ = a.CheckUpdate()
}
func (a *App) OpenShopURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("пустая ссылка")
}
lower := strings.ToLower(raw)
switch {
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
raw = "https://evilfox.win/"
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
default:
return fmt.Errorf("разрешена только ссылка evilfox.win")
}
if a.OpenURL != nil {
return a.OpenURL(raw)
}
return fmt.Errorf("openURL не настроен")
}
// Handler serves embedded UI and JSON-RPC style /api/<method> endpoints.
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := appui.IndexHTML
if a.APIToken != "" {
// Inject session token for the HTTP bridge (macOS).
html = strings.Replace(html, "</head>",
"<script>window.__NAVIS_TOKEN__="+jsonString(a.APIToken)+";</script></head>", 1)
}
_, _ = io.WriteString(w, html)
})
mux.HandleFunc("/api/", a.handleAPI)
return mux
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func (a *App) handleAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST 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
}
}
name := strings.TrimPrefix(r.URL.Path, "/api/")
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
var req struct {
Args []json.RawMessage `json:"args"`
}
_ = json.Unmarshal(body, &req)
result, err := a.dispatch(name, req.Args)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"result": result})
}
func arg[T any](args []json.RawMessage, i int, def T) T {
if i >= len(args) {
return def
}
var v T
if err := json.Unmarshal(args[i], &v); err != nil {
return def
}
return v
}
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
switch name {
case "getState":
return a.GetStateSince(arg(args, 0, ""))
case "getEditState":
return a.GetEditState()
case "connect":
return nil, a.Connect()
case "disconnect":
return nil, a.Disconnect()
case "connectProfile":
return nil, a.ConnectProfile(arg(args, 0, ""))
case "saveProfile":
return nil, a.SaveProfile(arg(args, 0, ""), arg(args, 1, ""), arg(args, 2, true))
case "createProfile":
return nil, a.CreateProfile(arg(args, 0, ""), arg(args, 1, ""), arg(args, 2, true))
case "selectProfile":
return nil, a.SelectProfile(arg(args, 0, ""))
case "deleteProfile":
return nil, a.DeleteProfile(arg(args, 0, ""))
case "installCore":
return a.InstallCore()
case "openURL":
return nil, a.OpenShopURL(arg(args, 0, ""))
case "pingServers":
return a.PingServers()
case "pingBest":
return a.PingBest(arg(args, 0, false))
case "checkUpdate":
return a.CheckUpdate()
case "applyUpdate":
return a.ApplyUpdate()
case "saveHy2":
var opts core.Hy2Options
if len(args) > 0 {
_ = json.Unmarshal(args[0], &opts)
}
return nil, a.SaveHy2(opts)
case "importSubscription":
return a.ImportSubscription(arg(args, 0, ""))
case "getLogs":
return a.GetLogs(), nil
case "probeTunnel":
return nil, a.ProbeTunnel()
case "savePrefs":
return nil, a.SavePrefs(arg(args, 0, false), arg(args, 1, false), arg(args, 2, true))
case "quit":
go func() {
time.Sleep(200 * time.Millisecond)
_ = a.Mgr.Disconnect()
os.Exit(0)
}()
return "ok", nil
default:
return nil, fmt.Errorf("unknown method %s", name)
}
}
// ListenLocal binds 127.0.0.1:0, generates an API token, and returns listener + URL.
func ListenLocal() (net.Listener, string, string, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, "", "", err
}
tok, err := randomToken(24)
if err != nil {
_ = ln.Close()
return nil, "", "", err
}
url := fmt.Sprintf("http://%s/", ln.Addr().String())
return ln, url, tok, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}