Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
Delta getState by rev, pause when hidden, cancellable background loops, hostCache bounds, unlock PollUI around engine status, corebin singleflight, PingAll worker pool. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -254,7 +254,8 @@ https://evilfox.win/
|
|||||||
- трей (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+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.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 = 3080204
|
versionCode = 3080205
|
||||||
versionName = "3.8.2+4"
|
versionName = "3.8.2+5"
|
||||||
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.4 -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
|
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.4 -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
|
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.4 -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
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
echo Built Mac GUI + CLI:
|
echo Built Mac GUI + CLI:
|
||||||
|
|||||||
@@ -93,11 +93,13 @@ func main() {
|
|||||||
sig := make(chan os.Signal, 1)
|
sig := make(chan os.Signal, 1)
|
||||||
signal.Notify(sig, shutdownSignals()...)
|
signal.Notify(sig, shutdownSignals()...)
|
||||||
<-sig
|
<-sig
|
||||||
|
a.Shutdown()
|
||||||
_ = mgr.Disconnect()
|
_ = mgr.Disconnect()
|
||||||
_ = srv.Close()
|
_ = srv.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
|
a.Shutdown()
|
||||||
_ = mgr.Disconnect()
|
_ = mgr.Disconnect()
|
||||||
_ = srv.Close()
|
_ = srv.Close()
|
||||||
w.Destroy()
|
w.Destroy()
|
||||||
|
|||||||
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+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).",
|
"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",
|
"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": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9",
|
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
||||||
"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": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9",
|
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
||||||
"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+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).",
|
"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",
|
"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": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9",
|
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
||||||
"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": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9",
|
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/diskfs/go-diskfs v1.9.4
|
github.com/diskfs/go-diskfs v1.9.4
|
||||||
github.com/ebitengine/purego v0.10.1
|
github.com/ebitengine/purego v0.10.1
|
||||||
golang.org/x/image v0.44.0
|
golang.org/x/image v0.44.0
|
||||||
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/sys v0.43.0
|
golang.org/x/sys v0.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +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/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 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
|
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-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.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
|||||||
+79
-7
@@ -3,6 +3,7 @@ package apphost
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -39,6 +40,8 @@ type App struct {
|
|||||||
OpenURL func(string) error
|
OpenURL func(string) error
|
||||||
OnAfterUpdate func()
|
OnAfterUpdate func()
|
||||||
APIToken string
|
APIToken string
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type UIState struct {
|
type UIState struct {
|
||||||
@@ -63,6 +66,8 @@ type UIState struct {
|
|||||||
ConnectOnLaunch bool `json:"connect_on_launch"`
|
ConnectOnLaunch bool `json:"connect_on_launch"`
|
||||||
AutoReconnect bool `json:"auto_reconnect"`
|
AutoReconnect bool `json:"auto_reconnect"`
|
||||||
LogTail string `json:"log_tail,omitempty"`
|
LogTail string `json:"log_tail,omitempty"`
|
||||||
|
Rev string `json:"rev,omitempty"`
|
||||||
|
Unchanged bool `json:"unchanged,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PingBestResult struct {
|
type PingBestResult struct {
|
||||||
@@ -74,10 +79,13 @@ 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())
|
||||||
return &App{
|
return &App{
|
||||||
Mgr: mgr,
|
Mgr: mgr,
|
||||||
CfgPath: cfgPath,
|
CfgPath: cfgPath,
|
||||||
LogBuf: logBuf,
|
LogBuf: logBuf,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
UpdateStatus: update.Status{
|
UpdateStatus: update.Status{
|
||||||
Current: update.DisplayVersion(),
|
Current: update.DisplayVersion(),
|
||||||
ManifestURL: update.DefaultManifestURL,
|
ManifestURL: update.DefaultManifestURL,
|
||||||
@@ -85,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) {
|
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).
|
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
|
||||||
func (a *App) GetEditState() (UIState, error) {
|
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) {
|
func (a *App) getState(includeSecrets bool) (UIState, error) {
|
||||||
@@ -155,6 +190,23 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
|
|||||||
return out, nil
|
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 {
|
func trimLogTail(s string, max int) string {
|
||||||
if max <= 0 || len(s) <= max {
|
if max <= 0 || len(s) <= max {
|
||||||
return s
|
return s
|
||||||
@@ -318,7 +370,11 @@ func (a *App) StartBackground() {
|
|||||||
prefs := a.Mgr.Prefs()
|
prefs := a.Mgr.Prefs()
|
||||||
if prefs.ConnectOnLaunch {
|
if prefs.ConnectOnLaunch {
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1200 * time.Millisecond)
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(1200 * time.Millisecond):
|
||||||
|
}
|
||||||
if a.Mgr.Status().Connected {
|
if a.Mgr.Status().Connected {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -331,8 +387,13 @@ func (a *App) dockIconLoop() {
|
|||||||
dockicon.SetConnected(false)
|
dockicon.SetConnected(false)
|
||||||
var last bool
|
var last bool
|
||||||
first := true
|
first := true
|
||||||
|
t := time.NewTicker(400 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
time.Sleep(400 * time.Millisecond)
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
up := a.Mgr.Status().Connected
|
up := a.Mgr.Status().Connected
|
||||||
if first || up != last {
|
if first || up != last {
|
||||||
first = false
|
first = false
|
||||||
@@ -341,10 +402,16 @@ func (a *App) dockIconLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) watchdogLoop() {
|
func (a *App) watchdogLoop() {
|
||||||
|
t := time.NewTicker(2 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
time.Sleep(2 * time.Second)
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
if !a.Mgr.TakeUnexpectedDrop() {
|
if !a.Mgr.TakeUnexpectedDrop() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -352,10 +419,15 @@ func (a *App) watchdogLoop() {
|
|||||||
if !prefs.AutoReconnect {
|
if !prefs.AutoReconnect {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
time.Sleep(800 * time.Millisecond)
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(800 * time.Millisecond):
|
||||||
|
}
|
||||||
_ = a.Connect()
|
_ = a.Connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) InstallCore() (string, error) {
|
func (a *App) InstallCore() (string, error) {
|
||||||
paths, err := a.Mgr.EnsureAllCores()
|
paths, err := a.Mgr.EnsureAllCores()
|
||||||
@@ -602,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) {
|
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
||||||
switch name {
|
switch name {
|
||||||
case "getState":
|
case "getState":
|
||||||
return a.GetState()
|
return a.GetStateSince(arg(args, 0, ""))
|
||||||
case "getEditState":
|
case "getEditState":
|
||||||
return a.GetEditState()
|
return a.GetEditState()
|
||||||
case "connect":
|
case "connect":
|
||||||
|
|||||||
@@ -50,6 +50,33 @@ func TestHTTPBridgeGetState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestHTTPBridgeRejectsBadToken(t *testing.T) {
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
mgr, err := core.NewManager("", cfg, logbuf.New(0))
|
mgr, err := core.NewManager("", cfg, logbuf.New(0))
|
||||||
|
|||||||
+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 () {
|
(function () {
|
||||||
const methods = [
|
const methods = [
|
||||||
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
||||||
@@ -208,6 +208,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let listFP = "";
|
let listFP = "";
|
||||||
|
let stateRev = "";
|
||||||
|
|
||||||
function profilesFingerprint(state) {
|
function profilesFingerprint(state) {
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -444,13 +445,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(opts) {
|
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);
|
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
|
||||||
let state;
|
let state;
|
||||||
if (needSecrets && typeof getEditState === "function") {
|
if (needSecrets && typeof getEditState === "function") {
|
||||||
state = await getEditState();
|
state = await getEditState();
|
||||||
} else {
|
} 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);
|
paint(state, opts);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -697,5 +707,11 @@
|
|||||||
setMeta(String(e), "err");
|
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(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ func WriteExample(path string) error {
|
|||||||
|
|
||||||
// Save writes config as indented JSON (atomic replace).
|
// Save writes config as indented JSON (atomic replace).
|
||||||
func Save(path string, cfg Config) error {
|
func Save(path string, cfg Config) error {
|
||||||
|
InvalidateHostCache()
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type ProfileInfo struct {
|
|||||||
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
|
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
|
||||||
var hostCache sync.Map // proxy URI/conf → host string
|
var hostCache sync.Map // proxy URI/conf → host string
|
||||||
|
|
||||||
|
const hostCacheMax = 2048
|
||||||
|
|
||||||
func cachedProxyHost(proxy string) string {
|
func cachedProxyHost(proxy string) string {
|
||||||
proxy = strings.TrimSpace(proxy)
|
proxy = strings.TrimSpace(proxy)
|
||||||
if proxy == "" {
|
if proxy == "" {
|
||||||
@@ -28,9 +30,32 @@ func cachedProxyHost(proxy string) string {
|
|||||||
}
|
}
|
||||||
h := proxyHost(proxy)
|
h := proxyHost(proxy)
|
||||||
hostCache.Store(proxy, h)
|
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
|
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.
|
// ListProfiles returns UI-friendly profile summaries.
|
||||||
func (c *Config) ListProfiles() []ProfileInfo {
|
func (c *Config) ListProfiles() []ProfileInfo {
|
||||||
out := make([]ProfileInfo, 0, len(c.Profiles))
|
out := make([]ProfileInfo, 0, len(c.Profiles))
|
||||||
|
|||||||
+20
-10
@@ -380,19 +380,20 @@ type UIPoll struct {
|
|||||||
AutoReconnect bool
|
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 {
|
func (m *Manager) PollUI(includeSecrets bool) UIPoll {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
out := UIPoll{
|
out := UIPoll{
|
||||||
Status: Status{SystemProxy: m.sys.Enabled()},
|
|
||||||
BinDir: m.binDir,
|
BinDir: m.binDir,
|
||||||
SystemProxy: false,
|
SystemProxy: false,
|
||||||
Subscription: "",
|
Subscription: "",
|
||||||
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
|
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
|
||||||
LastError: m.lastError,
|
LastError: m.lastError,
|
||||||
}
|
}
|
||||||
|
var eng Engine
|
||||||
|
var profCopy *config.Profile
|
||||||
if m.cfg != nil {
|
if m.cfg != nil {
|
||||||
out.Active = m.cfg.Active
|
out.Active = m.cfg.Active
|
||||||
out.SystemProxy = m.cfg.SystemProxy
|
out.SystemProxy = m.cfg.SystemProxy
|
||||||
@@ -424,16 +425,25 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.engine != nil && m.engine.Running() {
|
eng = m.engine
|
||||||
out.Status.Connected = true
|
|
||||||
if m.profile != nil {
|
if m.profile != nil {
|
||||||
out.Status.Profile = m.profile.Name
|
p := *m.profile
|
||||||
out.Status.Protocol = m.profile.Protocol
|
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
|
out.Status.HTTPProxy = hp
|
||||||
}
|
}
|
||||||
if sp, ok := m.engine.LocalSOCKSProxy(); ok {
|
if sp, ok := eng.LocalSOCKSProxy(); ok {
|
||||||
out.Status.SOCKSProxy = sp
|
out.Status.SOCKSProxy = sp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package corebin
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cache = map[string]string{}
|
cache = map[string]string{}
|
||||||
|
group singleflight.Group
|
||||||
)
|
)
|
||||||
|
|
||||||
// CacheKey identifies a resolved core binary for a binDir + protocol.
|
// CacheKey identifies a resolved core binary for a binDir + protocol.
|
||||||
@@ -48,7 +51,12 @@ func Invalidate() {
|
|||||||
// Resolve caches the result of fn under key.
|
// Resolve caches the result of fn under key.
|
||||||
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
|
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
|
||||||
// Embedded/virtual paths never touch the filesystem.
|
// 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) {
|
func Resolve(key string, fn func() (string, error)) (string, error) {
|
||||||
|
if path, ok := Get(key); ok {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
v, err, _ := group.Do(key, func() (any, error) {
|
||||||
if path, ok := Get(key); ok {
|
if path, ok := Get(key); ok {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
@@ -58,6 +66,12 @@ func Resolve(key string, fn func() (string, error)) (string, error) {
|
|||||||
}
|
}
|
||||||
Set(key, path)
|
Set(key, path)
|
||||||
return path, nil
|
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).
|
// ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan).
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package corebin
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveTrustsCacheWithoutStat(t *testing.T) {
|
func TestResolveTrustsCacheWithoutStat(t *testing.T) {
|
||||||
@@ -35,6 +38,36 @@ func TestResolveTrustsCacheWithoutStat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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) {
|
func TestVirtualEmbeddedAWG(t *testing.T) {
|
||||||
Invalidate()
|
Invalidate()
|
||||||
key := CacheKey("/bin", "awg")
|
key := CacheKey("/bin", "awg")
|
||||||
|
|||||||
@@ -57,20 +57,26 @@ func PingAll(ctx context.Context, targets []Target) []Result {
|
|||||||
if len(targets) < workers {
|
if len(targets) < workers {
|
||||||
workers = len(targets)
|
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
|
var wg sync.WaitGroup
|
||||||
for i, t := range targets {
|
for w := 0; w < workers; w++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int, t Target) {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
sem <- struct{}{}
|
for i := range jobs {
|
||||||
defer func() { <-sem }()
|
t := targets[i]
|
||||||
if strings.TrimSpace(t.Proxy) == "" {
|
if strings.TrimSpace(t.Proxy) == "" {
|
||||||
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
|
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
|
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
|
||||||
}(i, t)
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 4
|
const BuildNumber = 5
|
||||||
|
|
||||||
// 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"
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest()
|
|||||||
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
|
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
|
||||||
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
|
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
|
||||||
notes = (
|
notes = (
|
||||||
f"Navis {ver}+{build}: единый GUI Windows/macOS на glaze + HTTP /api "
|
f"Navis {ver}+{build}: delta-poll UI (getState по rev); пауза опроса при скрытом окне; "
|
||||||
f"(вместо WebView2 Bind); автоинкремент номера сборки при compile; "
|
f"cancel lifecycle dock/watchdog; invalidate/лимит hostCache; "
|
||||||
f"артефакты Windows с версией в имени (Navis-x.y.z.b.exe)."
|
f"PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool."
|
||||||
)
|
)
|
||||||
paths = [
|
paths = [
|
||||||
Path('dist/update.json'),
|
Path('dist/update.json'),
|
||||||
|
|||||||
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).",
|
"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",
|
"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": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9",
|
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
||||||
"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": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9",
|
"sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
|
||||||
"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": 4
|
"Build": 5
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 4
|
"Build": 5
|
||||||
},
|
},
|
||||||
"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.4",
|
"FileVersion": "3.8.2.5",
|
||||||
"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.4",
|
"ProductVersion": "3.8.2.5",
|
||||||
"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