diff --git a/README.md b/README.md index 1fa5783..a951928 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ | OS | Артефакты | UI | |----|----------|----| | Windows amd64 | `Navis.exe` | GUI (WebView2) | -| macOS universal | `Navis.dmg` · `Navis.app.zip` | CLI / .app — **рекомендуется** | -| macOS arm64 | `Navis` · `Navis.dmg` · `Navis.app.zip` | CLI / .app | +| macOS universal | `Navis.dmg` · `Navis.app.zip` | **GUI** (как Windows) | +| macOS arm64 / amd64 | `Navis` · DMG · `Navis-cli` | GUI + CLI | | Android | `Navis.apk` | GUI (VpnService) | ## Скачать macOS прямо с git @@ -243,6 +243,9 @@ https://evilfox.win/ Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси. +В 2.7.0: +- GUI на macOS (тот же интерфейс, что на Windows): DMG → Navis.app открывает окно клиента; CLI лежит как `Navis-cli`. + В 2.6.2: - быстрое отключение AWG: принудительно закрываются прокси-соединения, UI больше не зависает. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3b8c34c..2f0f5e1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "win.evilfox.navis" minSdk = 26 targetSdk = 35 - versionCode = 261 - versionName = "2.6.1" + versionCode = 270 + versionName = "2.7.0" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/build-macos.bat b/build-macos.bat index bfd17b3..6f65961 100644 --- a/build-macos.bat +++ b/build-macos.bat @@ -9,11 +9,15 @@ mkdir "dist\navis-release\darwin-universal" 2>nul set CGO_ENABLED=0 set GOOS=darwin set GOARCH=arm64 -go build -ldflags="-s -w" -o "dist\navis-release\darwin-arm64\Navis" .\cmd\vpnclient +go build -ldflags="-s -w" -o "dist\navis-release\darwin-arm64\Navis" .\cmd\vpnapp +if errorlevel 1 exit /b 1 +go build -ldflags="-s -w" -o "dist\navis-release\darwin-arm64\Navis-cli" .\cmd\vpnclient if errorlevel 1 exit /b 1 set GOARCH=amd64 -go build -ldflags="-s -w" -o "dist\navis-release\darwin-amd64\Navis" .\cmd\vpnclient +go build -ldflags="-s -w" -o "dist\navis-release\darwin-amd64\Navis" .\cmd\vpnapp +if errorlevel 1 exit /b 1 +go build -ldflags="-s -w" -o "dist\navis-release\darwin-amd64\Navis-cli" .\cmd\vpnclient if errorlevel 1 exit /b 1 set GOOS= @@ -24,18 +28,20 @@ go build -o "tools\mklipo\mklipo.exe" .\tools\mklipo if errorlevel 1 exit /b 1 tools\mklipo\mklipo.exe "dist\navis-release\darwin-amd64\Navis" "dist\navis-release\darwin-arm64\Navis" "dist\navis-release\darwin-universal\Navis" if errorlevel 1 exit /b 1 +tools\mklipo\mklipo.exe "dist\navis-release\darwin-amd64\Navis-cli" "dist\navis-release\darwin-arm64\Navis-cli" "dist\navis-release\darwin-universal\Navis-cli" +if errorlevel 1 exit /b 1 go build -o "tools\packmac\packmac.exe" .\tools\packmac if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 2.6.2 -arch arm64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 2.7.0 -arch arm64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 2.6.2 -arch amd64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 2.7.0 -arch amd64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 2.6.2 -arch universal +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 2.7.0 -arch universal if errorlevel 1 exit /b 1 -echo Built: +echo Built Mac GUI + CLI: dir "dist\navis-release\darwin-universal" dir "dist\navis-release\darwin-arm64" dir "dist\navis-release\darwin-amd64" diff --git a/cmd/vpnapp/main_darwin.go b/cmd/vpnapp/main_darwin.go new file mode 100644 index 0000000..70e111f --- /dev/null +++ b/cmd/vpnapp/main_darwin.go @@ -0,0 +1,104 @@ +//go:build darwin + +package main + +import ( + "bytes" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "vpnclient/internal/apphost" + "vpnclient/internal/config" + "vpnclient/internal/core" + "vpnclient/internal/update" +) + +func main() { + if update.MaybeFinishUpdate(os.Args[1:]) { + return + } + update.CleanupStaleDownloads() + log.SetFlags(log.LstdFlags | log.Lshortfile) + + cfgPath, err := config.LocateConfig() + if err != nil { + fatalf("Не удалось найти путь конфига: %v", err) + } + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if err := config.WriteExample(cfgPath); err != nil { + fatalf("Не удалось создать конфиг: %v", err) + } + } + cfg, err := config.Load(cfgPath) + if err != nil { + fatalf("Ошибка конфига %s: %v", cfgPath, err) + } + logBuf := &bytes.Buffer{} + mgr, err := core.NewManager(cfgPath, cfg, logBuf) + if err != nil { + fatalf("%v", err) + } + + a := apphost.New(mgr, cfgPath, logBuf) + a.OpenURL = func(u string) error { + return exec.Command("open", u).Start() + } + a.OnAfterUpdate = func() { + go func() { + time.Sleep(300 * time.Millisecond) + os.Exit(0) + }() + } + + ln, uiURL, err := apphost.ListenLocal() + if err != nil { + fatalf("listen: %v", err) + } + srv := &http.Server{Handler: a.Handler()} + go func() { + _ = srv.Serve(ln) + }() + go a.AutoCheckUpdate() + + if err := openAppWindow(uiURL); err != nil { + log.Printf("open UI: %v — откройте вручную: %s", err, uiURL) + fmt.Println(uiURL) + } else { + log.Printf("Navis UI: %s", uiURL) + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + _ = mgr.Disconnect() + _ = srv.Close() +} + +func openAppWindow(uiURL string) error { + // Prefer Chromium-based --app window (looks like a desktop client). + for _, app := range []string{"Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser", "Arc"} { + cmd := exec.Command("open", "-na", app, "--args", "--app="+uiURL, "--new-window") + if err := cmd.Start(); err == nil { + return nil + } + } + // Fallback: default browser + return exec.Command("open", uiURL).Start() +} + +func fatalf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + log.Println(msg) + _ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run() + os.Exit(1) +} + +// Ensure we keep a reference for potential future dock icon path helpers. +var _ = filepath.Separator diff --git a/cmd/vpnapp/main_other.go b/cmd/vpnapp/main_other.go index 9862e82..66ae7be 100644 --- a/cmd/vpnapp/main_other.go +++ b/cmd/vpnapp/main_other.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !darwin package main @@ -8,7 +8,7 @@ import ( ) func main() { - fmt.Fprintln(os.Stderr, "Navis GUI (WebView2) is Windows-only.") - fmt.Fprintln(os.Stderr, "On macOS use the CLI binary: ./Navis connect | install-core | check-update") + fmt.Fprintln(os.Stderr, "Navis GUI supports Windows (WebView2) and macOS.") + fmt.Fprintln(os.Stderr, "On this OS use the CLI: ./Navis connect | install-core | check-update") os.Exit(1) } diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index 27d1a8b..5bd4c20 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/darwin-amd64/Navis b/dist/navis-release/darwin-amd64/Navis index a54f163..418572f 100644 Binary files a/dist/navis-release/darwin-amd64/Navis and b/dist/navis-release/darwin-amd64/Navis differ diff --git a/dist/navis-release/darwin-amd64/Navis-cli b/dist/navis-release/darwin-amd64/Navis-cli new file mode 100644 index 0000000..814ba3a Binary files /dev/null and b/dist/navis-release/darwin-amd64/Navis-cli differ diff --git a/dist/navis-release/darwin-amd64/Navis.app.zip b/dist/navis-release/darwin-amd64/Navis.app.zip index 8b68f0c..d221f2f 100644 Binary files a/dist/navis-release/darwin-amd64/Navis.app.zip and b/dist/navis-release/darwin-amd64/Navis.app.zip differ diff --git a/dist/navis-release/darwin-amd64/Navis.dmg b/dist/navis-release/darwin-amd64/Navis.dmg index 9194eab..ef9d960 100644 Binary files a/dist/navis-release/darwin-amd64/Navis.dmg and b/dist/navis-release/darwin-amd64/Navis.dmg differ diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis index 9555aa2..555d959 100644 Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ diff --git a/dist/navis-release/darwin-arm64/Navis-cli b/dist/navis-release/darwin-arm64/Navis-cli new file mode 100644 index 0000000..e5a6a05 Binary files /dev/null and b/dist/navis-release/darwin-arm64/Navis-cli differ diff --git a/dist/navis-release/darwin-arm64/Navis.app.zip b/dist/navis-release/darwin-arm64/Navis.app.zip index 6231d1d..2ab4972 100644 Binary files a/dist/navis-release/darwin-arm64/Navis.app.zip and b/dist/navis-release/darwin-arm64/Navis.app.zip differ diff --git a/dist/navis-release/darwin-arm64/Navis.dmg b/dist/navis-release/darwin-arm64/Navis.dmg index 3cfdc52..cda76ee 100644 Binary files a/dist/navis-release/darwin-arm64/Navis.dmg and b/dist/navis-release/darwin-arm64/Navis.dmg differ diff --git a/dist/navis-release/darwin-universal/Navis b/dist/navis-release/darwin-universal/Navis index 0067d27..0191cda 100644 Binary files a/dist/navis-release/darwin-universal/Navis and b/dist/navis-release/darwin-universal/Navis differ diff --git a/dist/navis-release/darwin-universal/Navis-cli b/dist/navis-release/darwin-universal/Navis-cli new file mode 100644 index 0000000..4a6be2b Binary files /dev/null and b/dist/navis-release/darwin-universal/Navis-cli differ diff --git a/dist/navis-release/darwin-universal/Navis.app.zip b/dist/navis-release/darwin-universal/Navis.app.zip index be11e64..d16bbac 100644 Binary files a/dist/navis-release/darwin-universal/Navis.app.zip and b/dist/navis-release/darwin-universal/Navis.app.zip differ diff --git a/dist/navis-release/darwin-universal/Navis.dmg b/dist/navis-release/darwin-universal/Navis.dmg index b352f3e..eb2e2f5 100644 Binary files a/dist/navis-release/darwin-universal/Navis.dmg and b/dist/navis-release/darwin-universal/Navis.dmg differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 67a31f1..fe04ef1 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,22 +1,22 @@ { - "version": "2.6.2", - "notes": "Быстрое отключение AWG без зависания UI («приложение не отвечает»)", + "version": "2.7.0", + "notes": "GUI на macOS (как на Windows). Откройте Navis.app — окно клиента. CLI: Navis-cli", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "14fe9afb7978433a811662175ed21e9f65e66420c105ef943ca6f11aeeada072", + "sha256": "60392f389c9ed48b11124ce440d787ab7b051e7593833f15172e1c09434245dd", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "14fe9afb7978433a811662175ed21e9f65e66420c105ef943ca6f11aeeada072", + "sha256": "60392f389c9ed48b11124ce440d787ab7b051e7593833f15172e1c09434245dd", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "497cdf66b74e28061622debbbc6fa922ae64ae532e867dec876188af15e85278", + "sha256": "dd15b856bed836b82f2892e64db4db71771a947f3a392ce7d9f9e7f1b59edf1d", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", @@ -24,7 +24,7 @@ }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "95ccb649116bd15cbcdddccdd57df77651bf5f41ec19a4c6a0e91f8139621df7", + "sha256": "e71d7c0080e9acdcaa2355e4dd8fa3ccec8d4ed24646e27dbc1a814b0a4019cc", "os": "darwin", "arch": "amd64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", @@ -32,7 +32,7 @@ }, "darwin-universal": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", - "sha256": "abdb4d5807145dfab7afa1fc9fd4d0382a4f5d9b4ce7621128b2b393c7f0c928", + "sha256": "6b58907bb81583cf1730b34012cce97e7f758cf3eb12e630c971d0232cce5262", "os": "darwin", "arch": "universal", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", diff --git a/dist/update.json b/dist/update.json index 67a31f1..fe04ef1 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,22 +1,22 @@ { - "version": "2.6.2", - "notes": "Быстрое отключение AWG без зависания UI («приложение не отвечает»)", + "version": "2.7.0", + "notes": "GUI на macOS (как на Windows). Откройте Navis.app — окно клиента. CLI: Navis-cli", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "14fe9afb7978433a811662175ed21e9f65e66420c105ef943ca6f11aeeada072", + "sha256": "60392f389c9ed48b11124ce440d787ab7b051e7593833f15172e1c09434245dd", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "14fe9afb7978433a811662175ed21e9f65e66420c105ef943ca6f11aeeada072", + "sha256": "60392f389c9ed48b11124ce440d787ab7b051e7593833f15172e1c09434245dd", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "497cdf66b74e28061622debbbc6fa922ae64ae532e867dec876188af15e85278", + "sha256": "dd15b856bed836b82f2892e64db4db71771a947f3a392ce7d9f9e7f1b59edf1d", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", @@ -24,7 +24,7 @@ }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "95ccb649116bd15cbcdddccdd57df77651bf5f41ec19a4c6a0e91f8139621df7", + "sha256": "e71d7c0080e9acdcaa2355e4dd8fa3ccec8d4ed24646e27dbc1a814b0a4019cc", "os": "darwin", "arch": "amd64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", @@ -32,7 +32,7 @@ }, "darwin-universal": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", - "sha256": "abdb4d5807145dfab7afa1fc9fd4d0382a4f5d9b4ce7621128b2b393c7f0c928", + "sha256": "6b58907bb81583cf1730b34012cce97e7f758cf3eb12e630c971d0232cce5262", "os": "darwin", "arch": "universal", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", diff --git a/internal/apphost/app.go b/internal/apphost/app.go new file mode 100644 index 0000000..94be36e --- /dev/null +++ b/internal/apphost/app.go @@ -0,0 +1,488 @@ +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 +} diff --git a/internal/appui/index.html b/internal/appui/index.html index c7016a0..e0ceb54 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -725,7 +725,7 @@

Navis

- 2.6.2 + 2.7

Быстрый клиент · Naive · Hy2 · AWG · Xray

@@ -866,6 +866,7 @@

Navis

+