Release 3.8.2.6: perceived-speed UX — search, tray hide, core warm, SSE.
Server search/hotkeys; Windows close-to-tray; background EnsureCore; SSE status with rare poll fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: After every compile/bump, always write release notes for the new build
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Описание изменений после компиляции
|
||||||
|
|
||||||
|
После любой успешной сборки с бампом `BuildNumber` (через `build.bat`, `build-macos.bat` / скрипты bump, или ручной bump) **обязательно** обновить описание сделанных изменений — не оставлять только бинарник.
|
||||||
|
|
||||||
|
## Что обновлять
|
||||||
|
|
||||||
|
1. `notes` в:
|
||||||
|
- `dist/update.json`
|
||||||
|
- `dist/navis-release/update.json`
|
||||||
|
- `server/update.json`
|
||||||
|
2. Краткую строку в changelog `README.md` (секция текущей версии, формат `x.y.z+N: …`).
|
||||||
|
3. При релизе Windows-артефакта — `sha256` для `Navis.exe` / `windows-amd64` в тех же `update.json`.
|
||||||
|
|
||||||
|
## Как писать notes
|
||||||
|
|
||||||
|
- На русском, в стиле существующих записей.
|
||||||
|
- Начинать с `Navis x.y.z+N:` (как в `DisplayVersion` / build).
|
||||||
|
- Перечислить суть изменений для пользователя и ключевые тех. точки (не дамп диффа).
|
||||||
|
- Синхронизировать смысл между `update.json` и `README.md` (README можно короче).
|
||||||
|
|
||||||
|
Не редактировать plan-файлы ради notes. Не коммитить и не пушить, пока пользователь явно не попросил.
|
||||||
@@ -255,7 +255,8 @@ https://evilfox.win/
|
|||||||
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
|
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
|
||||||
- 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone;
|
- 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+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.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool;
|
||||||
|
- 3.8.2+6: поиск по серверам и хоткеи; Windows close→tray (Открыть/Выход); фоновый прогрев активного core; SSE `/api/events` + редкий poll fallback.
|
||||||
|
|
||||||
В 3.8.1:
|
В 3.8.1:
|
||||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
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 = 3080205
|
versionCode = 3080206
|
||||||
versionName = "3.8.2+5"
|
versionName = "3.8.2+6"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||||
|
|||||||
+3
-3
@@ -42,11 +42,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 3.8.2 -build 3.8.2.5 -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.6 -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 3.8.2 -build 3.8.2.5 -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.6 -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 3.8.2 -build 3.8.2.5 -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.6 -arch universal
|
||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
echo Built Mac GUI + CLI:
|
echo Built Mac GUI + CLI:
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ func main() {
|
|||||||
trayhost.Start("Navis", trayhost.Hooks{
|
trayhost.Start("Navis", trayhost.Hooks{
|
||||||
Connect: func() { _ = a.Connect() },
|
Connect: func() { _ = a.Connect() },
|
||||||
Disconnect: func() { _ = a.Disconnect() },
|
Disconnect: func() { _ = a.Disconnect() },
|
||||||
|
Show: showMainWindow,
|
||||||
Quit: func() {
|
Quit: func() {
|
||||||
_ = a.Disconnect()
|
_ = a.Disconnect()
|
||||||
os.Exit(0)
|
a.Shutdown()
|
||||||
|
closeMainWindow()
|
||||||
},
|
},
|
||||||
IsUp: func() bool { return a.Mgr.Status().Connected },
|
IsUp: func() bool { return a.Mgr.Status().Connected },
|
||||||
})
|
})
|
||||||
@@ -98,6 +100,7 @@ func main() {
|
|||||||
_ = srv.Close()
|
_ = srv.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.OnQuit = closeMainWindow
|
||||||
defer func() {
|
defer func() {
|
||||||
a.Shutdown()
|
a.Shutdown()
|
||||||
_ = mgr.Disconnect()
|
_ = mgr.Disconnect()
|
||||||
|
|||||||
@@ -31,3 +31,11 @@ func decorateWindow(w glaze.WebView) {
|
|||||||
// macOS Dock/app icon comes from the .app bundle; nothing to apply here.
|
// macOS Dock/app icon comes from the .app bundle; nothing to apply here.
|
||||||
_ = w
|
_ = w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func showMainWindow() {}
|
||||||
|
|
||||||
|
func prepareWindowQuit() {}
|
||||||
|
|
||||||
|
func closeMainWindow() {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,12 +7,38 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"github.com/crgimenes/glaze"
|
"github.com/crgimenes/glaze"
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
procGetWindowLongPtr = user32.NewProc("GetWindowLongPtrW")
|
||||||
|
procSetWindowLongPtr = user32.NewProc("SetWindowLongPtrW")
|
||||||
|
procCallWindowProc = user32.NewProc("CallWindowProcW")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
procSetForeground = user32.NewProc("SetForegroundWindow")
|
||||||
|
procPostMessage = user32.NewProc("PostMessageW")
|
||||||
|
procIsIconic = user32.NewProc("IsIconic")
|
||||||
|
|
||||||
|
mainHWND uintptr
|
||||||
|
origWndProc uintptr
|
||||||
|
closeHookCB uintptr
|
||||||
|
allowDestroy atomic.Bool
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// GWLP_WNDPROC as unsigned pointer-sized -4.
|
||||||
|
gwlpWndProc = ^uintptr(3)
|
||||||
|
wmClose = 0x0010
|
||||||
|
swHide = 0
|
||||||
|
swShow = 5
|
||||||
|
swRestore = 9
|
||||||
|
)
|
||||||
|
|
||||||
func openExternal(raw string) error {
|
func openExternal(raw string) error {
|
||||||
verb, err := windows.UTF16PtrFromString("open")
|
verb, err := windows.UTF16PtrFromString("open")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -40,7 +66,61 @@ func decorateWindow(w glaze.WebView) {
|
|||||||
if w == nil {
|
if w == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyWindowIcon(w.Window())
|
hwnd := w.Window()
|
||||||
|
applyWindowIcon(hwnd)
|
||||||
|
installCloseToHide(hwnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installCloseToHide(hwnd unsafe.Pointer) {
|
||||||
|
if hwnd == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mainHWND = uintptr(hwnd)
|
||||||
|
allowDestroy.Store(false)
|
||||||
|
if closeHookCB == 0 {
|
||||||
|
closeHookCB = windows.NewCallback(closeToHideWndProc)
|
||||||
|
}
|
||||||
|
orig, _, _ := procGetWindowLongPtr.Call(mainHWND, gwlpWndProc)
|
||||||
|
if orig == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origWndProc = orig
|
||||||
|
procSetWindowLongPtr.Call(mainHWND, gwlpWndProc, closeHookCB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeToHideWndProc(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
|
||||||
|
if msg == wmClose && !allowDestroy.Load() {
|
||||||
|
procShowWindow.Call(hwnd, uintptr(swHide))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r, _, _ := procCallWindowProc.Call(origWndProc, hwnd, uintptr(msg), wParam, lParam)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func showMainWindow() {
|
||||||
|
if mainHWND == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iconic, _, _ := procIsIconic.Call(mainHWND)
|
||||||
|
if iconic != 0 {
|
||||||
|
procShowWindow.Call(mainHWND, uintptr(swRestore))
|
||||||
|
} else {
|
||||||
|
procShowWindow.Call(mainHWND, uintptr(swShow))
|
||||||
|
}
|
||||||
|
procSetForeground.Call(mainHWND)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareWindowQuit() {
|
||||||
|
allowDestroy.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeMainWindow() {
|
||||||
|
prepareWindowQuit()
|
||||||
|
if mainHWND == 0 {
|
||||||
|
os.Exit(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
procPostMessage.Call(mainHWND, uintptr(wmClose), 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyWindowIcon(hwnd unsafe.Pointer) {
|
func applyWindowIcon(hwnd unsafe.Pointer) {
|
||||||
@@ -52,7 +132,6 @@ func applyWindowIcon(hwnd unsafe.Pointer) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user32 := windows.NewLazySystemDLL("user32.dll")
|
|
||||||
loadImage := user32.NewProc("LoadImageW")
|
loadImage := user32.NewProc("LoadImageW")
|
||||||
sendMessage := user32.NewProc("SendMessageW")
|
sendMessage := user32.NewProc("SendMessageW")
|
||||||
const (
|
const (
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"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.",
|
"notes": "Navis 3.8.2+6: поиск/фильтр серверов и хоткеи (/, Ctrl+F, Space/Enter, Esc); Windows: закрытие окна = скрытие в трей, «Открыть»/Выход из трея; фоновый EnsureCore активного протокола; SSE /api/events для мгновенного статуса + poll 12 с как safety net.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
"sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
"sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"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.",
|
"notes": "Navis 3.8.2+6: поиск/фильтр серверов и хоткеи (/, Ctrl+F, Space/Enter, Esc); Windows: закрытие окна = скрытие в трей, «Открыть»/Выход из трея; фоновый EnsureCore активного протокола; SSE /api/events для мгновенного статуса + poll 12 с как safety net.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
"sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
"sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+39
-6
@@ -39,9 +39,11 @@ type App struct {
|
|||||||
Pings []netcheck.Result
|
Pings []netcheck.Result
|
||||||
OpenURL func(string) error
|
OpenURL func(string) error
|
||||||
OnAfterUpdate func()
|
OnAfterUpdate func()
|
||||||
|
OnQuit func()
|
||||||
APIToken string
|
APIToken string
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
events *eventBroker
|
||||||
}
|
}
|
||||||
|
|
||||||
type UIState struct {
|
type UIState struct {
|
||||||
@@ -81,11 +83,12 @@ type PingBestResult struct {
|
|||||||
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
|
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &App{
|
return &App{
|
||||||
Mgr: mgr,
|
Mgr: mgr,
|
||||||
CfgPath: cfgPath,
|
CfgPath: cfgPath,
|
||||||
LogBuf: logBuf,
|
LogBuf: logBuf,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
events: newEventBroker(),
|
||||||
UpdateStatus: update.Status{
|
UpdateStatus: update.Status{
|
||||||
Current: update.DisplayVersion(),
|
Current: update.DisplayVersion(),
|
||||||
ManifestURL: update.DefaultManifestURL,
|
ManifestURL: update.DefaultManifestURL,
|
||||||
@@ -257,8 +260,13 @@ func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
|
|||||||
|
|
||||||
func (a *App) SelectProfile(name string) error {
|
func (a *App) SelectProfile(name string) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
err := a.Mgr.SetActiveProfile(name)
|
||||||
return a.Mgr.SetActiveProfile(name)
|
a.mu.Unlock()
|
||||||
|
if err == nil {
|
||||||
|
go a.warmActiveCore()
|
||||||
|
a.NotifyState("profile")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DeleteProfile(name string) error {
|
func (a *App) DeleteProfile(name string) error {
|
||||||
@@ -305,6 +313,7 @@ func (a *App) ConnectProfile(name string) error {
|
|||||||
for attempt := 0; attempt < 2; attempt++ {
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
||||||
a.Mgr.SetLastError(err.Error())
|
a.Mgr.SetLastError(err.Error())
|
||||||
|
a.NotifyState("error")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
@@ -320,6 +329,7 @@ func (a *App) ConnectProfile(name string) error {
|
|||||||
pcancel()
|
pcancel()
|
||||||
if perr == nil {
|
if perr == nil {
|
||||||
dockicon.SetConnected(true)
|
dockicon.SetConnected(true)
|
||||||
|
a.NotifyState("connected")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
|
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
|
||||||
@@ -329,12 +339,14 @@ func (a *App) ConnectProfile(name string) error {
|
|||||||
time.Sleep(400 * time.Millisecond)
|
time.Sleep(400 * time.Millisecond)
|
||||||
}
|
}
|
||||||
dockicon.SetConnected(false)
|
dockicon.SetConnected(false)
|
||||||
|
a.NotifyState("error")
|
||||||
return last
|
return last
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Disconnect() error {
|
func (a *App) Disconnect() error {
|
||||||
err := a.Mgr.Disconnect()
|
err := a.Mgr.Disconnect()
|
||||||
dockicon.SetConnected(false)
|
dockicon.SetConnected(false)
|
||||||
|
a.NotifyState("disconnected")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +379,7 @@ func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error
|
|||||||
func (a *App) StartBackground() {
|
func (a *App) StartBackground() {
|
||||||
go a.watchdogLoop()
|
go a.watchdogLoop()
|
||||||
go a.dockIconLoop()
|
go a.dockIconLoop()
|
||||||
|
go a.warmActiveCore()
|
||||||
prefs := a.Mgr.Prefs()
|
prefs := a.Mgr.Prefs()
|
||||||
if prefs.ConnectOnLaunch {
|
if prefs.ConnectOnLaunch {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -383,6 +396,20 @@ func (a *App) StartBackground() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warmActiveCore downloads/resolves the binary for the active profile protocol.
|
||||||
|
// Non-blocking; Connect still EnsureCore if this has not finished yet.
|
||||||
|
func (a *App) warmActiveCore() {
|
||||||
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(350 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
||||||
|
// Soft fail — Connect will surface the error if the user tries.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) dockIconLoop() {
|
func (a *App) dockIconLoop() {
|
||||||
dockicon.SetConnected(false)
|
dockicon.SetConnected(false)
|
||||||
var last bool
|
var last bool
|
||||||
@@ -620,6 +647,7 @@ func (a *App) Handler() http.Handler {
|
|||||||
}
|
}
|
||||||
_, _ = io.WriteString(w, html)
|
_, _ = io.WriteString(w, html)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/api/events", a.handleEvents)
|
||||||
mux.HandleFunc("/api/", a.handleAPI)
|
mux.HandleFunc("/api/", a.handleAPI)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -721,6 +749,11 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
|||||||
go func() {
|
go func() {
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
_ = a.Mgr.Disconnect()
|
_ = a.Mgr.Disconnect()
|
||||||
|
a.Shutdown()
|
||||||
|
if a.OnQuit != nil {
|
||||||
|
a.OnQuit()
|
||||||
|
return
|
||||||
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
return "ok", nil
|
return "ok", nil
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package apphost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventBroker fans out lightweight UI notifications (SSE).
|
||||||
|
type eventBroker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
subs map[chan []byte]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEventBroker() *eventBroker {
|
||||||
|
return &eventBroker{subs: make(map[chan []byte]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *eventBroker) subscribe() chan []byte {
|
||||||
|
ch := make(chan []byte, 8)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.subs[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *eventBroker) unsubscribe(ch chan []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if _, ok := b.subs[ch]; ok {
|
||||||
|
delete(b.subs, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *eventBroker) publish(payload []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for ch := range b.subs {
|
||||||
|
select {
|
||||||
|
case ch <- payload:
|
||||||
|
default:
|
||||||
|
// Drop if a slow client is behind; safety-net poll will catch up.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyState pushes a small SSE payload so the UI can refresh immediately.
|
||||||
|
func (a *App) NotifyState(kind string) {
|
||||||
|
if a == nil || a.events == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
kind = "state"
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(map[string]string{"type": kind})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.events.publish(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.APIToken != "" {
|
||||||
|
tok := r.Header.Get("X-Navis-Token")
|
||||||
|
if tok == "" {
|
||||||
|
tok = r.URL.Query().Get("t")
|
||||||
|
}
|
||||||
|
if tok != a.APIToken {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "stream unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ch := a.events.subscribe()
|
||||||
|
defer a.events.unsubscribe(ch)
|
||||||
|
|
||||||
|
ping := time.NewTicker(20 * time.Second)
|
||||||
|
defer ping.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ping.C:
|
||||||
|
if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -548,6 +548,28 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.server-toolbar .section-title { margin: 0; }
|
.server-toolbar .section-title { margin: 0; }
|
||||||
|
.server-search-wrap {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.server-search-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid rgba(8, 90, 68, 0.18);
|
||||||
|
background: var(--field-bg, rgba(255,255,255,.55));
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: .88rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.server-search-wrap input:focus {
|
||||||
|
border-color: rgba(37, 88, 75, 0.45);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .server-search-wrap input {
|
||||||
|
background: rgba(0,0,0,.22);
|
||||||
|
border-color: rgba(180, 220, 200, 0.14);
|
||||||
|
}
|
||||||
.server-actions {
|
.server-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
+71
-3
@@ -51,6 +51,7 @@
|
|||||||
const bestBtn = $("bestBtn");
|
const bestBtn = $("bestBtn");
|
||||||
const autoBest = $("autoBest");
|
const autoBest = $("autoBest");
|
||||||
const serverList = $("serverList");
|
const serverList = $("serverList");
|
||||||
|
const serverSearch = $("serverSearch");
|
||||||
const updCheckBtn = $("updCheckBtn");
|
const updCheckBtn = $("updCheckBtn");
|
||||||
const updateBanner = $("updateBanner");
|
const updateBanner = $("updateBanner");
|
||||||
const updateBtn = $("updateBtn");
|
const updateBtn = $("updateBtn");
|
||||||
@@ -251,17 +252,35 @@
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filteredProfiles() {
|
||||||
|
const q = (serverSearch && serverSearch.value || "").trim().toLowerCase();
|
||||||
|
const list = orderedProfiles();
|
||||||
|
if (!q) return list;
|
||||||
|
return list.filter((p) => {
|
||||||
|
const hay = [p.name, p.host, p.protocol].map((x) => String(x || "").toLowerCase()).join(" ");
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderServerList(activeName) {
|
function renderServerList(activeName) {
|
||||||
const active = activeName || profile.value;
|
const active = activeName || profile.value;
|
||||||
serverList.innerHTML = "";
|
serverList.innerHTML = "";
|
||||||
const list = orderedProfiles();
|
const all = orderedProfiles();
|
||||||
if (!list.length) {
|
const list = filteredProfiles();
|
||||||
|
if (!all.length) {
|
||||||
const empty = document.createElement("div");
|
const empty = document.createElement("div");
|
||||||
empty.className = "server-empty";
|
empty.className = "server-empty";
|
||||||
empty.textContent = "Нет серверов — добавьте профиль или подписку";
|
empty.textContent = "Нет серверов — добавьте профиль или подписку";
|
||||||
serverList.appendChild(empty);
|
serverList.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!list.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "server-empty";
|
||||||
|
empty.textContent = "Ничего не найдено";
|
||||||
|
serverList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
list.forEach((p) => {
|
list.forEach((p) => {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.type = "button";
|
row.type = "button";
|
||||||
@@ -707,11 +726,60 @@
|
|||||||
setMeta(String(e), "err");
|
setMeta(String(e), "err");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
if (serverSearch) {
|
||||||
|
serverSearch.addEventListener("input", () => renderServerList(profile.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function typingInField(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = (el.tagName || "").toLowerCase();
|
||||||
|
return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && modal.classList.contains("open")) {
|
||||||
|
modal.classList.remove("open");
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typingInField(document.activeElement)) return;
|
||||||
|
if ((e.key === "/" || (e.key === "f" && (e.ctrlKey || e.metaKey))) && serverSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
serverSearch.focus();
|
||||||
|
serverSearch.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.key === "Enter" || e.key === " ") && !busy) {
|
||||||
|
e.preventDefault();
|
||||||
|
btn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safety-net poll; primary updates come from SSE /api/events when available.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (document.hidden || busy || modal.classList.contains("open")) return;
|
if (document.hidden || busy || modal.classList.contains("open")) return;
|
||||||
refresh().catch(() => {});
|
refresh().catch(() => {});
|
||||||
}, 4000);
|
}, 12000);
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!document.hidden && !busy) refresh({ force: true }).catch(() => {});
|
if (!document.hidden && !busy) refresh({ force: true }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(function startEventStream() {
|
||||||
|
if (!window.__navisHttp || typeof EventSource === "undefined") return;
|
||||||
|
const tok = window.__NAVIS_TOKEN__ || "";
|
||||||
|
const url = "/api/events" + (tok ? ("?t=" + encodeURIComponent(tok)) : "");
|
||||||
|
let es;
|
||||||
|
const connectES = () => {
|
||||||
|
try { if (es) es.close(); } catch (_) {}
|
||||||
|
es = new EventSource(url);
|
||||||
|
es.onmessage = () => {
|
||||||
|
if (!busy) refresh({ force: true }).catch(() => {});
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
try { es.close(); } catch (_) {}
|
||||||
|
setTimeout(connectES, 2500);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connectES();
|
||||||
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,9 @@
|
|||||||
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
|
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="server-search-wrap">
|
||||||
|
<input id="serverSearch" type="search" placeholder="Поиск по имени, хосту, протоколу…" spellcheck="false" autocomplete="off" />
|
||||||
|
</div>
|
||||||
<label class="auto-best">
|
<label class="auto-best">
|
||||||
<input id="autoBest" type="checkbox" />
|
<input id="autoBest" type="checkbox" />
|
||||||
Авто: пинг + подключение к лучшему
|
Авто: пинг + подключение к лучшему
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package trayhost
|
|||||||
type Hooks struct {
|
type Hooks struct {
|
||||||
Connect func()
|
Connect func()
|
||||||
Disconnect func()
|
Disconnect func()
|
||||||
|
Show func()
|
||||||
Quit func()
|
Quit func()
|
||||||
IsUp func() bool
|
IsUp func() bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ const (
|
|||||||
tpmRight = 0x0020
|
tpmRight = 0x0020
|
||||||
tpmBottom = 0x0020
|
tpmBottom = 0x0020
|
||||||
idiApplication = 32512
|
idiApplication = 32512
|
||||||
|
idOpen = 1000
|
||||||
idConnect = 1001
|
idConnect = 1001
|
||||||
idDisconnect = 1002
|
idDisconnect = 1002
|
||||||
idQuit = 1003
|
idQuit = 1003
|
||||||
|
wmLButtonDblClk = 0x0203
|
||||||
)
|
)
|
||||||
|
|
||||||
type notifyIconData struct {
|
type notifyIconData struct {
|
||||||
@@ -107,12 +109,21 @@ func runTray(appName string, h Hooks) {
|
|||||||
var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr {
|
var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr {
|
||||||
switch msgU {
|
switch msgU {
|
||||||
case wmTray:
|
case wmTray:
|
||||||
if lParam == wmRButtonUp || lParam == wmLButtonUp {
|
switch lParam {
|
||||||
|
case wmLButtonDblClk:
|
||||||
|
if h.Show != nil {
|
||||||
|
go h.Show()
|
||||||
|
}
|
||||||
|
case wmRButtonUp, wmLButtonUp:
|
||||||
showMenu(hwnd, h)
|
showMenu(hwnd, h)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
case wmCommand:
|
case wmCommand:
|
||||||
switch int(wParam & 0xffff) {
|
switch int(wParam & 0xffff) {
|
||||||
|
case idOpen:
|
||||||
|
if h.Show != nil {
|
||||||
|
go h.Show()
|
||||||
|
}
|
||||||
case idConnect:
|
case idConnect:
|
||||||
if h.Connect != nil {
|
if h.Connect != nil {
|
||||||
go h.Connect()
|
go h.Connect()
|
||||||
@@ -178,6 +189,10 @@ func showMenu(hwnd windows.Handle, h Hooks) {
|
|||||||
p, _ := windows.UTF16PtrFromString(text)
|
p, _ := windows.UTF16PtrFromString(text)
|
||||||
procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p)))
|
procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p)))
|
||||||
}
|
}
|
||||||
|
if h.Show != nil {
|
||||||
|
add(idOpen, "Открыть")
|
||||||
|
procAppendMenu.Call(menu, mfSeparator, 0, 0)
|
||||||
|
}
|
||||||
up := h.IsUp != nil && h.IsUp()
|
up := h.IsUp != nil && h.IsUp()
|
||||||
if up {
|
if up {
|
||||||
add(idDisconnect, "Отключить")
|
add(idDisconnect, "Отключить")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
|
|||||||
|
|
||||||
// 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 = 5
|
const BuildNumber = 6
|
||||||
|
|
||||||
// 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"
|
||||||
|
|||||||
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"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.",
|
"notes": "Navis 3.8.2+6: поиск/фильтр серверов и хоткеи (/, Ctrl+F, Space/Enter, Esc); Windows: закрытие окна = скрытие в трей, «Открыть»/Выход из трея; фоновый EnsureCore активного протокола; SSE /api/events для мгновенного статуса + poll 12 с как safety net.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
"sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
"sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -4,13 +4,13 @@
|
|||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 5
|
"Build": 6
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 5
|
"Build": 6
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "3.8.2.5",
|
"FileVersion": "3.8.2.6",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "3.8.2.5",
|
"ProductVersion": "3.8.2.6",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user