package apphost import ( "context" "crypto/rand" "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 } 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"` } 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 { return &App{ Mgr: mgr, CfgPath: cfgPath, LogBuf: logBuf, UpdateStatus: update.Status{ Current: update.DisplayVersion(), ManifestURL: update.DefaultManifestURL, }, } } func (a *App) GetState() (UIState, error) { return a.getState(false) } // GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls). func (a *App) GetEditState() (UIState, error) { return a.getState(true) } 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 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() { time.Sleep(1200 * time.Millisecond) if a.Mgr.Status().Connected { return } _ = a.Connect() }() } } func (a *App) dockIconLoop() { dockicon.SetConnected(false) var last bool first := true for { time.Sleep(400 * time.Millisecond) up := a.Mgr.Status().Connected if first || up != last { first = false last = up dockicon.SetConnected(up) } } } func (a *App) watchdogLoop() { for { time.Sleep(2 * time.Second) if !a.Mgr.TakeUnexpectedDrop() { continue } prefs := a.Mgr.Prefs() if !prefs.AutoReconnect { continue } time.Sleep(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/ 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, "", "", 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.GetState() 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 }