Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54b5b87990 | ||
|
|
64c097d1e7 | ||
|
|
041cbb1250 | ||
|
|
dc700f2bac | ||
|
|
43882b95ad |
@@ -25,4 +25,10 @@ android/app/src/main/assets/cores/**/hev-socks5-tunnel
|
||||
*.apk
|
||||
!dist/navis-release/android/*.apk
|
||||
|
||||
# Local backups (pre-release archives)
|
||||
backups/
|
||||
|
||||
# Local Go toolchain
|
||||
.tools/
|
||||
|
||||
|
||||
|
||||
@@ -243,6 +243,20 @@ https://evilfox.win/
|
||||
|
||||
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
||||
|
||||
В 3.8.0:
|
||||
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
|
||||
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
|
||||
- SHA-256 проверка при установке cores (naive / hy2 / xray);
|
||||
- snapshot конфига в UI (`Config().Clone`).
|
||||
|
||||
В 2.7.3:
|
||||
- восстановление системного прокси после аварийного завершения;
|
||||
- auth-токен для локального macOS `/api`;
|
||||
- обязательный SHA-256 при обновлении;
|
||||
- исправлен ложный UDP ping; HTTPS-only подписки; UI не зависает на connect;
|
||||
- 2.7.3+2: импорт Amnezia `vpn://` — очистка невидимых символов, создание профиля при активном подключении, ошибка в модалке;
|
||||
- 2.7.3+3: тёмная тема интерфейса (переключатель в шапке, сохранение выбора).
|
||||
|
||||
В 2.7.2:
|
||||
- убран вечный цикл обновления (проверка больше не устанавливает сама; только кнопка «Обновить»);
|
||||
- номер сборки в версии (`2.7.2+N` / FileVersion `2.7.2.N`); сравнение обновлений только по product semver.
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
targetSdk = 35
|
||||
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
||||
versionCode = 2_070_201
|
||||
versionName = "2.7.2+1"
|
||||
versionName = "2.7.3+1"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 11 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 22 KiB |
+3
-3
@@ -34,11 +34,11 @@ 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.7.2 -build 2.7.2.1 -arch arm64
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.0 -build 3.8.0.1 -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.7.2 -build 2.7.2.1 -arch amd64
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.0 -build 3.8.0.1 -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.7.2 -build 2.7.2.1 -arch universal
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.0 -build 3.8.0.1 -arch universal
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo Built Mac GUI + CLI:
|
||||
|
||||
+27
-27
@@ -10,10 +10,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/crgimenes/glaze"
|
||||
|
||||
"vpnclient/internal/apphost"
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
@@ -57,40 +58,42 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
ln, uiURL, err := apphost.ListenLocal()
|
||||
ln, uiURL, token, err := apphost.ListenLocal()
|
||||
if err != nil {
|
||||
fatalf("listen: %v", err)
|
||||
}
|
||||
a.APIToken = token
|
||||
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
|
||||
w, err := glaze.New(false)
|
||||
if err != nil || w == nil {
|
||||
log.Printf("webview unavailable (%v); opening default browser", err)
|
||||
if err2 := exec.Command("open", uiURL).Run(); err2 != nil {
|
||||
fatalf("Не удалось открыть интерфейс:\n%v\n%v\n%s", err, err2, uiURL)
|
||||
}
|
||||
log.Printf("Navis UI: %s", uiURL)
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
<-sig
|
||||
_ = mgr.Disconnect()
|
||||
_ = srv.Close()
|
||||
return
|
||||
}
|
||||
// Fallback: default browser
|
||||
return exec.Command("open", uiURL).Start()
|
||||
defer func() {
|
||||
_ = mgr.Disconnect()
|
||||
_ = srv.Close()
|
||||
w.Destroy()
|
||||
}()
|
||||
|
||||
w.SetTitle("Navis 2")
|
||||
w.SetSize(500, 900, glaze.HintNone)
|
||||
w.Navigate(uiURL)
|
||||
log.Printf("Navis UI: %s", uiURL)
|
||||
w.Run()
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
@@ -99,6 +102,3 @@ func fatalf(format string, args ...any) {
|
||||
_ = 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
|
||||
|
||||
+28
-390
@@ -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,328 +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: 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()
|
||||
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 {
|
||||
// Do not hold a.mu across engine teardown — getState polling would freeze the UI.
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
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.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 {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
3.8.0+1
|
||||
+1
@@ -0,0 +1 @@
|
||||
3.8.0.1
|
||||
Vendored
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "2.7.2",
|
||||
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».",
|
||||
"version": "3.8.0",
|
||||
"notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb",
|
||||
"sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||
@@ -39,4 +39,4 @@
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "2.7.2",
|
||||
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».",
|
||||
"version": "3.8.0",
|
||||
"notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb",
|
||||
"sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||
@@ -39,4 +39,4 @@
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module vpnclient
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/amnezia-vpn/amneziawg-go v0.2.19
|
||||
@@ -12,7 +12,9 @@ require (
|
||||
|
||||
require (
|
||||
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||
github.com/crgimenes/glaze v0.0.33 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -2,12 +2,16 @@ github.com/amnezia-vpn/amneziawg-go v0.2.19 h1:l3rOmrA4o5z38kpgnA5iSk1yOm7Cv3Aaf
|
||||
github.com/amnezia-vpn/amneziawg-go v0.2.19/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A=
|
||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/crgimenes/glaze v0.0.33 h1:XZm2cFTSFSY7UarC4w/ziCMJ7Zwkce5Dh1NaO1Koj5Q=
|
||||
github.com/crgimenes/glaze v0.0.33/go.mod h1:ZuCIST0F5U6wJLw5ZtqfcTIQA7LI/m2MHv7iMOBmu6U=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc=
|
||||
github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
|
||||
+68
-18
@@ -3,6 +3,8 @@ package apphost
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -26,14 +28,15 @@ import (
|
||||
|
||||
// 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
|
||||
mu sync.Mutex
|
||||
Mgr *core.Manager
|
||||
CfgPath string
|
||||
LogBuf *bytes.Buffer
|
||||
UpdateStatus update.Status
|
||||
Pings []netcheck.Result
|
||||
OpenURL func(string) error
|
||||
OnAfterUpdate func()
|
||||
APIToken string
|
||||
}
|
||||
|
||||
type UIState struct {
|
||||
@@ -124,7 +127,7 @@ func (a *App) GetState() (UIState, error) {
|
||||
CoreReady: coreReady,
|
||||
CorePath: corePath,
|
||||
ConfigPath: a.CfgPath,
|
||||
Profiles: cfg.ListProfiles(),
|
||||
Profiles: config.RedactProfileList(cfg.ListProfiles()),
|
||||
Version: update.DisplayVersion(),
|
||||
Update: a.UpdateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
||||
@@ -180,28 +183,35 @@ 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 {
|
||||
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
|
||||
}
|
||||
@@ -215,8 +225,6 @@ func (a *App) Disconnect() error {
|
||||
}
|
||||
|
||||
func (a *App) InstallCore() (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
paths, err := a.Mgr.EnsureAllCores()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -256,32 +264,40 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -394,17 +410,38 @@ func (a *App) Handler() http.Handler {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, appui.IndexHTML)
|
||||
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 {
|
||||
@@ -482,12 +519,25 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListenLocal binds 127.0.0.1:0 and returns listener + URL.
|
||||
func ListenLocal() (net.Listener, string, error) {
|
||||
// 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
|
||||
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, nil
|
||||
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
|
||||
}
|
||||
|
||||
+210
-50
@@ -22,6 +22,79 @@
|
||||
--shadow: 0 18px 50px rgba(8, 40, 32, 0.12);
|
||||
--radius: 22px;
|
||||
--ease: cubic-bezier(.22,.8,.24,1);
|
||||
--page-bg:
|
||||
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
|
||||
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
|
||||
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
|
||||
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
|
||||
--hero-bg: linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
|
||||
--hero-on-bg: linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
|
||||
--input-bg: rgba(255,255,255,.94);
|
||||
--chip-bg: rgba(255,255,255,.75);
|
||||
--row-bg: rgba(255,255,255,.72);
|
||||
--row-active-bg: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
|
||||
--modal-bg: #f5fbf8;
|
||||
--modal-scrim: rgba(8, 28, 24, .42);
|
||||
--soft-btn: linear-gradient(180deg, #d2efe4, #bfe6d6);
|
||||
--soft-btn-hover: linear-gradient(180deg, #c5e9db, #aedfcb);
|
||||
--danger-btn: linear-gradient(180deg, #f8d8d4, #efc4bf);
|
||||
--danger-btn-hover: linear-gradient(180deg, #f3c5bf, #e8aea7);
|
||||
--danger-ink: #9e2a22;
|
||||
--danger-ink-hover: #7f1f19;
|
||||
--meta-bg: rgba(255,255,255,.35);
|
||||
--meta-ok-bg: rgba(216,243,233,.65);
|
||||
--meta-err-bg: rgba(255,236,234,.75);
|
||||
--update-bg: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
|
||||
--shop-bg: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
|
||||
--switch-track: #c7d5cf;
|
||||
--switch-knob: #fff;
|
||||
--ms-good: #0a7a3e;
|
||||
--ms-mid: #b8860b;
|
||||
--focus-ring: rgba(13,138,102,.12);
|
||||
--theme-btn-bg: rgba(255,255,255,.7);
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--ink: #e6f4ee;
|
||||
--muted: #8eaaa0;
|
||||
--line: rgba(230, 244, 238, 0.12);
|
||||
--accent: #2bbf8a;
|
||||
--accent-deep: #7ee0b8;
|
||||
--accent-soft: rgba(43, 191, 138, 0.16);
|
||||
--danger: #f07167;
|
||||
--ok: #2bbf8a;
|
||||
--surface: rgba(18, 28, 26, 0.88);
|
||||
--surface-2: rgba(28, 42, 38, 0.72);
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||
--page-bg:
|
||||
radial-gradient(720px 400px at 6% -12%, rgba(20, 90, 70, .55) 0%, transparent 55%),
|
||||
radial-gradient(560px 340px at 100% 0%, rgba(30, 55, 80, .45) 0%, transparent 50%),
|
||||
radial-gradient(480px 300px at 50% 110%, rgba(18, 70, 55, .4) 0%, transparent 45%),
|
||||
linear-gradient(165deg, #0c1412 0%, #121c1a 48%, #101820 100%);
|
||||
--hero-bg: linear-gradient(160deg, rgba(28, 42, 38, .95), rgba(22, 34, 31, .9));
|
||||
--hero-on-bg: linear-gradient(160deg, rgba(24, 70, 55, .55), rgba(28, 42, 38, .95));
|
||||
--input-bg: rgba(12, 20, 18, .92);
|
||||
--chip-bg: rgba(12, 20, 18, .75);
|
||||
--row-bg: rgba(22, 34, 31, .78);
|
||||
--row-active-bg: linear-gradient(135deg, rgba(43, 191, 138, .18), rgba(28, 42, 38, .95));
|
||||
--modal-bg: #182420;
|
||||
--modal-scrim: rgba(0, 0, 0, .58);
|
||||
--soft-btn: linear-gradient(180deg, #1f3d34, #18332b);
|
||||
--soft-btn-hover: linear-gradient(180deg, #264d42, #1d3d34);
|
||||
--danger-btn: linear-gradient(180deg, #4a2a28, #3d2220);
|
||||
--danger-btn-hover: linear-gradient(180deg, #5a3230, #4a2826);
|
||||
--danger-ink: #f0a39c;
|
||||
--danger-ink-hover: #ffc4be;
|
||||
--meta-bg: rgba(12, 20, 18, .45);
|
||||
--meta-ok-bg: rgba(43, 191, 138, .14);
|
||||
--meta-err-bg: rgba(240, 113, 103, .14);
|
||||
--update-bg: linear-gradient(135deg, rgba(43,191,138,.16), rgba(22,34,31,.85));
|
||||
--shop-bg: linear-gradient(135deg, rgba(43,191,138,.12), rgba(22,34,31,.7));
|
||||
--switch-track: #3a4f48;
|
||||
--switch-knob: #e6f4ee;
|
||||
--ms-good: #4ad69a;
|
||||
--ms-mid: #e0b84a;
|
||||
--focus-ring: rgba(43,191,138,.2);
|
||||
--theme-btn-bg: rgba(22, 34, 31, .85);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -29,11 +102,12 @@
|
||||
min-height: 100%;
|
||||
font-family: Figtree, sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
|
||||
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
|
||||
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
|
||||
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
|
||||
background: var(--page-bg);
|
||||
transition: color .2s var(--ease), background .25s var(--ease);
|
||||
color-scheme: light;
|
||||
}
|
||||
html[data-theme="dark"], html[data-theme="dark"] body {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
@@ -61,7 +135,7 @@
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(13, 138, 102, 0.28);
|
||||
background: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
|
||||
background: var(--update-bg);
|
||||
animation: rise .4s var(--ease) both;
|
||||
}
|
||||
.update-banner.show { display: block; }
|
||||
@@ -105,6 +179,29 @@
|
||||
}
|
||||
.logo svg { width: 30px; height: 30px; }
|
||||
.brand-wrap { min-width: 0; flex: 1; }
|
||||
.theme-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--theme-btn-bg);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: background .15s, border-color .15s, transform .15s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.06);
|
||||
}
|
||||
.theme-btn:hover {
|
||||
border-color: rgba(13,138,102,.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.theme-btn svg { width: 18px; height: 18px; display: block; }
|
||||
.theme-btn .icon-sun { display: none; }
|
||||
.theme-btn .icon-moon { display: block; }
|
||||
html[data-theme="dark"] .theme-btn .icon-sun { display: block; }
|
||||
html[data-theme="dark"] .theme-btn .icon-moon { display: none; }
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -123,7 +220,7 @@
|
||||
font-size: .68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
text-transform: none;
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid rgba(13,138,102,.18);
|
||||
@@ -142,8 +239,7 @@
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
|
||||
background: var(--hero-bg);
|
||||
padding: 16px 16px 14px;
|
||||
margin-bottom: 14px;
|
||||
transition: border-color .25s, box-shadow .25s, background .25s;
|
||||
@@ -151,8 +247,7 @@
|
||||
.hero.on {
|
||||
border-color: rgba(13,138,102,.35);
|
||||
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
|
||||
background:
|
||||
linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
|
||||
background: var(--hero-on-bg);
|
||||
}
|
||||
.hero::after {
|
||||
content: "";
|
||||
@@ -202,7 +297,7 @@
|
||||
letter-spacing: .03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,.75);
|
||||
background: var(--chip-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
@@ -265,13 +360,13 @@
|
||||
select, input[type="text"], textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
background: var(--input-bg);
|
||||
border-radius: 14px;
|
||||
padding: 11px 13px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||
}
|
||||
textarea {
|
||||
min-height: 78px;
|
||||
@@ -282,7 +377,7 @@
|
||||
}
|
||||
select:focus, input[type="text"]:focus, textarea:focus {
|
||||
border-color: rgba(13,138,102,.5);
|
||||
box-shadow: 0 0 0 4px rgba(13,138,102,.12);
|
||||
box-shadow: 0 0 0 4px var(--focus-ring);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 44px;
|
||||
@@ -291,7 +386,7 @@
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(8, 90, 68, 0.22);
|
||||
background: linear-gradient(180deg, #d2efe4, #bfe6d6);
|
||||
background: var(--soft-btn);
|
||||
color: var(--accent-deep);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
@@ -300,19 +395,19 @@
|
||||
box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: linear-gradient(180deg, #c5e9db, #aedfcb);
|
||||
background: var(--soft-btn-hover);
|
||||
border-color: rgba(8, 90, 68, 0.38);
|
||||
color: #05553f;
|
||||
color: var(--accent-deep);
|
||||
box-shadow: 0 6px 14px rgba(8, 90, 68, 0.14);
|
||||
}
|
||||
.icon-btn.danger {
|
||||
color: #9e2a22;
|
||||
background: linear-gradient(180deg, #f8d8d4, #efc4bf);
|
||||
color: var(--danger-ink);
|
||||
background: var(--danger-btn);
|
||||
border-color: rgba(158, 42, 34, 0.28);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
background: linear-gradient(180deg, #f3c5bf, #e8aea7);
|
||||
color: #7f1f19;
|
||||
background: var(--danger-btn-hover);
|
||||
color: var(--danger-ink-hover);
|
||||
}
|
||||
.icon-btn.wide {
|
||||
width: auto;
|
||||
@@ -374,7 +469,7 @@
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,.6);
|
||||
background: var(--surface-2);
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
font-size: .9rem;
|
||||
@@ -382,7 +477,7 @@
|
||||
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute; inset: 0; background: #c7d5cf;
|
||||
position: absolute; inset: 0; background: var(--switch-track);
|
||||
border-radius: 999px; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.slider::before {
|
||||
@@ -390,7 +485,7 @@
|
||||
position: absolute;
|
||||
width: 20px; height: 20px;
|
||||
left: 3px; top: 3px;
|
||||
background: #fff;
|
||||
background: var(--switch-knob);
|
||||
border-radius: 50%;
|
||||
transition: transform .15s;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.12);
|
||||
@@ -472,7 +567,7 @@
|
||||
.server-actions .mini {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(8, 90, 68, 0.22);
|
||||
background: linear-gradient(180deg, #d2efe4, #bfe6d6);
|
||||
background: var(--soft-btn);
|
||||
color: var(--accent-deep);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
@@ -506,7 +601,7 @@
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.4);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.server-row {
|
||||
display: grid;
|
||||
@@ -516,7 +611,7 @@
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255,255,255,.72);
|
||||
background: var(--row-bg);
|
||||
cursor: pointer;
|
||||
transition: background .12s, border-color .12s, box-shadow .12s;
|
||||
text-align: left;
|
||||
@@ -526,11 +621,11 @@
|
||||
}
|
||||
.server-row:hover {
|
||||
border-color: rgba(13,138,102,.25);
|
||||
background: #fff;
|
||||
background: var(--input-bg);
|
||||
}
|
||||
.server-row.active {
|
||||
border-color: rgba(13,138,102,.4);
|
||||
background: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
|
||||
background: var(--row-active-bg);
|
||||
box-shadow: 0 4px 14px rgba(13,138,102,.1);
|
||||
}
|
||||
.server-row .left { min-width: 0; }
|
||||
@@ -573,8 +668,8 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
.server-row .ms.ok { color: var(--ok); }
|
||||
.server-row .ms.good { color: #0a7a3e; }
|
||||
.server-row .ms.mid { color: #b8860b; }
|
||||
.server-row .ms.good { color: var(--ms-good); }
|
||||
.server-row .ms.mid { color: var(--ms-mid); }
|
||||
.server-row .ms.bad { color: var(--danger); }
|
||||
.server-empty {
|
||||
padding: 14px 10px;
|
||||
@@ -600,18 +695,18 @@
|
||||
min-height: 2.4em;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,.35);
|
||||
background: var(--meta-bg);
|
||||
border: 1px solid transparent;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.meta.ok {
|
||||
color: var(--ok);
|
||||
background: rgba(216,243,233,.65);
|
||||
background: var(--meta-ok-bg);
|
||||
border-color: rgba(13,138,102,.18);
|
||||
}
|
||||
.meta.err {
|
||||
color: var(--danger);
|
||||
background: rgba(255,236,234,.75);
|
||||
background: var(--meta-err-bg);
|
||||
border-color: rgba(192,54,44,.16);
|
||||
}
|
||||
|
||||
@@ -627,7 +722,7 @@
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.6);
|
||||
background: var(--row-bg);
|
||||
font-size: .82rem;
|
||||
}
|
||||
.ping-item .ms { font-weight: 700; }
|
||||
@@ -639,7 +734,7 @@
|
||||
padding: 13px 14px 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(13, 138, 102, 0.16);
|
||||
background: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
|
||||
background: var(--shop-bg);
|
||||
}
|
||||
.shop h3 {
|
||||
font-family: Sora, sans-serif;
|
||||
@@ -680,7 +775,7 @@
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(8, 28, 24, .42);
|
||||
background: var(--modal-scrim);
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
place-items: center;
|
||||
@@ -690,12 +785,13 @@
|
||||
.modal-backdrop.open { display: grid; }
|
||||
.modal {
|
||||
width: min(400px, 100%);
|
||||
background: #f5fbf8;
|
||||
background: var(--modal-bg);
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
animation: rise .35s var(--ease) both;
|
||||
color: var(--ink);
|
||||
}
|
||||
.modal h2 {
|
||||
font-family: Sora, sans-serif;
|
||||
@@ -715,6 +811,18 @@
|
||||
.tools { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var key = "navis.theme";
|
||||
var saved = localStorage.getItem(key);
|
||||
var theme = saved === "dark" || saved === "light"
|
||||
? saved
|
||||
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
@@ -730,17 +838,29 @@
|
||||
<header class="top">
|
||||
<div class="logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 32 32" fill="none">
|
||||
<path d="M8 26V6h4.2L20 18.4V6H24v20h-4.2L12 13.6V26H8z" fill="#f4fff9"/>
|
||||
<path d="M22.5 8.2l3.2-1.1-1.1 3.2-5.6 5.6 1.6 1.6 5.6-5.6 3.2-1.1-1.1 3.2" fill="#b8f0d8" opacity=".9"/>
|
||||
<!-- Bold N -->
|
||||
<path fill="#f4fff9" d="M8 25.2V7.6h4.4l7.4 11.2V7.6H24v17.6h-4.4L12.2 14V25.2H8z"/>
|
||||
<!-- Plug prongs on left stem -->
|
||||
<rect x="9.05" y="4.2" width="1.55" height="3.6" rx=".45" fill="#f4fff9"/>
|
||||
<rect x="11.55" y="4.2" width="1.55" height="3.6" rx=".45" fill="#f4fff9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-wrap">
|
||||
<div class="brand-row">
|
||||
<h1 class="brand">Navis</h1>
|
||||
<span class="badge-ver">2.7.1</span>
|
||||
<span class="badge-ver" id="badgeVer">3.8.0</span>
|
||||
</div>
|
||||
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
||||
</div>
|
||||
<button class="theme-btn" id="themeBtn" type="button" title="Тема" aria-label="Переключить тему">
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M15.5 3.5a8.5 8.5 0 1 0 5 14.2A7 7 0 1 1 15.5 3.5Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.2 5.2l1.6 1.6M17.2 17.2l1.6 1.6M18.8 5.2l-1.6 1.6M6.8 17.2l-1.6 1.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="hero" id="hero">
|
||||
@@ -890,8 +1010,9 @@
|
||||
<input id="newName" type="text" placeholder="Server EU" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="newProxy">Ссылка</label>
|
||||
<input id="newProxy" type="text" placeholder="vless:// · vmess:// · trojan:// · hy2 · naive · awg" />
|
||||
<label class="field" for="newProxy">Ссылка / конфиг</label>
|
||||
<textarea id="newProxy" rows="4" placeholder="vpn:// · awg:// · vless:// · hy2:// · naive · или текст [Interface]" style="min-height:88px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem"></textarea>
|
||||
<p class="meta" id="modalErr" style="display:none;margin-top:8px;min-height:0"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -912,11 +1033,14 @@
|
||||
if (typeof window.getState === "function") return;
|
||||
window.__navisHttp = true;
|
||||
async function call(name, args) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (window.__NAVIS_TOKEN__) headers["X-Navis-Token"] = window.__NAVIS_TOKEN__;
|
||||
const r = await fetch("/api/" + name, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: headers,
|
||||
body: JSON.stringify({ args: args || [] })
|
||||
});
|
||||
if (r.status === 401) throw "unauthorized";
|
||||
const j = await r.json();
|
||||
if (j && j.error) throw j.error;
|
||||
return j ? j.result : null;
|
||||
@@ -980,6 +1104,25 @@
|
||||
const heroHint = $("heroHint");
|
||||
const SHOP_URL = "https://evilfox.win/";
|
||||
const AUTO_BEST_KEY = "navis.autoBest";
|
||||
const THEME_KEY = "navis.theme";
|
||||
|
||||
(function initThemeToggle() {
|
||||
const btn = $("themeBtn");
|
||||
if (!btn) return;
|
||||
const apply = (theme) => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
|
||||
btn.title = theme === "dark" ? "Светлая тема" : "Тёмная тема";
|
||||
btn.setAttribute("aria-label", btn.title);
|
||||
};
|
||||
btn.addEventListener("click", () => {
|
||||
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
apply(cur === "dark" ? "light" : "dark");
|
||||
});
|
||||
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
btn.title = cur === "dark" ? "Светлая тема" : "Тёмная тема";
|
||||
btn.setAttribute("aria-label", btn.title);
|
||||
})();
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(AUTO_BEST_KEY);
|
||||
@@ -1192,6 +1335,8 @@
|
||||
|
||||
function renderUpdate(u, version) {
|
||||
verLabel.textContent = "Navis v" + (version || "?");
|
||||
const badge = $("badgeVer");
|
||||
if (badge && version) badge.textContent = version;
|
||||
if (!u) {
|
||||
updateBanner.classList.remove("show");
|
||||
return;
|
||||
@@ -1376,6 +1521,8 @@
|
||||
addBtn.addEventListener("click", () => {
|
||||
$("newName").value = "";
|
||||
$("newProxy").value = "";
|
||||
const me = $("modalErr");
|
||||
if (me) { me.style.display = "none"; me.textContent = ""; me.className = "meta"; }
|
||||
modal.classList.add("open");
|
||||
$("newName").focus();
|
||||
});
|
||||
@@ -1383,17 +1530,32 @@
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) modal.classList.remove("open");
|
||||
});
|
||||
function sanitizeShare(s) {
|
||||
return String(s || "")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF\u2066-\u2069\u200E\u200F\u202A-\u202E]/g, "")
|
||||
.trim();
|
||||
}
|
||||
$("createNew").addEventListener("click", () => withBusy(async () => {
|
||||
const me = $("modalErr");
|
||||
const showModalErr = (msg) => {
|
||||
if (!me) { setMeta(String(msg), "err"); return; }
|
||||
me.style.display = "block";
|
||||
me.className = "meta err";
|
||||
me.textContent = String(msg);
|
||||
};
|
||||
try {
|
||||
const n = $("newName").value.trim();
|
||||
const p = $("newProxy").value.trim();
|
||||
const p = sanitizeShare($("newProxy").value);
|
||||
if (!n) throw "Укажите название профиля";
|
||||
if (!p) throw "Вставьте ссылку или конфиг Amnezia/AWG";
|
||||
$("newProxy").value = p;
|
||||
await createProfile(n, p, !!sysproxy.checked);
|
||||
modal.classList.remove("open");
|
||||
if (me) { me.style.display = "none"; me.textContent = ""; }
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Профиль создан", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
} catch (e) { showModalErr(e); }
|
||||
}));
|
||||
|
||||
async function openShop(e) {
|
||||
@@ -1484,9 +1646,7 @@
|
||||
(async () => {
|
||||
try {
|
||||
await refresh({ syncForm: true });
|
||||
if (profiles.length > 1 && autoBest.checked && !connected) {
|
||||
await withBusy(async () => { await runBest(true); });
|
||||
}
|
||||
// Auto-best connect is opt-in via checkbox only — never on first paint.
|
||||
} catch (e) {
|
||||
setMeta(String(e), "err");
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Protocol identifies a VPN/proxy backend.
|
||||
@@ -142,6 +144,26 @@ func lastIndex(s, substr string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the config (JSON round-trip).
|
||||
func (c *Config) Clone() *Config {
|
||||
if c == nil {
|
||||
return &Config{}
|
||||
}
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
out := *c
|
||||
out.Profiles = append([]Profile(nil), c.Profiles...)
|
||||
return &out
|
||||
}
|
||||
var out Config
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
cp := *c
|
||||
cp.Profiles = append([]Profile(nil), c.Profiles...)
|
||||
return &cp
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// Load reads and validates a config file.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -192,10 +214,47 @@ func (c *Config) Validate() error {
|
||||
default:
|
||||
return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol)
|
||||
}
|
||||
for _, listen := range p.Listen {
|
||||
if err := validateListenURI(listen); err != nil {
|
||||
return fmt.Errorf("config: profile %q: %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListenURI(uri string) error {
|
||||
uri = strings.TrimSpace(uri)
|
||||
if uri == "" {
|
||||
return nil
|
||||
}
|
||||
var hostPort string
|
||||
var ok bool
|
||||
for _, scheme := range []string{"http", "socks", "socks5", "https"} {
|
||||
if hostPort, ok = parseListenHostPort(uri, scheme); ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
lower := strings.ToLower(uri)
|
||||
if strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, "localhost") || strings.Contains(lower, "[::1]") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("listen %q must bind to loopback", uri)
|
||||
}
|
||||
host, _, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
host = hostPort
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
switch strings.ToLower(host) {
|
||||
case "127.0.0.1", "localhost", "::1", "":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("listen host %q is not loopback (only 127.0.0.1 / ::1)", host)
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveProfile returns the selected profile.
|
||||
func (c *Config) ActiveProfile() (*Profile, error) {
|
||||
for i := range c.Profiles {
|
||||
|
||||
@@ -4,6 +4,7 @@ import "strings"
|
||||
|
||||
// DetectProtocol infers protocol from a share/proxy URI or config body.
|
||||
func DetectProtocol(raw string) Protocol {
|
||||
raw = stripShareNoise(raw)
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
|
||||
@@ -29,3 +30,22 @@ func DetectProtocol(raw string) Protocol {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func stripShareNoise(raw string) string {
|
||||
raw = strings.TrimPrefix(raw, "\ufeff")
|
||||
var b strings.Builder
|
||||
for _, r := range raw {
|
||||
switch r {
|
||||
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
|
||||
'\u2066', '\u2067', '\u2068', '\u2069',
|
||||
'\u200e', '\u200f',
|
||||
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e':
|
||||
continue
|
||||
}
|
||||
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
@@ -29,6 +29,17 @@ func (c *Config) ListProfiles() []ProfileInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
// RedactProfileList clears Proxy URIs from a profile list (host/protocol remain).
|
||||
// Use for periodic UI polls so credentials are not echoed for every profile.
|
||||
func RedactProfileList(in []ProfileInfo) []ProfileInfo {
|
||||
out := make([]ProfileInfo, len(in))
|
||||
for i, p := range in {
|
||||
out[i] = p
|
||||
out[i].Proxy = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func proxyHost(proxy string) string {
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
@@ -79,7 +90,17 @@ func (c *Config) UpsertProfile(name, proxy string) error {
|
||||
}
|
||||
|
||||
// UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol.
|
||||
// The upserted profile becomes Active.
|
||||
func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) error {
|
||||
return c.upsertProfile(name, proxy, proto, true)
|
||||
}
|
||||
|
||||
// UpsertProfileKeepActive creates/updates a profile without changing Active.
|
||||
func (c *Config) UpsertProfileKeepActive(name, proxy string, proto Protocol) error {
|
||||
return c.upsertProfile(name, proxy, proto, false)
|
||||
}
|
||||
|
||||
func (c *Config) upsertProfile(name, proxy string, proto Protocol, activate bool) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("имя профиля пустое")
|
||||
@@ -99,7 +120,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
|
||||
if len(c.Profiles[i].Listen) == 0 {
|
||||
c.Profiles[i].Listen = defaultListen()
|
||||
}
|
||||
c.Active = name
|
||||
if activate {
|
||||
c.Active = name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -110,7 +133,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
|
||||
Proxy: proxy,
|
||||
Listen: defaultListen(),
|
||||
})
|
||||
c.Active = name
|
||||
if activate {
|
||||
c.Active = name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+108
-31
@@ -33,6 +33,8 @@ type Manager struct {
|
||||
profile *config.Profile
|
||||
stderr io.Writer
|
||||
binDir string
|
||||
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
|
||||
lastStop sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||
@@ -43,22 +45,50 @@ func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{
|
||||
m := &Manager{
|
||||
cfgPath: cfgPath,
|
||||
cfg: cfg,
|
||||
sys: sysproxy.New(),
|
||||
stderr: stderr,
|
||||
binDir: binDir,
|
||||
}, nil
|
||||
}
|
||||
m.RecoverSystemProxy()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// RecoverSystemProxy clears a system proxy left on after an unclean exit.
|
||||
func (m *Manager) RecoverSystemProxy() {
|
||||
if m == nil || m.sys == nil {
|
||||
return
|
||||
}
|
||||
if !sysproxy.HasSentinel(m.cfgPath) {
|
||||
return
|
||||
}
|
||||
if err := m.sys.ForceDisable(); err != nil {
|
||||
fmt.Fprintf(m.stderr, "recover system proxy: %v\n", err)
|
||||
}
|
||||
sysproxy.ClearSentinel(m.cfgPath)
|
||||
}
|
||||
|
||||
func (m *Manager) waitEngineStopped() {
|
||||
m.lastStop.Wait()
|
||||
}
|
||||
|
||||
func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
// Wait outside the lock so a slow Disconnect can finish freeing :1080/:1081.
|
||||
m.waitEngineStopped()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("already connected; disconnect first")
|
||||
}
|
||||
if m.engine != nil {
|
||||
// Defunct reference (stop finished or engine died) — drop before start.
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
}
|
||||
|
||||
if profileName != "" {
|
||||
m.cfg.Active = profileName
|
||||
@@ -90,6 +120,8 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("enable system proxy: %w", err)
|
||||
}
|
||||
} else {
|
||||
sysproxy.WriteSentinel(m.cfgPath, httpProxy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,24 +136,41 @@ func (m *Manager) Disconnect() error {
|
||||
engine := m.engine
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
if engine != nil {
|
||||
m.lastStop.Add(1)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
var first error
|
||||
if sys != nil && sys.Enabled() {
|
||||
if err := sys.Disable(); err != nil && first == nil {
|
||||
first = err
|
||||
if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(m.cfgPath)) {
|
||||
var err error
|
||||
if sys.Enabled() {
|
||||
err = sys.Disable()
|
||||
} else {
|
||||
err = sys.ForceDisable()
|
||||
}
|
||||
if err != nil {
|
||||
if err2 := sys.ForceDisable(); err2 != nil && first == nil {
|
||||
first = err2
|
||||
} else if first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
sysproxy.ClearSentinel(m.cfgPath)
|
||||
}
|
||||
if engine != nil {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- engine.Stop() }()
|
||||
go func() {
|
||||
defer m.lastStop.Done()
|
||||
done <- engine.Stop()
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
// Engine Stop can block on userspace relays; don't freeze the UI.
|
||||
// Stop continues in background; Connect will wait on lastStop.
|
||||
if first == nil {
|
||||
first = fmt.Errorf("отключение заняло слишком много времени")
|
||||
}
|
||||
@@ -207,10 +256,14 @@ func (m *Manager) BinDir() string { return m.binDir }
|
||||
|
||||
func (m *Manager) ConfigPath() string { return m.cfgPath }
|
||||
|
||||
// Config returns a deep snapshot for UI reads. Mutations must go through Manager methods.
|
||||
func (m *Manager) Config() *config.Config {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg
|
||||
if m.cfg == nil {
|
||||
return &config.Config{}
|
||||
}
|
||||
return m.cfg.Clone()
|
||||
}
|
||||
|
||||
func (m *Manager) SetSystemProxy(enabled bool) {
|
||||
@@ -254,8 +307,13 @@ func (m *Manager) SetActiveProfile(name string) error {
|
||||
func (m *Manager) SaveProfile(name, proxy string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("сначала отключитесь")
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("имя профиля пустое")
|
||||
}
|
||||
connected := m.engine != nil && m.engine.Running()
|
||||
if connected && name == m.cfg.Active {
|
||||
return fmt.Errorf("сначала отключитесь, чтобы изменить активный профиль")
|
||||
}
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
var proto config.Protocol
|
||||
@@ -267,7 +325,13 @@ func (m *Manager) SaveProfile(name, proxy string) error {
|
||||
proxy = normalized
|
||||
proto = detected
|
||||
}
|
||||
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
|
||||
var err error
|
||||
if connected {
|
||||
err = m.cfg.UpsertProfileKeepActive(name, proxy, proto)
|
||||
} else {
|
||||
err = m.cfg.UpsertProfileWithProtocol(name, proxy, proto)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p, err := m.cfg.ActiveProfile(); err == nil {
|
||||
@@ -507,26 +571,39 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
|
||||
|
||||
// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded).
|
||||
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||
type item struct {
|
||||
key string
|
||||
path string
|
||||
err error
|
||||
}
|
||||
jobs := []struct {
|
||||
key string
|
||||
fn func() (string, error)
|
||||
}{
|
||||
{"naive", func() (string, error) { return naive.EnsureBinary(m.binDir) }},
|
||||
{"hysteria2", func() (string, error) { return hysteria2.EnsureBinary(m.binDir) }},
|
||||
{"awg", func() (string, error) { return awg.EnsureBinary(m.binDir) }},
|
||||
{"xray", func() (string, error) { return xray.EnsureBinary(m.binDir) }},
|
||||
}
|
||||
ch := make(chan item, len(jobs))
|
||||
for _, j := range jobs {
|
||||
j := j
|
||||
go func() {
|
||||
path, err := j.fn()
|
||||
ch <- item{key: j.key, path: path, err: err}
|
||||
}()
|
||||
}
|
||||
out := map[string]string{}
|
||||
p1, err := naive.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("naive: %w", err)
|
||||
var first error
|
||||
for range jobs {
|
||||
it := <-ch
|
||||
if it.err != nil {
|
||||
if first == nil {
|
||||
first = fmt.Errorf("%s: %w", it.key, it.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out[it.key] = it.path
|
||||
}
|
||||
out["naive"] = p1
|
||||
p2, err := hysteria2.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("hysteria2: %w", err)
|
||||
}
|
||||
out["hysteria2"] = p2
|
||||
p3, err := awg.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("awg: %w", err)
|
||||
}
|
||||
out["awg"] = p3
|
||||
p4, err := xray.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("xray: %w", err)
|
||||
}
|
||||
out["xray"] = p4
|
||||
return out, nil
|
||||
return out, first
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
type fakeEngine struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopDelay time.Duration
|
||||
stops atomic.Int32
|
||||
}
|
||||
|
||||
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
|
||||
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.running = true
|
||||
return nil
|
||||
}
|
||||
func (e *fakeEngine) Stop() error {
|
||||
e.stops.Add(1)
|
||||
if e.stopDelay > 0 {
|
||||
time.Sleep(e.stopDelay)
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.running = false
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (e *fakeEngine) Running() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.running
|
||||
}
|
||||
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
|
||||
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
|
||||
|
||||
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "t",
|
||||
SystemProxy: false,
|
||||
BinDir: t.TempDir(),
|
||||
Profiles: []config.Profile{{
|
||||
Name: "t",
|
||||
Protocol: config.ProtocolNaive,
|
||||
Proxy: "https://u:p@example.com",
|
||||
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
|
||||
}},
|
||||
}
|
||||
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
|
||||
m.mu.Lock()
|
||||
m.engine = eng
|
||||
m.profile = &cfg.Profiles[0]
|
||||
m.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
_ = m.Disconnect()
|
||||
// Connect must wait for Stop to finish even if Disconnect returned early...
|
||||
// our Disconnect waits up to 3s, so with 200ms it should complete.
|
||||
if time.Since(start) < 150*time.Millisecond {
|
||||
t.Fatalf("disconnect returned too fast")
|
||||
}
|
||||
if eng.Running() {
|
||||
t.Fatal("engine still running")
|
||||
}
|
||||
|
||||
// Simulate slow stop that outlives Disconnect timeout.
|
||||
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
|
||||
m.mu.Lock()
|
||||
m.engine = eng2
|
||||
m.profile = &cfg.Profiles[0]
|
||||
m.mu.Unlock()
|
||||
|
||||
// Force short path: call the wait mechanism like Connect does.
|
||||
m.mu.Lock()
|
||||
engine := m.engine
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
m.lastStop.Add(1)
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
defer m.lastStop.Done()
|
||||
_ = engine.Stop()
|
||||
}()
|
||||
|
||||
waitStart := time.Now()
|
||||
m.waitEngineStopped()
|
||||
if time.Since(waitStart) < 400*time.Millisecond {
|
||||
t.Fatalf("waitEngineStopped returned before stop finished")
|
||||
}
|
||||
if eng2.Running() {
|
||||
t.Fatal("engine2 still running after wait")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigReturnsClone(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "a",
|
||||
Profiles: []config.Profile{{
|
||||
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
|
||||
}},
|
||||
}
|
||||
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := m.Config()
|
||||
snap.Active = "mutated"
|
||||
snap.Profiles[0].Proxy = "leaked"
|
||||
if m.cfg.Active != "a" {
|
||||
t.Fatal("Config() leaked mutable Active")
|
||||
}
|
||||
if m.cfg.Profiles[0].Proxy != "https://x" {
|
||||
t.Fatal("Config() leaked mutable Profile")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package coredl
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
|
||||
type ReleaseAsset struct {
|
||||
Name string
|
||||
BrowserDownloadURL string
|
||||
SHA256 string // lowercase hex, empty if unknown
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// FetchLatestAssets loads assets for a repo's latest GitHub release.
|
||||
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
out := make([]ReleaseAsset, 0, len(rel.Assets))
|
||||
for _, a := range rel.Assets {
|
||||
out = append(out, ReleaseAsset{
|
||||
Name: a.Name,
|
||||
BrowserDownloadURL: a.BrowserDownloadURL,
|
||||
SHA256: parseDigestSHA256(a.Digest),
|
||||
})
|
||||
}
|
||||
return rel.TagName, out, nil
|
||||
}
|
||||
|
||||
func parseDigestSHA256(digest string) string {
|
||||
digest = strings.TrimSpace(strings.ToLower(digest))
|
||||
if digest == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(digest, "sha256:") {
|
||||
return strings.TrimPrefix(digest, "sha256:")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DownloadFile writes url to path (size-capped).
|
||||
func DownloadFile(path, url string, maxBytes int64) error {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 120 << 20
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
|
||||
return err
|
||||
}
|
||||
|
||||
// FileSHA256 returns lowercase hex digest of path.
|
||||
func FileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
|
||||
func VerifySHA256(path, want string) error {
|
||||
want = strings.TrimSpace(strings.ToLower(want))
|
||||
if want == "" {
|
||||
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
|
||||
}
|
||||
got, err := FileSHA256(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got != want {
|
||||
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireSHA returns sha or an error if the asset has none.
|
||||
func RequireSHA(a ReleaseAsset) (string, error) {
|
||||
if a.SHA256 != "" {
|
||||
return a.SHA256, nil
|
||||
}
|
||||
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
|
||||
}
|
||||
|
||||
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
|
||||
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
|
||||
if a.SHA256 != "" {
|
||||
return a.SHA256, nil
|
||||
}
|
||||
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
|
||||
for _, name := range companions {
|
||||
for _, o := range all {
|
||||
if !strings.EqualFold(o.Name, name) {
|
||||
continue
|
||||
}
|
||||
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sum != "" {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return RequireSHA(a)
|
||||
}
|
||||
|
||||
func fetchChecksumFile(url, assetName string) (string, error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("checksum download: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := string(body)
|
||||
lowerName := strings.ToLower(assetName)
|
||||
lines := strings.Split(text, "\n")
|
||||
var shaLine string
|
||||
for _, line := range lines {
|
||||
l := strings.TrimSpace(strings.ToLower(line))
|
||||
if strings.HasPrefix(l, "sha256:") {
|
||||
shaLine = strings.TrimSpace(line[len("sha256:"):])
|
||||
// keep scanning — prefer line that also mentions the asset
|
||||
if strings.Contains(l, lowerName) {
|
||||
return strings.ToLower(strings.Fields(shaLine)[0]), nil
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
sum := strings.ToLower(fields[0])
|
||||
file := strings.TrimPrefix(fields[len(fields)-1], "*")
|
||||
if len(sum) == 64 && strings.EqualFold(file, assetName) {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if shaLine != "" {
|
||||
parts := strings.Fields(shaLine)
|
||||
if len(parts) > 0 && len(parts[0]) == 64 {
|
||||
return strings.ToLower(parts[0]), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// DownloadAndVerify downloads url to path and checks sha256.
|
||||
func DownloadAndVerify(path, url, wantSHA256 string) error {
|
||||
if err := DownloadFile(path, url, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := VerifySHA256(path, wantSHA256); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package coredl
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDigestAndVerify(t *testing.T) {
|
||||
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
|
||||
t.Fatalf("parse: %q", got)
|
||||
}
|
||||
if parseDigestSHA256("md5:x") != "" {
|
||||
t.Fatal("expected empty for non-sha256")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package linknorm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/protocols/awg"
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
|
||||
// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect).
|
||||
func Normalize(proto config.Protocol, raw string) (normalized string, detected config.Protocol, remark string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = SanitizeShareText(raw)
|
||||
if raw == "" {
|
||||
return "", proto, "", fmt.Errorf("пустая ссылка")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package linknorm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// SanitizeShareText strips BOM, bidi isolates and zero-width chars that break
|
||||
// scheme detection (common when pasting from messengers / Amnezia share).
|
||||
func SanitizeShareText(raw string) string {
|
||||
raw = strings.TrimPrefix(raw, "\ufeff")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(raw))
|
||||
for _, r := range raw {
|
||||
switch r {
|
||||
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', // zero-width / BOM
|
||||
'\u2066', '\u2067', '\u2068', '\u2069', // LRI/RLI/FSI/PDI
|
||||
'\u200e', '\u200f', // LRM/RLM
|
||||
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e': // embedding overrides
|
||||
continue
|
||||
}
|
||||
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
|
||||
continue
|
||||
}
|
||||
if unicode.Is(unicode.Cf, r) { // other format controls
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package linknorm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeShareText_BidiWrappers(t *testing.T) {
|
||||
raw := "\u2068vpn://abc\u2069"
|
||||
got := SanitizeShareText(raw)
|
||||
if got != "vpn://abc" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_DirtyVPN(t *testing.T) {
|
||||
raw := "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxLjEuMS4xClByaXZhdGVLZXkgPSBhSkpSYUFFY1RidWdJYjB2emFJcW5MQkZJQ0dqNlRkcXo1RC84OU13T0hZPQpKYyA9IDYKCltQZWVyXQpQdWJsaWNLZXkgPSBOUThJMVpPcUtxWmhGUXlxcXBuakRWc1poWXpkUk11Qm9tVXBzNWZPTTNzPQpFbmRwb2ludCA9IHJ1LmRlNGltYS51azo0MTQyMQpBbGxvd2VkSVBzID0gMC4wLjAuMC8w\u2069"
|
||||
// Use full user link instead for reliable parse
|
||||
raw = "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1\u2069"
|
||||
u, proto, _, err := Normalize("", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proto != "awg" {
|
||||
t.Fatalf("proto %q", proto)
|
||||
}
|
||||
if u == "" || !containsFold(u, "[Interface]") {
|
||||
t.Fatalf("normalized %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func containsFold(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
|
||||
(len(s) > 0 && (stringIndexFold(s, sub) >= 0)))
|
||||
}
|
||||
|
||||
func stringIndexFold(s, sub string) int {
|
||||
// simple contains for test
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -212,7 +212,8 @@ func probeUDP(ctx context.Context, host, port string) error {
|
||||
return nil
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return nil
|
||||
// UDP is connectionless: no reply does NOT mean the port is open.
|
||||
return fmt.Errorf("udp timeout")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ type Conf struct {
|
||||
|
||||
// Detect reports whether raw looks like an AWG/WireGuard config or share link.
|
||||
func Detect(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = sanitizeShare(raw)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func Detect(raw string) bool {
|
||||
|
||||
// Parse accepts a WireGuard/AWG .conf body, awg:// URI, vpn:// blob, or Amnezia JSON.
|
||||
func Parse(raw string) (Conf, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = sanitizeShare(raw)
|
||||
if raw == "" {
|
||||
return Conf{}, fmt.Errorf("пустой AWG конфиг")
|
||||
}
|
||||
@@ -83,6 +83,26 @@ func Parse(raw string) (Conf, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeShare(raw string) string {
|
||||
raw = strings.TrimPrefix(raw, "\ufeff")
|
||||
var b strings.Builder
|
||||
b.Grow(len(raw))
|
||||
for _, r := range raw {
|
||||
switch r {
|
||||
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
|
||||
'\u2066', '\u2067', '\u2068', '\u2069',
|
||||
'\u200e', '\u200f',
|
||||
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e':
|
||||
continue
|
||||
}
|
||||
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func parseINI(raw string) (Conf, error) {
|
||||
var c Conf
|
||||
section := "interface" // treat leading keys before [Interface] as Interface
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package awg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseUserVPNLink(t *testing.T) {
|
||||
raw := `vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1`
|
||||
c, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if c.Endpoint != "ru.de4ima.uk:41421" {
|
||||
t.Fatalf("endpoint %q", c.Endpoint)
|
||||
}
|
||||
if !strings.Contains(c.H1, "-") {
|
||||
t.Fatalf("expected range H1, got %q", c.H1)
|
||||
}
|
||||
u, rem, err := NormalizeShareLink(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize: %v", err)
|
||||
}
|
||||
t.Logf("remark=%q normalized_len=%d h1=%q", rem, len(u), c.H1)
|
||||
|
||||
// invisible unicode wrappers often come from mobile share / chat paste
|
||||
dirty := "\u2068" + raw + "\u2069\u2005"
|
||||
if !Detect(dirty) {
|
||||
t.Fatal("Detect should accept dirty bidi-wrapped vpn://")
|
||||
}
|
||||
_, _, err = NormalizeShareLink(dirty)
|
||||
if err != nil {
|
||||
t.Fatalf("dirty NormalizeShareLink: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/coredl"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
|
||||
@@ -72,7 +70,11 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
|
||||
name, url, err := findReleaseAsset(want)
|
||||
asset, all, err := findReleaseAsset(want)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum, err := coredl.ResolveSHA(asset, all)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -81,8 +83,8 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
destName = "hysteria.exe"
|
||||
}
|
||||
dest := filepath.Join(binDir, destName)
|
||||
tmp := filepath.Join(binDir, name+".download")
|
||||
if err := downloadFile(tmp, url); err != nil {
|
||||
tmp := filepath.Join(binDir, asset.Name+".download")
|
||||
if err := coredl.DownloadAndVerify(tmp, asset.BrowserDownloadURL, sum); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Remove(dest)
|
||||
@@ -114,73 +116,27 @@ func releaseAssetName() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return coredl.ReleaseAsset{}, nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Prefer exact non-avx name; fall back to avx variant on Windows.
|
||||
var fallback string
|
||||
var fallbackURL string
|
||||
for _, a := range rel.Assets {
|
||||
var fallback coredl.ReleaseAsset
|
||||
for _, a := range assets {
|
||||
if a.Name == exactName {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
return a, assets, nil
|
||||
}
|
||||
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
|
||||
fallback, fallbackURL = a.Name, a.BrowserDownloadURL
|
||||
fallback = a
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback, fallbackURL, nil
|
||||
if fallback.Name != "" {
|
||||
return fallback, assets, nil
|
||||
}
|
||||
// Prefix match for versioned names
|
||||
for _, a := range rel.Assets {
|
||||
for _, a := range assets {
|
||||
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
return a, assets, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in hysteria release", exactName)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,15 @@ package naive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/coredl"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
|
||||
@@ -51,12 +50,16 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
|
||||
assetName, url, err := findReleaseAsset(pattern)
|
||||
asset, all, err := findReleaseAsset(pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
archivePath := filepath.Join(binDir, assetName)
|
||||
if err := downloadFile(archivePath, url); err != nil {
|
||||
sum, err := coredl.ResolveSHA(asset, all)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
archivePath := filepath.Join(binDir, asset.Name+".download")
|
||||
if err := coredl.DownloadAndVerify(archivePath, asset.BrowserDownloadURL, sum); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(archivePath)
|
||||
@@ -88,52 +91,26 @@ func assetNameForPlatform() (string, error) {
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
||||
return "naiveproxy-*-linux-x64.zip", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
|
||||
// Newer releases use mac-x64-x64; older used mac-x64.
|
||||
return "naiveproxy-*-mac-x64", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
|
||||
// Newer releases use mac-arm64-arm64; older used mac-arm64.
|
||||
return "naiveproxy-*-mac-arm64", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
func findReleaseAsset(pattern string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "vpnclient-go")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
return coredl.ReleaseAsset{}, nil, err
|
||||
}
|
||||
prefix := strings.Split(pattern, "*")[0]
|
||||
needle := ""
|
||||
if parts := strings.SplitN(pattern, "*", 2); len(parts) == 2 {
|
||||
needle = parts[1]
|
||||
}
|
||||
var bestName, bestURL string
|
||||
for _, a := range rel.Assets {
|
||||
var best coredl.ReleaseAsset
|
||||
for _, a := range assets {
|
||||
name := a.Name
|
||||
if strings.Contains(name, "plugin") {
|
||||
continue
|
||||
@@ -144,41 +121,22 @@ func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
|
||||
if needle != "" && !strings.Contains(name, needle) {
|
||||
continue
|
||||
}
|
||||
// Prefer desktop archives over openwrt/apk.
|
||||
if strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".tar.xz") || strings.HasSuffix(name, ".txz") {
|
||||
return name, a.BrowserDownloadURL, nil
|
||||
return a, assets, nil
|
||||
}
|
||||
if bestName == "" {
|
||||
bestName, bestURL = name, a.BrowserDownloadURL
|
||||
if best.Name == "" {
|
||||
best = a
|
||||
}
|
||||
}
|
||||
if bestName != "" {
|
||||
return bestName, bestURL, nil
|
||||
if best.Name != "" {
|
||||
return best, assets, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset matching %s in release %s", pattern, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset matching %s in naive release", pattern)
|
||||
}
|
||||
|
||||
func extractNaiveArchive(archivePath, destDir string) error {
|
||||
lower := strings.ToLower(archivePath)
|
||||
lower = strings.TrimSuffix(lower, ".download")
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
return unzipNaive(archivePath, destDir)
|
||||
|
||||
@@ -2,16 +2,15 @@ package xray
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/coredl"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
|
||||
@@ -56,13 +55,17 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
|
||||
name, url, err := findReleaseAsset(want)
|
||||
asset, all, err := findReleaseAsset(want)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zipPath := filepath.Join(binDir, name+".download")
|
||||
sum, err := coredl.ResolveSHA(asset, all)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zipPath := filepath.Join(binDir, asset.Name+".download")
|
||||
_ = os.Remove(zipPath)
|
||||
if err := downloadFile(zipPath, url); err != nil {
|
||||
if err := coredl.DownloadAndVerify(zipPath, asset.BrowserDownloadURL, sum); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(zipPath)
|
||||
@@ -73,7 +76,6 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
}
|
||||
dest := filepath.Join(binDir, destName)
|
||||
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
|
||||
// Some zips nest the binary; try case-insensitive match.
|
||||
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
|
||||
return "", fmt.Errorf("%v; %w", err, err2)
|
||||
}
|
||||
@@ -104,65 +106,17 @@ func releaseZipName() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return coredl.ReleaseAsset{}, nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, a := range rel.Assets {
|
||||
if a.Name == exactName {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
for _, a := range assets {
|
||||
if a.Name == exactName || strings.EqualFold(a.Name, exactName) {
|
||||
return a, assets, nil
|
||||
}
|
||||
}
|
||||
for _, a := range rel.Assets {
|
||||
if strings.EqualFold(a.Name, exactName) {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 15 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
||||
return err
|
||||
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in xray release", exactName)
|
||||
}
|
||||
|
||||
func extractNamedFromZip(zipPath, wantName, dest string) error {
|
||||
|
||||
@@ -27,7 +27,6 @@ type Item struct {
|
||||
|
||||
var (
|
||||
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
|
||||
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
|
||||
)
|
||||
|
||||
// Fetch downloads a subscription body and parses proxy share links.
|
||||
@@ -37,8 +36,8 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
|
||||
return nil, fmt.Errorf("пустой URL подписки")
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return nil, fmt.Errorf("нужен http(s) URL подписки")
|
||||
if err != nil || u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("нужен https URL подписки")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
@@ -219,7 +218,6 @@ func extractClashHY2(text string) []string {
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
_ = reClashHY2 // kept for possible future tightening
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,16 @@ func (c *darwinController) Disable() error {
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
services := c.services
|
||||
return c.disableServices(c.services)
|
||||
}
|
||||
|
||||
func (c *darwinController) ForceDisable() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.disableServices(nil)
|
||||
}
|
||||
|
||||
func (c *darwinController) disableServices(services []string) error {
|
||||
if len(services) == 0 {
|
||||
var err error
|
||||
services, err = listNetworkServices()
|
||||
|
||||
@@ -9,3 +9,4 @@ func newPlatform() Controller { return stubController{} }
|
||||
func (stubController) Enable(string) error { return ErrUnsupported }
|
||||
func (stubController) Disable() error { return nil }
|
||||
func (stubController) Enabled() bool { return false }
|
||||
func (stubController) ForceDisable() error { return nil }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package sysproxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SentinelPath is written while Navis owns the OS system proxy.
|
||||
// Survives crash/kill so the next launch can clear a stuck proxy.
|
||||
func SentinelPath(cfgPath string) string {
|
||||
dir := filepath.Dir(strings.TrimSpace(cfgPath))
|
||||
if dir == "" || dir == "." {
|
||||
dir, _ = os.UserConfigDir()
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
dir = filepath.Join(dir, "Navis")
|
||||
}
|
||||
return filepath.Join(dir, ".navis-proxy-on")
|
||||
}
|
||||
|
||||
// WriteSentinel marks that system proxy was enabled by Navis.
|
||||
func WriteSentinel(cfgPath, httpHostPort string) {
|
||||
path := SentinelPath(cfgPath)
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
_ = os.WriteFile(path, []byte(strings.TrimSpace(httpHostPort)+"\n"), 0o600)
|
||||
}
|
||||
|
||||
// ClearSentinel removes the ownership marker after a clean Disable.
|
||||
func ClearSentinel(cfgPath string) {
|
||||
_ = os.Remove(SentinelPath(cfgPath))
|
||||
}
|
||||
|
||||
// HasSentinel reports whether a previous session left the proxy marked on.
|
||||
func HasSentinel(cfgPath string) bool {
|
||||
_, err := os.Stat(SentinelPath(cfgPath))
|
||||
return err == nil
|
||||
}
|
||||
@@ -7,6 +7,9 @@ type Controller interface {
|
||||
Enable(httpHostPort string) error
|
||||
Disable() error
|
||||
Enabled() bool
|
||||
// ForceDisable turns off proxy settings even if this process did not Enable them
|
||||
// (used to recover after crash/kill when a sentinel file remains).
|
||||
ForceDisable() error
|
||||
}
|
||||
|
||||
// New returns a platform-specific controller.
|
||||
|
||||
@@ -5,6 +5,7 @@ package sysproxy
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
@@ -16,6 +17,7 @@ const (
|
||||
)
|
||||
|
||||
type windowsController struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
hadProxy bool
|
||||
oldEnable uint64
|
||||
@@ -27,9 +29,15 @@ func newPlatform() Controller {
|
||||
return &windowsController{}
|
||||
}
|
||||
|
||||
func (c *windowsController) Enabled() bool { return c.enabled }
|
||||
func (c *windowsController) Enabled() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.enabled
|
||||
}
|
||||
|
||||
func (c *windowsController) Enable(httpHostPort string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if httpHostPort == "" {
|
||||
return fmt.Errorf("sysproxy: empty http proxy address")
|
||||
}
|
||||
@@ -92,9 +100,21 @@ func (c *windowsController) Enable(httpHostPort string) error {
|
||||
}
|
||||
|
||||
func (c *windowsController) Disable() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
return c.disableLocked(true)
|
||||
}
|
||||
|
||||
func (c *windowsController) ForceDisable() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.disableLocked(false)
|
||||
}
|
||||
|
||||
func (c *windowsController) disableLocked(restoreSaved bool) error {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER,
|
||||
`Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
|
||||
registry.QUERY_VALUE|registry.SET_VALUE)
|
||||
@@ -103,7 +123,7 @@ func (c *windowsController) Disable() error {
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if c.hadProxy {
|
||||
if restoreSaved && c.hadProxy {
|
||||
_ = key.SetDWordValue("ProxyEnable", uint32(c.oldEnable))
|
||||
if c.oldServer != "" {
|
||||
_ = key.SetStringValue("ProxyServer", c.oldServer)
|
||||
@@ -114,12 +134,21 @@ func (c *windowsController) Disable() error {
|
||||
_ = key.SetStringValue("ProxyOverride", c.oldOverride)
|
||||
}
|
||||
} else {
|
||||
_ = key.SetDWordValue("ProxyEnable", 0)
|
||||
// Crash recovery: if proxy still points at local Navis ports, clear it.
|
||||
server, _, _ := key.GetStringValue("ProxyServer")
|
||||
lower := strings.ToLower(server)
|
||||
if strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, "localhost") {
|
||||
_ = key.SetDWordValue("ProxyEnable", 0)
|
||||
_ = key.DeleteValue("ProxyServer")
|
||||
} else if !restoreSaved {
|
||||
_ = key.SetDWordValue("ProxyEnable", 0)
|
||||
}
|
||||
}
|
||||
|
||||
_ = notifyInternetSettingsChanged()
|
||||
|
||||
c.enabled = false
|
||||
c.hadProxy = false
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||
// Keep major.minor.patch only — no build suffix here.
|
||||
const CurrentVersion = "2.7.2"
|
||||
const CurrentVersion = "3.8.0"
|
||||
|
||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||
@@ -289,16 +289,18 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
|
||||
}
|
||||
}
|
||||
}
|
||||
if wantSHA != "" {
|
||||
sum, err := fileSHA256(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
if !strings.EqualFold(sum, wantSHA) {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
if wantSHA == "" {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
|
||||
}
|
||||
sum, err := fileSHA256(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
if !strings.EqualFold(sum, wantSHA) {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
return st.Latest, st.URL, wantSHA, exe, tmp, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -73,6 +74,11 @@ func MaybeFinishUpdate(args []string) bool {
|
||||
self, _ = filepath.Abs(self)
|
||||
target, _ = filepath.Abs(target)
|
||||
|
||||
// Only allow replacing Navis.exe in the same directory as this pending updater.
|
||||
if !safeUpdateTarget(self, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
waitPIDExit(uint32(pid), 120*time.Second)
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
@@ -93,6 +99,16 @@ func MaybeFinishUpdate(args []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func safeUpdateTarget(self, target string) bool {
|
||||
selfDir := filepath.Clean(filepath.Dir(self))
|
||||
targetDir := filepath.Clean(filepath.Dir(target))
|
||||
if selfDir != targetDir {
|
||||
return false
|
||||
}
|
||||
base := strings.ToLower(filepath.Base(target))
|
||||
return base == "navis.exe"
|
||||
}
|
||||
|
||||
// CleanupStaleDownloads removes leftover update temps from failed/old runs.
|
||||
func CleanupStaleDownloads() {
|
||||
exe, err := os.Executable()
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# Build Navis GUI for Apple Silicon (arm64).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
VERSION="$(python3 - <<'PY'
|
||||
import re
|
||||
from pathlib import Path
|
||||
t = Path('internal/update/update.go').read_text()
|
||||
m = re.search(r'CurrentVersion\s*=\s*"([^"]+)"', t)
|
||||
print(m.group(1) if m else '0.0.0')
|
||||
PY
|
||||
)"
|
||||
BUILD="$(python3 - <<'PY'
|
||||
import re
|
||||
from pathlib import Path
|
||||
t = Path('internal/update/update.go').read_text()
|
||||
m = re.search(r'const BuildNumber\s*=\s*(\d+)', t)
|
||||
print(m.group(1) if m else '0')
|
||||
PY
|
||||
)"
|
||||
FULL="${VERSION}.${BUILD}"
|
||||
echo "Building Navis ${VERSION}+${BUILD} (arm64)..."
|
||||
|
||||
OUT="dist/navis-release/darwin-arm64"
|
||||
mkdir -p "$OUT"
|
||||
LDFLAGS="-s -w"
|
||||
|
||||
export PATH="/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
|
||||
go build -ldflags="$LDFLAGS" -trimpath -o "$OUT/Navis" ./cmd/vpnapp
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
|
||||
go build -ldflags="$LDFLAGS" -trimpath -o "$OUT/Navis-cli" ./cmd/vpnclient
|
||||
|
||||
printf '%s\n' "$FULL" > "$OUT/VERSION"
|
||||
printf '%s\n' "${VERSION}+${BUILD}" > "$OUT/Navis.version"
|
||||
|
||||
if command -v codesign >/dev/null 2>&1; then
|
||||
codesign -s - --force "$OUT/Navis"
|
||||
codesign -s - --force "$OUT/Navis-cli"
|
||||
fi
|
||||
|
||||
go build -o tools/packmac/packmac ./tools/packmac
|
||||
tools/packmac/packmac \
|
||||
-bin "$OUT/Navis" \
|
||||
-out "$OUT" \
|
||||
-version "$VERSION" \
|
||||
-build "$FULL" \
|
||||
-arch arm64
|
||||
|
||||
cp -f "$OUT/Navis.dmg" "$OUT/Navis-${FULL}-arm64.dmg"
|
||||
cp -f "$OUT/Navis.app.zip" "$OUT/Navis-${FULL}-arm64.app.zip"
|
||||
|
||||
python3 - <<PY
|
||||
import hashlib, json
|
||||
from pathlib import Path
|
||||
ver = "${VERSION}"
|
||||
build = "${BUILD}"
|
||||
full = "${FULL}"
|
||||
binp = Path('dist/navis-release/darwin-arm64/Navis')
|
||||
h = hashlib.sha256(binp.read_bytes()).hexdigest()
|
||||
notes = f"Navis {ver}+{build}: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot."
|
||||
for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
|
||||
if not p.exists():
|
||||
continue
|
||||
d = json.loads(p.read_text())
|
||||
d['version'] = ver
|
||||
d['notes'] = notes
|
||||
plats = d.setdefault('platforms', {})
|
||||
if 'darwin-arm64' in plats:
|
||||
plats['darwin-arm64']['sha256'] = h
|
||||
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + '\n')
|
||||
print('updated', p, '->', ver, 'sha', h[:12])
|
||||
PY
|
||||
|
||||
echo ""
|
||||
echo "Done: Navis ${VERSION}+${BUILD}"
|
||||
ls -lh "$OUT"
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "2.7.2",
|
||||
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».",
|
||||
"version": "3.8.0",
|
||||
"notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb",
|
||||
"sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||
@@ -39,4 +39,4 @@
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +130,13 @@ func writeHdiutilDMG(dmgPath, stageDir, volume string) error {
|
||||
|
||||
func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error {
|
||||
macOSDir := filepath.Join(appRoot, "Contents", "MacOS")
|
||||
resDir := filepath.Join(appRoot, "Contents", "Resources")
|
||||
if err := os.MkdirAll(macOSDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(resDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
archPriority := architecturePriorityXML(arch)
|
||||
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -142,6 +146,7 @@ func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error
|
||||
<key>CFBundleIdentifier</key><string>win.evilfox.navis</string>
|
||||
<key>CFBundleName</key><string>Navis</string>
|
||||
<key>CFBundleDisplayName</key><string>Navis</string>
|
||||
<key>CFBundleIconFile</key><string>AppIcon</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>%s</string>
|
||||
<key>CFBundleVersion</key><string>%s</string>
|
||||
@@ -157,6 +162,11 @@ func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error
|
||||
if err := os.WriteFile(filepath.Join(appRoot, "Contents", "PkgInfo"), []byte("APPL????"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if icns := findAsset("navis.icns"); icns != "" {
|
||||
if err := copyFile(icns, filepath.Join(resDir, "AppIcon.icns"), 0o644); err != nil {
|
||||
return fmt.Errorf("app icon: %w", err)
|
||||
}
|
||||
}
|
||||
dest := filepath.Join(macOSDir, "Navis")
|
||||
if err := copyFile(binPath, dest, 0o755); err != nil {
|
||||
return err
|
||||
@@ -384,6 +394,34 @@ func mustStat(path string) os.FileInfo {
|
||||
return fi
|
||||
}
|
||||
|
||||
// findAsset locates a file under assets/ relative to cwd or the packmac binary.
|
||||
func findAsset(name string) string {
|
||||
candidates := []string{
|
||||
filepath.Join("assets", name),
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidates = append(candidates,
|
||||
filepath.Join(dir, "..", "..", "assets", name),
|
||||
filepath.Join(dir, "..", "assets", name),
|
||||
filepath.Join(dir, "assets", name),
|
||||
)
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(wd, "assets", name))
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
abs, err := filepath.Abs(c)
|
||||
if err == nil {
|
||||
return abs
|
||||
}
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, mode os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": { "Major": 2, "Minor": 7, "Patch": 2, "Build": 1 },
|
||||
"ProductVersion": { "Major": 2, "Minor": 7, "Patch": 2, "Build": 1 },
|
||||
"FileVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 },
|
||||
"ProductVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 },
|
||||
"FileFlagsMask": "3f",
|
||||
"FileFlags": "00",
|
||||
"FileOS": "40004",
|
||||
@@ -10,13 +10,13 @@
|
||||
},
|
||||
"StringFileInfo": {
|
||||
"CompanyName": "EvilFox",
|
||||
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||
"FileVersion": "2.7.2.1",
|
||||
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||
"FileVersion": "3.8.0.1",
|
||||
"InternalName": "Navis",
|
||||
"LegalCopyright": "Copyright (c) EvilFox",
|
||||
"OriginalFilename": "Navis.exe",
|
||||
"ProductName": "Navis",
|
||||
"ProductVersion": "2.7.2.1",
|
||||
"ProductVersion": "3.8.0.1",
|
||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
Reference in New Issue
Block a user