Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad376b49e | ||
|
|
ff5d87ab9f | ||
|
|
464a61aebf | ||
|
|
d1570b23d3 | ||
|
|
838f9e340b |
@@ -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:
|
||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||
|
||||
@@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 18 KiB |
+12
-4
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
3.8.2+1
|
||||
3.8.2+3
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.8.2.1
|
||||
3.8.2.3
|
||||
|
||||
Vendored
+6
-6
@@ -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",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+6
-6
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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(() => {});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user