Author SHA1 Message Date
M4 64c097d1e7 Release 2.7.3.3: dark theme and Amnezia vpn:// import fixes. 2026-07-29 19:49:48 +03:00
M4andCursor 041cbb1250 Release 2.7.3.1: harden proxy recovery, updates and local API.
Restore orphaned system proxy after crash, require update SHA-256, add macOS /api auth token, fix UDP ping false positives, HTTPS-only subscriptions, and keep the UI responsive during connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:48:54 +03:00
M4andCursor dc700f2bac Merge origin/main: Navis 2.7.2+2 with app icon and update UX.
Combine remote update check/skip flow with green N AppIcon packaging for macOS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:08:01 +03:00
M4andCursor 43882b95ad Release 2.7.2.4: macOS app icon, glaze UI and auto build numbers.
Add green N AppIcon to the .app bundle, keep WKWebView GUI stable, and stamp builds via BaseVersion/BuildNumber.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:57:18 +03:00
45 changed files with 886 additions and 178 deletions
+8
View File
@@ -243,6 +243,14 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси. Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
В 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:
- убран вечный цикл обновления (проверка больше не устанавливает сама; только кнопка «Обновить»); - убран вечный цикл обновления (проверка больше не устанавливает сама; только кнопка «Обновить»);
- номер сборки в версии (`2.7.2+N` / FileVersion `2.7.2.N`); сравнение обновлений только по product semver. - номер сборки в версии (`2.7.2+N` / FileVersion `2.7.2.N`); сравнение обновлений только по product semver.
+1 -1
View File
@@ -14,7 +14,7 @@ android {
targetSdk = 35 targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 2_070_201 versionCode = 2_070_201
versionName = "2.7.2+1" versionName = "2.7.3+1"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") 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

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+3 -3
View File
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1 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 2.7.3 -build 2.7.3.3 -arch arm64
if errorlevel 1 exit /b 1 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 2.7.3 -build 2.7.3.3 -arch amd64
if errorlevel 1 exit /b 1 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 2.7.3 -build 2.7.3.3 -arch universal
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI: echo Built Mac GUI + CLI:
+22 -22
View File
@@ -10,10 +10,11 @@ import (
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time" "time"
"github.com/crgimenes/glaze"
"vpnclient/internal/apphost" "vpnclient/internal/apphost"
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/core" "vpnclient/internal/core"
@@ -57,40 +58,42 @@ func main() {
}() }()
} }
ln, uiURL, err := apphost.ListenLocal() ln, uiURL, token, err := apphost.ListenLocal()
if err != nil { if err != nil {
fatalf("listen: %v", err) fatalf("listen: %v", err)
} }
a.APIToken = token
srv := &http.Server{Handler: a.Handler()} srv := &http.Server{Handler: a.Handler()}
go func() { go func() {
_ = srv.Serve(ln) _ = srv.Serve(ln)
}() }()
go a.AutoCheckUpdate() go a.AutoCheckUpdate()
if err := openAppWindow(uiURL); err != nil { w, err := glaze.New(false)
log.Printf("open UI: %v — откройте вручную: %s", err, uiURL) if err != nil || w == nil {
fmt.Println(uiURL) log.Printf("webview unavailable (%v); opening default browser", err)
} else { if err2 := exec.Command("open", uiURL).Run(); err2 != nil {
log.Printf("Navis UI: %s", uiURL) fatalf("Не удалось открыть интерфейс:\n%v\n%v\n%s", err, err2, uiURL)
} }
log.Printf("Navis UI: %s", uiURL)
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig <-sig
_ = mgr.Disconnect() _ = mgr.Disconnect()
_ = srv.Close() _ = srv.Close()
return
} }
defer func() {
_ = mgr.Disconnect()
_ = srv.Close()
w.Destroy()
}()
func openAppWindow(uiURL string) error { w.SetTitle("Navis 2")
// Prefer Chromium-based --app window (looks like a desktop client). w.SetSize(500, 900, glaze.HintNone)
for _, app := range []string{"Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser", "Arc"} { w.Navigate(uiURL)
cmd := exec.Command("open", "-na", app, "--args", "--app="+uiURL, "--new-window") log.Printf("Navis UI: %s", uiURL)
if err := cmd.Start(); err == nil { w.Run()
return nil
}
}
// Fallback: default browser
return exec.Command("open", uiURL).Start()
} }
func fatalf(format string, args ...any) { 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() _ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
os.Exit(1) os.Exit(1)
} }
// Ensure we keep a reference for potential future dock icon path helpers.
var _ = filepath.Separator
+18 -6
View File
@@ -203,7 +203,7 @@ func (a *app) getState() (uiState, error) {
CoreReady: coreReady, CoreReady: coreReady,
CorePath: corePath, CorePath: corePath,
ConfigPath: a.cfgPath, ConfigPath: a.cfgPath,
Profiles: cfg.ListProfiles(), Profiles: config.RedactProfileList(cfg.ListProfiles()),
Version: update.DisplayVersion(), Version: update.DisplayVersion(),
Update: a.updateStatus, Update: a.updateStatus,
Pings: append([]netcheck.Result(nil), a.pings...), Pings: append([]netcheck.Result(nil), a.pings...),
@@ -263,30 +263,36 @@ func (a *app) connect() error {
// If already connected to another profile, disconnects first. // If already connected to another profile, disconnects first.
func (a *app) connectProfile(name string) error { func (a *app) connectProfile(name string) error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
st := a.mgr.Status() st := a.mgr.Status()
if st.Connected { if st.Connected {
if name == "" || st.Profile == name { if name == "" || st.Profile == name {
a.mu.Unlock()
return nil return nil
} }
a.mu.Unlock()
if err := a.mgr.Disconnect(); err != nil { if err := a.mgr.Disconnect(); err != nil {
return err return err
} }
a.mu.Lock()
} }
if name != "" { if name != "" {
if err := a.mgr.SetActiveProfile(name); err != nil { if err := a.mgr.SetActiveProfile(name); err != nil {
a.mu.Unlock()
return err return err
} }
} }
if p, err := a.mgr.Config().ActiveProfile(); err != nil { if p, err := a.mgr.Config().ActiveProfile(); err != nil {
a.mu.Unlock()
return err return err
} else if strings.TrimSpace(p.Proxy) == "" { } else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера") 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 { if _, err := a.mgr.EnsureCore(""); err != nil {
return err return err
} }
@@ -301,8 +307,6 @@ func (a *app) disconnect() error {
} }
func (a *app) installCore() (string, error) { func (a *app) installCore() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
paths, err := a.mgr.EnsureAllCores() paths, err := a.mgr.EnsureAllCores()
if err != nil { if err != nil {
return "", err return "", err
@@ -352,32 +356,40 @@ func (a *app) pingBest(autoConnect bool) (pingBestResult, error) {
out.BestMs = best.Ms out.BestMs = best.Ms
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock()
st := a.mgr.Status() st := a.mgr.Status()
alreadyBest := st.Connected && st.Profile == best.Name alreadyBest := st.Connected && st.Profile == best.Name
if st.Connected && !alreadyBest { if st.Connected && !alreadyBest {
a.mu.Unlock()
if err := a.mgr.Disconnect(); err != nil { if err := a.mgr.Disconnect(); err != nil {
return out, err return out, err
} }
a.mu.Lock()
} }
if !alreadyBest { if !alreadyBest {
if err := a.mgr.SetActiveProfile(best.Name); err != nil { if err := a.mgr.SetActiveProfile(best.Name); err != nil {
a.mu.Unlock()
return out, err return out, err
} }
} }
out.Selected = true out.Selected = true
if !autoConnect { if !autoConnect {
a.mu.Unlock()
return out, nil return out, nil
} }
if alreadyBest { if alreadyBest {
a.mu.Unlock()
out.Connected = true out.Connected = true
return out, nil return out, nil
} }
if p, err := a.mgr.Config().ActiveProfile(); err != nil { if p, err := a.mgr.Config().ActiveProfile(); err != nil {
a.mu.Unlock()
return out, err return out, err
} else if strings.TrimSpace(p.Proxy) == "" { } else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера") return out, fmt.Errorf("пустая ссылка у лучшего сервера")
} }
a.mu.Unlock()
if _, err := a.mgr.EnsureCore(""); err != nil { if _, err := a.mgr.EnsureCore(""); err != nil {
return out, err return out, err
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
2.7.3+3
+1
View File
@@ -0,0 +1 @@
2.7.3.3
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"version": "2.7.2", "version": "2.7.3",
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».", "notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
@@ -16,7 +16,7 @@
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb", "sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"version": "2.7.2", "version": "2.7.3",
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».", "notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
@@ -16,7 +16,7 @@
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb", "sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
+3 -1
View File
@@ -1,6 +1,6 @@
module vpnclient module vpnclient
go 1.25.0 go 1.26.5
require ( require (
github.com/amnezia-vpn/amneziawg-go v0.2.19 github.com/amnezia-vpn/amneziawg-go v0.2.19
@@ -12,7 +12,9 @@ require (
require ( require (
github.com/anchore/go-lzo v0.1.0 // indirect 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/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/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
github.com/google/btree v1.1.3 // indirect github.com/google/btree v1.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
+4
View File
@@ -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/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 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc=
github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc= 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 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= 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 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= 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= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
+61 -11
View File
@@ -3,6 +3,8 @@ package apphost
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -34,6 +36,7 @@ type App struct {
Pings []netcheck.Result Pings []netcheck.Result
OpenURL func(string) error OpenURL func(string) error
OnAfterUpdate func() OnAfterUpdate func()
APIToken string
} }
type UIState struct { type UIState struct {
@@ -124,7 +127,7 @@ func (a *App) GetState() (UIState, error) {
CoreReady: coreReady, CoreReady: coreReady,
CorePath: corePath, CorePath: corePath,
ConfigPath: a.CfgPath, ConfigPath: a.CfgPath,
Profiles: cfg.ListProfiles(), Profiles: config.RedactProfileList(cfg.ListProfiles()),
Version: update.DisplayVersion(), Version: update.DisplayVersion(),
Update: a.UpdateStatus, Update: a.UpdateStatus,
Pings: append([]netcheck.Result(nil), a.Pings...), 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 { func (a *App) ConnectProfile(name string) error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
st := a.Mgr.Status() st := a.Mgr.Status()
if st.Connected { if st.Connected {
if name == "" || st.Profile == name { if name == "" || st.Profile == name {
a.mu.Unlock()
return nil return nil
} }
a.mu.Unlock()
if err := a.Mgr.Disconnect(); err != nil { if err := a.Mgr.Disconnect(); err != nil {
return err return err
} }
a.mu.Lock()
} }
if name != "" { if name != "" {
if err := a.Mgr.SetActiveProfile(name); err != nil { if err := a.Mgr.SetActiveProfile(name); err != nil {
a.mu.Unlock()
return err return err
} }
} }
if p, err := a.Mgr.Config().ActiveProfile(); err != nil { if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
a.mu.Unlock()
return err return err
} else if strings.TrimSpace(p.Proxy) == "" { } else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера") 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 { if _, err := a.Mgr.EnsureCore(""); err != nil {
return err return err
} }
@@ -215,8 +225,6 @@ func (a *App) Disconnect() error {
} }
func (a *App) InstallCore() (string, error) { func (a *App) InstallCore() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
paths, err := a.Mgr.EnsureAllCores() paths, err := a.Mgr.EnsureAllCores()
if err != nil { if err != nil {
return "", err return "", err
@@ -256,32 +264,40 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
out.BestMs = best.Ms out.BestMs = best.Ms
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock()
st := a.Mgr.Status() st := a.Mgr.Status()
alreadyBest := st.Connected && st.Profile == best.Name alreadyBest := st.Connected && st.Profile == best.Name
if st.Connected && !alreadyBest { if st.Connected && !alreadyBest {
a.mu.Unlock()
if err := a.Mgr.Disconnect(); err != nil { if err := a.Mgr.Disconnect(); err != nil {
return out, err return out, err
} }
a.mu.Lock()
} }
if !alreadyBest { if !alreadyBest {
if err := a.Mgr.SetActiveProfile(best.Name); err != nil { if err := a.Mgr.SetActiveProfile(best.Name); err != nil {
a.mu.Unlock()
return out, err return out, err
} }
} }
out.Selected = true out.Selected = true
if !autoConnect { if !autoConnect {
a.mu.Unlock()
return out, nil return out, nil
} }
if alreadyBest { if alreadyBest {
a.mu.Unlock()
out.Connected = true out.Connected = true
return out, nil return out, nil
} }
if p, err := a.Mgr.Config().ActiveProfile(); err != nil { if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
a.mu.Unlock()
return out, err return out, err
} else if strings.TrimSpace(p.Proxy) == "" { } else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера") return out, fmt.Errorf("пустая ссылка у лучшего сервера")
} }
a.mu.Unlock()
if _, err := a.Mgr.EnsureCore(""); err != nil { if _, err := a.Mgr.EnsureCore(""); err != nil {
return out, err return out, err
} }
@@ -394,17 +410,38 @@ func (a *App) Handler() http.Handler {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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) mux.HandleFunc("/api/", a.handleAPI)
return mux return mux
} }
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func (a *App) handleAPI(w http.ResponseWriter, r *http.Request) { func (a *App) handleAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed) http.Error(w, "POST only", http.StatusMethodNotAllowed)
return 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/") name := strings.TrimPrefix(r.URL.Path, "/api/")
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
var req struct { 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. // ListenLocal binds 127.0.0.1:0, generates an API token, and returns listener + URL.
func ListenLocal() (net.Listener, string, error) { func ListenLocal() (net.Listener, string, string, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { 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()) 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
View File
@@ -22,6 +22,79 @@
--shadow: 0 18px 50px rgba(8, 40, 32, 0.12); --shadow: 0 18px 50px rgba(8, 40, 32, 0.12);
--radius: 22px; --radius: 22px;
--ease: cubic-bezier(.22,.8,.24,1); --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; } * { box-sizing: border-box; }
html, body { html, body {
@@ -29,11 +102,12 @@
min-height: 100%; min-height: 100%;
font-family: Figtree, sans-serif; font-family: Figtree, sans-serif;
color: var(--ink); color: var(--ink);
background: background: var(--page-bg);
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%), transition: color .2s var(--ease), background .25s var(--ease);
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%), color-scheme: light;
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%), }
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%); html[data-theme="dark"], html[data-theme="dark"] body {
color-scheme: dark;
} }
body { body {
display: grid; display: grid;
@@ -61,7 +135,7 @@
padding: 12px 14px; padding: 12px 14px;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(13, 138, 102, 0.28); 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; animation: rise .4s var(--ease) both;
} }
.update-banner.show { display: block; } .update-banner.show { display: block; }
@@ -105,6 +179,29 @@
} }
.logo svg { width: 30px; height: 30px; } .logo svg { width: 30px; height: 30px; }
.brand-wrap { min-width: 0; flex: 1; } .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 { .brand-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -123,7 +220,7 @@
font-size: .68rem; font-size: .68rem;
font-weight: 700; font-weight: 700;
letter-spacing: .04em; letter-spacing: .04em;
text-transform: uppercase; text-transform: none;
color: var(--accent-deep); color: var(--accent-deep);
background: var(--accent-soft); background: var(--accent-soft);
border: 1px solid rgba(13,138,102,.18); border: 1px solid rgba(13,138,102,.18);
@@ -142,8 +239,7 @@
overflow: hidden; overflow: hidden;
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid var(--line); border: 1px solid var(--line);
background: background: var(--hero-bg);
linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
padding: 16px 16px 14px; padding: 16px 16px 14px;
margin-bottom: 14px; margin-bottom: 14px;
transition: border-color .25s, box-shadow .25s, background .25s; transition: border-color .25s, box-shadow .25s, background .25s;
@@ -151,8 +247,7 @@
.hero.on { .hero.on {
border-color: rgba(13,138,102,.35); border-color: rgba(13,138,102,.35);
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14); box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
background: background: var(--hero-on-bg);
linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
} }
.hero::after { .hero::after {
content: ""; content: "";
@@ -202,7 +297,7 @@
letter-spacing: .03em; letter-spacing: .03em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
background: rgba(255,255,255,.75); background: var(--chip-bg);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
padding: 5px 10px; padding: 5px 10px;
@@ -265,13 +360,13 @@
select, input[type="text"], textarea { select, input[type="text"], textarea {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255,255,255,.94); background: var(--input-bg);
border-radius: 14px; border-radius: 14px;
padding: 11px 13px; padding: 11px 13px;
font: inherit; font: inherit;
color: var(--ink); color: var(--ink);
outline: none; outline: none;
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s, background .15s;
} }
textarea { textarea {
min-height: 78px; min-height: 78px;
@@ -282,7 +377,7 @@
} }
select:focus, input[type="text"]:focus, textarea:focus { select:focus, input[type="text"]:focus, textarea:focus {
border-color: rgba(13,138,102,.5); 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 { .icon-btn {
width: 44px; width: 44px;
@@ -291,7 +386,7 @@
place-items: center; place-items: center;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(8, 90, 68, 0.22); border: 1px solid rgba(8, 90, 68, 0.22);
background: linear-gradient(180deg, #d2efe4, #bfe6d6); background: var(--soft-btn);
color: var(--accent-deep); color: var(--accent-deep);
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
@@ -300,19 +395,19 @@
box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08); box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08);
} }
.icon-btn:hover { .icon-btn:hover {
background: linear-gradient(180deg, #c5e9db, #aedfcb); background: var(--soft-btn-hover);
border-color: rgba(8, 90, 68, 0.38); 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); box-shadow: 0 6px 14px rgba(8, 90, 68, 0.14);
} }
.icon-btn.danger { .icon-btn.danger {
color: #9e2a22; color: var(--danger-ink);
background: linear-gradient(180deg, #f8d8d4, #efc4bf); background: var(--danger-btn);
border-color: rgba(158, 42, 34, 0.28); border-color: rgba(158, 42, 34, 0.28);
} }
.icon-btn.danger:hover { .icon-btn.danger:hover {
background: linear-gradient(180deg, #f3c5bf, #e8aea7); background: var(--danger-btn-hover);
color: #7f1f19; color: var(--danger-ink-hover);
} }
.icon-btn.wide { .icon-btn.wide {
width: auto; width: auto;
@@ -374,7 +469,7 @@
padding: 11px 13px; padding: 11px 13px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 14px;
background: rgba(255,255,255,.6); background: var(--surface-2);
margin-bottom: 12px; margin-bottom: 12px;
font-weight: 600; font-weight: 600;
font-size: .9rem; font-size: .9rem;
@@ -382,7 +477,7 @@
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; } .switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; } .switch input { opacity: 0; width: 0; height: 0; }
.slider { .slider {
position: absolute; inset: 0; background: #c7d5cf; position: absolute; inset: 0; background: var(--switch-track);
border-radius: 999px; cursor: pointer; transition: background .15s; border-radius: 999px; cursor: pointer; transition: background .15s;
} }
.slider::before { .slider::before {
@@ -390,7 +485,7 @@
position: absolute; position: absolute;
width: 20px; height: 20px; width: 20px; height: 20px;
left: 3px; top: 3px; left: 3px; top: 3px;
background: #fff; background: var(--switch-knob);
border-radius: 50%; border-radius: 50%;
transition: transform .15s; transition: transform .15s;
box-shadow: 0 2px 6px rgba(0,0,0,.12); box-shadow: 0 2px 6px rgba(0,0,0,.12);
@@ -472,7 +567,7 @@
.server-actions .mini { .server-actions .mini {
appearance: none; appearance: none;
border: 1px solid rgba(8, 90, 68, 0.22); border: 1px solid rgba(8, 90, 68, 0.22);
background: linear-gradient(180deg, #d2efe4, #bfe6d6); background: var(--soft-btn);
color: var(--accent-deep); color: var(--accent-deep);
border-radius: 999px; border-radius: 999px;
padding: 6px 10px; padding: 6px 10px;
@@ -506,7 +601,7 @@
padding: 4px; padding: 4px;
border-radius: 14px; border-radius: 14px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255,255,255,.4); background: var(--surface-2);
} }
.server-row { .server-row {
display: grid; display: grid;
@@ -516,7 +611,7 @@
padding: 6px 10px; padding: 6px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid transparent; border: 1px solid transparent;
background: rgba(255,255,255,.72); background: var(--row-bg);
cursor: pointer; cursor: pointer;
transition: background .12s, border-color .12s, box-shadow .12s; transition: background .12s, border-color .12s, box-shadow .12s;
text-align: left; text-align: left;
@@ -526,11 +621,11 @@
} }
.server-row:hover { .server-row:hover {
border-color: rgba(13,138,102,.25); border-color: rgba(13,138,102,.25);
background: #fff; background: var(--input-bg);
} }
.server-row.active { .server-row.active {
border-color: rgba(13,138,102,.4); 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); box-shadow: 0 4px 14px rgba(13,138,102,.1);
} }
.server-row .left { min-width: 0; } .server-row .left { min-width: 0; }
@@ -573,8 +668,8 @@
color: var(--muted); color: var(--muted);
} }
.server-row .ms.ok { color: var(--ok); } .server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: #0a7a3e; } .server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: #b8860b; } .server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); } .server-row .ms.bad { color: var(--danger); }
.server-empty { .server-empty {
padding: 14px 10px; padding: 14px 10px;
@@ -600,18 +695,18 @@
min-height: 2.4em; min-height: 2.4em;
padding: 8px 10px; padding: 8px 10px;
border-radius: 12px; border-radius: 12px;
background: rgba(255,255,255,.35); background: var(--meta-bg);
border: 1px solid transparent; border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s; transition: color .15s, border-color .15s, background .15s;
} }
.meta.ok { .meta.ok {
color: var(--ok); color: var(--ok);
background: rgba(216,243,233,.65); background: var(--meta-ok-bg);
border-color: rgba(13,138,102,.18); border-color: rgba(13,138,102,.18);
} }
.meta.err { .meta.err {
color: var(--danger); color: var(--danger);
background: rgba(255,236,234,.75); background: var(--meta-err-bg);
border-color: rgba(192,54,44,.16); border-color: rgba(192,54,44,.16);
} }
@@ -627,7 +722,7 @@
padding: 8px 12px; padding: 8px 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255,255,255,.6); background: var(--row-bg);
font-size: .82rem; font-size: .82rem;
} }
.ping-item .ms { font-weight: 700; } .ping-item .ms { font-weight: 700; }
@@ -639,7 +734,7 @@
padding: 13px 14px 12px; padding: 13px 14px 12px;
border-radius: 18px; border-radius: 18px;
border: 1px solid rgba(13, 138, 102, 0.16); 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 { .shop h3 {
font-family: Sora, sans-serif; font-family: Sora, sans-serif;
@@ -680,7 +775,7 @@
.modal-backdrop { .modal-backdrop {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(8, 28, 24, .42); background: var(--modal-scrim);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: none; display: none;
place-items: center; place-items: center;
@@ -690,12 +785,13 @@
.modal-backdrop.open { display: grid; } .modal-backdrop.open { display: grid; }
.modal { .modal {
width: min(400px, 100%); width: min(400px, 100%);
background: #f5fbf8; background: var(--modal-bg);
border-radius: 22px; border-radius: 22px;
border: 1px solid var(--line); border: 1px solid var(--line);
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: 18px; padding: 18px;
animation: rise .35s var(--ease) both; animation: rise .35s var(--ease) both;
color: var(--ink);
} }
.modal h2 { .modal h2 {
font-family: Sora, sans-serif; font-family: Sora, sans-serif;
@@ -715,6 +811,18 @@
.tools { grid-template-columns: 1fr; } .tools { grid-template-columns: 1fr; }
} }
</style> </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> </head>
<body> <body>
<main class="shell"> <main class="shell">
@@ -730,17 +838,29 @@
<header class="top"> <header class="top">
<div class="logo" aria-hidden="true"> <div class="logo" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none"> <svg viewBox="0 0 32 32" fill="none">
<path d="M8 26V6h4.2L20 18.4V6H24v20h-4.2L12 13.6V26H8z" fill="#f4fff9"/> <!-- Bold N -->
<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"/> <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> </svg>
</div> </div>
<div class="brand-wrap"> <div class="brand-wrap">
<div class="brand-row"> <div class="brand-row">
<h1 class="brand">Navis</h1> <h1 class="brand">Navis</h1>
<span class="badge-ver">2.7.1</span> <span class="badge-ver" id="badgeVer">2.7.3</span>
</div> </div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p> <p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div> </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> </header>
<section class="hero" id="hero"> <section class="hero" id="hero">
@@ -890,8 +1010,9 @@
<input id="newName" type="text" placeholder="Server EU" /> <input id="newName" type="text" placeholder="Server EU" />
</div> </div>
<div> <div>
<label class="field" for="newProxy">Ссылка</label> <label class="field" for="newProxy">Ссылка / конфиг</label>
<input id="newProxy" type="text" placeholder="vless:// · vmess:// · trojan:// · hy2 · naive · awg" /> <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> </div>
<div class="actions"> <div class="actions">
@@ -912,11 +1033,14 @@
if (typeof window.getState === "function") return; if (typeof window.getState === "function") return;
window.__navisHttp = true; window.__navisHttp = true;
async function call(name, args) { 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, { const r = await fetch("/api/" + name, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: headers,
body: JSON.stringify({ args: args || [] }) body: JSON.stringify({ args: args || [] })
}); });
if (r.status === 401) throw "unauthorized";
const j = await r.json(); const j = await r.json();
if (j && j.error) throw j.error; if (j && j.error) throw j.error;
return j ? j.result : null; return j ? j.result : null;
@@ -980,6 +1104,25 @@
const heroHint = $("heroHint"); const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/"; const SHOP_URL = "https://evilfox.win/";
const AUTO_BEST_KEY = "navis.autoBest"; 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 { try {
const saved = localStorage.getItem(AUTO_BEST_KEY); const saved = localStorage.getItem(AUTO_BEST_KEY);
@@ -1192,6 +1335,8 @@
function renderUpdate(u, version) { function renderUpdate(u, version) {
verLabel.textContent = "Navis v" + (version || "?"); verLabel.textContent = "Navis v" + (version || "?");
const badge = $("badgeVer");
if (badge && version) badge.textContent = version;
if (!u) { if (!u) {
updateBanner.classList.remove("show"); updateBanner.classList.remove("show");
return; return;
@@ -1376,6 +1521,8 @@
addBtn.addEventListener("click", () => { addBtn.addEventListener("click", () => {
$("newName").value = ""; $("newName").value = "";
$("newProxy").value = ""; $("newProxy").value = "";
const me = $("modalErr");
if (me) { me.style.display = "none"; me.textContent = ""; me.className = "meta"; }
modal.classList.add("open"); modal.classList.add("open");
$("newName").focus(); $("newName").focus();
}); });
@@ -1383,17 +1530,32 @@
modal.addEventListener("click", (e) => { modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.remove("open"); 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 () => { $("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 { try {
const n = $("newName").value.trim(); const n = $("newName").value.trim();
const p = $("newProxy").value.trim(); const p = sanitizeShare($("newProxy").value);
if (!n) throw "Укажите название профиля"; if (!n) throw "Укажите название профиля";
if (!p) throw "Вставьте ссылку или конфиг Amnezia/AWG";
$("newProxy").value = p;
await createProfile(n, p, !!sysproxy.checked); await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open"); modal.classList.remove("open");
if (me) { me.style.display = "none"; me.textContent = ""; }
formHydrated = false; formHydrated = false;
dirty = false; dirty = false;
setMeta("Профиль создан", "ok"); setMeta("Профиль создан", "ok");
} catch (e) { setMeta(String(e), "err"); } } catch (e) { showModalErr(e); }
})); }));
async function openShop(e) { async function openShop(e) {
@@ -1484,9 +1646,7 @@
(async () => { (async () => {
try { try {
await refresh({ syncForm: true }); await refresh({ syncForm: true });
if (profiles.length > 1 && autoBest.checked && !connected) { // Auto-best connect is opt-in via checkbox only — never on first paint.
await withBusy(async () => { await runBest(true); });
}
} catch (e) { } catch (e) {
setMeta(String(e), "err"); setMeta(String(e), "err");
} }
+39
View File
@@ -3,8 +3,10 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// Protocol identifies a VPN/proxy backend. // Protocol identifies a VPN/proxy backend.
@@ -192,10 +194,47 @@ func (c *Config) Validate() error {
default: default:
return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol) 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 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. // ActiveProfile returns the selected profile.
func (c *Config) ActiveProfile() (*Profile, error) { func (c *Config) ActiveProfile() (*Profile, error) {
for i := range c.Profiles { for i := range c.Profiles {
+20
View File
@@ -4,6 +4,7 @@ import "strings"
// DetectProtocol infers protocol from a share/proxy URI or config body. // DetectProtocol infers protocol from a share/proxy URI or config body.
func DetectProtocol(raw string) Protocol { func DetectProtocol(raw string) Protocol {
raw = stripShareNoise(raw)
lower := strings.ToLower(strings.TrimSpace(raw)) lower := strings.ToLower(strings.TrimSpace(raw))
switch { switch {
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"): case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
@@ -29,3 +30,22 @@ func DetectProtocol(raw string) Protocol {
return "" 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())
}
+25
View File
@@ -29,6 +29,17 @@ func (c *Config) ListProfiles() []ProfileInfo {
return out 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 { func proxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy) proxy = strings.TrimSpace(proxy)
if proxy == "" { if proxy == "" {
@@ -79,7 +90,17 @@ func (c *Config) UpsertProfile(name, proxy string) error {
} }
// UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol. // UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol.
// The upserted profile becomes Active.
func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) error { 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) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return fmt.Errorf("имя профиля пустое") return fmt.Errorf("имя профиля пустое")
@@ -99,7 +120,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
if len(c.Profiles[i].Listen) == 0 { if len(c.Profiles[i].Listen) == 0 {
c.Profiles[i].Listen = defaultListen() c.Profiles[i].Listen = defaultListen()
} }
if activate {
c.Active = name c.Active = name
}
return nil return nil
} }
} }
@@ -110,7 +133,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
Proxy: proxy, Proxy: proxy,
Listen: defaultListen(), Listen: defaultListen(),
}) })
if activate {
c.Active = name c.Active = name
}
return nil return nil
} }
+78 -25
View File
@@ -43,13 +43,29 @@ func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager,
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Manager{ m := &Manager{
cfgPath: cfgPath, cfgPath: cfgPath,
cfg: cfg, cfg: cfg,
sys: sysproxy.New(), sys: sysproxy.New(),
stderr: stderr, stderr: stderr,
binDir: binDir, 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) Connect(ctx context.Context, profileName string) error { func (m *Manager) Connect(ctx context.Context, profileName string) error {
@@ -90,6 +106,8 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
_ = engine.Stop() _ = engine.Stop()
return fmt.Errorf("enable system proxy: %w", err) return fmt.Errorf("enable system proxy: %w", err)
} }
} else {
sysproxy.WriteSentinel(m.cfgPath, httpProxy)
} }
} }
@@ -107,11 +125,22 @@ func (m *Manager) Disconnect() error {
m.mu.Unlock() m.mu.Unlock()
var first error var first error
if sys != nil && sys.Enabled() { if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(m.cfgPath)) {
if err := sys.Disable(); err != nil && first == nil { 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 first = err
} }
} }
sysproxy.ClearSentinel(m.cfgPath)
}
if engine != nil { if engine != nil {
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- engine.Stop() }() go func() { done <- engine.Stop() }()
@@ -254,8 +283,13 @@ func (m *Manager) SetActiveProfile(name string) error {
func (m *Manager) SaveProfile(name, proxy string) error { func (m *Manager) SaveProfile(name, proxy string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() { name = strings.TrimSpace(name)
return fmt.Errorf("сначала отключитесь") 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) proxy = strings.TrimSpace(proxy)
var proto config.Protocol var proto config.Protocol
@@ -267,7 +301,13 @@ func (m *Manager) SaveProfile(name, proxy string) error {
proxy = normalized proxy = normalized
proto = detected 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 return err
} }
if p, err := m.cfg.ActiveProfile(); err == nil { if p, err := m.cfg.ActiveProfile(); err == nil {
@@ -507,26 +547,39 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded). // EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded).
func (m *Manager) EnsureAllCores() (map[string]string, error) { 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{} out := map[string]string{}
p1, err := naive.EnsureBinary(m.binDir) var first error
if err != nil { for range jobs {
return out, fmt.Errorf("naive: %w", err) it := <-ch
if it.err != nil {
if first == nil {
first = fmt.Errorf("%s: %w", it.key, it.err)
} }
out["naive"] = p1 continue
p2, err := hysteria2.EnsureBinary(m.binDir)
if err != nil {
return out, fmt.Errorf("hysteria2: %w", err)
} }
out["hysteria2"] = p2 out[it.key] = it.path
p3, err := awg.EnsureBinary(m.binDir)
if err != nil {
return out, fmt.Errorf("awg: %w", err)
} }
out["awg"] = p3 return out, first
p4, err := xray.EnsureBinary(m.binDir)
if err != nil {
return out, fmt.Errorf("xray: %w", err)
}
out["xray"] = p4
return out, nil
} }
+1 -2
View File
@@ -2,7 +2,6 @@ package linknorm
import ( import (
"fmt" "fmt"
"strings"
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/awg"
@@ -13,7 +12,7 @@ import (
// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect). // 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) { 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 == "" { if raw == "" {
return "", proto, "", fmt.Errorf("пустая ссылка") return "", proto, "", fmt.Errorf("пустая ссылка")
} }
+35
View File
@@ -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())
}
+42
View File
@@ -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
}
+2 -1
View File
@@ -212,7 +212,8 @@ func probeUDP(ctx context.Context, host, port string) error {
return nil return nil
} }
if ne, ok := err.(net.Error); ok && ne.Timeout() { 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 return err
} }
+22 -2
View File
@@ -35,7 +35,7 @@ type Conf struct {
// Detect reports whether raw looks like an AWG/WireGuard config or share link. // Detect reports whether raw looks like an AWG/WireGuard config or share link.
func Detect(raw string) bool { func Detect(raw string) bool {
raw = strings.TrimSpace(raw) raw = sanitizeShare(raw)
if raw == "" { if raw == "" {
return false 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. // Parse accepts a WireGuard/AWG .conf body, awg:// URI, vpn:// blob, or Amnezia JSON.
func Parse(raw string) (Conf, error) { func Parse(raw string) (Conf, error) {
raw = strings.TrimSpace(raw) raw = sanitizeShare(raw)
if raw == "" { if raw == "" {
return Conf{}, fmt.Errorf("пустой AWG конфиг") 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) { func parseINI(raw string) (Conf, error) {
var c Conf var c Conf
section := "interface" // treat leading keys before [Interface] as Interface section := "interface" // treat leading keys before [Interface] as Interface
+35
View File
@@ -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 -1
View File
@@ -181,6 +181,6 @@ func downloadFile(path, url string) error {
return err return err
} }
defer f.Close() defer f.Close()
_, err = io.Copy(f, resp.Body) _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
return err return err
} }
+1 -1
View File
@@ -173,7 +173,7 @@ func downloadFile(path, url string) error {
return err return err
} }
defer f.Close() defer f.Close()
_, err = io.Copy(f, resp.Body) _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
return err return err
} }
+2 -4
View File
@@ -27,7 +27,6 @@ type Item struct {
var ( var (
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`) 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. // 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 подписки") return nil, fmt.Errorf("пустой URL подписки")
} }
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") { if err != nil || u.Scheme != "https" {
return nil, fmt.Errorf("нужен http(s) URL подписки") return nil, fmt.Errorf("нужен https URL подписки")
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
@@ -219,7 +218,6 @@ func extractClashHY2(text string) []string {
} }
out = append(out, link) out = append(out, link)
} }
_ = reClashHY2 // kept for possible future tightening
return out return out
} }
+10 -1
View File
@@ -82,7 +82,16 @@ func (c *darwinController) Disable() error {
if !c.enabled { if !c.enabled {
return nil 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 { if len(services) == 0 {
var err error var err error
services, err = listNetworkServices() services, err = listNetworkServices()
+1
View File
@@ -9,3 +9,4 @@ func newPlatform() Controller { return stubController{} }
func (stubController) Enable(string) error { return ErrUnsupported } func (stubController) Enable(string) error { return ErrUnsupported }
func (stubController) Disable() error { return nil } func (stubController) Disable() error { return nil }
func (stubController) Enabled() bool { return false } func (stubController) Enabled() bool { return false }
func (stubController) ForceDisable() error { return nil }
+39
View File
@@ -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
}
+3
View File
@@ -7,6 +7,9 @@ type Controller interface {
Enable(httpHostPort string) error Enable(httpHostPort string) error
Disable() error Disable() error
Enabled() bool 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. // New returns a platform-specific controller.
+18 -1
View File
@@ -95,6 +95,14 @@ func (c *windowsController) Disable() error {
if !c.enabled { if !c.enabled {
return nil return nil
} }
return c.disableLocked(true)
}
func (c *windowsController) ForceDisable() error {
return c.disableLocked(false)
}
func (c *windowsController) disableLocked(restoreSaved bool) error {
key, err := registry.OpenKey(registry.CURRENT_USER, key, err := registry.OpenKey(registry.CURRENT_USER,
`Software\Microsoft\Windows\CurrentVersion\Internet Settings`, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
registry.QUERY_VALUE|registry.SET_VALUE) registry.QUERY_VALUE|registry.SET_VALUE)
@@ -103,7 +111,7 @@ func (c *windowsController) Disable() error {
} }
defer key.Close() defer key.Close()
if c.hadProxy { if restoreSaved && c.hadProxy {
_ = key.SetDWordValue("ProxyEnable", uint32(c.oldEnable)) _ = key.SetDWordValue("ProxyEnable", uint32(c.oldEnable))
if c.oldServer != "" { if c.oldServer != "" {
_ = key.SetStringValue("ProxyServer", c.oldServer) _ = key.SetStringValue("ProxyServer", c.oldServer)
@@ -114,12 +122,21 @@ func (c *windowsController) Disable() error {
_ = key.SetStringValue("ProxyOverride", c.oldOverride) _ = key.SetStringValue("ProxyOverride", c.oldOverride)
} }
} else { } else {
// 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.SetDWordValue("ProxyEnable", 0)
_ = key.DeleteValue("ProxyServer")
} else if !restoreSaved {
_ = key.SetDWordValue("ProxyEnable", 0)
}
} }
_ = notifyInternetSettingsChanged() _ = notifyInternetSettingsChanged()
c.enabled = false c.enabled = false
c.hadProxy = false
return nil return nil
} }
+6 -4
View File
@@ -18,11 +18,11 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version"). // CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here. // Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "2.7.2" const CurrentVersion = "2.7.3"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part, // BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build. // macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 1 const BuildNumber = 3
// DefaultManifestURL is the update feed (hosted in the project git repo). // DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json" const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
@@ -289,7 +289,10 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
} }
} }
} }
if wantSHA != "" { if wantSHA == "" {
_ = os.Remove(tmp)
return "", "", "", "", "", fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
}
sum, err := fileSHA256(tmp) sum, err := fileSHA256(tmp)
if err != nil { if err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
@@ -299,6 +302,5 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
_ = os.Remove(tmp) _ = os.Remove(tmp)
return "", "", "", "", "", fmt.Errorf("checksum mismatch") return "", "", "", "", "", fmt.Errorf("checksum mismatch")
} }
}
return st.Latest, st.URL, wantSHA, exe, tmp, nil return st.Latest, st.URL, wantSHA, exe, tmp, nil
} }
+16
View File
@@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
@@ -73,6 +74,11 @@ func MaybeFinishUpdate(args []string) bool {
self, _ = filepath.Abs(self) self, _ = filepath.Abs(self)
target, _ = filepath.Abs(target) 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) waitPIDExit(uint32(pid), 120*time.Second)
time.Sleep(400 * time.Millisecond) time.Sleep(400 * time.Millisecond)
@@ -93,6 +99,16 @@ func MaybeFinishUpdate(args []string) bool {
return true 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. // CleanupStaleDownloads removes leftover update temps from failed/old runs.
func CleanupStaleDownloads() { func CleanupStaleDownloads() {
exe, err := os.Executable() exe, err := os.Executable()
+78
View File
@@ -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}: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке)."
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"
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"version": "2.7.2", "version": "2.7.3",
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».", "notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
@@ -16,7 +16,7 @@
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb", "sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
+38
View File
@@ -130,9 +130,13 @@ func writeHdiutilDMG(dmgPath, stageDir, volume string) error {
func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error { func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error {
macOSDir := filepath.Join(appRoot, "Contents", "MacOS") macOSDir := filepath.Join(appRoot, "Contents", "MacOS")
resDir := filepath.Join(appRoot, "Contents", "Resources")
if err := os.MkdirAll(macOSDir, 0o755); err != nil { if err := os.MkdirAll(macOSDir, 0o755); err != nil {
return err return err
} }
if err := os.MkdirAll(resDir, 0o755); err != nil {
return err
}
archPriority := architecturePriorityXML(arch) archPriority := architecturePriorityXML(arch)
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> 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"> <!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>CFBundleIdentifier</key><string>win.evilfox.navis</string>
<key>CFBundleName</key><string>Navis</string> <key>CFBundleName</key><string>Navis</string>
<key>CFBundleDisplayName</key><string>Navis</string> <key>CFBundleDisplayName</key><string>Navis</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundlePackageType</key><string>APPL</string> <key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>%s</string> <key>CFBundleShortVersionString</key><string>%s</string>
<key>CFBundleVersion</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 { if err := os.WriteFile(filepath.Join(appRoot, "Contents", "PkgInfo"), []byte("APPL????"), 0o644); err != nil {
return err 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") dest := filepath.Join(macOSDir, "Navis")
if err := copyFile(binPath, dest, 0o755); err != nil { if err := copyFile(binPath, dest, 0o755); err != nil {
return err return err
@@ -384,6 +394,34 @@ func mustStat(path string) os.FileInfo {
return fi 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 { func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src) in, err := os.Open(src)
if err != nil { if err != nil {
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"FixedFileInfo": { "FixedFileInfo": {
"FileVersion": { "Major": 2, "Minor": 7, "Patch": 2, "Build": 1 }, "FileVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
"ProductVersion": { "Major": 2, "Minor": 7, "Patch": 2, "Build": 1 }, "ProductVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
"FileFlags": "00", "FileFlags": "00",
"FileOS": "40004", "FileOS": "40004",
@@ -11,12 +11,12 @@
"StringFileInfo": { "StringFileInfo": {
"CompanyName": "EvilFox", "CompanyName": "EvilFox",
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", "FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "2.7.2.1", "FileVersion": "2.7.3.3",
"InternalName": "Navis", "InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox", "LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe", "OriginalFilename": "Navis.exe",
"ProductName": "Navis", "ProductName": "Navis",
"ProductVersion": "2.7.2.1", "ProductVersion": "2.7.3.3",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/" "Comments": "Open-source VPN/proxy client. https://evilfox.win/"
}, },
"VarFileInfo": { "VarFileInfo": {