Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks.
This commit is contained in:
+28
-402
@@ -4,60 +4,23 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/jchv/go-webview2"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"vpnclient/internal/apphost"
|
||||
"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"
|
||||
)
|
||||
|
||||
type app struct {
|
||||
mu sync.Mutex
|
||||
mgr *core.Manager
|
||||
cfgPath string
|
||||
logBuf *bytes.Buffer
|
||||
updateStatus update.Status
|
||||
pings []netcheck.Result
|
||||
webview webview2.WebView
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if update.MaybeFinishUpdate(os.Args[1:]) {
|
||||
return
|
||||
@@ -87,27 +50,25 @@ func main() {
|
||||
fatalDialog("%v", err)
|
||||
}
|
||||
|
||||
a := &app{
|
||||
mgr: mgr,
|
||||
cfgPath: cfgPath,
|
||||
logBuf: logBuf,
|
||||
updateStatus: update.Status{
|
||||
Current: update.DisplayVersion(),
|
||||
ManifestURL: update.DefaultManifestURL,
|
||||
},
|
||||
a := apphost.New(mgr, cfgPath, logBuf)
|
||||
a.OpenURL = shellOpen
|
||||
a.OnAfterUpdate = func() {
|
||||
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
|
||||
_ = os.MkdirAll(dataPath, 0o755)
|
||||
|
||||
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
|
||||
|
||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||
Debug: false,
|
||||
AutoFocus: true,
|
||||
DataPath: dataPath,
|
||||
WindowOptions: webview2.WindowOptions{
|
||||
Title: "Navis 2",
|
||||
Title: "Navis",
|
||||
Width: 500,
|
||||
Height: 900,
|
||||
Center: true,
|
||||
@@ -117,33 +78,32 @@ func main() {
|
||||
if w == nil {
|
||||
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
|
||||
}
|
||||
a.webview = w
|
||||
defer func() {
|
||||
_ = a.mgr.Disconnect()
|
||||
_ = mgr.Disconnect()
|
||||
w.Destroy()
|
||||
}()
|
||||
|
||||
applyWindowIcon(w.Window())
|
||||
w.SetSize(500, 900, webview2.HintNone)
|
||||
|
||||
mustBind(w, "getState", a.getState)
|
||||
mustBind(w, "connect", a.connect)
|
||||
mustBind(w, "disconnect", a.disconnect)
|
||||
mustBind(w, "connectProfile", a.connectProfile)
|
||||
mustBind(w, "saveProfile", a.saveProfile)
|
||||
mustBind(w, "createProfile", a.createProfile)
|
||||
mustBind(w, "selectProfile", a.selectProfile)
|
||||
mustBind(w, "deleteProfile", a.deleteProfile)
|
||||
mustBind(w, "installCore", a.installCore)
|
||||
mustBind(w, "openURL", openURL)
|
||||
mustBind(w, "pingServers", a.pingServers)
|
||||
mustBind(w, "pingBest", a.pingBest)
|
||||
mustBind(w, "checkUpdate", a.checkUpdate)
|
||||
mustBind(w, "applyUpdate", a.applyUpdate)
|
||||
mustBind(w, "saveHy2", a.saveHy2)
|
||||
mustBind(w, "importSubscription", a.importSubscription)
|
||||
mustBind(w, "getState", a.GetState)
|
||||
mustBind(w, "connect", a.Connect)
|
||||
mustBind(w, "disconnect", a.Disconnect)
|
||||
mustBind(w, "connectProfile", a.ConnectProfile)
|
||||
mustBind(w, "saveProfile", a.SaveProfile)
|
||||
mustBind(w, "createProfile", a.CreateProfile)
|
||||
mustBind(w, "selectProfile", a.SelectProfile)
|
||||
mustBind(w, "deleteProfile", a.DeleteProfile)
|
||||
mustBind(w, "installCore", a.InstallCore)
|
||||
mustBind(w, "openURL", a.OpenShopURL)
|
||||
mustBind(w, "pingServers", a.PingServers)
|
||||
mustBind(w, "pingBest", a.PingBest)
|
||||
mustBind(w, "checkUpdate", a.CheckUpdate)
|
||||
mustBind(w, "applyUpdate", a.ApplyUpdate)
|
||||
mustBind(w, "saveHy2", a.SaveHy2)
|
||||
mustBind(w, "importSubscription", a.ImportSubscription)
|
||||
|
||||
go a.autoCheckUpdate()
|
||||
go a.AutoCheckUpdate()
|
||||
|
||||
w.SetHtml(appui.IndexHTML)
|
||||
w.Run()
|
||||
@@ -155,340 +115,6 @@ func mustBind(w webview2.WebView, name string, fn interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
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: config.RedactProfileList(cfg.ListProfiles()),
|
||||
Version: update.DisplayVersion(),
|
||||
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("")
|
||||
}
|
||||
|
||||
// connectProfile switches to the named profile (if set) and connects.
|
||||
// If already connected to another profile, disconnects first.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("сначала вставьте ссылку сервера")
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
|
||||
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 {
|
||||
// Do not hold a.mu across engine teardown — getState polling would freeze the UI.
|
||||
return a.mgr.Disconnect()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 (a *app) pingServers() ([]netcheck.Result, error) {
|
||||
res, err := a.runPings()
|
||||
return res, err
|
||||
}
|
||||
|
||||
// pingBest measures all nodes, activates the fastest OK one, and optionally connects.
|
||||
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
|
||||
}
|
||||
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
|
||||
a.mu.Unlock()
|
||||
return out, err
|
||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
||||
a.mu.Unlock()
|
||||
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
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.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()
|
||||
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate (can hang).
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
|
||||
}
|
||||
|
||||
func (a *app) autoCheckUpdate() {
|
||||
// Only refresh update status for the UI banner — never auto-install.
|
||||
// (Previously auto-apply caused an infinite update loop when the feed version
|
||||
// stayed ahead of the shipped CurrentVersion in the downloaded binary.)
|
||||
time.Sleep(3 * time.Second)
|
||||
_, _ = a.checkUpdate()
|
||||
}
|
||||
|
||||
func openURL(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/"):
|
||||
// allow shop deep-links on evilfox.win only
|
||||
default:
|
||||
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
||||
}
|
||||
return shellOpen(raw)
|
||||
}
|
||||
|
||||
func shellOpen(raw string) error {
|
||||
verb, err := windows.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user