Release 3.8.2.11: idle rev-cache, port-safe Connect, single-instance tray UX.
Faster unchanged polls; orphan-core cleanup on listen ports; clearer busy-port errors; cores only from binDir; incremental server list; CSP without Google Fonts; single-instance activates existing window; close-to-tray without kill-others or balloon. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -257,7 +257,11 @@ https://evilfox.win/
|
|||||||
- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`);
|
- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`);
|
||||||
- 3.8.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool;
|
- 3.8.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool;
|
||||||
- 3.8.2+6: поиск по серверам и хоткеи; Windows close→tray (Открыть/Выход); фоновый прогрев активного core; SSE `/api/events` + редкий poll fallback;
|
- 3.8.2+6: поиск по серверам и хоткеи; Windows close→tray (Открыть/Выход); фоновый прогрев активного core; SSE `/api/events` + редкий poll fallback;
|
||||||
- 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS).
|
- 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS);
|
||||||
|
- 3.8.2+8: дешёвый idle rev-cache; иконка без 400 ms ticker; ping без копирования proxy; cores только из binDir; инкрементальный список серверов; CSP без Google Fonts; ReadHeaderTimeout;
|
||||||
|
- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
|
||||||
|
- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
|
||||||
|
- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей.
|
||||||
|
|
||||||
В 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 = 3080207
|
versionCode = 3080211
|
||||||
versionName = "3.8.2+7"
|
versionName = "3.8.2+11"
|
||||||
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.7 -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.11 -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.7 -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.11 -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.7 -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.11 -arch universal
|
||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
echo Built Mac GUI + CLI:
|
echo Built Mac GUI + CLI:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
"vpnclient/internal/dockicon"
|
"vpnclient/internal/dockicon"
|
||||||
"vpnclient/internal/logbuf"
|
"vpnclient/internal/logbuf"
|
||||||
|
"vpnclient/internal/singleinstance"
|
||||||
"vpnclient/internal/trayhost"
|
"vpnclient/internal/trayhost"
|
||||||
"vpnclient/internal/update"
|
"vpnclient/internal/update"
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
update.CleanupStaleDownloads()
|
update.CleanupStaleDownloads()
|
||||||
|
|
||||||
|
releaseInstance, err := singleinstance.Lock("Navis")
|
||||||
|
if err != nil {
|
||||||
|
if err == singleinstance.ErrAlreadyRunning {
|
||||||
|
// Existing window was activated when possible.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fatalf("Не удалось получить single-instance lock:\n%v", err)
|
||||||
|
}
|
||||||
|
defer releaseInstance()
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
|
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
|
||||||
|
|
||||||
@@ -66,14 +77,18 @@ func main() {
|
|||||||
fatalf("listen: %v", err)
|
fatalf("listen: %v", err)
|
||||||
}
|
}
|
||||||
a.APIToken = token
|
a.APIToken = token
|
||||||
srv := &http.Server{Handler: a.Handler()}
|
srv := &http.Server{
|
||||||
|
Handler: a.Handler(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
// No WriteTimeout: SSE /api/events is long-lived.
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
_ = srv.Serve(ln)
|
_ = srv.Serve(ln)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
a.StartBackground()
|
a.StartBackground()
|
||||||
dockicon.OnIconChange = trayhost.SetConnectedIcon
|
dockicon.OnIconChange = trayhost.SetConnectedIcon
|
||||||
trayhost.Start("Navis", trayhost.Hooks{
|
trayOK := trayhost.Start("Navis", trayhost.Hooks{
|
||||||
Connect: func() { _ = a.Connect() },
|
Connect: func() { _ = a.Connect() },
|
||||||
Disconnect: func() { _ = a.Disconnect() },
|
Disconnect: func() { _ = a.Disconnect() },
|
||||||
Show: showMainWindow,
|
Show: showMainWindow,
|
||||||
@@ -112,8 +127,9 @@ func main() {
|
|||||||
|
|
||||||
w.SetTitle("Navis")
|
w.SetTitle("Navis")
|
||||||
w.SetSize(500, 900, glaze.HintNone)
|
w.SetSize(500, 900, glaze.HintNone)
|
||||||
decorateWindow(w)
|
// Close→hide only when tray is alive; otherwise X would leave invisible zombies.
|
||||||
|
decorateWindow(w, trayOK)
|
||||||
w.Navigate(uiURL)
|
w.Navigate(uiURL)
|
||||||
log.Printf("Navis UI: %s", uiURL)
|
log.Printf("Navis UI: %s (tray=%v hideOnClose=%v)", uiURL, trayOK, trayOK)
|
||||||
w.Run()
|
w.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ func shutdownSignals() []os.Signal {
|
|||||||
return []os.Signal{os.Interrupt, syscall.SIGTERM}
|
return []os.Signal{os.Interrupt, syscall.SIGTERM}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decorateWindow(w glaze.WebView) {
|
func decorateWindow(w glaze.WebView, hideOnClose bool) {
|
||||||
// macOS Dock/app icon comes from the .app bundle; nothing to apply here.
|
// macOS Dock/app icon comes from the .app bundle; close-to-hide is Windows-only.
|
||||||
_ = w
|
_ = w
|
||||||
|
_ = hideOnClose
|
||||||
}
|
}
|
||||||
|
|
||||||
func showMainWindow() {}
|
func showMainWindow() {}
|
||||||
|
|||||||
@@ -64,15 +64,17 @@ func shutdownSignals() []os.Signal {
|
|||||||
return []os.Signal{os.Interrupt}
|
return []os.Signal{os.Interrupt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decorateWindow(w glaze.WebView) {
|
func decorateWindow(w glaze.WebView, hideOnClose bool) {
|
||||||
if w == nil {
|
if w == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hwnd := w.Window()
|
hwnd := w.Window()
|
||||||
applyWindowIcon(hwnd)
|
applyWindowIcon(hwnd)
|
||||||
dockicon.BindWindow(hwnd)
|
dockicon.BindWindow(hwnd)
|
||||||
|
if hideOnClose {
|
||||||
installCloseToHide(hwnd)
|
installCloseToHide(hwnd)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func installCloseToHide(hwnd unsafe.Pointer) {
|
func installCloseToHide(hwnd unsafe.Pointer) {
|
||||||
if hwnd == nil {
|
if hwnd == nil {
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
|
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
|
||||||
"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": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||||
"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": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
|
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
|
||||||
"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": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||||
"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": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+66
-45
@@ -44,6 +44,9 @@ type App struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
events *eventBroker
|
events *eventBroker
|
||||||
|
revMu sync.Mutex
|
||||||
|
cachedRev string
|
||||||
|
cachedConn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UIState struct {
|
type UIState struct {
|
||||||
@@ -111,17 +114,35 @@ func (a *App) GetState() (UIState, error) {
|
|||||||
// GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since
|
// GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since
|
||||||
// matches the current fingerprint (cheap idle HTTP polls).
|
// matches the current fingerprint (cheap idle HTTP polls).
|
||||||
func (a *App) GetStateSince(since string) (UIState, error) {
|
func (a *App) GetStateSince(since string) (UIState, error) {
|
||||||
|
if since != "" {
|
||||||
|
a.revMu.Lock()
|
||||||
|
cachedRev, cachedConn := a.cachedRev, a.cachedConn
|
||||||
|
a.revMu.Unlock()
|
||||||
|
if cachedRev != "" && cachedRev == since && a.Mgr.Status().Connected == cachedConn {
|
||||||
|
return UIState{Rev: cachedRev, Unchanged: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
out, err := a.getState(false)
|
out, err := a.getState(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Rev = fingerprintState(out)
|
out.Rev = fingerprintState(out)
|
||||||
|
a.revMu.Lock()
|
||||||
|
a.cachedRev = out.Rev
|
||||||
|
a.cachedConn = out.Connected
|
||||||
|
a.revMu.Unlock()
|
||||||
if since != "" && since == out.Rev {
|
if since != "" && since == out.Rev {
|
||||||
return UIState{Rev: out.Rev, Unchanged: true}, nil
|
return UIState{Rev: out.Rev, Unchanged: true}, nil
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) invalidateRevCache() {
|
||||||
|
a.revMu.Lock()
|
||||||
|
a.cachedRev = ""
|
||||||
|
a.revMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
out, err := a.getState(true)
|
out, err := a.getState(true)
|
||||||
@@ -238,24 +259,34 @@ func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready
|
|||||||
|
|
||||||
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
|
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
a.mu.Unlock()
|
||||||
return fmt.Errorf("укажите название профиля")
|
return fmt.Errorf("укажите название профиля")
|
||||||
}
|
}
|
||||||
a.Mgr.SetSystemProxy(systemProxy)
|
a.Mgr.SetSystemProxy(systemProxy)
|
||||||
return a.Mgr.SaveActiveProfile(name, proxy)
|
err := a.Mgr.SaveActiveProfile(name, proxy)
|
||||||
|
a.mu.Unlock()
|
||||||
|
if err == nil {
|
||||||
|
a.NotifyState("profile")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
|
func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
a.mu.Unlock()
|
||||||
return fmt.Errorf("укажите название профиля")
|
return fmt.Errorf("укажите название профиля")
|
||||||
}
|
}
|
||||||
a.Mgr.SetSystemProxy(systemProxy)
|
a.Mgr.SetSystemProxy(systemProxy)
|
||||||
return a.Mgr.SaveProfile(name, proxy)
|
err := a.Mgr.SaveProfile(name, proxy)
|
||||||
|
a.mu.Unlock()
|
||||||
|
if err == nil {
|
||||||
|
a.NotifyState("profile")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SelectProfile(name string) error {
|
func (a *App) SelectProfile(name string) error {
|
||||||
@@ -271,8 +302,12 @@ func (a *App) SelectProfile(name string) error {
|
|||||||
|
|
||||||
func (a *App) DeleteProfile(name string) error {
|
func (a *App) DeleteProfile(name string) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
err := a.Mgr.DeleteProfile(name)
|
||||||
return a.Mgr.DeleteProfile(name)
|
a.mu.Unlock()
|
||||||
|
if err == nil {
|
||||||
|
a.NotifyState("profile")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Connect() error { return a.ConnectProfile("") }
|
func (a *App) Connect() error { return a.ConnectProfile("") }
|
||||||
@@ -368,17 +403,21 @@ func (a *App) ProbeTunnel() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
|
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
|
||||||
return a.Mgr.SavePrefs(core.Prefs{
|
err := a.Mgr.SavePrefs(core.Prefs{
|
||||||
ConnectOnLaunch: connectOnLaunch,
|
ConnectOnLaunch: connectOnLaunch,
|
||||||
AutoReconnect: autoReconnect,
|
AutoReconnect: autoReconnect,
|
||||||
SystemProxy: systemProxy,
|
SystemProxy: systemProxy,
|
||||||
})
|
})
|
||||||
|
if err == nil {
|
||||||
|
a.NotifyState("prefs")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync.
|
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and core warmup.
|
||||||
func (a *App) StartBackground() {
|
func (a *App) StartBackground() {
|
||||||
go a.watchdogLoop()
|
go a.watchdogLoop()
|
||||||
go a.dockIconLoop()
|
dockicon.SetConnected(false)
|
||||||
go a.warmActiveCore()
|
go a.warmActiveCore()
|
||||||
prefs := a.Mgr.Prefs()
|
prefs := a.Mgr.Prefs()
|
||||||
if prefs.ConnectOnLaunch {
|
if prefs.ConnectOnLaunch {
|
||||||
@@ -408,27 +447,7 @@ func (a *App) warmActiveCore() {
|
|||||||
// Soft fail — Connect will surface the error if the user tries.
|
// Soft fail — Connect will surface the error if the user tries.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
a.NotifyState("cores")
|
||||||
|
|
||||||
func (a *App) dockIconLoop() {
|
|
||||||
dockicon.SetConnected(false)
|
|
||||||
var last bool
|
|
||||||
first := true
|
|
||||||
t := time.NewTicker(400 * time.Millisecond)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
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() {
|
func (a *App) watchdogLoop() {
|
||||||
@@ -443,6 +462,8 @@ func (a *App) watchdogLoop() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
prefs := a.Mgr.Prefs()
|
prefs := a.Mgr.Prefs()
|
||||||
|
dockicon.SetConnected(false)
|
||||||
|
a.NotifyState("disconnected")
|
||||||
if !prefs.AutoReconnect {
|
if !prefs.AutoReconnect {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -465,17 +486,26 @@ func (a *App) InstallCore() (string, error) {
|
|||||||
for k, v := range paths {
|
for k, v := range paths {
|
||||||
parts = append(parts, k+": "+v)
|
parts = append(parts, k+": "+v)
|
||||||
}
|
}
|
||||||
|
a.NotifyState("cores")
|
||||||
return strings.Join(parts, " | "), nil
|
return strings.Join(parts, " | "), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SaveHy2(opts core.Hy2Options) error {
|
func (a *App) SaveHy2(opts core.Hy2Options) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
err := a.Mgr.SaveHy2Options(opts)
|
||||||
return a.Mgr.SaveHy2Options(opts)
|
a.mu.Unlock()
|
||||||
|
if err == nil {
|
||||||
|
a.NotifyState("hy2")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ImportSubscription(rawURL string) (int, error) {
|
func (a *App) ImportSubscription(rawURL string) (int, error) {
|
||||||
return a.Mgr.ImportSubscription(rawURL)
|
n, err := a.Mgr.ImportSubscription(rawURL)
|
||||||
|
if err == nil {
|
||||||
|
a.NotifyState("subscription")
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) PingServers() ([]netcheck.Result, error) {
|
func (a *App) PingServers() ([]netcheck.Result, error) {
|
||||||
@@ -540,24 +570,14 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) runPings() ([]netcheck.Result, error) {
|
func (a *App) runPings() ([]netcheck.Result, error) {
|
||||||
a.mu.Lock()
|
targets := a.Mgr.PingTargets()
|
||||||
list := a.Mgr.Profiles()
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
targets := make([]netcheck.Target, 0, len(list))
|
|
||||||
for _, p := range list {
|
|
||||||
targets = append(targets, netcheck.Target{
|
|
||||||
Name: p.Name,
|
|
||||||
Protocol: config.Protocol(p.Protocol),
|
|
||||||
Proxy: p.Proxy,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
out := netcheck.PingAll(ctx, targets)
|
out := netcheck.PingAll(ctx, targets)
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.Pings = out
|
a.Pings = out
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
a.NotifyState("pings")
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +593,7 @@ func (a *App) CheckUpdate() (update.Status, error) {
|
|||||||
}
|
}
|
||||||
a.UpdateStatus = st
|
a.UpdateStatus = st
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
a.NotifyState("update")
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ func (b *eventBroker) publish(payload []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyState pushes a small SSE payload so the UI can refresh immediately.
|
// NotifyState invalidates the idle rev cache and pushes a small SSE payload.
|
||||||
func (a *App) NotifyState(kind string) {
|
func (a *App) NotifyState(kind string) {
|
||||||
if a == nil || a.events == nil {
|
if a == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.invalidateRevCache()
|
||||||
|
if a.events == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
--ms-mid: #b8860b;
|
--ms-mid: #b8860b;
|
||||||
--focus-ring: rgba(13,138,102,.12);
|
--focus-ring: rgba(13,138,102,.12);
|
||||||
--theme-btn-bg: rgba(255,255,255,.7);
|
--theme-btn-bg: rgba(255,255,255,.7);
|
||||||
|
--font-ui: "Segoe UI Variable Text", "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||||
|
--font-display: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", "Helvetica Neue", sans-serif;
|
||||||
}
|
}
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
--ink: #e6f4ee;
|
--ink: #e6f4ee;
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
font-family: Figtree, sans-serif;
|
font-family: var(--font-ui);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--page-bg);
|
background: var(--page-bg);
|
||||||
transition: color .2s var(--ease), background .25s var(--ease);
|
transition: color .2s var(--ease), background .25s var(--ease);
|
||||||
@@ -130,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
.update-banner.show { display: block; }
|
.update-banner.show { display: block; }
|
||||||
.update-banner strong {
|
.update-banner strong {
|
||||||
font-family: Sora, sans-serif;
|
font-family: var(--font-display);
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
font-size: .95rem;
|
font-size: .95rem;
|
||||||
@@ -199,7 +201,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font-family: Sora, sans-serif;
|
font-family: var(--font-display);
|
||||||
font-size: 1.7rem;
|
font-size: 1.7rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.045em;
|
letter-spacing: -0.045em;
|
||||||
@@ -319,7 +321,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-family: Sora, sans-serif;
|
font-family: var(--font-display);
|
||||||
font-size: .78rem;
|
font-size: .78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
@@ -528,7 +530,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
font-size: 1.02rem;
|
font-size: 1.02rem;
|
||||||
font-family: Sora, sans-serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions { display: grid; gap: 8px; }
|
.actions { display: grid; gap: 8px; }
|
||||||
@@ -749,7 +751,7 @@
|
|||||||
background: var(--shop-bg);
|
background: var(--shop-bg);
|
||||||
}
|
}
|
||||||
.shop h3 {
|
.shop h3 {
|
||||||
font-family: Sora, sans-serif;
|
font-family: var(--font-display);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 5px;
|
margin: 0 0 5px;
|
||||||
@@ -806,7 +808,7 @@
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.modal h2 {
|
.modal h2 {
|
||||||
font-family: Sora, sans-serif;
|
font-family: var(--font-display);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
letter-spacing: -.02em;
|
letter-spacing: -.02em;
|
||||||
|
|||||||
+65
-40
@@ -262,69 +262,94 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderServerList(activeName) {
|
function pingLabel(pr) {
|
||||||
const active = activeName || profile.value;
|
if (pr && pr.ok) {
|
||||||
serverList.innerHTML = "";
|
return {
|
||||||
const all = orderedProfiles();
|
cls: "ms " + msClass(pr.ms, true),
|
||||||
const list = filteredProfiles();
|
text: (pr.soft ? "~" : "") + pr.ms + " ms",
|
||||||
if (!all.length) {
|
title: pr.soft ? "soft-up (UDP без ответа) — не выбирается как «Лучший»" : ""
|
||||||
const empty = document.createElement("div");
|
};
|
||||||
empty.className = "server-empty";
|
|
||||||
empty.textContent = "Нет серверов — добавьте профиль или подписку";
|
|
||||||
serverList.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!list.length) {
|
if (pr && pr.error) {
|
||||||
const empty = document.createElement("div");
|
return { cls: "ms bad", text: "—", title: pr.error };
|
||||||
empty.className = "server-empty";
|
}
|
||||||
empty.textContent = "Ничего не найдено";
|
return { cls: "ms", text: "…", title: "" };
|
||||||
serverList.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
list.forEach((p) => {
|
|
||||||
const row = document.createElement("button");
|
|
||||||
row.type = "button";
|
|
||||||
row.className = "server-row" + (p.name === active ? " active" : "");
|
|
||||||
row.dataset.name = p.name;
|
|
||||||
|
|
||||||
|
function ensureServerRow(p) {
|
||||||
|
let row = serverList.querySelector('.server-row[data-name="' + CSS.escape(p.name) + '"]');
|
||||||
|
if (row) return row;
|
||||||
|
row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "server-row";
|
||||||
|
row.dataset.name = p.name;
|
||||||
const left = document.createElement("div");
|
const left = document.createElement("div");
|
||||||
left.className = "left";
|
left.className = "left";
|
||||||
const nameEl = document.createElement("div");
|
const nameEl = document.createElement("div");
|
||||||
nameEl.className = "name";
|
nameEl.className = "name";
|
||||||
nameEl.textContent = p.name;
|
|
||||||
const sub = document.createElement("div");
|
const sub = document.createElement("div");
|
||||||
sub.className = "sub";
|
sub.className = "sub";
|
||||||
sub.textContent = p.host || "нет хоста";
|
|
||||||
left.appendChild(nameEl);
|
left.appendChild(nameEl);
|
||||||
left.appendChild(sub);
|
left.appendChild(sub);
|
||||||
|
|
||||||
const right = document.createElement("div");
|
const right = document.createElement("div");
|
||||||
right.className = "right";
|
right.className = "right";
|
||||||
const proto = document.createElement("span");
|
const proto = document.createElement("span");
|
||||||
proto.className = "proto";
|
proto.className = "proto";
|
||||||
proto.textContent = (p.protocol || "?").toString();
|
|
||||||
const ms = document.createElement("span");
|
const ms = document.createElement("span");
|
||||||
const pr = pingMap[p.name];
|
|
||||||
if (pr && pr.ok) {
|
|
||||||
ms.className = "ms " + msClass(pr.ms, true);
|
|
||||||
ms.textContent = (pr.soft ? "~" : "") + pr.ms + " ms";
|
|
||||||
if (pr.soft) ms.title = "soft-up (UDP без ответа) — не выбирается как «Лучший»";
|
|
||||||
} else if (pr && pr.error) {
|
|
||||||
ms.className = "ms bad";
|
|
||||||
ms.textContent = "—";
|
|
||||||
ms.title = pr.error;
|
|
||||||
} else {
|
|
||||||
ms.className = "ms";
|
ms.className = "ms";
|
||||||
ms.textContent = "…";
|
|
||||||
}
|
|
||||||
right.appendChild(proto);
|
right.appendChild(proto);
|
||||||
right.appendChild(ms);
|
right.appendChild(ms);
|
||||||
|
|
||||||
row.appendChild(left);
|
row.appendChild(left);
|
||||||
row.appendChild(right);
|
row.appendChild(right);
|
||||||
row.addEventListener("click", () => onServerClick(p.name));
|
row.addEventListener("click", () => onServerClick(p.name));
|
||||||
row.addEventListener("dblclick", () => onServerConnect(p.name));
|
row.addEventListener("dblclick", () => onServerConnect(p.name));
|
||||||
serverList.appendChild(row);
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServerList(activeName) {
|
||||||
|
const active = activeName || profile.value;
|
||||||
|
const all = orderedProfiles();
|
||||||
|
const list = filteredProfiles();
|
||||||
|
const keep = new Set();
|
||||||
|
|
||||||
|
if (!all.length || !list.length) {
|
||||||
|
serverList.innerHTML = "";
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "server-empty";
|
||||||
|
empty.textContent = !all.length
|
||||||
|
? "Нет серверов — добавьте профиль или подписку"
|
||||||
|
: "Ничего не найдено";
|
||||||
|
serverList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop empty-state nodes from prior renders.
|
||||||
|
serverList.querySelectorAll(".server-empty").forEach((el) => el.remove());
|
||||||
|
|
||||||
|
list.forEach((p) => {
|
||||||
|
keep.add(p.name);
|
||||||
|
const row = ensureServerRow(p);
|
||||||
|
row.className = "server-row" + (p.name === active ? " active" : "");
|
||||||
|
const nameEl = row.querySelector(".name");
|
||||||
|
const sub = row.querySelector(".sub");
|
||||||
|
const proto = row.querySelector(".proto");
|
||||||
|
const ms = row.querySelector(".ms");
|
||||||
|
if (nameEl && nameEl.textContent !== p.name) nameEl.textContent = p.name;
|
||||||
|
const hostText = p.host || "нет хоста";
|
||||||
|
if (sub && sub.textContent !== hostText) sub.textContent = hostText;
|
||||||
|
const protoText = (p.protocol || "?").toString();
|
||||||
|
if (proto && proto.textContent !== protoText) proto.textContent = protoText;
|
||||||
|
const pl = pingLabel(pingMap[p.name]);
|
||||||
|
if (ms) {
|
||||||
|
if (ms.className !== pl.cls) ms.className = pl.cls;
|
||||||
|
if (ms.textContent !== pl.text) ms.textContent = pl.text;
|
||||||
|
if ((ms.title || "") !== (pl.title || "")) ms.title = pl.title || "";
|
||||||
|
}
|
||||||
|
serverList.appendChild(row); // re-order if needed
|
||||||
|
});
|
||||||
|
|
||||||
|
serverList.querySelectorAll(".server-row").forEach((row) => {
|
||||||
|
if (!keep.has(row.dataset.name)) row.remove();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'none';" />
|
||||||
<title>Navis</title>
|
<title>Navis</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Figtree:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="app.css" />
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/corebin"
|
"vpnclient/internal/corebin"
|
||||||
"vpnclient/internal/linknorm"
|
"vpnclient/internal/linknorm"
|
||||||
|
"vpnclient/internal/listenutil"
|
||||||
|
"vpnclient/internal/netcheck"
|
||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
@@ -108,12 +110,19 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
|||||||
stderr := m.stderr
|
stderr := m.stderr
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
socksHP, _ := profileCopy.SOCKSListenHostPort()
|
||||||
|
httpHP, _ := profileCopy.HTTPListenHostPort()
|
||||||
|
listenAddrs := listenutil.ListenAddrs(socksHP, httpHP)
|
||||||
|
if err := listenutil.EnsureFree(binDir, listenAddrs...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
engine, err := m.newEngine(profileCopy.Protocol)
|
engine, err := m.newEngine(profileCopy.Protocol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := engine.Start(ctx, profileCopy, binDir); err != nil {
|
if err := engine.Start(ctx, profileCopy, binDir); err != nil {
|
||||||
return err
|
return listenutil.ClassifyStartFailure(string(profileCopy.Protocol), err, listenAddrs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -626,6 +635,22 @@ func (m *Manager) Profiles() []config.ProfileInfo {
|
|||||||
return m.cfg.ListProfiles()
|
return m.cfg.ListProfiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PingTargets returns name/protocol/host endpoints without copying proxy secrets.
|
||||||
|
func (m *Manager) PingTargets() []netcheck.Target {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
list := m.cfg.ListProfilesForPoll()
|
||||||
|
out := make([]netcheck.Target, 0, len(list))
|
||||||
|
for _, p := range list {
|
||||||
|
out = append(out, netcheck.Target{
|
||||||
|
Name: p.Name,
|
||||||
|
Protocol: config.Protocol(p.Protocol),
|
||||||
|
Host: p.Host,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Hy2Options are editable Hysteria 2 extras for the active profile.
|
// Hy2Options are editable Hysteria 2 extras for the active profile.
|
||||||
type Hy2Options struct {
|
type Hy2Options struct {
|
||||||
Congestion string `json:"congestion"`
|
Congestion string `json:"congestion"`
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package listenutil
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
func splitAddr(addr string) (host, port string, err error) {
|
||||||
|
return net.SplitHostPort(addr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package listenutil
|
||||||
|
|
||||||
|
// KillOrphanCores is best-effort on non-Windows (no-op).
|
||||||
|
func KillOrphanCores(binDir string, addrs ...string) error {
|
||||||
|
_ = binDir
|
||||||
|
_ = addrs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package listenutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var coreBasenames = map[string]struct{}{
|
||||||
|
"hysteria.exe": {},
|
||||||
|
"naive.exe": {},
|
||||||
|
"xray.exe": {},
|
||||||
|
"Xray.exe": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillOrphanCores terminates known VPN core processes holding listen addrs.
|
||||||
|
func KillOrphanCores(binDir string, addrs ...string) error {
|
||||||
|
_ = binDir // reserved for future path-scoped sweeps
|
||||||
|
wantPorts := map[string]struct{}{}
|
||||||
|
for _, addr := range ListenAddrs(addrs...) {
|
||||||
|
if _, port, err := splitAddr(addr); err == nil && port != "" {
|
||||||
|
wantPorts[port] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(wantPorts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self := uint32(os.Getpid())
|
||||||
|
var last error
|
||||||
|
for pid, port := range listeningPIDs() {
|
||||||
|
if _, ok := wantPorts[port]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pid == 0 || pid == self {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := queryImagePath(pid)
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if _, ok := coreBasenames[base]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := terminatePID(pid); err != nil {
|
||||||
|
last = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func listeningPIDs() map[uint32]string {
|
||||||
|
out := map[uint32]string{}
|
||||||
|
cmd := exec.Command("netstat", "-ano", "-p", "tcp")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
b, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(string(b)))
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) < 5 || !strings.EqualFold(fields[0], "TCP") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(fields[3], "LISTENING") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
port := portOf(fields[1])
|
||||||
|
pid64, err := strconv.ParseUint(fields[4], 10, 32)
|
||||||
|
if err != nil || port == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[uint32(pid64)] = port
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func portOf(local string) string {
|
||||||
|
if _, port, err := splitAddr(local); err == nil {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(local, ":"); i >= 0 {
|
||||||
|
return local[i+1:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryImagePath(pid uint32) string {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
var buf [windows.MAX_PATH]uint16
|
||||||
|
size := uint32(len(buf))
|
||||||
|
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return windows.UTF16ToString(buf[:size])
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminatePID(pid uint32) error {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open pid %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
if err := windows.TerminateProcess(h, 1); err != nil {
|
||||||
|
return fmt.Errorf("terminate pid %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package listenutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListenAddrs returns unique non-empty host:port listen targets.
|
||||||
|
func ListenAddrs(addrs ...string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []string
|
||||||
|
for _, hp := range addrs {
|
||||||
|
hp = strings.TrimSpace(hp)
|
||||||
|
if hp == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[hp]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[hp] = struct{}{}
|
||||||
|
out = append(out, hp)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusyError returns a user-facing error if any addr cannot be bound.
|
||||||
|
func BusyError(addrs ...string) error {
|
||||||
|
var busy []string
|
||||||
|
for _, addr := range addrs {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !canBindTCP(addr) {
|
||||||
|
busy = append(busy, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(busy) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("порт %s занят — закройте другой Navis/ядро или смените listen в профиле", strings.Join(busy, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureFree tries to free orphan cores once, then verifies ports are bindable.
|
||||||
|
func EnsureFree(binDir string, addrs ...string) error {
|
||||||
|
addrs = ListenAddrs(addrs...)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := BusyError(addrs...); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = KillOrphanCores(binDir, addrs...)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if err := BusyError(addrs...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyStartFailure rewrites a generic "exited immediately" into a port-busy
|
||||||
|
// message when listen addresses are occupied.
|
||||||
|
func ClassifyStartFailure(proto string, generic error, addrs ...string) error {
|
||||||
|
if err := BusyError(addrs...); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", proto, err)
|
||||||
|
}
|
||||||
|
return generic
|
||||||
|
}
|
||||||
|
|
||||||
|
func canBindTCP(addr string) bool {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = ln.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package listenutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBusyErrorFreePort(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
_ = ln.Close()
|
||||||
|
if err := BusyError(addr); err != nil {
|
||||||
|
t.Fatalf("expected free: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusyErrorTakenPort(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
err = BusyError(addr)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "занят") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyStartFailure(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
generic := errString("exited immediately")
|
||||||
|
got := ClassifyStartFailure("hysteria2", generic, ln.Addr().String())
|
||||||
|
if got == nil || !strings.Contains(got.Error(), "занят") {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
@@ -29,9 +29,12 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Target describes one profile to ping.
|
// Target describes one profile to ping.
|
||||||
|
// Prefer Host (host or host:port) so large proxy/AWG bodies are not copied;
|
||||||
|
// Proxy is used only when Host is empty.
|
||||||
type Target struct {
|
type Target struct {
|
||||||
Name string
|
Name string
|
||||||
Protocol config.Protocol
|
Protocol config.Protocol
|
||||||
|
Host string
|
||||||
Proxy string
|
Proxy string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +73,10 @@ func PingAll(ctx context.Context, targets []Target) []Result {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := range jobs {
|
for i := range jobs {
|
||||||
t := targets[i]
|
t := targets[i]
|
||||||
|
if hp := strings.TrimSpace(t.Host); hp != "" {
|
||||||
|
out[i] = PingHost(ctx, t.Name, t.Protocol, hp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if strings.TrimSpace(t.Proxy) == "" {
|
if strings.TrimSpace(t.Proxy) == "" {
|
||||||
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
|
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
|
||||||
continue
|
continue
|
||||||
@@ -114,6 +121,65 @@ func BestOK(results []Result) (Result, bool) {
|
|||||||
return best, found
|
return best, found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PingHost probes a pre-parsed host or host:port (no proxy URI / secrets).
|
||||||
|
// Naive/Xray use TCP; Hysteria 2 / AWG use UDP.
|
||||||
|
func PingHost(ctx context.Context, name string, proto config.Protocol, hostPort string) Result {
|
||||||
|
res := Result{Name: name}
|
||||||
|
hostPort = strings.TrimSpace(hostPort)
|
||||||
|
if hostPort == "" {
|
||||||
|
res.Error = "нет хоста"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
host, port := splitHostPort(hostPort, "443")
|
||||||
|
res.Host = host
|
||||||
|
res.Port = port
|
||||||
|
if host == "" {
|
||||||
|
res.Error = "нет хоста"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
udp := proto == config.ProtocolHysteria2 || proto == config.ProtocolAWG
|
||||||
|
var (
|
||||||
|
rtt time.Duration
|
||||||
|
soft bool
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if udp {
|
||||||
|
rtt, soft, err = probeUDP(ctx, host, port)
|
||||||
|
} else {
|
||||||
|
rtt, err = probeTCP(ctx, host, port)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
res.Error = friendlyDialError(err, udp)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
ms := rtt.Milliseconds()
|
||||||
|
if ms < 1 {
|
||||||
|
ms = 1
|
||||||
|
}
|
||||||
|
res.Ms = ms
|
||||||
|
res.OK = true
|
||||||
|
res.Soft = soft
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPort(hostPort, defaultPort string) (host, port string) {
|
||||||
|
hostPort = strings.TrimSpace(hostPort)
|
||||||
|
if hostPort == "" {
|
||||||
|
return "", defaultPort
|
||||||
|
}
|
||||||
|
if h, p, err := net.SplitHostPort(hostPort); err == nil {
|
||||||
|
return h, p
|
||||||
|
}
|
||||||
|
// bare IPv6 without port
|
||||||
|
if strings.Count(hostPort, ":") >= 2 && !strings.HasPrefix(hostPort, "[") {
|
||||||
|
return hostPort, defaultPort
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(hostPort, ":"); i >= 0 && !strings.Contains(hostPort[i+1:], "]") {
|
||||||
|
return hostPort[:i], hostPort[i+1:]
|
||||||
|
}
|
||||||
|
return hostPort, defaultPort
|
||||||
|
}
|
||||||
|
|
||||||
// PingProfile pings using protocol hints when available.
|
// PingProfile pings using protocol hints when available.
|
||||||
// Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused".
|
// Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused".
|
||||||
// When proto is set, Detect is skipped (trust stored protocol).
|
// When proto is set, Detect is skipped (trust stored protocol).
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ func TestPingProfileEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitHostPort(t *testing.T) {
|
||||||
|
h, p := splitHostPort("example.com:8443", "443")
|
||||||
|
if h != "example.com" || p != "8443" {
|
||||||
|
t.Fatalf("%s %s", h, p)
|
||||||
|
}
|
||||||
|
h, p = splitHostPort("example.com", "443")
|
||||||
|
if h != "example.com" || p != "443" {
|
||||||
|
t.Fatalf("%s %s", h, p)
|
||||||
|
}
|
||||||
|
h, p = splitHostPort("[2001:db8::1]:443", "80")
|
||||||
|
if h != "2001:db8::1" || p != "443" {
|
||||||
|
t.Fatalf("%s %s", h, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingHostEmpty(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r := PingHost(ctx, "x", config.ProtocolNaive, "")
|
||||||
|
if r.OK || r.Error == "" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
|
func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
|
||||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/
|
|||||||
// ResolveBinary finds hysteria client binary.
|
// ResolveBinary finds hysteria client binary.
|
||||||
func ResolveBinary(binDir string) (string, error) {
|
func ResolveBinary(binDir string) (string, error) {
|
||||||
names := candidateNames()
|
names := candidateNames()
|
||||||
|
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
|
||||||
candidates := []string{}
|
candidates := []string{}
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
candidates = append(candidates,
|
candidates = append(candidates,
|
||||||
@@ -24,12 +25,6 @@ func ResolveBinary(binDir string) (string, error) {
|
|||||||
filepath.Join(binDir, "hysteria", name),
|
filepath.Join(binDir, "hysteria", name),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if exe, err := os.Executable(); err == nil {
|
|
||||||
dir := filepath.Dir(exe)
|
|
||||||
for _, name := range names {
|
|
||||||
candidates = append(candidates, filepath.Join(dir, name), filepath.Join(dir, "bin", name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||||
return c, nil
|
return c, nil
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
|
"vpnclient/internal/listenutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine runs the official apernet/hysteria client (Hysteria 2).
|
// Engine runs the official apernet/hysteria client (Hysteria 2).
|
||||||
@@ -102,7 +103,11 @@ func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir strin
|
|||||||
var startErr error
|
var startErr error
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
startErr = fmt.Errorf("hysteria2: process exited immediately; check URI/password and binary")
|
socks, _ := profile.SOCKSListenHostPort()
|
||||||
|
httpL, _ := profile.HTTPListenHostPort()
|
||||||
|
startErr = listenutil.ClassifyStartFailure("hysteria2",
|
||||||
|
fmt.Errorf("hysteria2: process exited immediately; check URI/password and binary"),
|
||||||
|
socks, httpL)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = e.Stop()
|
_ = e.Stop()
|
||||||
startErr = ctx.Err()
|
startErr = ctx.Err()
|
||||||
|
|||||||
@@ -21,14 +21,11 @@ func ResolveBinary(binDir string) (string, error) {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
name = "naive.exe"
|
name = "naive.exe"
|
||||||
}
|
}
|
||||||
|
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
filepath.Join(binDir, name),
|
filepath.Join(binDir, name),
|
||||||
filepath.Join(binDir, "naiveproxy", name),
|
filepath.Join(binDir, "naiveproxy", name),
|
||||||
}
|
}
|
||||||
if exe, err := os.Executable(); err == nil {
|
|
||||||
candidates = append(candidates, filepath.Join(filepath.Dir(exe), name))
|
|
||||||
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "bin", name))
|
|
||||||
}
|
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||||
return c, nil
|
return c, nil
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
|
"vpnclient/internal/listenutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine runs the official Chromium-based naive binary.
|
// Engine runs the official Chromium-based naive binary.
|
||||||
@@ -105,7 +106,11 @@ func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir strin
|
|||||||
var startErr error
|
var startErr error
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
startErr = fmt.Errorf("naive: process exited immediately; check proxy URI, credentials, and binary")
|
socks, _ := profile.SOCKSListenHostPort()
|
||||||
|
httpL, _ := profile.HTTPListenHostPort()
|
||||||
|
startErr = listenutil.ClassifyStartFailure("naive",
|
||||||
|
fmt.Errorf("naive: process exited immediately; check proxy URI, credentials, and binary"),
|
||||||
|
socks, httpL)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = e.Stop()
|
_ = e.Stop()
|
||||||
startErr = ctx.Err()
|
startErr = ctx.Err()
|
||||||
|
|||||||
@@ -17,15 +17,12 @@ const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/la
|
|||||||
|
|
||||||
// ResolveBinary finds xray executable.
|
// ResolveBinary finds xray executable.
|
||||||
func ResolveBinary(binDir string) (string, error) {
|
func ResolveBinary(binDir string) (string, error) {
|
||||||
|
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
|
||||||
for _, name := range candidateNames() {
|
for _, name := range candidateNames() {
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
filepath.Join(binDir, name),
|
filepath.Join(binDir, name),
|
||||||
filepath.Join(binDir, "xray", name),
|
filepath.Join(binDir, "xray", name),
|
||||||
}
|
}
|
||||||
if exe, err := os.Executable(); err == nil {
|
|
||||||
dir := filepath.Dir(exe)
|
|
||||||
candidates = append(candidates, filepath.Join(dir, name), filepath.Join(dir, "bin", name))
|
|
||||||
}
|
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||||
return c, nil
|
return c, nil
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
|
"vpnclient/internal/listenutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine runs official XTLS/Xray-core for VLESS / VMess / Trojan.
|
// Engine runs official XTLS/Xray-core for VLESS / VMess / Trojan.
|
||||||
@@ -106,7 +107,11 @@ func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir strin
|
|||||||
var startErr error
|
var startErr error
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
startErr = fmt.Errorf("xray: process exited immediately; check link and install-core (xray)")
|
socks, _ := profile.SOCKSListenHostPort()
|
||||||
|
httpL, _ := profile.HTTPListenHostPort()
|
||||||
|
startErr = listenutil.ClassifyStartFailure("xray",
|
||||||
|
fmt.Errorf("xray: process exited immediately; check link and install-core (xray)"),
|
||||||
|
socks, httpL)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = e.Stop()
|
_ = e.Stop()
|
||||||
startErr = ctx.Err()
|
startErr = ctx.Err()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrAlreadyRunning means another Navis GUI instance holds the lock.
|
||||||
|
var ErrAlreadyRunning = errors.New("Navis уже запущен")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build plan9
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func flockExclusive(f *os.File) error { return nil }
|
||||||
|
func funlock(f *os.File) error { return nil }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !windows && !plan9
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func flockExclusive(f *os.File) error {
|
||||||
|
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func funlock(f *os.File) error {
|
||||||
|
return syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lock uses an exclusive lock file under the user config dir when available.
|
||||||
|
func Lock(appName string) (func(), error) {
|
||||||
|
if appName == "" {
|
||||||
|
appName = "Navis"
|
||||||
|
}
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, appName, "singleinstance.lock")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
if err := flockExclusive(f); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, fmt.Errorf("%w", ErrAlreadyRunning)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = funlock(f)
|
||||||
|
_ = f.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
procFindWindow = user32.NewProc("FindWindowW")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
procSetForeground = user32.NewProc("SetForegroundWindow")
|
||||||
|
procIsIconic = user32.NewProc("IsIconic")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
swShow = 5
|
||||||
|
swRestore = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lock acquires a per-user mutex. If another Navis holds it, activates that
|
||||||
|
// window (title "Navis") and returns ErrAlreadyRunning.
|
||||||
|
func Lock(appName string) (func(), error) {
|
||||||
|
if appName == "" {
|
||||||
|
appName = "Navis"
|
||||||
|
}
|
||||||
|
name, err := windows.UTF16PtrFromString("Local\\" + appName + "SingleInstance")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handle, err := windows.CreateMutex(nil, false, name)
|
||||||
|
if err == windows.ERROR_ALREADY_EXISTS {
|
||||||
|
if handle != 0 {
|
||||||
|
_ = windows.CloseHandle(handle)
|
||||||
|
}
|
||||||
|
activateWindow(appName)
|
||||||
|
return nil, ErrAlreadyRunning
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("singleinstance: %w", err)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = windows.ReleaseMutex(handle)
|
||||||
|
_ = windows.CloseHandle(handle)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateWindow(title string) {
|
||||||
|
t, err := windows.UTF16PtrFromString(title)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hwnd, _, _ := procFindWindow.Call(0, uintptr(unsafe.Pointer(t)))
|
||||||
|
if hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iconic, _, _ := procIsIconic.Call(hwnd)
|
||||||
|
if iconic != 0 {
|
||||||
|
procShowWindow.Call(hwnd, swRestore)
|
||||||
|
} else {
|
||||||
|
procShowWindow.Call(hwnd, swShow)
|
||||||
|
}
|
||||||
|
procSetForeground.Call(hwnd)
|
||||||
|
}
|
||||||
@@ -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 = 7
|
const BuildNumber = 11
|
||||||
|
|
||||||
// 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"
|
||||||
|
|||||||
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
|
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
|
||||||
"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": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||||
"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": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||||
"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": 7
|
"Build": 11
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 7
|
"Build": 11
|
||||||
},
|
},
|
||||||
"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.7",
|
"FileVersion": "3.8.2.11",
|
||||||
"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.7",
|
"ProductVersion": "3.8.2.11",
|
||||||
"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