5 Commits
Author SHA1 Message Date
M4andCursor 2ad376b49e Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:13:59 +03:00
M4andCursor ff5d87ab9f Docs: describe 3.8.2+4 glaze GUI unify and build auto-bump in release notes.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:05:02 +03:00
M4andCursor 464a61aebf Release 3.8.2.4: unify Windows/macOS GUI on glaze HTTP host.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Auto-bump build number on each compile; ship versioned Windows artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:01:25 +03:00
M4andCursor d1570b23d3 Release 3.8.2.3: cheaper poll I/O, batched macOS sysproxy, no Connect Clone.
Trust corebin cache without Stat; batch networksetup; ActiveProxyURI instead of Config.Clone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 03:02:19 +03:00
M4andCursor 838f9e340b Release 3.8.2.2: macOS Dock icon turns sea-wave N when connected.
Swap application icon on connect/disconnect via AppKit; ship arm64 build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:52:55 +03:00
48 changed files with 941 additions and 385 deletions
+11 -3
View File
@@ -6,8 +6,8 @@
| OS | Артефакты | UI |
|----|----------|----|
| Windows amd64 | `Navis.exe` | GUI (WebView2) |
| macOS universal | `Navis.dmg` · `Navis.app.zip` | **GUI** (как Windows) |
| Windows amd64 | `Navis.exe` | GUI (glaze → WebView2 + HTTP UI) |
| macOS universal | `Navis.dmg` · `Navis.app.zip` | GUI (glaze → WKWebView + HTTP UI) |
| macOS arm64 / amd64 | `Navis` · DMG · `Navis-cli` | GUI + CLI |
| Android | `Navis.apk` | GUI (VpnService) |
@@ -192,10 +192,14 @@ cd ~/Navis
## Сборка Windows
GUI — тот же HTML UI, что и на macOS: локальный HTTP API + окно [glaze](https://github.com/crgimenes/glaze) (на Windows под капотом Edge WebView2 Runtime).
```bat
build.bat
```
Артефакты: `Navis-<version>.<build>.exe`, `vpnclient-<version>.<build>.exe` (и копии в `dist\navis-release\`).
или:
```bat
@@ -247,7 +251,11 @@ https://evilfox.win/
- при неожиданном падении ядра снимается системный прокси и sentinel;
- логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение;
- подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP;
- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии.
- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии;
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
- 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone;
- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`);
- 3.8.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool.
В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 3080201
versionName = "3.8.2+1"
versionCode = 3080205
versionName = "3.8.2+5"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+12 -4
View File
@@ -1,7 +1,15 @@
@echo off
setlocal
setlocal EnableExtensions
cd /d "%~dp0"
echo Bumping build number...
where py >nul 2>&1 && py -3 scripts\bump-build.py && goto :bumped
where python >nul 2>&1 && python scripts\bump-build.py && goto :bumped
where python3 >nul 2>&1 && python3 scripts\bump-build.py && goto :bumped
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\bump-build.ps1"
if errorlevel 1 exit /b 1
:bumped
mkdir "dist\navis-release\darwin-arm64" 2>nul
mkdir "dist\navis-release\darwin-amd64" 2>nul
mkdir "dist\navis-release\darwin-universal" 2>nul
@@ -34,11 +42,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 3.8.2 -build 3.8.2.1 -arch arm64
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.5 -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 3.8.2 -build 3.8.2.1 -arch amd64
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.5 -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 3.8.2 -build 3.8.2.1 -arch universal
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.5 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
+66 -6
View File
@@ -1,7 +1,24 @@
@echo off
setlocal
setlocal EnableExtensions
cd /d "%~dp0"
echo Bumping build number...
call :bump_build
if errorlevel 1 exit /b 1
REM Full version = CurrentVersion.BuildNumber from versioninfo.json (e.g. 3.8.2.4)
set "VER="
for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content -Raw 'versioninfo.json' | ConvertFrom-Json).StringFileInfo.FileVersion"`) do set "VER=%%v"
if not defined VER (
echo Failed to read FileVersion from versioninfo.json
exit /b 1
)
set "OUT_GUI=Navis-%VER%.exe"
set "OUT_CLI=vpnclient-%VER%.exe"
set "DIST=dist\navis-release"
echo Building Navis %VER% for Windows...
echo Generating Windows icon resources...
go run ./tools/mkico
if errorlevel 1 exit /b 1
@@ -14,16 +31,59 @@ if errorlevel 1 (
goversioninfo -64 -icon assets\navis.ico -manifest assets\app.manifest -o cmd\vpnapp\resource.syso versioninfo.json
if errorlevel 1 exit /b 1
echo Building Navis GUI and CLI...
go build -ldflags="-H windowsgui -s -w" -trimpath -o Navis.exe ./cmd/vpnapp
echo Building GUI -^> %OUT_GUI%
go build -ldflags="-H windowsgui -s -w" -trimpath -o "%OUT_GUI%" ./cmd/vpnapp
if errorlevel 1 exit /b 1
go build -ldflags="-s -w" -trimpath -o vpnclient.exe ./cmd/vpnclient
echo Building CLI -^> %OUT_CLI%
go build -ldflags="-s -w" -trimpath -o "%OUT_CLI%" ./cmd/vpnclient
if errorlevel 1 exit /b 1
mkdir "%DIST%" 2>nul
copy /Y "%OUT_GUI%" "%DIST%\%OUT_GUI%" >nul
if errorlevel 1 exit /b 1
copy /Y "%OUT_CLI%" "%DIST%\%OUT_CLI%" >nul
if errorlevel 1 exit /b 1
REM Stable names for the update feed / installers (best-effort if file is locked)
call :copy_alias "%OUT_GUI%" "%DIST%\Navis.exe"
call :copy_alias "%OUT_CLI%" "%DIST%\vpnclient.exe"
echo.
echo Done:
echo Navis.exe
echo vpnclient.exe
echo %OUT_GUI%
echo %OUT_CLI%
echo %DIST%\%OUT_GUI%
echo %DIST%\%OUT_CLI%
echo %DIST%\Navis.exe (update alias)
echo assets\navis.ico
endlocal
exit /b 0
:bump_build
where py >nul 2>&1
if not errorlevel 1 (
py -3 scripts\bump-build.py
exit /b %ERRORLEVEL%
)
where python >nul 2>&1
if not errorlevel 1 (
python scripts\bump-build.py
exit /b %ERRORLEVEL%
)
where python3 >nul 2>&1
if not errorlevel 1 (
python3 scripts\bump-build.py
exit /b %ERRORLEVEL%
)
REM PowerShell fallback when Python is not installed
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\bump-build.ps1"
exit /b %ERRORLEVEL%
:copy_alias
if exist "%~2" del /F /Q "%~2" >nul 2>&1
copy /Y "%~1" "%~2" >nul 2>&1
if errorlevel 1 (
echo Warning: could not refresh %~2 ^(file in use^). Versioned build is still ready.
exit /b 0
)
exit /b 0
@@ -1,16 +1,13 @@
//go:build darwin
//go:build windows || darwin
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/crgimenes/glaze"
@@ -28,6 +25,7 @@ func main() {
return
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
@@ -44,6 +42,7 @@ func main() {
if err != nil {
fatalf("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
@@ -51,10 +50,9 @@ func main() {
}
a := apphost.New(mgr, cfgPath, logBuf)
a.OpenURL = func(u string) error {
return exec.Command("open", u).Start()
}
a.OpenURL = openExternal
a.OnAfterUpdate = func() {
// Hard-exit so the OS unlocks the binary for replacement.
go func() {
time.Sleep(300 * time.Millisecond)
_ = a.Disconnect()
@@ -71,6 +69,7 @@ func main() {
go func() {
_ = srv.Serve(ln)
}()
a.StartBackground()
trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() },
@@ -86,18 +85,21 @@ func main() {
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 {
if err2 := openExternal(uiURL); err2 != nil {
_ = srv.Close()
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)
signal.Notify(sig, shutdownSignals()...)
<-sig
a.Shutdown()
_ = mgr.Disconnect()
_ = srv.Close()
return
}
defer func() {
a.Shutdown()
_ = mgr.Disconnect()
_ = srv.Close()
w.Destroy()
@@ -105,14 +107,8 @@ func main() {
w.SetTitle("Navis")
w.SetSize(500, 900, glaze.HintNone)
decorateWindow(w)
w.Navigate(uiURL)
log.Printf("Navis UI: %s", uiURL)
w.Run()
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
os.Exit(1)
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func main() {
fmt.Fprintln(os.Stderr, "Navis GUI supports Windows (WebView2) and macOS.")
fmt.Fprintln(os.Stderr, "Navis GUI supports Windows and macOS (glaze + local HTTP UI).")
fmt.Fprintln(os.Stderr, "On this OS use the CLI: ./Navis connect | install-core | check-update")
os.Exit(1)
}
-202
View File
@@ -1,202 +0,0 @@
//go:build windows
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"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/logbuf"
"vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
fatalDialog("Не удалось найти путь конфига: %v", err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
if err := config.WriteExample(cfgPath); err != nil {
fatalDialog("Не удалось создать конфиг: %v", err)
}
}
cfg, err := config.Load(cfgPath)
if err != nil {
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalDialog("%v", err)
}
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)
_ = a.Disconnect()
os.Exit(0)
}()
}
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
_ = os.MkdirAll(dataPath, 0o755)
w := webview2.NewWithOptions(webview2.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
Title: "Navis",
Width: 500,
Height: 900,
Center: true,
IconId: 1,
},
})
if w == nil {
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
}
defer func() {
_ = mgr.Disconnect()
w.Destroy()
}()
applyWindowIcon(w.Window())
w.SetSize(500, 900, webview2.HintNone)
mustBind(w, "getState", a.GetState)
mustBind(w, "getEditState", a.GetEditState)
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)
mustBind(w, "getLogs", a.GetLogs)
mustBind(w, "probeTunnel", a.ProbeTunnel)
mustBind(w, "savePrefs", a.SavePrefs)
a.StartBackground()
trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() },
Disconnect: func() { _ = a.Disconnect() },
Quit: func() {
_ = a.Disconnect()
os.Exit(0)
},
IsUp: func() bool { return a.Mgr.Status().Connected },
})
go a.AutoCheckUpdate()
w.SetHtml(appui.IndexHTML)
w.Run()
}
func mustBind(w webview2.WebView, name string, fn interface{}) {
if err := w.Bind(name, fn); err != nil {
fatalDialog("bind %s: %v", name, err)
}
}
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath()
if ico == "" || hwnd == nil {
return
}
pathPtr, err := windows.UTF16PtrFromString(ico)
if err != nil {
return
}
user32 := windows.NewLazySystemDLL("user32.dll")
loadImage := user32.NewProc("LoadImageW")
sendMessage := user32.NewProc("SendMessageW")
const (
imageIcon = 1
lrLoadFromFile = 0x0010
lrDefaultSize = 0x0040
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
)
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
if h == 0 {
return
}
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
}
func findIconPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "assets", "navis.ico"),
filepath.Join(dir, "navis.ico"),
)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
func fatalDialog(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
messageBox(msg)
os.Exit(1)
}
+33
View File
@@ -0,0 +1,33 @@
//go:build darwin
package main
import (
"fmt"
"log"
"os"
"os/exec"
"syscall"
"github.com/crgimenes/glaze"
)
func openExternal(raw string) error {
return exec.Command("open", raw).Start()
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
os.Exit(1)
}
func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt, syscall.SIGTERM}
}
func decorateWindow(w glaze.WebView) {
// macOS Dock/app icon comes from the .app bundle; nothing to apply here.
_ = w
}
+92
View File
@@ -0,0 +1,92 @@
//go:build windows
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"unsafe"
"github.com/crgimenes/glaze"
"golang.org/x/sys/windows"
)
func openExternal(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
messageBox(msg)
os.Exit(1)
}
func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt}
}
func decorateWindow(w glaze.WebView) {
if w == nil {
return
}
applyWindowIcon(w.Window())
}
func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath()
if ico == "" || hwnd == nil {
return
}
pathPtr, err := windows.UTF16PtrFromString(ico)
if err != nil {
return
}
user32 := windows.NewLazySystemDLL("user32.dll")
loadImage := user32.NewProc("LoadImageW")
sendMessage := user32.NewProc("SendMessageW")
const (
imageIcon = 1
lrLoadFromFile = 0x0010
lrDefaultSize = 0x0040
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
)
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
if h == 0 {
return
}
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
}
func findIconPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "assets", "navis.ico"),
filepath.Join(dir, "navis.ico"),
)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3.8.2+1
3.8.2+3
+1 -1
View File
@@ -1 +1 @@
3.8.2.1
3.8.2.3
+6 -6
View File
@@ -1,28 +1,28 @@
{
"version": "3.8.2",
"notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.",
"notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd",
"dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f"
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -1,28 +1,28 @@
{
"version": "3.8.2",
"notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.",
"notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd",
"dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f"
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
+3 -4
View File
@@ -4,21 +4,20 @@ go 1.26.5
require (
github.com/amnezia-vpn/amneziawg-go v0.2.19
github.com/crgimenes/glaze v0.0.33
github.com/diskfs/go-diskfs v1.9.4
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
github.com/ebitengine/purego v0.10.1
golang.org/x/image v0.44.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.43.0
)
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
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/xattr v0.4.12 // indirect
+2 -6
View File
@@ -22,10 +22,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
@@ -48,8 +44,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+111 -13
View File
@@ -3,6 +3,7 @@ package apphost
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
@@ -18,6 +19,7 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/corebin"
"vpnclient/internal/dockicon"
"vpnclient/internal/logbuf"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
@@ -38,6 +40,8 @@ type App struct {
OpenURL func(string) error
OnAfterUpdate func()
APIToken string
ctx context.Context
cancel context.CancelFunc
}
type UIState struct {
@@ -62,6 +66,8 @@ type UIState struct {
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
LogTail string `json:"log_tail,omitempty"`
Rev string `json:"rev,omitempty"`
Unchanged bool `json:"unchanged,omitempty"`
}
type PingBestResult struct {
@@ -73,10 +79,13 @@ type PingBestResult struct {
}
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
ctx, cancel := context.WithCancel(context.Background())
return &App{
Mgr: mgr,
CfgPath: cfgPath,
LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
UpdateStatus: update.Status{
Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL,
@@ -84,13 +93,40 @@ func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
}
}
// Shutdown cancels background loops (dock icon / watchdog). Safe to call more than once.
func (a *App) Shutdown() {
if a == nil || a.cancel == nil {
return
}
a.cancel()
}
func (a *App) GetState() (UIState, error) {
return a.getState(false)
return a.GetStateSince("")
}
// GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since
// matches the current fingerprint (cheap idle HTTP polls).
func (a *App) GetStateSince(since string) (UIState, error) {
out, err := a.getState(false)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
if since != "" && since == out.Rev {
return UIState{Rev: out.Rev, Unchanged: true}, nil
}
return out, nil
}
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) {
return a.getState(true)
out, err := a.getState(true)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
return out, nil
}
func (a *App) getState(includeSecrets bool) (UIState, error) {
@@ -154,6 +190,23 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
return out, nil
}
func fingerprintState(s UIState) string {
var b strings.Builder
b.Grow(256)
fmt.Fprintf(&b, "%t|%s|%s|%s|%t|%s|%t|%t|%t|%s|",
s.Connected, s.Profile, s.ActiveProfile, s.Protocol, s.CoreReady, s.LastError,
s.SystemProxy, s.ConnectOnLaunch, s.AutoReconnect, s.Subscription)
fmt.Fprintf(&b, "%s|%s|%t|", s.Update.Latest, s.Update.Current, s.Update.Available)
for _, p := range s.Profiles {
fmt.Fprintf(&b, "%s,%s,%s,%t;", p.Name, p.Protocol, p.Host, p.Active)
}
for _, p := range s.Pings {
fmt.Fprintf(&b, "%s,%t,%t,%d,%s;", p.Name, p.OK, p.Soft, p.Ms, p.Error)
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:8])
}
func trimLogTail(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
@@ -237,10 +290,12 @@ func (a *App) ConnectProfile(name string) error {
return err
}
}
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return err
} else if strings.TrimSpace(p.Proxy) == "" {
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера")
}
@@ -264,18 +319,23 @@ func (a *App) ConnectProfile(name string) error {
perr := a.Mgr.Probe(pctx, "")
pcancel()
if perr == nil {
dockicon.SetConnected(true)
return nil
}
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
a.Mgr.SetLastError(last.Error())
_ = a.Mgr.Disconnect()
dockicon.SetConnected(false)
time.Sleep(400 * time.Millisecond)
}
dockicon.SetConnected(false)
return last
}
func (a *App) Disconnect() error {
return a.Mgr.Disconnect()
err := a.Mgr.Disconnect()
dockicon.SetConnected(false)
return err
}
func (a *App) GetLogs() string {
@@ -303,13 +363,18 @@ func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error
})
}
// StartBackground runs reconnect-on-crash and optional connect-on-launch.
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync.
func (a *App) StartBackground() {
go a.watchdogLoop()
go a.dockIconLoop()
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
go func() {
time.Sleep(1200 * time.Millisecond)
select {
case <-a.ctx.Done():
return
case <-time.After(1200 * time.Millisecond):
}
if a.Mgr.Status().Connected {
return
}
@@ -318,9 +383,35 @@ func (a *App) StartBackground() {
}
}
func (a *App) watchdogLoop() {
func (a *App) dockIconLoop() {
dockicon.SetConnected(false)
var last bool
first := true
t := time.NewTicker(400 * time.Millisecond)
defer t.Stop()
for {
time.Sleep(2 * time.Second)
select {
case <-a.ctx.Done():
return
case <-t.C:
up := a.Mgr.Status().Connected
if first || up != last {
first = false
last = up
dockicon.SetConnected(up)
}
}
}
}
func (a *App) watchdogLoop() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
if !a.Mgr.TakeUnexpectedDrop() {
continue
}
@@ -328,10 +419,15 @@ func (a *App) watchdogLoop() {
if !prefs.AutoReconnect {
continue
}
time.Sleep(800 * time.Millisecond)
select {
case <-a.ctx.Done():
return
case <-time.After(800 * time.Millisecond):
}
_ = a.Connect()
}
}
}
func (a *App) InstallCore() (string, error) {
paths, err := a.Mgr.EnsureAllCores()
@@ -398,10 +494,12 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
out.Connected = true
return out, nil
}
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return out, err
} else if strings.TrimSpace(p.Proxy) == "" {
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
}
@@ -576,7 +674,7 @@ func arg[T any](args []json.RawMessage, i int, def T) T {
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
switch name {
case "getState":
return a.GetState()
return a.GetStateSince(arg(args, 0, ""))
case "getEditState":
return a.GetEditState()
case "connect":
+95
View File
@@ -0,0 +1,95 @@
package apphost
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
)
func TestHTTPBridgeGetState(t *testing.T) {
cfg := &config.Config{
Active: "demo",
Profiles: []config.Profile{
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
},
}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "test-token"
body, _ := json.Marshal(map[string]any{"args": []any{}})
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Navis-Token", "test-token")
rr := httptest.NewRecorder()
a.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
}
var resp struct {
Result UIState `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Error != "" {
t.Fatalf("api error: %s", resp.Error)
}
if resp.Result.ActiveProfile != "demo" {
t.Fatalf("active_profile=%q", resp.Result.ActiveProfile)
}
}
func TestHTTPBridgeGetStateUnchanged(t *testing.T) {
cfg := &config.Config{
Active: "demo",
Profiles: []config.Profile{
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
},
}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "test-token"
full, err := a.GetStateSince("")
if err != nil || full.Unchanged || full.Rev == "" {
t.Fatalf("full: unchanged=%v rev=%q err=%v", full.Unchanged, full.Rev, err)
}
again, err := a.GetStateSince(full.Rev)
if err != nil || !again.Unchanged || again.Rev != full.Rev {
t.Fatalf("delta: %+v err=%v", again, err)
}
if len(again.Profiles) != 0 {
t.Fatalf("unchanged payload should omit profiles, got %d", len(again.Profiles))
}
}
func TestHTTPBridgeRejectsBadToken(t *testing.T) {
cfg := &config.Config{}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "secret"
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader([]byte(`{"args":[]}`)))
req.Header.Set("X-Navis-Token", "wrong")
rr := httptest.NewRecorder()
a.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rr.Code)
}
}
+19 -3
View File
@@ -1,4 +1,4 @@
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives).
// HTTP bridge for glaze GUI (Windows + macOS): POST /api/<method>.
(function () {
const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
@@ -208,6 +208,7 @@
}
let listFP = "";
let stateRev = "";
function profilesFingerprint(state) {
const parts = [
@@ -444,13 +445,22 @@
}
async function refresh(opts) {
const force = !!(opts && opts.force);
if (document.hidden && !force && !(opts && opts.syncForm)) {
return null;
}
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
let state;
if (needSecrets && typeof getEditState === "function") {
state = await getEditState();
} else {
state = await getState();
state = await getState(stateRev || "");
}
if (state && state.unchanged) {
if (state.rev) stateRev = state.rev;
return state;
}
if (state && state.rev) stateRev = state.rev;
paint(state, opts);
return state;
}
@@ -697,5 +707,11 @@
setMeta(String(e), "err");
}
})();
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000);
setInterval(() => {
if (document.hidden || busy || modal.classList.contains("open")) return;
refresh().catch(() => {});
}, 4000);
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !busy) refresh({ force: true }).catch(() => {});
});
+1 -1
View File
@@ -14,7 +14,7 @@ var appCSS string
//go:embed app.js
var appJS string
// IndexHTML is the full GUI document (CSS/JS inlined for WebView2 / glaze).
// IndexHTML is the full GUI document (CSS/JS inlined for glaze HTTP UI).
var IndexHTML = buildIndexHTML()
func buildIndexHTML() string {
+11 -2
View File
@@ -325,8 +325,9 @@ func WriteExample(path string) error {
return Save(path, Example())
}
// Save writes config as indented JSON.
// Save writes config as indented JSON (atomic replace).
func Save(path string, cfg Config) error {
InvalidateHostCache()
if err := cfg.Validate(); err != nil {
return err
}
@@ -341,7 +342,15 @@ func Save(path string, cfg Config) error {
return err
}
}
return os.WriteFile(path, data, 0o600)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// SetActiveProxy updates the active profile proxy URI.
+44 -2
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"strings"
"sync"
)
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
@@ -14,6 +15,47 @@ type ProfileInfo struct {
Active bool `json:"active"`
}
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
var hostCache sync.Map // proxy URI/conf → host string
const hostCacheMax = 2048
func cachedProxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
if v, ok := hostCache.Load(proxy); ok {
return v.(string)
}
h := proxyHost(proxy)
hostCache.Store(proxy, h)
// Soft bound: subscription churn can retain stale URI keys; drop all if oversized.
n := 0
over := false
hostCache.Range(func(_, _ any) bool {
n++
if n > hostCacheMax {
over = true
return false
}
return true
})
if over {
InvalidateHostCache()
hostCache.Store(proxy, h)
}
return h
}
// InvalidateHostCache clears parsed host strings (call after profile mutations).
func InvalidateHostCache() {
hostCache.Range(func(k, _ any) bool {
hostCache.Delete(k)
return true
})
}
// ListProfiles returns UI-friendly profile summaries.
func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
@@ -22,7 +64,7 @@ func (c *Config) ListProfiles() []ProfileInfo {
Name: p.Name,
Protocol: string(p.Protocol),
Proxy: p.Proxy,
Host: proxyHost(p.Proxy),
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
@@ -36,7 +78,7 @@ func (c *Config) ListProfilesForPoll() []ProfileInfo {
out = append(out, ProfileInfo{
Name: p.Name,
Protocol: string(p.Protocol),
Host: proxyHost(p.Proxy),
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
+34 -10
View File
@@ -350,6 +350,20 @@ func (m *Manager) Config() *config.Config {
return m.cfg.Clone()
}
// ActiveProxyURI returns the active profile proxy without cloning the whole config.
func (m *Manager) ActiveProxyURI() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return "", fmt.Errorf("no config")
}
p, err := m.cfg.ActiveProfile()
if err != nil {
return "", err
}
return p.Proxy, nil
}
// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
type UIPoll struct {
Status Status
@@ -366,19 +380,20 @@ type UIPoll struct {
AutoReconnect bool
}
// PollUI gathers status + profile list under one lock without Config().Clone().
// PollUI gathers status + profile list without Config().Clone().
// Config/profile fields are copied under the manager lock; engine status is
// read after unlock so a slow Engine.Running cannot block config saves.
func (m *Manager) PollUI(includeSecrets bool) UIPoll {
m.mu.Lock()
defer m.mu.Unlock()
out := UIPoll{
Status: Status{SystemProxy: m.sys.Enabled()},
BinDir: m.binDir,
SystemProxy: false,
Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
LastError: m.lastError,
}
var eng Engine
var profCopy *config.Profile
if m.cfg != nil {
out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy
@@ -410,16 +425,25 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll {
}
}
}
if m.engine != nil && m.engine.Running() {
out.Status.Connected = true
eng = m.engine
if m.profile != nil {
out.Status.Profile = m.profile.Name
out.Status.Protocol = m.profile.Protocol
p := *m.profile
profCopy = &p
}
if hp, ok := m.engine.LocalHTTPProxy(); ok {
sys := m.sys
m.mu.Unlock()
out.Status = Status{SystemProxy: sys.Enabled()}
if eng != nil && eng.Running() {
out.Status.Connected = true
if profCopy != nil {
out.Status.Profile = profCopy.Name
out.Status.Protocol = profCopy.Protocol
}
if hp, ok := eng.LocalHTTPProxy(); ok {
out.Status.HTTPProxy = hp
}
if sp, ok := m.engine.LocalSOCKSProxy(); ok {
if sp, ok := eng.LocalSOCKSProxy(); ok {
out.Status.SOCKSProxy = sp
}
}
+23 -7
View File
@@ -1,13 +1,16 @@
package corebin
import (
"os"
"strings"
"sync"
"golang.org/x/sync/singleflight"
)
var (
mu sync.Mutex
cache = map[string]string{}
group singleflight.Group
)
// CacheKey identifies a resolved core binary for a binDir + protocol.
@@ -15,6 +18,11 @@ func CacheKey(binDir, proto string) string {
return binDir + "\x00" + proto
}
// VirtualPath reports paths that are not on-disk binaries (e.g. embedded AWG).
func VirtualPath(path string) bool {
return strings.HasPrefix(path, "embedded-")
}
// Get returns a cached absolute path if present.
func Get(key string) (string, bool) {
mu.Lock()
@@ -33,22 +41,24 @@ func Set(key, path string) {
mu.Unlock()
}
// Invalidate clears all cached paths (call after EnsureCore / binDir change).
// Invalidate clears all cached paths (call after EnsureCore / binDir change / failed Start).
func Invalidate() {
mu.Lock()
cache = map[string]string{}
mu.Unlock()
}
// Resolve caches the result of fn under key. Stale paths (missing on disk) are refreshed.
// Resolve caches the result of fn under key.
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
// Embedded/virtual paths never touch the filesystem.
// Concurrent cold misses for the same key share one fn call (singleflight).
func Resolve(key string, fn func() (string, error)) (string, error) {
if path, ok := Get(key); ok {
if _, err := os.Stat(path); err == nil {
return path, nil
}
mu.Lock()
delete(cache, key)
mu.Unlock()
v, err, _ := group.Do(key, func() (any, error) {
if path, ok := Get(key); ok {
return path, nil
}
path, err := fn()
if err != nil {
@@ -56,6 +66,12 @@ func Resolve(key string, fn func() (string, error)) (string, error) {
}
Set(key, path)
return path, nil
})
if err != nil {
return "", err
}
path, _ := v.(string)
return path, nil
}
// ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan).
+61 -14
View File
@@ -3,10 +3,13 @@ package corebin
import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestResolveCacheAndStale(t *testing.T) {
func TestResolveTrustsCacheWithoutStat(t *testing.T) {
Invalidate()
dir := t.TempDir()
path := filepath.Join(dir, "core")
@@ -20,26 +23,70 @@ func TestResolveCacheAndStale(t *testing.T) {
return path, nil
}
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("first: path=%q calls=%d err=%v", p, calls, err)
t.Fatalf("first: %q %v calls=%d", p, err, calls)
}
_ = os.Remove(path) // missing on disk — cache still trusted until Invalidate
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("cached: path=%q calls=%d err=%v", p, calls, err)
t.Fatalf("cached without Stat: %q %v calls=%d", p, err, calls)
}
_ = os.Remove(path)
if _, err := Resolve(key, fn); err != nil {
// fn returns removed path; Resolve still succeeds but next Stat misses
t.Fatalf("unexpected err after delete before recreate: %v", err)
Invalidate()
if _, err := Resolve(key, fn); err == nil && calls != 2 {
// fn returns deleted path; Resolve still succeeds
}
if calls != 2 {
t.Fatalf("expected refresh call, got %d", calls)
t.Fatalf("after Invalidate expected refresh, calls=%d", calls)
}
if err := os.WriteFile(path, []byte("y"), 0o755); err != nil {
t.Fatal(err)
}
if p, err := Resolve(key, fn); err != nil || p != path {
t.Fatalf("after recreate: %q %v", p, err)
func TestResolveSingleflight(t *testing.T) {
Invalidate()
key := CacheKey(t.TempDir(), "naive")
var calls atomic.Int32
var started sync.WaitGroup
started.Add(1)
fn := func() (string, error) {
calls.Add(1)
started.Wait()
time.Sleep(20 * time.Millisecond)
return "embedded-test-core", nil
}
if ProtoKey("vless") != "xray" || ProtoKey("naive") != "naive" {
t.Fatal("ProtoKey")
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := Resolve(key, fn); err != nil {
t.Errorf("resolve: %v", err)
}
}()
}
time.Sleep(10 * time.Millisecond)
started.Done()
wg.Wait()
if calls.Load() != 1 {
t.Fatalf("expected 1 cold resolve, got %d", calls.Load())
}
}
func TestVirtualEmbeddedAWG(t *testing.T) {
Invalidate()
key := CacheKey("/bin", "awg")
calls := 0
p, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p != "embedded-amneziawg-go" || calls != 1 {
t.Fatalf("%q %v calls=%d", p, err, calls)
}
if !VirtualPath(p) {
t.Fatal("expected virtual")
}
p2, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p2 != p || calls != 1 {
t.Fatalf("second: %q calls=%d", p2, calls)
}
}
+106
View File
@@ -0,0 +1,106 @@
//go:build darwin
package dockicon
import (
_ "embed"
"log"
"sync"
"unsafe"
"github.com/ebitengine/purego"
"github.com/ebitengine/purego/objc"
)
//go:embed icon_idle.png
var iconIdlePNG []byte
//go:embed icon_connected.png
var iconConnectedPNG []byte
var (
once sync.Once
ready bool
mu sync.Mutex
last *bool
selSet objc.SEL
selPerf objc.SEL
selShared objc.SEL
selData objc.SEL
selAlloc objc.SEL
selInit objc.SEL
selRetain objc.SEL
clsApp objc.Class
clsData objc.Class
clsImage objc.Class
)
func initAppKit() {
once.Do(func() {
if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: Foundation: %v", err)
return
}
if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: AppKit: %v", err)
return
}
clsApp = objc.GetClass("NSApplication")
clsData = objc.GetClass("NSData")
clsImage = objc.GetClass("NSImage")
if clsApp == 0 || clsData == 0 || clsImage == 0 {
log.Printf("dockicon: missing AppKit classes")
return
}
selShared = objc.RegisterName("sharedApplication")
selSet = objc.RegisterName("setApplicationIconImage:")
selPerf = objc.RegisterName("performSelectorOnMainThread:withObject:waitUntilDone:")
selData = objc.RegisterName("dataWithBytes:length:")
selAlloc = objc.RegisterName("alloc")
selInit = objc.RegisterName("initWithData:")
selRetain = objc.RegisterName("retain")
ready = true
})
}
// SetConnected swaps the Dock icon: sea-wave N when connected, original when idle.
func SetConnected(connected bool) {
initAppKit()
if !ready {
return
}
mu.Lock()
if last != nil && *last == connected {
mu.Unlock()
return
}
v := connected
last = &v
mu.Unlock()
png := iconIdlePNG
if connected {
png = iconConnectedPNG
}
if len(png) == 0 {
return
}
data := objc.ID(clsData).Send(selData, unsafe.Pointer(&png[0]), len(png))
if data == 0 {
return
}
img := objc.ID(clsImage).Send(selAlloc)
img = img.Send(selInit, data)
if img == 0 {
return
}
img.Send(selRetain) // keep alive across async main-thread call
app := objc.ID(clsApp).Send(selShared)
if app == 0 {
return
}
// AppKit must run on the main thread (glaze owns it).
app.Send(selPerf, selSet, img, false)
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !darwin
package dockicon
// SetConnected is a no-op outside macOS (Dock icons are a Darwin feature).
func SetConnected(connected bool) {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+13 -7
View File
@@ -57,20 +57,26 @@ func PingAll(ctx context.Context, targets []Target) []Result {
if len(targets) < workers {
workers = len(targets)
}
sem := make(chan struct{}, workers)
jobs := make(chan int, len(targets))
for i := range targets {
jobs <- i
}
close(jobs)
var wg sync.WaitGroup
for i, t := range targets {
for w := 0; w < workers; w++ {
wg.Add(1)
go func(i int, t Target) {
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
for i := range jobs {
t := targets[i]
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
return
continue
}
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
}(i, t)
}
}()
}
wg.Wait()
+34 -34
View File
@@ -47,32 +47,25 @@ func (c *darwinController) Enable(httpHostPort string) error {
return fmt.Errorf("sysproxy: no network services")
}
// One bash process for all networksetup calls (was 6 execs × N services).
// setwebproxy / setsecurewebproxy / setsocksfirewallproxy also turn the proxy on.
var b strings.Builder
b.WriteString("set -e\n")
for _, svc := range services {
if err := runNetworkSetup("-setwebproxy", svc, host, port); err != nil {
return err
q := shellQuote(svc)
fmt.Fprintf(&b, "networksetup -setwebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
fmt.Fprintf(&b, "networksetup -setsecurewebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxy %s %s %s\n", q, shellQuote(host), shellQuote(socks))
}
if err := runNetworkSetup("-setsecurewebproxy", svc, host, port); err != nil {
if err := runBash(b.String()); err != nil {
return err
}
if err := runNetworkSetup("-setsocksfirewallproxy", svc, host, socks); err != nil {
return err
}
if err := runNetworkSetup("-setwebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsecurewebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsocksfirewallproxystate", svc, "on"); err != nil {
return err
}
}
c.enabled = true
c.host = host
c.httpPort = port
c.socksPort = socks
c.services = services
c.services = append([]string(nil), services...)
return nil
}
@@ -88,7 +81,8 @@ func (c *darwinController) Disable() error {
func (c *darwinController) ForceDisable() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.disableServices(nil)
svcs := c.services
return c.disableServices(svcs)
}
func (c *darwinController) disableServices(services []string) error {
@@ -99,21 +93,23 @@ func (c *darwinController) disableServices(services []string) error {
return err
}
}
var first error
for _, svc := range services {
for _, args := range [][]string{
{"-setwebproxystate", svc, "off"},
{"-setsecurewebproxystate", svc, "off"},
{"-setsocksfirewallproxystate", svc, "off"},
} {
if err := runNetworkSetup(args...); err != nil && first == nil {
first = err
}
}
}
if len(services) == 0 {
c.enabled = false
c.services = nil
return first
return nil
}
var b strings.Builder
b.WriteString("set +e\n") // best-effort: turn off what we can
for _, svc := range services {
q := shellQuote(svc)
fmt.Fprintf(&b, "networksetup -setwebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsecurewebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxystate %s off\n", q)
}
err := runBash(b.String())
c.enabled = false
c.services = nil
return err
}
func (c *darwinController) Enabled() bool {
@@ -142,11 +138,15 @@ func listNetworkServices() ([]string, error) {
return services, sc.Err()
}
func runNetworkSetup(args ...string) error {
cmd := exec.Command("networksetup", args...)
func runBash(script string) error {
cmd := exec.Command("/bin/bash", "-c", script)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("networksetup %v: %w (%s)", args, err, strings.TrimSpace(string(out)))
return fmt.Errorf("networksetup batch: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 1
const BuildNumber = 5
// 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"
+4 -4
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")/.."
# Prefer local toolchain if present.
export PATH="$(pwd)/.tools/go/bin:/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH"
python3 scripts/sync-version.py
python3 scripts/bump-build.py
VERSION="$(python3 - <<'PY'
import re
@@ -77,9 +77,9 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest()
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
notes = (
f"Navis {ver}+{build}: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; "
f"автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; "
f"CI и строгая подпись релиза."
f"Navis {ver}+{build}: delta-poll UI (getState по rev); пауза опроса при скрытом окне; "
f"cancel lifecycle dock/watchdog; invalidate/лимит hostCache; "
f"PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool."
)
paths = [
Path('dist/update.json'),
+59
View File
@@ -0,0 +1,59 @@
# Increment BuildNumber in update.go and sync version artifacts (no Python required).
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
$updateGo = Join-Path $Root "internal\update\update.go"
$text = [IO.File]::ReadAllText($updateGo)
$m = [regex]::Match($text, 'const BuildNumber\s*=\s*(\d+)')
if (-not $m.Success) { throw "cannot find BuildNumber in update.go" }
$old = [int]$m.Groups[1].Value
$new = $old + 1
$text2 = [regex]::Replace($text, 'const BuildNumber\s*=\s*\d+', "const BuildNumber = $new", 1)
[IO.File]::WriteAllText($updateGo, $text2)
Write-Host "BuildNumber: $old -> $new"
$verM = [regex]::Match($text2, 'CurrentVersion\s*=\s*"([^"]+)"')
if (-not $verM.Success) { throw "cannot find CurrentVersion" }
$ver = $verM.Groups[1].Value
$parts = @($ver.Split('.') | ForEach-Object { [int]$_ })
while ($parts.Count -lt 3) { $parts += 0 }
$major, $minor, $patch = $parts[0], $parts[1], $parts[2]
$full = "$ver.$new"
$display = "$ver+$new"
$versionCode = $major * 1000000 + $minor * 10000 + $patch * 100 + $new
$viPath = Join-Path $Root "versioninfo.json"
$vi = [IO.File]::ReadAllText($viPath)
$vi = [regex]::Replace($vi, '("FileVersion"\s*:\s*\{\s*"Major"\s*:\s*)\d+', "`${1}$major", 1)
$vi = [regex]::Replace($vi, '("Minor"\s*:\s*)\d+(\s*,\s*"Patch")', "`${1}$minor`${2}", 1)
$vi = [regex]::Replace($vi, '("Patch"\s*:\s*)\d+(\s*,\s*"Build")', "`${1}$patch`${2}", 1)
$vi = [regex]::Replace($vi, '("Build"\s*:\s*)\d+', "`${1}$new", 2)
$vi = [regex]::Replace($vi, '("FileVersion"\s*:\s*")\d+\.\d+\.\d+\.\d+(")', "`${1}$full`${2}")
$vi = [regex]::Replace($vi, '("ProductVersion"\s*:\s*")\d+\.\d+\.\d+\.\d+(")', "`${1}$full`${2}")
[IO.File]::WriteAllText($viPath, $vi)
Write-Host "updated versioninfo.json -> $full"
$batPath = Join-Path $Root "build-macos.bat"
$bat = [IO.File]::ReadAllText($batPath)
$bat2 = [regex]::Replace(
$bat,
'-version\s+\d+\.\d+\.\d+\s+-build\s+\d+\.\d+\.\d+\.\d+',
"-version $ver -build $full"
)
if ($bat2 -ne $bat) {
[IO.File]::WriteAllText($batPath, $bat2)
Write-Host "updated build-macos.bat -> $ver $full"
}
$gradlePath = Join-Path $Root "android\app\build.gradle.kts"
if (Test-Path $gradlePath) {
$g = [IO.File]::ReadAllText($gradlePath)
$g2 = [regex]::Replace($g, 'versionCode\s*=\s*[\d_]+', "versionCode = $versionCode")
$g2 = [regex]::Replace($g2, 'versionName\s*=\s*"[^"]+"', "versionName = `"$display`"")
if ($g2 -ne $g) {
[IO.File]::WriteAllText($gradlePath, $g2)
Write-Host "updated android gradle -> $display $versionCode"
}
}
Write-Host "bump complete: $full"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Increment BuildNumber in update.go, then sync versioninfo / macOS bat / Android."""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UPDATE_GO = ROOT / "internal" / "update" / "update.go"
def bump() -> int:
text = UPDATE_GO.read_text(encoding="utf-8")
m = re.search(r"const BuildNumber\s*=\s*(\d+)", text)
if not m:
raise SystemExit("cannot find BuildNumber in update.go")
n = int(m.group(1)) + 1
text2, count = re.subn(
r"const BuildNumber\s*=\s*\d+",
f"const BuildNumber = {n}",
text,
count=1,
)
if count != 1:
raise SystemExit("failed to rewrite BuildNumber")
UPDATE_GO.write_text(text2, encoding="utf-8")
print(f"BuildNumber: {m.group(1)} -> {n}", flush=True)
return n
def main() -> None:
bump()
sync = ROOT / "scripts" / "sync-version.py"
r = subprocess.run([sys.executable, str(sync)], cwd=ROOT, check=False)
if r.returncode != 0:
raise SystemExit(r.returncode)
if __name__ == "__main__":
main()
+6 -6
View File
@@ -1,28 +1,28 @@
{
"version": "3.8.2",
"notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.",
"notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd",
"dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f"
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
+4 -4
View File
@@ -4,13 +4,13 @@
"Major": 3,
"Minor": 8,
"Patch": 2,
"Build": 1
"Build": 5
},
"ProductVersion": {
"Major": 3,
"Minor": 8,
"Patch": 2,
"Build": 1
"Build": 5
},
"FileFlagsMask": "3f",
"FileFlags": "00",
@@ -21,12 +21,12 @@
"StringFileInfo": {
"CompanyName": "EvilFox",
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "3.8.2.1",
"FileVersion": "3.8.2.5",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
"ProductVersion": "3.8.2.1",
"ProductVersion": "3.8.2.5",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {