package apphost import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "os" "strings" "sync" "time" "vpnclient/internal/appui" "vpnclient/internal/config" "vpnclient/internal/core" "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 *bytes.Buffer UpdateStatus update.Status Pings []netcheck.Result OpenURL func(string) error OnAfterUpdate func() } 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"` } 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 *bytes.Buffer) *App { return &App{ Mgr: mgr, CfgPath: cfgPath, LogBuf: logBuf, UpdateStatus: update.Status{ Current: update.CurrentVersion, ManifestURL: update.DefaultManifestURL, }, } } func (a *App) GetState() (UIState, error) { a.mu.Lock() defer a.mu.Unlock() st := a.Mgr.Status() cfg := a.Mgr.Config() proxy := "" active := cfg.Active if p, err := cfg.ActiveProfile(); err == nil { proxy = p.Proxy active = p.Name } corePath := "" coreReady := false if p, err := cfg.ActiveProfile(); err == nil { switch p.Protocol { case config.ProtocolHysteria2: if path, err := hysteria2.ResolveBinary(a.Mgr.BinDir()); err == nil { corePath, coreReady = path, true } case config.ProtocolAWG: if path, err := awg.ResolveBinary(a.Mgr.BinDir()); err == nil { corePath, coreReady = path, true } case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: if path, err := xray.ResolveBinary(a.Mgr.BinDir()); err == nil { corePath, coreReady = path, true } default: if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil { corePath, coreReady = path, true } } } else if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil { corePath, coreReady = path, true } out := UIState{ Connected: st.Connected, Profile: st.Profile, ActiveProfile: active, Protocol: string(st.Protocol), HTTPProxy: st.HTTPProxy, SOCKSProxy: st.SOCKSProxy, SystemProxy: cfg.SystemProxy, Proxy: proxy, CoreReady: coreReady, CorePath: corePath, ConfigPath: a.CfgPath, Profiles: cfg.ListProfiles(), Version: update.CurrentVersion, Update: a.UpdateStatus, Pings: append([]netcheck.Result(nil), a.Pings...), Subscription: cfg.SubscriptionURL, Hy2: a.Mgr.ActiveHy2Options(), } if out.Protocol == "" { if p, err := cfg.ActiveProfile(); err == nil { out.Protocol = string(p.Protocol) } } if st.Connected { out.SystemProxy = st.SystemProxy } return out, nil } 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() defer a.mu.Unlock() name = strings.TrimSpace(name) st := a.Mgr.Status() if st.Connected { if name == "" || st.Profile == name { return nil } if err := a.Mgr.Disconnect(); err != nil { return err } } if name != "" { if err := a.Mgr.SetActiveProfile(name); err != nil { return err } } if p, err := a.Mgr.Config().ActiveProfile(); err != nil { return err } else if strings.TrimSpace(p.Proxy) == "" { return fmt.Errorf("сначала вставьте ссылку сервера") } if _, err := a.Mgr.EnsureCore(""); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() return a.Mgr.Connect(ctx, "") } func (a *App) Disconnect() error { return a.Mgr.Disconnect() } func (a *App) InstallCore() (string, error) { a.mu.Lock() defer a.mu.Unlock() 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() defer a.mu.Unlock() st := a.Mgr.Status() alreadyBest := st.Connected && st.Profile == best.Name if st.Connected && !alreadyBest { if err := a.Mgr.Disconnect(); err != nil { return out, err } } if !alreadyBest { if err := a.Mgr.SetActiveProfile(best.Name); err != nil { return out, err } } out.Selected = true if !autoConnect { return out, nil } if alreadyBest { out.Connected = true return out, nil } if p, err := a.Mgr.Config().ActiveProfile(); err != nil { return out, err } else if strings.TrimSpace(p.Proxy) == "" { return out, fmt.Errorf("пустая ссылка у лучшего сервера") } if _, err := a.Mgr.EnsureCore(""); err != nil { return out, err } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() if err := a.Mgr.Connect(ctx, ""); 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.CurrentVersion st.ManifestURL = update.DefaultManifestURL st.Error = err.Error() } a.UpdateStatus = st a.mu.Unlock() return st, nil } func (a *App) ApplyUpdate() (string, error) { 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() { time.Sleep(3 * time.Second) st, err := a.CheckUpdate() if err != nil || !st.Available { return } time.Sleep(1500 * time.Millisecond) _, _ = a.ApplyUpdate() } 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") _, _ = io.WriteString(w, appui.IndexHTML) }) mux.HandleFunc("/api/", a.handleAPI) return mux } func (a *App) handleAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) 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 "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 "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 and returns listener + URL. func ListenLocal() (net.Listener, string, error) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, "", err } url := fmt.Sprintf("http://%s/", ln.Addr().String()) return ln, url, nil }