Author SHA1 Message Date
M4andCursor d1570b23d3 Release 3.8.2.3: cheaper poll I/O, batched macOS sysproxy, no Connect Clone.
Trust corebin cache without Stat; batch networksetup; ActiveProxyURI instead of Config.Clone.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:52:55 +03:00
M4andCursor 621c847cb3 Release 3.8.2.1: reliability, UI probe/logs, CI, signing gates.
Clear sysproxy on core death; probe+reconnect; subscription prune;
Best ignores soft UDP; Windows tray; Android protocol gate; CI workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:44:48 +03:00
M4andCursor 6b6c13c933 Release 3.8.1.1: lighter idle UI poll, core cache, multicore ping.
Reduce idle CPU (cheap PollUI, binary cache, logbuf cap, DOM fingerprint),
speed ping/AWG HostPort, CopyBuffer pool; ship arm64 build and update notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:27:17 +03:00
M4 bec6c8392d Release 3.8.0.3: fix Hy2/AWG UDP ping false negatives. 2026-07-30 01:49:19 +03:00
M4 6a7480dceb Release 3.8.0.2: app.zip updates, lock hygiene, secret-safe getState. 2026-07-29 22:00:39 +03:00
M4 54b5b87990 Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks. 2026-07-29 21:43:41 +03:00
66 changed files with 4269 additions and 2326 deletions
+31
View File
@@ -0,0 +1,31 @@
name: ci
on:
push:
branches: [main, master]
pull_request:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test
run: go test ./...
- name: Build GUI
shell: bash
run: |
if [[ "${{ runner.os }}" == "Windows" ]]; then
go build -o Navis.exe ./cmd/vpnapp
else
go build -o Navis ./cmd/vpnapp
fi
- name: Build CLI
run: go build -o Navis-cli ./cmd/vpnclient
+31
View File
@@ -0,0 +1,31 @@
name: ci
on:
push:
branches: [main, master]
pull_request:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test
run: go test ./...
- name: Build GUI
shell: bash
run: |
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
go build -o Navis.exe ./cmd/vpnapp
else
go build -o Navis ./cmd/vpnapp
fi
- name: Build CLI
run: go build -o Navis-cli ./cmd/vpnclient
+6
View File
@@ -25,4 +25,10 @@ android/app/src/main/assets/cores/**/hev-socks5-tunnel
*.apk
!dist/navis-release/android/*.apk
# Local backups (pre-release archives)
backups/
# Local Go toolchain
.tools/
+23
View File
@@ -243,6 +243,29 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
В 3.8.2:
- при неожиданном падении ядра снимается системный прокси и sentinel;
- логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение;
- подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP;
- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии;
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
- 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone.
В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
- список серверов в UI не пересобирается без изменений; интервал опроса 4 с;
- быстрее и точнее пинг (учитывается протокол профиля; лёгкий разбор AWG Endpoint);
- AWG-прокси копирует трафик через буферный pool; PingAll и установка cores используют несколько ядер CPU;
- 3.8.1+1: первая сборка линейки 3.8.1.
В 3.8.0:
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
- SHA-256 проверка при установке cores (naive / hy2 / xray);
- snapshot конфига в UI (`Config().Clone`);
- 3.8.0+2: обновление Navis.app через zip+SHA; Start вне mutex; getState без секретов; подпись/notarize scripts; UI CSS/JS split;
- 3.8.0+3: пинг Hy2/AWG по UDP — soft-ok при тишине (без ложных «недоступен»).
В 2.7.3:
- восстановление системного прокси после аварийного завершения;
- auth-токен для локального macOS `/api`;
+4 -4
View File
@@ -1,8 +1,8 @@
# Navis Android
Клиент с UI как на Windows 2.2: список нод, пинг, «лучший», автоподключение, подписка.
Клиент с UI как на Desktop: список нод, пинг, «лучший», автоподключение, подписка.
Версия APK: **2.3.0**
Версия APK: **3.8.2+1** (синхронизируется с Desktop через `scripts/sync-version.py`).
## Протоколы
@@ -10,8 +10,8 @@
|----------|--------------|
| VLESS / VMess / Trojan (Xray) | SOCKS core + VpnService |
| Hysteria 2 | SOCKS core + VpnService |
| NaiveProxy | импорт в UI; Android Chromium-бинарник не бандлится |
| AmneziaWG | импорт в UI; полный Tun — следующий релиз |
| NaiveProxy | импорт в UI; подключение отключено (только Desktop) |
| AmneziaWG | импорт в UI; подключение отключено (только Desktop) |
## Сборка
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 2_070_201
versionName = "2.7.3+1"
versionCode = 3080203
versionName = "3.8.2+3"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
@@ -24,6 +24,12 @@ enum class Proto(val label: String) {
}
}
}
/** Desktop has Naive/AWG; this APK only runs Xray + Hysteria2 in VpnService. */
fun supportedOnAndroid(): Boolean = when (this) {
VLESS, VMESS, TROJAN, HYSTERIA2 -> true
else -> false
}
}
data class Profile(
@@ -59,6 +59,10 @@ class MainActivity : ComponentActivity() {
vm.setMeta("Выберите сервер", true)
return
}
if (!p.protocol.supportedOnAndroid()) {
vm.setMeta("${p.protocol.label}: на Android недоступен — используйте VLESS/VMess/Trojan/Hy2", true)
return
}
prepareVpnThen {
startService(
Intent(this, NavisVpnService::class.java)
@@ -139,12 +139,18 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
val rows = store.pingAll(profiles)
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
val best = store.bestOf(rows) ?: run {
setMeta("нет доступных серверов", true)
val supported = profiles.filter { it.protocol.supportedOnAndroid() }
val supportedRows = rows.filter { r -> supported.any { it.id == r.id } }
val best = store.bestOf(supportedRows) ?: run {
setMeta("нет доступных серверов (Android: VLESS/VMess/Trojan/Hy2)", true)
return
}
store.setActiveId(best.id)
val profile = profiles.first { it.id == best.id }
if (!profile.protocol.supportedOnAndroid()) {
setMeta("${profile.protocol.label}: на Android недоступен", true)
return
}
_state.update { it.copy(activeId = best.id) }
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
if (connect && prepare != null) {
@@ -100,7 +100,7 @@ fun NavisScreen(
Text("2.6.1", Modifier.padding(horizontal = 8.dp, vertical = 2.dp), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = AccentDeep)
}
}
Text("Android · Naive · Hy2 · AWG · Xray", color = Muted, fontSize = 13.sp)
Text("Android · Xray · Hy2 (Naive/AWG — только Desktop)", color = Muted, fontSize = 13.sp)
Spacer(Modifier.height(16.dp))
Surface(shape = RoundedCornerShape(22.dp), color = Color.White.copy(alpha = 0.85f), tonalElevation = 2.dp) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+3 -3
View File
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 2.7.3 -build 2.7.3.3 -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.3 -arch arm64
if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 2.7.3 -build 2.7.3.3 -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.3 -arch amd64
if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 2.7.3 -build 2.7.3.3 -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.3 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
+17 -3
View File
@@ -3,13 +3,13 @@
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"time"
@@ -18,6 +18,8 @@ import (
"vpnclient/internal/apphost"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
"vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
@@ -27,6 +29,7 @@ func main() {
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
@@ -41,7 +44,7 @@ func main() {
if err != nil {
fatalf("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := &bytes.Buffer{}
logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalf("%v", err)
@@ -54,6 +57,7 @@ func main() {
a.OnAfterUpdate = func() {
go func() {
time.Sleep(300 * time.Millisecond)
_ = a.Disconnect()
os.Exit(0)
}()
}
@@ -67,6 +71,16 @@ func main() {
go func() {
_ = srv.Serve(ln)
}()
a.StartBackground()
trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() },
Disconnect: func() { _ = a.Disconnect() },
Quit: func() {
_ = a.Disconnect()
os.Exit(0)
},
IsUp: func() bool { return a.Mgr.Status().Connected },
})
go a.AutoCheckUpdate()
w, err := glaze.New(false)
@@ -89,7 +103,7 @@ func main() {
w.Destroy()
}()
w.SetTitle("Navis 2")
w.SetTitle("Navis")
w.SetSize(500, 900, glaze.HintNone)
w.Navigate(uiURL)
log.Printf("Navis UI: %s", uiURL)
+49 -404
View File
@@ -3,61 +3,26 @@
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"runtime"
"time"
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
"vpnclient/internal/apphost"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/logbuf"
"vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
type app struct {
mu sync.Mutex
mgr *core.Manager
cfgPath string
logBuf *bytes.Buffer
updateStatus update.Status
pings []netcheck.Result
webview webview2.WebView
}
type uiState struct {
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
}
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
@@ -65,6 +30,7 @@ func main() {
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
@@ -81,33 +47,32 @@ func main() {
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := &bytes.Buffer{}
logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalDialog("%v", err)
}
a := &app{
mgr: mgr,
cfgPath: cfgPath,
logBuf: logBuf,
updateStatus: update.Status{
Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL,
},
a := apphost.New(mgr, cfgPath, logBuf)
a.OpenURL = shellOpen
a.OnAfterUpdate = func() {
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
go func() {
time.Sleep(300 * time.Millisecond)
_ = a.Disconnect()
os.Exit(0)
}()
}
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
_ = os.MkdirAll(dataPath, 0o755)
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
w := webview2.NewWithOptions(webview2.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
Title: "Navis 2",
Title: "Navis",
Width: 500,
Height: 900,
Center: true,
@@ -117,33 +82,47 @@ func main() {
if w == nil {
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
}
a.webview = w
defer func() {
_ = a.mgr.Disconnect()
_ = mgr.Disconnect()
w.Destroy()
}()
applyWindowIcon(w.Window())
w.SetSize(500, 900, webview2.HintNone)
mustBind(w, "getState", a.getState)
mustBind(w, "connect", a.connect)
mustBind(w, "disconnect", a.disconnect)
mustBind(w, "connectProfile", a.connectProfile)
mustBind(w, "saveProfile", a.saveProfile)
mustBind(w, "createProfile", a.createProfile)
mustBind(w, "selectProfile", a.selectProfile)
mustBind(w, "deleteProfile", a.deleteProfile)
mustBind(w, "installCore", a.installCore)
mustBind(w, "openURL", openURL)
mustBind(w, "pingServers", a.pingServers)
mustBind(w, "pingBest", a.pingBest)
mustBind(w, "checkUpdate", a.checkUpdate)
mustBind(w, "applyUpdate", a.applyUpdate)
mustBind(w, "saveHy2", a.saveHy2)
mustBind(w, "importSubscription", a.importSubscription)
mustBind(w, "getState", a.GetState)
mustBind(w, "getEditState", a.GetEditState)
mustBind(w, "connect", a.Connect)
mustBind(w, "disconnect", a.Disconnect)
mustBind(w, "connectProfile", a.ConnectProfile)
mustBind(w, "saveProfile", a.SaveProfile)
mustBind(w, "createProfile", a.CreateProfile)
mustBind(w, "selectProfile", a.SelectProfile)
mustBind(w, "deleteProfile", a.DeleteProfile)
mustBind(w, "installCore", a.InstallCore)
mustBind(w, "openURL", a.OpenShopURL)
mustBind(w, "pingServers", a.PingServers)
mustBind(w, "pingBest", a.PingBest)
mustBind(w, "checkUpdate", a.CheckUpdate)
mustBind(w, "applyUpdate", a.ApplyUpdate)
mustBind(w, "saveHy2", a.SaveHy2)
mustBind(w, "importSubscription", a.ImportSubscription)
mustBind(w, "getLogs", a.GetLogs)
mustBind(w, "probeTunnel", a.ProbeTunnel)
mustBind(w, "savePrefs", a.SavePrefs)
go a.autoCheckUpdate()
a.StartBackground()
trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() },
Disconnect: func() { _ = a.Disconnect() },
Quit: func() {
_ = a.Disconnect()
os.Exit(0)
},
IsUp: func() bool { return a.Mgr.Status().Connected },
})
go a.AutoCheckUpdate()
w.SetHtml(appui.IndexHTML)
w.Run()
@@ -155,340 +134,6 @@ func mustBind(w webview2.WebView, name string, fn interface{}) {
}
}
func (a *app) getState() (uiState, error) {
a.mu.Lock()
defer a.mu.Unlock()
st := a.mgr.Status()
cfg := a.mgr.Config()
proxy := ""
active := cfg.Active
if p, err := cfg.ActiveProfile(); err == nil {
proxy = p.Proxy
active = p.Name
}
corePath := ""
coreReady := false
if p, err := cfg.ActiveProfile(); err == nil {
switch p.Protocol {
case config.ProtocolHysteria2:
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolAWG:
if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
if path, err := xray.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
default:
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
}
} else if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
out := uiState{
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: cfg.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: a.cfgPath,
Profiles: config.RedactProfileList(cfg.ListProfiles()),
Version: update.DisplayVersion(),
Update: a.updateStatus,
Pings: append([]netcheck.Result(nil), a.pings...),
Subscription: cfg.SubscriptionURL,
Hy2: a.mgr.ActiveHy2Options(),
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
out.Protocol = string(p.Protocol)
}
}
if st.Connected {
out.SystemProxy = st.SystemProxy
}
return out, nil
}
func (a *app) saveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.mgr.SetSystemProxy(systemProxy)
return a.mgr.SaveActiveProfile(name, proxy)
}
func (a *app) createProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.mgr.SetSystemProxy(systemProxy)
return a.mgr.SaveProfile(name, proxy)
}
func (a *app) selectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.SetActiveProfile(name)
}
func (a *app) deleteProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.DeleteProfile(name)
}
func (a *app) connect() error {
return a.connectProfile("")
}
// connectProfile switches to the named profile (if set) and connects.
// If already connected to another profile, disconnects first.
func (a *app) connectProfile(name string) error {
a.mu.Lock()
name = strings.TrimSpace(name)
st := a.mgr.Status()
if st.Connected {
if name == "" || st.Profile == name {
a.mu.Unlock()
return nil
}
a.mu.Unlock()
if err := a.mgr.Disconnect(); err != nil {
return err
}
a.mu.Lock()
}
if name != "" {
if err := a.mgr.SetActiveProfile(name); err != nil {
a.mu.Unlock()
return err
}
}
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
a.mu.Unlock()
return err
} else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера")
}
a.mu.Unlock()
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
if _, err := a.mgr.EnsureCore(""); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
return a.mgr.Connect(ctx, "")
}
func (a *app) disconnect() error {
// Do not hold a.mu across engine teardown — getState polling would freeze the UI.
return a.mgr.Disconnect()
}
func (a *app) installCore() (string, error) {
paths, err := a.mgr.EnsureAllCores()
if err != nil {
return "", err
}
parts := make([]string, 0, len(paths))
for k, v := range paths {
parts = append(parts, k+": "+v)
}
return strings.Join(parts, " | "), nil
}
func (a *app) saveHy2(opts core.Hy2Options) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.SaveHy2Options(opts)
}
func (a *app) importSubscription(rawURL string) (int, error) {
return a.mgr.ImportSubscription(rawURL)
}
type pingBestResult struct {
Pings []netcheck.Result `json:"pings"`
BestName string `json:"best_name,omitempty"`
BestMs int64 `json:"best_ms,omitempty"`
Selected bool `json:"selected"`
Connected bool `json:"connected"`
}
func (a *app) pingServers() ([]netcheck.Result, error) {
res, err := a.runPings()
return res, err
}
// pingBest measures all nodes, activates the fastest OK one, and optionally connects.
func (a *app) pingBest(autoConnect bool) (pingBestResult, error) {
pings, err := a.runPings()
out := pingBestResult{Pings: pings}
if err != nil {
return out, err
}
best, ok := netcheck.BestOK(pings)
if !ok {
return out, fmt.Errorf("нет доступных серверов")
}
out.BestName = best.Name
out.BestMs = best.Ms
a.mu.Lock()
st := a.mgr.Status()
alreadyBest := st.Connected && st.Profile == best.Name
if st.Connected && !alreadyBest {
a.mu.Unlock()
if err := a.mgr.Disconnect(); err != nil {
return out, err
}
a.mu.Lock()
}
if !alreadyBest {
if err := a.mgr.SetActiveProfile(best.Name); err != nil {
a.mu.Unlock()
return out, err
}
}
out.Selected = true
if !autoConnect {
a.mu.Unlock()
return out, nil
}
if alreadyBest {
a.mu.Unlock()
out.Connected = true
return out, nil
}
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
a.mu.Unlock()
return out, err
} else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
}
a.mu.Unlock()
if _, err := a.mgr.EnsureCore(""); err != nil {
return out, err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := a.mgr.Connect(ctx, ""); err != nil {
return out, err
}
out.Connected = true
return out, nil
}
func (a *app) runPings() ([]netcheck.Result, error) {
a.mu.Lock()
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)
defer cancel()
out := netcheck.PingAll(ctx, targets)
a.mu.Lock()
a.pings = out
a.mu.Unlock()
return out, nil
}
func (a *app) checkUpdate() (update.Status, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
st, err := update.Check(ctx, update.DefaultManifestURL)
a.mu.Lock()
if err != nil {
st.Current = update.DisplayVersion()
st.ManifestURL = update.DefaultManifestURL
st.Error = err.Error()
}
a.updateStatus = st
a.mu.Unlock()
return st, nil
}
func (a *app) applyUpdate() (string, error) {
// Explicit user action only — never called from autoCheckUpdate.
st, err := a.checkUpdate()
if err != nil {
return "", err
}
if !st.Available {
return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
latest, err := update.Apply(ctx, update.DefaultManifestURL)
if err != nil {
return "", err
}
_ = a.mgr.Disconnect()
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate (can hang).
go func() {
time.Sleep(300 * time.Millisecond)
os.Exit(0)
}()
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
}
func (a *app) autoCheckUpdate() {
// Only refresh update status for the UI banner — never auto-install.
// (Previously auto-apply caused an infinite update loop when the feed version
// stayed ahead of the shipped CurrentVersion in the downloaded binary.)
time.Sleep(3 * time.Second)
_, _ = a.checkUpdate()
}
func openURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("пустая ссылка")
}
lower := strings.ToLower(raw)
switch {
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
raw = "https://evilfox.win/"
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
// allow shop deep-links on evilfox.win only
default:
return fmt.Errorf("разрешена только ссылка evilfox.win")
}
return shellOpen(raw)
}
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2.7.3+3
3.8.2+3
+1 -1
View File
@@ -1 +1 @@
2.7.3.3
3.8.2.3
+12 -6
View File
@@ -1,6 +1,6 @@
{
"version": "2.7.3",
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
"version": "3.8.2",
"notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,11 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
@@ -28,7 +30,9 @@
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
@@ -36,7 +40,9 @@
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
+12 -6
View File
@@ -1,6 +1,6 @@
{
"version": "2.7.3",
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
"version": "3.8.2",
"notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,11 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
@@ -28,7 +30,9 @@
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
@@ -36,7 +40,9 @@
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
+49
View File
@@ -0,0 +1,49 @@
# Code signing (release)
## macOS
1. Create a **Developer ID Application** certificate in Apple Developer.
2. Store notary credentials once:
```bash
xcrun notarytool store-credentials "navis-notary" \
--apple-id "you@example.com" \
--team-id "TEAMID" \
--password "app-specific-password"
```
3. Build with identity (packmac picks it up):
```bash
export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
./scripts/build-macos-arm64.sh
```
4. Notarize the DMG:
```bash
export NAVIS_NOTARY_PROFILE="navis-notary"
./scripts/sign-macos.sh dist/navis-release/darwin-arm64/Navis.dmg
```
Without these env vars, packmac uses **ad-hoc** signing (`-`) — fine for local/dev.
For a **release gate** (fail if unsigned):
```bash
export REQUIRE_CODESIGN=1
export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
./scripts/build-macos-arm64.sh
```
In-app macOS updates **do not** re-sign with ad-hoc `-` (that would strip Developer ID). They only re-sign when `NAVIS_CODESIGN_IDENTITY` is set to a real identity.
## Windows
Sign `Navis.exe` with Authenticode (EV or standard code signing cert):
```bat
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /a dist\navis-release\Navis.exe
signtool verify /pa dist\navis-release\Navis.exe
```
Then refresh `sha256` in `dist/update.json` (or re-run the Windows release script that stamps it).
+227 -89
View File
@@ -1,7 +1,6 @@
package apphost
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
@@ -18,6 +17,9 @@ import (
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/corebin"
"vpnclient/internal/dockicon"
"vpnclient/internal/logbuf"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
@@ -31,7 +33,7 @@ type App struct {
mu sync.Mutex
Mgr *core.Manager
CfgPath string
LogBuf *bytes.Buffer
LogBuf *logbuf.Buffer
UpdateStatus update.Status
Pings []netcheck.Result
OpenURL func(string) error
@@ -40,23 +42,27 @@ type App struct {
}
type UIState struct {
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
LastError string `json:"last_error,omitempty"`
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
LogTail string `json:"log_tail,omitempty"`
}
type PingBestResult struct {
@@ -67,7 +73,7 @@ type PingBestResult struct {
Connected bool `json:"connected"`
}
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
return &App{
Mgr: mgr,
CfgPath: cfgPath,
@@ -80,64 +86,68 @@ func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
}
func (a *App) GetState() (UIState, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.getState(false)
}
st := a.Mgr.Status()
cfg := a.Mgr.Config()
proxy := ""
active := cfg.Active
if p, err := cfg.ActiveProfile(); err == nil {
proxy = p.Proxy
active = p.Name
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) {
return a.getState(true)
}
func (a *App) getState(includeSecrets bool) (UIState, error) {
// Hold App.mu only for fields mutated by update/ping handlers.
a.mu.Lock()
upd := a.UpdateStatus
pings := append([]netcheck.Result(nil), a.Pings...)
cfgPath := a.CfgPath
a.mu.Unlock()
poll := a.Mgr.PollUI(includeSecrets)
st := poll.Status
proxy := poll.ActiveProxy
if !includeSecrets {
proxy = config.RedactProxyURI(proxy)
}
corePath := ""
coreReady := false
if p, err := cfg.ActiveProfile(); err == nil {
switch p.Protocol {
case config.ProtocolHysteria2:
if path, err := hysteria2.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolAWG:
if path, err := awg.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
if path, err := xray.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
default:
if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
}
} else if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
hy2 := poll.Hy2
if !includeSecrets {
hy2.ObfsPassword = ""
}
corePath, coreReady := resolveCoreCached(poll.BinDir, poll.ActiveProtocol)
profiles := poll.Profiles
if includeSecrets {
// Editor needs state.Proxy; list still omits other profiles' URIs.
profiles = config.RedactProfileList(profiles)
}
out := UIState{
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: cfg.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: a.CfgPath,
Profiles: config.RedactProfileList(cfg.ListProfiles()),
Version: update.DisplayVersion(),
Update: a.UpdateStatus,
Pings: append([]netcheck.Result(nil), a.Pings...),
Subscription: cfg.SubscriptionURL,
Hy2: a.Mgr.ActiveHy2Options(),
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: poll.Active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: poll.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: cfgPath,
Profiles: profiles,
Version: update.DisplayVersion(),
Update: upd,
Pings: pings,
Subscription: poll.Subscription,
Hy2: hy2,
LastError: poll.LastError,
ConnectOnLaunch: poll.ConnectOnLaunch,
AutoReconnect: poll.AutoReconnect,
}
if includeSecrets && a.LogBuf != nil {
out.LogTail = trimLogTail(a.LogBuf.String(), 4000)
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
out.Protocol = string(p.Protocol)
}
out.Protocol = string(poll.ActiveProtocol)
}
if st.Connected {
out.SystemProxy = st.SystemProxy
@@ -145,6 +155,32 @@ func (a *App) GetState() (UIState, error) {
return out, nil
}
func trimLogTail(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return "…\n" + s[len(s)-max:]
}
func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready bool) {
key := corebin.CacheKey(binDir, corebin.ProtoKey(string(proto)))
var err error
switch proto {
case config.ProtocolHysteria2:
path, err = corebin.Resolve(key, func() (string, error) { return hysteria2.ResolveBinary(binDir) })
case config.ProtocolAWG:
path, err = corebin.Resolve(key, func() (string, error) { return awg.ResolveBinary(binDir) })
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
path, err = corebin.Resolve(key, func() (string, error) { return xray.ResolveBinary(binDir) })
default:
path, err = corebin.Resolve(key, func() (string, error) { return naive.ResolveBinary(binDir) })
}
if err != nil {
return "", false
}
return path, true
}
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
@@ -202,26 +238,123 @@ func (a *App) ConnectProfile(name string) error {
return err
}
}
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return err
} else if strings.TrimSpace(p.Proxy) == "" {
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера")
}
a.mu.Unlock()
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
if _, err := a.Mgr.EnsureCore(""); err != nil {
return err
var last error
for attempt := 0; attempt < 2; attempt++ {
if _, err := a.Mgr.EnsureCore(""); err != nil {
a.Mgr.SetLastError(err.Error())
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
err := a.Mgr.Connect(ctx, "")
cancel()
if err != nil {
a.Mgr.SetLastError(err.Error())
last = err
continue
}
pctx, pcancel := context.WithTimeout(context.Background(), 12*time.Second)
perr := a.Mgr.Probe(pctx, "")
pcancel()
if perr == nil {
dockicon.SetConnected(true)
return nil
}
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
a.Mgr.SetLastError(last.Error())
_ = a.Mgr.Disconnect()
dockicon.SetConnected(false)
time.Sleep(400 * time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
return a.Mgr.Connect(ctx, "")
dockicon.SetConnected(false)
return last
}
func (a *App) Disconnect() error {
return a.Mgr.Disconnect()
err := a.Mgr.Disconnect()
dockicon.SetConnected(false)
return err
}
func (a *App) GetLogs() string {
if a.LogBuf == nil {
return ""
}
return trimLogTail(a.LogBuf.String(), 12000)
}
func (a *App) ProbeTunnel() error {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
err := a.Mgr.Probe(ctx, "")
if err != nil {
a.Mgr.SetLastError(err.Error())
}
return err
}
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
return a.Mgr.SavePrefs(core.Prefs{
ConnectOnLaunch: connectOnLaunch,
AutoReconnect: autoReconnect,
SystemProxy: systemProxy,
})
}
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync.
func (a *App) StartBackground() {
go a.watchdogLoop()
go a.dockIconLoop()
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
go func() {
time.Sleep(1200 * time.Millisecond)
if a.Mgr.Status().Connected {
return
}
_ = a.Connect()
}()
}
}
func (a *App) dockIconLoop() {
dockicon.SetConnected(false)
var last bool
first := true
for {
time.Sleep(400 * time.Millisecond)
up := a.Mgr.Status().Connected
if first || up != last {
first = false
last = up
dockicon.SetConnected(up)
}
}
}
func (a *App) watchdogLoop() {
for {
time.Sleep(2 * time.Second)
if !a.Mgr.TakeUnexpectedDrop() {
continue
}
prefs := a.Mgr.Prefs()
if !prefs.AutoReconnect {
continue
}
time.Sleep(800 * time.Millisecond)
_ = a.Connect()
}
}
func (a *App) InstallCore() (string, error) {
@@ -289,21 +422,18 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
out.Connected = true
return out, nil
}
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return out, err
} else if strings.TrimSpace(p.Proxy) == "" {
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
}
a.mu.Unlock()
if _, err := a.Mgr.EnsureCore(""); err != nil {
return out, err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := a.Mgr.Connect(ctx, ""); err != nil {
if err := a.ConnectProfile(""); err != nil {
return out, err
}
out.Connected = true
@@ -473,6 +603,8 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
switch name {
case "getState":
return a.GetState()
case "getEditState":
return a.GetEditState()
case "connect":
return nil, a.Connect()
case "disconnect":
@@ -507,6 +639,12 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return nil, a.SaveHy2(opts)
case "importSubscription":
return a.ImportSubscription(arg(args, 0, ""))
case "getLogs":
return a.GetLogs(), nil
case "probeTunnel":
return nil, a.ProbeTunnel()
case "savePrefs":
return nil, a.SavePrefs(arg(args, 0, false), arg(args, 1, false), arg(args, 2, true))
case "quit":
go func() {
time.Sleep(200 * time.Millisecond)
+818
View File
@@ -0,0 +1,818 @@
:root {
--ink: #0b1c18;
--muted: #5a736b;
--line: rgba(11, 28, 24, 0.10);
--accent: #0d8a66;
--accent-deep: #086b4f;
--accent-soft: #d8f3e9;
--danger: #c0362c;
--ok: #0d8a66;
--surface: rgba(255, 255, 255, 0.82);
--surface-2: rgba(255, 255, 255, 0.62);
--shadow: 0 18px 50px rgba(8, 40, 32, 0.12);
--radius: 22px;
--ease: cubic-bezier(.22,.8,.24,1);
--page-bg:
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
--hero-bg: linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
--hero-on-bg: linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
--input-bg: rgba(255,255,255,.94);
--chip-bg: rgba(255,255,255,.75);
--row-bg: rgba(255,255,255,.72);
--row-active-bg: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
--modal-bg: #f5fbf8;
--modal-scrim: rgba(8, 28, 24, .42);
--soft-btn: linear-gradient(180deg, #d2efe4, #bfe6d6);
--soft-btn-hover: linear-gradient(180deg, #c5e9db, #aedfcb);
--danger-btn: linear-gradient(180deg, #f8d8d4, #efc4bf);
--danger-btn-hover: linear-gradient(180deg, #f3c5bf, #e8aea7);
--danger-ink: #9e2a22;
--danger-ink-hover: #7f1f19;
--meta-bg: rgba(255,255,255,.35);
--meta-ok-bg: rgba(216,243,233,.65);
--meta-err-bg: rgba(255,236,234,.75);
--update-bg: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
--shop-bg: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
--switch-track: #c7d5cf;
--switch-knob: #fff;
--ms-good: #0a7a3e;
--ms-mid: #b8860b;
--focus-ring: rgba(13,138,102,.12);
--theme-btn-bg: rgba(255,255,255,.7);
}
html[data-theme="dark"] {
--ink: #e6f4ee;
--muted: #8eaaa0;
--line: rgba(230, 244, 238, 0.12);
--accent: #2bbf8a;
--accent-deep: #7ee0b8;
--accent-soft: rgba(43, 191, 138, 0.16);
--danger: #f07167;
--ok: #2bbf8a;
--surface: rgba(18, 28, 26, 0.88);
--surface-2: rgba(28, 42, 38, 0.72);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
--page-bg:
radial-gradient(720px 400px at 6% -12%, rgba(20, 90, 70, .55) 0%, transparent 55%),
radial-gradient(560px 340px at 100% 0%, rgba(30, 55, 80, .45) 0%, transparent 50%),
radial-gradient(480px 300px at 50% 110%, rgba(18, 70, 55, .4) 0%, transparent 45%),
linear-gradient(165deg, #0c1412 0%, #121c1a 48%, #101820 100%);
--hero-bg: linear-gradient(160deg, rgba(28, 42, 38, .95), rgba(22, 34, 31, .9));
--hero-on-bg: linear-gradient(160deg, rgba(24, 70, 55, .55), rgba(28, 42, 38, .95));
--input-bg: rgba(12, 20, 18, .92);
--chip-bg: rgba(12, 20, 18, .75);
--row-bg: rgba(22, 34, 31, .78);
--row-active-bg: linear-gradient(135deg, rgba(43, 191, 138, .18), rgba(28, 42, 38, .95));
--modal-bg: #182420;
--modal-scrim: rgba(0, 0, 0, .58);
--soft-btn: linear-gradient(180deg, #1f3d34, #18332b);
--soft-btn-hover: linear-gradient(180deg, #264d42, #1d3d34);
--danger-btn: linear-gradient(180deg, #4a2a28, #3d2220);
--danger-btn-hover: linear-gradient(180deg, #5a3230, #4a2826);
--danger-ink: #f0a39c;
--danger-ink-hover: #ffc4be;
--meta-bg: rgba(12, 20, 18, .45);
--meta-ok-bg: rgba(43, 191, 138, .14);
--meta-err-bg: rgba(240, 113, 103, .14);
--update-bg: linear-gradient(135deg, rgba(43,191,138,.16), rgba(22,34,31,.85));
--shop-bg: linear-gradient(135deg, rgba(43,191,138,.12), rgba(22,34,31,.7));
--switch-track: #3a4f48;
--switch-knob: #e6f4ee;
--ms-good: #4ad69a;
--ms-mid: #e0b84a;
--focus-ring: rgba(43,191,138,.2);
--theme-btn-bg: rgba(22, 34, 31, .85);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
font-family: Figtree, sans-serif;
color: var(--ink);
background: var(--page-bg);
transition: color .2s var(--ease), background .25s var(--ease);
color-scheme: light;
}
html[data-theme="dark"], html[data-theme="dark"] body {
color-scheme: dark;
}
body {
display: grid;
place-items: start center;
padding: 16px 12px 20px;
}
.shell {
width: min(480px, 100%);
background: var(--surface);
backdrop-filter: blur(18px) saturate(1.15);
border: 1px solid var(--line);
box-shadow: var(--shadow);
border-radius: 28px;
padding: 18px 18px 16px;
animation: rise .55s var(--ease) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(14px) scale(.985); }
to { opacity: 1; transform: none; }
}
.update-banner {
display: none;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(13, 138, 102, 0.28);
background: var(--update-bg);
animation: rise .4s var(--ease) both;
}
.update-banner.show { display: block; }
.update-banner strong {
font-family: Sora, sans-serif;
display: block;
margin-bottom: 4px;
font-size: .95rem;
}
.update-banner p {
margin: 0 0 10px;
color: var(--muted);
font-size: .84rem;
}
.update-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.update-actions .action {
flex: 1 1 auto;
min-width: 140px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.logo {
width: 52px;
height: 52px;
border-radius: 16px;
overflow: hidden;
flex: 0 0 auto;
box-shadow: 0 10px 24px rgba(13, 138, 102, 0.28);
background: linear-gradient(145deg, #0d8a66, #1bb887);
display: grid;
place-items: center;
}
.logo svg { width: 30px; height: 30px; }
.brand-wrap { min-width: 0; flex: 1; }
.theme-btn {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--theme-btn-bg);
color: var(--ink);
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s, border-color .15s, transform .15s;
box-shadow: 0 4px 12px rgba(0,0,0,.06);
}
.theme-btn:hover {
border-color: rgba(13,138,102,.35);
transform: translateY(-1px);
}
.theme-btn svg { width: 18px; height: 18px; display: block; }
.theme-btn .icon-sun { display: none; }
.theme-btn .icon-moon { display: block; }
html[data-theme="dark"] .theme-btn .icon-sun { display: block; }
html[data-theme="dark"] .theme-btn .icon-moon { display: none; }
.brand-row {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.brand {
font-family: Sora, sans-serif;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
margin: 0;
}
.badge-ver {
font-size: .68rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: none;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(13,138,102,.18);
border-radius: 999px;
padding: 3px 8px;
}
.tagline {
margin: 5px 0 0;
color: var(--muted);
font-size: .84rem;
line-height: 1.35;
}
.hero {
position: relative;
overflow: hidden;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--hero-bg);
padding: 16px 16px 14px;
margin-bottom: 14px;
transition: border-color .25s, box-shadow .25s, background .25s;
}
.hero.on {
border-color: rgba(13,138,102,.35);
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
background: var(--hero-on-bg);
}
.hero::after {
content: "";
position: absolute;
width: 160px; height: 160px;
right: -40px; top: -50px;
border-radius: 50%;
background: radial-gradient(circle, rgba(13,138,102,.18), transparent 70%);
pointer-events: none;
transition: opacity .3s;
opacity: .55;
}
.hero.on::after { opacity: 1; }
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
position: relative;
z-index: 1;
}
.status {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: .98rem;
}
.dot {
width: 11px; height: 11px; border-radius: 50%;
background: #9aaba4;
transition: background .2s;
}
.dot.on {
background: var(--ok);
animation: pulse 1.8s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(13,138,102,.4); }
70% { box-shadow: 0 0 0 12px rgba(13,138,102,0); }
100% { box-shadow: 0 0 0 0 rgba(13,138,102,0); }
}
.proto-chip {
font-size: .72rem;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--muted);
background: var(--chip-bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
max-width: 46%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.proto-chip.active {
color: var(--accent-deep);
background: var(--accent-soft);
border-color: rgba(13,138,102,.22);
}
.hero-actions { position: relative; z-index: 1; display: grid; gap: 8px; }
.hero-hint {
margin: 8px 0 0;
font-size: .8rem;
color: var(--muted);
min-height: 1.2em;
line-height: 1.35;
}
.section {
margin-bottom: 12px;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
font-family: Sora, sans-serif;
font-size: .78rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--muted);
}
label.field {
display: block;
font-size: .72rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 6px;
}
.profile-bar {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
margin-bottom: 10px;
}
.sub-bar {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
select, input[type="text"], textarea {
width: 100%;
border: 1px solid var(--line);
background: var(--input-bg);
border-radius: 14px;
padding: 11px 13px;
font: inherit;
color: var(--ink);
outline: none;
transition: border-color .15s, box-shadow .15s, background .15s;
}
textarea {
min-height: 78px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .78rem;
line-height: 1.4;
}
select:focus, input[type="text"]:focus, textarea:focus {
border-color: rgba(13,138,102,.5);
box-shadow: 0 0 0 4px var(--focus-ring);
}
.icon-btn {
width: 44px;
padding: 0;
display: grid;
place-items: center;
border-radius: 14px;
border: 1px solid rgba(8, 90, 68, 0.22);
background: var(--soft-btn);
color: var(--accent-deep);
font-size: 1.15rem;
font-weight: 700;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08);
}
.icon-btn:hover {
background: var(--soft-btn-hover);
border-color: rgba(8, 90, 68, 0.38);
color: var(--accent-deep);
box-shadow: 0 6px 14px rgba(8, 90, 68, 0.14);
}
.icon-btn.danger {
color: var(--danger-ink);
background: var(--danger-btn);
border-color: rgba(158, 42, 34, 0.28);
}
.icon-btn.danger:hover {
background: var(--danger-btn-hover);
color: var(--danger-ink-hover);
}
.icon-btn.wide {
width: auto;
padding: 0 14px;
font-size: .82rem;
font-weight: 700;
}
.stack { display: grid; gap: 10px; }
details.panel {
margin-bottom: 10px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 8px 12px 10px;
background: var(--surface-2);
}
details.panel summary {
cursor: pointer;
font-weight: 700;
font-size: .86rem;
color: var(--ink);
list-style: none;
padding: 6px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
details.panel summary::-webkit-details-marker { display: none; }
details.panel summary::after {
content: "+";
color: var(--muted);
font-weight: 700;
font-size: 1rem;
transition: transform .2s;
}
details.panel[open] summary::after { content: ""; }
details.panel .grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
details.panel label.field { margin-top: 4px; }
.check {
display: flex;
align-items: center;
gap: 8px;
font-size: .86rem;
margin-top: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface-2);
margin-bottom: 12px;
font-weight: 600;
font-size: .9rem;
}
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: var(--switch-track);
border-radius: 999px; cursor: pointer; transition: background .15s;
}
.slider::before {
content: "";
position: absolute;
width: 20px; height: 20px;
left: 3px; top: 3px;
background: var(--switch-knob);
border-radius: 50%;
transition: transform .15s;
box-shadow: 0 2px 6px rgba(0,0,0,.12);
}
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(18px); }
button.action {
appearance: none;
border: 0;
border-radius: 15px;
padding: 13px 15px;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform .12s, background .15s, opacity .15s, box-shadow .15s, border-color .15s, color .15s;
letter-spacing: -0.01em;
}
button.action:active { transform: translateY(1px); }
button.action:disabled { opacity: .55; cursor: not-allowed; transform: none; box-shadow: none; }
.primary {
background: linear-gradient(145deg, var(--accent-deep), var(--accent));
color: #f3fff9;
box-shadow: 0 12px 28px rgba(13, 138, 102, 0.28);
}
.primary:hover:not(:disabled) {
background: linear-gradient(145deg, #0a7a59, #12a078);
box-shadow: 0 14px 30px rgba(13, 138, 102, 0.34);
}
.primary.danger {
background: linear-gradient(145deg, #a52c24, #d14a3f);
box-shadow: 0 12px 28px rgba(192, 54, 44, 0.24);
}
.primary.danger:hover:not(:disabled) {
background: linear-gradient(145deg, #b8342b, #e0574b);
}
.secondary {
background: linear-gradient(180deg, #2f6f5f, #25584b);
color: #f2fff9;
border: 1px solid rgba(12, 48, 40, 0.35);
box-shadow: 0 8px 18px rgba(20, 70, 56, 0.18);
}
.secondary:hover:not(:disabled) {
background: linear-gradient(180deg, #37806d, #2c6757);
color: #ffffff;
border-color: rgba(12, 48, 40, 0.45);
box-shadow: 0 10px 22px rgba(20, 70, 56, 0.24);
}
.cta {
width: 100%;
min-height: 52px;
font-size: 1.02rem;
font-family: Sora, sans-serif;
}
.actions { display: grid; gap: 8px; }
.tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.tools .action { min-height: 44px; font-size: .9rem; }
.server-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.server-toolbar .section-title { margin: 0; }
.server-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.server-actions .mini {
appearance: none;
border: 1px solid rgba(8, 90, 68, 0.22);
background: var(--soft-btn);
color: var(--accent-deep);
border-radius: 999px;
padding: 6px 10px;
font: inherit;
font-size: .72rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.server-actions .mini:hover { filter: brightness(.97); }
.server-actions .mini.accent {
background: linear-gradient(180deg, #2f6f5f, #25584b);
color: #f2fff9;
border-color: rgba(12, 48, 40, 0.35);
}
.auto-best {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: .82rem;
font-weight: 600;
color: var(--muted);
}
.server-list {
max-height: min(42vh, 360px);
overflow: auto;
display: grid;
gap: 3px;
margin-bottom: 10px;
padding: 4px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--surface-2);
}
.server-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid transparent;
background: var(--row-bg);
cursor: pointer;
transition: background .12s, border-color .12s, box-shadow .12s;
text-align: left;
width: 100%;
font: inherit;
color: inherit;
}
.server-row:hover {
border-color: rgba(13,138,102,.25);
background: var(--input-bg);
}
.server-row.active {
border-color: rgba(13,138,102,.4);
background: var(--row-active-bg);
box-shadow: 0 4px 14px rgba(13,138,102,.1);
}
.server-row .left { min-width: 0; }
.server-row .name {
font-weight: 700;
font-size: .84rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .sub {
margin-top: 2px;
font-size: .72rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex: 0 0 auto;
}
.server-row .proto {
font-size: .62rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--accent-deep);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 6px;
}
.server-row .ms {
font-size: .78rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); }
.server-empty {
padding: 14px 10px;
text-align: center;
color: var(--muted);
font-size: .82rem;
}
.profile-edit summary {
cursor: pointer;
list-style: none;
font-size: .78rem;
font-weight: 700;
color: var(--muted);
padding: 4px 0;
}
.profile-edit summary::-webkit-details-marker { display: none; }
.meta {
margin: 10px 0 0;
font-size: .82rem;
color: var(--muted);
line-height: 1.45;
min-height: 2.4em;
padding: 8px 10px;
border-radius: 12px;
background: var(--meta-bg);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
}
.meta.ok {
color: var(--ok);
background: var(--meta-ok-bg);
border-color: rgba(13,138,102,.18);
}
.meta.err {
color: var(--danger);
background: var(--meta-err-bg);
border-color: rgba(192,54,44,.16);
}
.ping-list {
margin-top: 8px;
display: grid;
gap: 6px;
}
.ping-item {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--row-bg);
font-size: .82rem;
}
.ping-item .ms { font-weight: 700; }
.ping-item .ms.ok { color: var(--ok); }
.ping-item .ms.bad { color: var(--danger); }
.shop {
margin-top: 12px;
padding: 13px 14px 12px;
border-radius: 18px;
border: 1px solid rgba(13, 138, 102, 0.16);
background: var(--shop-bg);
}
.shop h3 {
font-family: Sora, sans-serif;
font-size: 1rem;
letter-spacing: -0.02em;
margin: 0 0 5px;
}
.shop p {
margin: 0 0 10px;
color: var(--muted);
font-size: .82rem;
line-height: 1.4;
}
.shop .action { width: 100%; }
.shop-link {
display: block;
width: 100%;
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
text-align: center;
font: inherit;
font-size: .76rem;
color: var(--accent);
font-weight: 600;
cursor: pointer;
box-shadow: none;
}
.shop-link:hover { text-decoration: underline; }
.ver {
margin-top: 8px;
font-size: .72rem;
color: var(--muted);
text-align: center;
}
.modal-backdrop {
position: fixed; inset: 0;
background: var(--modal-scrim);
backdrop-filter: blur(4px);
display: none;
place-items: center;
padding: 18px;
z-index: 20;
}
.modal-backdrop.open { display: grid; }
.modal {
width: min(400px, 100%);
background: var(--modal-bg);
border-radius: 22px;
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 18px;
animation: rise .35s var(--ease) both;
color: var(--ink);
}
.modal h2 {
font-family: Sora, sans-serif;
margin: 0 0 12px;
font-size: 1.2rem;
letter-spacing: -.02em;
}
.modal .actions {
margin-top: 12px;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 420px) {
.shell { padding: 14px 12px 12px; border-radius: 22px; }
.brand { font-size: 1.45rem; }
details.panel .grid2 { grid-template-columns: 1fr; }
.tools { grid-template-columns: 1fr; }
}
.log-tail {
max-height: 160px;
overflow: auto;
font-size: .72rem;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
background: var(--panel-bg, rgba(0,0,0,.04));
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
margin: 8px 0;
}
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
+701
View File
@@ -0,0 +1,701 @@
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives).
(function () {
const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","getLogs","probeTunnel","savePrefs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
async function call(name, args) {
const headers = { "Content-Type": "application/json" };
if (window.__NAVIS_TOKEN__) headers["X-Navis-Token"] = window.__NAVIS_TOKEN__;
const r = await fetch("/api/" + name, {
method: "POST",
headers: headers,
body: JSON.stringify({ args: args || [] })
});
if (r.status === 401) throw "unauthorized";
const j = await r.json();
if (j && j.error) throw j.error;
return j ? j.result : null;
}
methods.forEach((name) => {
window[name] = function () {
return call(name, Array.prototype.slice.call(arguments));
};
});
})();
const $ = (id) => document.getElementById(id);
const meta = $("meta");
const btn = $("toggleBtn");
const coreBtn = $("coreBtn");
const saveBtn = $("saveBtn");
const addBtn = $("addBtn");
const delBtn = $("delBtn");
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
const bootConnect = $("bootConnect");
const autoReconnect = $("autoReconnect");
const logTail = $("logTail");
const logRefreshBtn = $("logRefreshBtn");
const probeBtn = $("probeBtn");
const profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const updCheckBtn = $("updCheckBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const skipUpdateBtn = $("skipUpdateBtn");
const verLabel = $("verLabel");
const SKIP_UPDATE_KEY = "navis.skipUpdateVersion";
function skippedVersion() {
try { return localStorage.getItem(SKIP_UPDATE_KEY) || ""; } catch (_) { return ""; }
}
function setSkippedVersion(v) {
try {
if (v) localStorage.setItem(SKIP_UPDATE_KEY, String(v));
else localStorage.removeItem(SKIP_UPDATE_KEY);
} catch (_) {}
}
const quitBtn = $("quitBtn");
if (window.__navisHttp && quitBtn) {
quitBtn.hidden = false;
quitBtn.style.display = "block";
quitBtn.addEventListener("click", () => {
withBusy(async () => {
setMeta("Выход…");
try { await quit(); } catch (_) {}
});
});
}
const subUrl = $("subUrl");
const subBtn = $("subBtn");
const hero = $("hero");
const protoChip = $("protoChip");
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
const AUTO_BEST_KEY = "navis.autoBest";
const THEME_KEY = "navis.theme";
(function initThemeToggle() {
const btn = $("themeBtn");
if (!btn) return;
const apply = (theme) => {
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
btn.title = theme === "dark" ? "Светлая тема" : "Тёмная тема";
btn.setAttribute("aria-label", btn.title);
};
btn.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
apply(cur === "dark" ? "light" : "dark");
});
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
btn.title = cur === "dark" ? "Светлая тема" : "Тёмная тема";
btn.setAttribute("aria-label", btn.title);
})();
try {
const saved = localStorage.getItem(AUTO_BEST_KEY);
autoBest.checked = saved === "1";
} catch (_) {}
autoBest.addEventListener("change", () => {
try { localStorage.setItem(AUTO_BEST_KEY, autoBest.checked ? "1" : "0"); } catch (_) {}
});
function readHy2() {
return {
congestion: $("hy2Congestion").value,
bbr_profile: $("hy2Bbr").value,
bandwidth_up: $("hy2Up").value.trim(),
bandwidth_down: $("hy2Down").value.trim(),
obfs: $("hy2Obfs").value,
obfs_password: $("hy2ObfsPass").value.trim(),
sni: $("hy2Sni").value.trim(),
insecure: !!$("hy2Insecure").checked,
pin_sha256: $("hy2Pin").value.trim(),
fast_open: !!$("hy2Fast").checked,
lazy: !!$("hy2Lazy").checked,
hop_interval: $("hy2Hop").value.trim()
};
}
function fillHy2(h) {
if (!h) return;
if (h.congestion) $("hy2Congestion").value = h.congestion;
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
$("hy2Up").value = h.bandwidth_up || "";
$("hy2Down").value = h.bandwidth_down || "";
$("hy2Obfs").value = h.obfs || "";
$("hy2ObfsPass").value = h.obfs_password || "";
$("hy2Sni").value = h.sni || "";
$("hy2Pin").value = h.pin_sha256 || "";
$("hy2Hop").value = h.hop_interval || "";
$("hy2Insecure").checked = !!h.insecure;
$("hy2Fast").checked = !!h.fast_open;
$("hy2Lazy").checked = !!h.lazy;
}
async function persistHy2IfNeeded(protocol) {
const p = (protocol || "").toLowerCase();
const link = proxy.value.trim().toLowerCase();
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
await saveHy2(readHy2());
}
}
let busy = false;
let connected = false;
let formHydrated = false;
let dirty = false;
let profiles = [];
let pingMap = {};
let metaHoldUntil = 0;
function markDirty() { dirty = true; }
[
proxy, nameInput, sysproxy, subUrl,
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
].forEach((el) => {
if (!el) return;
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
function setMeta(text, kind) {
meta.textContent = text || "";
meta.classList.remove("ok", "err");
if (kind === "ok") meta.classList.add("ok");
if (kind === "err") meta.classList.add("err");
if (kind === "ok" || kind === "err") metaHoldUntil = Date.now() + 8000;
}
function detectProtoLabel(state, active) {
const p = (state.protocol || active.protocol || "").toLowerCase();
const link = (typeof state.proxy === "string" ? state.proxy : (active.proxy || "")).trim().toLowerCase();
if (p) return p;
if (link.startsWith("vless://")) return "vless";
if (link.startsWith("vmess://")) return "vmess";
if (link.startsWith("trojan://")) return "trojan";
if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) return "hysteria2";
if (link.startsWith("awg://") || link.includes("[interface]")) return "awg";
if (link.startsWith("naive") || link.startsWith("https://") || link.startsWith("quic://")) return "naive";
return "протокол";
}
function msClass(ms, ok) {
if (!ok) return "bad";
if (ms < 80) return "good";
if (ms < 180) return "ok";
if (ms < 350) return "mid";
return "bad";
}
function rememberPings(pings) {
pingMap = {};
(pings || []).forEach((p) => { pingMap[p.name] = p; });
}
let listFP = "";
function profilesFingerprint(state) {
const parts = [
state.connected ? "1" : "0",
state.active_profile || state.profile || "",
];
(state.profiles || []).forEach((p) => {
parts.push(p.name + "|" + (p.protocol || "") + "|" + (p.host || ""));
});
(state.pings || []).forEach((p) => {
parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.soft ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || ""));
});
return parts.join("\n");
}
function fillProfiles(list, active) {
profiles = list || [];
const cur = profile.value;
profile.innerHTML = "";
profiles.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = p.name;
profile.appendChild(opt);
});
const want = active || cur || (profiles[0] && profiles[0].name);
if (want) profile.value = want;
renderServerList(want);
}
function orderedProfiles() {
const list = profiles.slice();
list.sort((a, b) => {
const pa = pingMap[a.name], pb = pingMap[b.name];
const oa = pa && pa.ok, ob = pb && pb.ok;
if (oa !== ob) return oa ? -1 : 1;
if (oa && ob && pa.ms !== pb.ms) return pa.ms - pb.ms;
return String(a.name).localeCompare(String(b.name));
});
return list;
}
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedProfiles();
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — добавьте профиль или подписку";
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;
const left = document.createElement("div");
left.className = "left";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = p.name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = p.host || "нет хоста";
left.appendChild(nameEl);
left.appendChild(sub);
const right = document.createElement("div");
right.className = "right";
const proto = document.createElement("span");
proto.className = "proto";
proto.textContent = (p.protocol || "?").toString();
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.textContent = "…";
}
right.appendChild(proto);
right.appendChild(ms);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
});
}
async function onServerClick(name) {
if (busy) return;
if (connected) {
if (name !== profile.value) await onServerConnect(name);
return;
}
await withBusy(async () => {
await selectProfile(name);
dirty = false;
formHydrated = false;
});
}
async function onServerConnect(name) {
if (busy) return;
await withBusy(async () => {
setMeta("Подключение к «" + name + "»…");
await connectProfile(name);
setMeta("Подключено: " + name, "ok");
});
}
function renderUpdate(u, version) {
verLabel.textContent = "Navis v" + (version || "?");
const badge = $("badgeVer");
if (badge && version) badge.textContent = version;
if (!u) {
updateBanner.classList.remove("show");
return;
}
const latest = (u.latest || "").toString();
const skip = skippedVersion();
if (u.available && latest && skip === latest) {
updateBanner.classList.remove("show");
return;
}
if (u.available) {
updateBanner.classList.add("show");
$("updateTitle").textContent = "Доступно обновление " + latest;
$("updateNotes").textContent = u.notes || ("У вас " + (u.current || version || "?") + ". Обновление только по кнопке.");
updateBanner.dataset.latest = latest;
} else {
updateBanner.classList.remove("show");
}
}
function paint(state, opts) {
const syncForm = opts && opts.syncForm;
connected = !!state.connected;
dot.classList.toggle("on", connected);
hero.classList.toggle("on", connected);
statusText.textContent = connected
? ("Подключено" + (state.profile ? " · " + state.profile : ""))
: "Отключено";
btn.textContent = connected ? "Отключить" : "Подключить";
btn.classList.toggle("danger", connected);
const lock = connected || busy;
proxy.disabled = lock;
nameInput.disabled = lock;
sysproxy.disabled = lock;
if (bootConnect) bootConnect.disabled = busy;
if (autoReconnect) autoReconnect.disabled = busy;
if (probeBtn) probeBtn.disabled = busy || !connected;
if (logRefreshBtn) logRefreshBtn.disabled = busy;
profile.disabled = lock;
addBtn.disabled = lock;
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
saveBtn.disabled = lock;
coreBtn.disabled = busy;
pingBtn.disabled = busy;
bestBtn.disabled = busy;
updCheckBtn.disabled = busy;
updateBtn.disabled = busy;
subBtn.disabled = busy;
subUrl.disabled = busy;
btn.disabled = busy;
rememberPings(state.pings || []);
const fp = profilesFingerprint(state);
if (syncForm || fp !== listFP) {
listFP = fp;
fillProfiles(state.profiles || [], state.active_profile || state.profile);
}
renderUpdate(state.update, state.version);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
nameInput.value = active.name || state.active_profile || "";
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
if (bootConnect && typeof state.connect_on_launch === "boolean") bootConnect.checked = state.connect_on_launch;
if (autoReconnect && typeof state.auto_reconnect === "boolean") autoReconnect.checked = state.auto_reconnect;
fillHy2(state.hy2);
formHydrated = true;
if (syncForm) dirty = false;
}
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
const label = detectProtoLabel(state, active);
protoChip.textContent = label;
protoChip.classList.toggle("active", !!state.connected || label !== "протокол");
const hy2 = (label === "hysteria2" || label === "hy2");
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
let detail = "";
if (connected) {
const parts = [];
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
detail = parts.join(" · ") || "Туннель активен";
heroHint.textContent = detail;
} else if (state.core_ready === false) {
detail = "Сначала установите cores в разделе «Сервис»";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
const n = (state.profiles || []).length;
detail = n > 1 ? ("Серверов: " + n + " · нажмите Пинг или Лучший") : "Готово к подключению";
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
}
if (!busy && Date.now() > metaHoldUntil) {
if (!connected && state.last_error) setMeta(state.last_error, "err");
else setMeta(detail, state.core_ready === false ? "err" : "");
metaHoldUntil = 0;
}
}
async function persistPrefs() {
if (typeof savePrefs !== "function") return;
await savePrefs(!!(bootConnect && bootConnect.checked), !!(autoReconnect && autoReconnect.checked), !!sysproxy.checked);
}
async function refresh(opts) {
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
let state;
if (needSecrets && typeof getEditState === "function") {
state = await getEditState();
} else {
state = await getState();
}
paint(state, opts);
return state;
}
function fmtErr(e) {
if (e == null) return "ошибка";
if (typeof e === "string") return e;
if (typeof e === "object" && e.message) return String(e.message);
return String(e);
}
async function withBusy(fn) {
if (busy) return;
busy = true;
paintButtonsLocked(true);
try {
await fn();
} catch (e) {
setMeta(fmtErr(e), "err");
} finally {
busy = false;
try {
await refresh({ syncForm: true });
} catch (e) {
setMeta(fmtErr(e), "err");
paintButtonsLocked(false);
}
}
}
function paintButtonsLocked(locked) {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
if (b) b.disabled = locked;
});
}
async function runBest(autoConnect) {
setMeta(autoConnect ? "Пинг и автоподключение…" : "Пинг и выбор лучшего…");
const res = await pingBest(!!autoConnect);
rememberPings(res.pings || []);
renderServerList(res.best_name || profile.value);
if (res.best_name) {
setMeta("Лучший: " + res.best_name + " · " + res.best_ms + " ms" + (res.connected ? " · подключено" : ""), "ok");
}
return res;
}
profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value);
dirty = false;
formHydrated = false;
}));
btn.addEventListener("click", () => withBusy(async () => {
try {
if (connected) {
setMeta("Отключение…");
await disconnect();
setMeta("Отключено", "ok");
} else {
setMeta("Подключение…");
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
await connect();
setMeta("Туннель активен", "ok");
}
} catch (e) { setMeta(String(e), "err"); }
}));
saveBtn.addEventListener("click", () => withBusy(async () => {
try {
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
setMeta("Профиль сохранён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
coreBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание official cores…");
const path = await installCore();
setMeta("Cores готовы: " + path, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
delBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm("Удалить профиль «" + profile.value + "»?")) return;
await deleteProfile(profile.value);
formHydrated = false;
dirty = false;
setMeta("Профиль удалён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
addBtn.addEventListener("click", () => {
$("newName").value = "";
$("newProxy").value = "";
const me = $("modalErr");
if (me) { me.style.display = "none"; me.textContent = ""; me.className = "meta"; }
modal.classList.add("open");
$("newName").focus();
});
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.remove("open");
});
function sanitizeShare(s) {
return String(s || "")
.replace(/[\u200B-\u200D\u2060\uFEFF\u2066-\u2069\u200E\u200F\u202A-\u202E]/g, "")
.trim();
}
$("createNew").addEventListener("click", () => withBusy(async () => {
const me = $("modalErr");
const showModalErr = (msg) => {
if (!me) { setMeta(String(msg), "err"); return; }
me.style.display = "block";
me.className = "meta err";
me.textContent = String(msg);
};
try {
const n = $("newName").value.trim();
const p = sanitizeShare($("newProxy").value);
if (!n) throw "Укажите название профиля";
if (!p) throw "Вставьте ссылку или конфиг Amnezia/AWG";
$("newProxy").value = p;
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
if (me) { me.style.display = "none"; me.textContent = ""; }
formHydrated = false;
dirty = false;
setMeta("Профиль создан", "ok");
} catch (e) { showModalErr(e); }
}));
async function openShop(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
setMeta("Открываю evilfox.win…");
await openURL(SHOP_URL);
setMeta("Открыто в браузере", "ok");
} catch (err) {
setMeta("Не удалось открыть ссылку: " + String(err), "err");
}
}
shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop);
async function runImportSubscription() {
const url = subUrl.value.trim();
if (!url) {
setMeta("Вставьте URL подписки", "err");
return;
}
setMeta("Загрузка подписки…");
const n = await importSubscription(url);
formHydrated = false;
dirty = false;
setMeta("Импортировано: " + n + " · измеряю пинг…", "ok");
await runBest(!!autoBest.checked);
}
subBtn.addEventListener("click", () => withBusy(async () => {
try {
await runImportSubscription();
} catch (e) { setMeta(String(e), "err"); }
}));
subUrl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
subBtn.click();
}
});
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Пинг серверов…");
const rows = await pingServers();
rememberPings(rows);
renderServerList(profile.value);
const ok = (rows || []).filter((r) => r.ok).length;
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
} catch (e) { setMeta(String(e), "err"); }
}));
bestBtn.addEventListener("click", () => withBusy(async () => {
try {
await runBest(!!autoBest.checked);
} catch (e) { setMeta(String(e), "err"); }
}));
updCheckBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка обновлений…");
const st = await checkUpdate();
renderUpdate(st, st.current);
if (st.available) setMeta("Доступна версия " + st.latest, "ok");
else if (st.error) setMeta("Обновление: " + st.error, "err");
else setMeta("У вас актуальная версия " + st.current, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
updateBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание и установка обновления…");
const msg = await applyUpdate();
setMeta(String(msg || "Перезапуск…"), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
skipUpdateBtn.addEventListener("click", () => {
const latest = updateBanner.dataset.latest || "";
if (latest) setSkippedVersion(latest);
updateBanner.classList.remove("show");
setMeta("Версия " + (latest || "?") + " пропущена. Следующую предложим.", "ok");
});
if (sysproxy) sysproxy.addEventListener("change", () => { persistPrefs().catch(() => {}); dirty = true; });
if (bootConnect) bootConnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
if (autoReconnect) autoReconnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
if (logRefreshBtn) logRefreshBtn.addEventListener("click", () => withBusy(async () => {
try {
const t = await getLogs();
if (logTail) logTail.textContent = t || "—";
setMeta("Лог обновлён", "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
if (probeBtn) probeBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка туннеля…");
await probeTunnel();
setMeta("Туннель OK", "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
(async () => {
try {
await refresh({ syncForm: true });
// Auto-best connect is opt-in via checkbox only — never on first paint.
} catch (e) {
setMeta(String(e), "err");
}
})();
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000);
+21 -3
View File
@@ -1,7 +1,25 @@
package appui
import _ "embed"
import (
_ "embed"
"strings"
)
//go:embed index.html
var IndexHTML string
var indexHTML string
//go:embed app.css
var appCSS string
//go:embed app.js
var appJS string
// IndexHTML is the full GUI document (CSS/JS inlined for WebView2 / glaze).
var IndexHTML = buildIndexHTML()
func buildIndexHTML() string {
s := indexHTML
s = strings.Replace(s, `<link rel="stylesheet" href="app.css" />`, "<style>\n"+appCSS+"\n</style>", 1)
s = strings.Replace(s, `<script src="app.js"></script>`, "<script>\n"+appJS+"\n</script>", 1)
return s
}
+21
View File
@@ -0,0 +1,21 @@
package appui
import (
"strings"
"testing"
)
func TestIndexHTMLInlinesAssets(t *testing.T) {
if !strings.Contains(IndexHTML, "--accent:") {
t.Fatal("css missing")
}
if !strings.Contains(IndexHTML, "getEditState") {
t.Fatal("js missing")
}
if strings.Contains(IndexHTML, `href="app.css"`) {
t.Fatal("css link not replaced")
}
if strings.Contains(IndexHTML, `src="app.js"`) {
t.Fatal("js src not replaced")
}
}
+26 -1435
View File
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -35,6 +35,15 @@ type Config struct {
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
SubscriptionURL string `json:"subscription_url,omitempty"`
// SubscriptionNames are profile names last imported from SubscriptionURL (for prune).
SubscriptionNames []string `json:"subscription_names,omitempty"`
// ConnectOnLaunch connects the active profile when the GUI starts.
ConnectOnLaunch bool `json:"connect_on_launch,omitempty"`
// AutoReconnect reconnects after an unexpected core crash.
AutoReconnect bool `json:"auto_reconnect,omitempty"`
Profiles []Profile `json:"profiles"`
}
@@ -144,6 +153,26 @@ func lastIndex(s, substr string) int {
return -1
}
// Clone returns a deep copy of the config (JSON round-trip).
func (c *Config) Clone() *Config {
if c == nil {
return &Config{}
}
data, err := json.Marshal(c)
if err != nil {
out := *c
out.Profiles = append([]Profile(nil), c.Profiles...)
return &out
}
var out Config
if err := json.Unmarshal(data, &out); err != nil {
cp := *c
cp.Profiles = append([]Profile(nil), c.Profiles...)
return &cp
}
return &out
}
// Load reads and validates a config file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
@@ -296,7 +325,7 @@ func WriteExample(path string) error {
return Save(path, Example())
}
// Save writes config as indented JSON.
// Save writes config as indented JSON (atomic replace).
func Save(path string, cfg Config) error {
if err := cfg.Validate(); err != nil {
return err
@@ -312,7 +341,15 @@ func Save(path string, cfg Config) error {
return err
}
}
return os.WriteFile(path, data, 0o600)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// SetActiveProxy updates the active profile proxy URI.
+55 -1
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"strings"
"sync"
)
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
@@ -14,6 +15,22 @@ type ProfileInfo struct {
Active bool `json:"active"`
}
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
var hostCache sync.Map // proxy URI/conf → host string
func cachedProxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
if v, ok := hostCache.Load(proxy); ok {
return v.(string)
}
h := proxyHost(proxy)
hostCache.Store(proxy, h)
return h
}
// ListProfiles returns UI-friendly profile summaries.
func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
@@ -22,7 +39,21 @@ func (c *Config) ListProfiles() []ProfileInfo {
Name: p.Name,
Protocol: string(p.Protocol),
Proxy: p.Proxy,
Host: proxyHost(p.Proxy),
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
return out
}
// ListProfilesForPoll is like ListProfiles but omits Proxy URIs (cheaper idle polls).
func (c *Config) ListProfilesForPoll() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
for _, p := range c.Profiles {
out = append(out, ProfileInfo{
Name: p.Name,
Protocol: string(p.Protocol),
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
@@ -40,6 +71,29 @@ func RedactProfileList(in []ProfileInfo) []ProfileInfo {
return out
}
func RedactProxyURI(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
lower := strings.ToLower(proxy)
if strings.Contains(lower, "[interface]") || strings.Contains(lower, "privatekey") {
return "[конфиг скрыт — откройте редактор профиля]"
}
if i := strings.Index(proxy, "://"); i >= 0 {
scheme := proxy[:i+3]
rest := proxy[i+3:]
if at := strings.LastIndex(rest, "@"); at >= 0 {
return scheme + "***@" + rest[at+1:]
}
return scheme + rest
}
if at := strings.LastIndex(proxy, "@"); at >= 0 {
return "***@" + proxy[at+1:]
}
return proxyHost(proxy)
}
func proxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"strings"
"testing"
)
func TestRedactProxyURI(t *testing.T) {
got := RedactProxyURI("https://user:pass@example.com:443/?x=1")
if got != "https://***@example.com:443/?x=1" {
t.Fatalf("got %q", got)
}
if RedactProxyURI("") != "" {
t.Fatal("empty")
}
r := RedactProxyURI("[Interface]\nPrivateKey = abc\n")
if !strings.Contains(r, "скрыт") {
t.Fatalf("awg redact: %q", r)
}
}
+328 -27
View File
@@ -14,6 +14,7 @@ import (
"time"
"vpnclient/internal/config"
"vpnclient/internal/corebin"
"vpnclient/internal/linknorm"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
@@ -23,7 +24,7 @@ import (
"vpnclient/internal/sysproxy"
)
// Manager orchestrates protocol engines and Windows system proxy.
// Manager orchestrates protocol engines and OS system proxy.
type Manager struct {
mu sync.Mutex
cfgPath string
@@ -33,6 +34,12 @@ type Manager struct {
profile *config.Profile
stderr io.Writer
binDir string
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
lastStop sync.WaitGroup
lastError string
unexpectedDrop bool
watchGeneration uint64
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -68,60 +75,154 @@ func (m *Manager) RecoverSystemProxy() {
sysproxy.ClearSentinel(m.cfgPath)
}
func (m *Manager) Connect(ctx context.Context, profileName string) error {
m.mu.Lock()
defer m.mu.Unlock()
func (m *Manager) waitEngineStopped() {
m.lastStop.Wait()
}
func (m *Manager) Connect(ctx context.Context, profileName string) error {
// Wait outside the lock so a slow Disconnect can finish freeing :1080/:1081.
m.waitEngineStopped()
m.mu.Lock()
if m.engine != nil && m.engine.Running() {
m.mu.Unlock()
return fmt.Errorf("already connected; disconnect first")
}
if m.engine != nil {
m.engine = nil
m.profile = nil
}
if profileName != "" {
m.cfg.Active = profileName
}
profile, err := m.cfg.ActiveProfile()
if err != nil {
m.mu.Unlock()
return err
}
profileCopy := *profile
binDir := m.binDir
wantSysProxy := m.cfg.SystemProxy
cfgPath := m.cfgPath
sys := m.sys
stderr := m.stderr
m.mu.Unlock()
engine, err := m.newEngine(profile.Protocol)
engine, err := m.newEngine(profileCopy.Protocol)
if err != nil {
return err
}
if err := engine.Start(ctx, *profile, m.binDir); err != nil {
if err := engine.Start(ctx, profileCopy, binDir); err != nil {
return err
}
if m.cfg.SystemProxy {
m.mu.Lock()
if m.engine != nil && m.engine.Running() {
_ = engine.Stop()
m.mu.Unlock()
return fmt.Errorf("already connected; disconnect first")
}
if wantSysProxy {
httpProxy, ok := engine.LocalHTTPProxy()
if !ok {
m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
}
if err := m.sys.Enable(httpProxy); err != nil {
if err := sys.Enable(httpProxy); err != nil {
if errors.Is(err, sysproxy.ErrUnsupported) {
fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
fmt.Fprintf(stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
} else {
m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("enable system proxy: %w", err)
}
} else {
sysproxy.WriteSentinel(m.cfgPath, httpProxy)
sysproxy.WriteSentinel(cfgPath, httpProxy)
}
}
m.engine = engine
m.profile = profile
m.profile = &profileCopy
m.lastError = ""
m.unexpectedDrop = false
m.watchGeneration++
gen := m.watchGeneration
m.mu.Unlock()
go m.watchEngine(gen, engine)
return nil
}
// LastError returns the last connection / core failure message.
func (m *Manager) LastError() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastError
}
// SetLastError stores a UI-facing error string.
func (m *Manager) SetLastError(msg string) {
m.mu.Lock()
m.lastError = strings.TrimSpace(msg)
m.mu.Unlock()
}
// TakeUnexpectedDrop reports and clears a crash-driven disconnect.
func (m *Manager) TakeUnexpectedDrop() bool {
m.mu.Lock()
defer m.mu.Unlock()
if !m.unexpectedDrop {
return false
}
m.unexpectedDrop = false
return true
}
func (m *Manager) watchEngine(gen uint64, eng Engine) {
if eng == nil {
return
}
for {
time.Sleep(800 * time.Millisecond)
m.mu.Lock()
if m.watchGeneration != gen || m.engine != eng {
m.mu.Unlock()
return
}
if eng.Running() {
m.mu.Unlock()
continue
}
// Core died without Disconnect().
m.engine = nil
m.profile = nil
m.lastError = "процесс ядра завершился неожиданно"
m.unexpectedDrop = true
sys := m.sys
cfgPath := m.cfgPath
m.mu.Unlock()
if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(cfgPath)) {
_ = sys.ForceDisable()
sysproxy.ClearSentinel(cfgPath)
fmt.Fprintf(m.stderr, "core exited: system proxy cleared\n")
}
return
}
}
func (m *Manager) Disconnect() error {
m.mu.Lock()
sys := m.sys
engine := m.engine
m.engine = nil
m.profile = nil
m.watchGeneration++ // stop watcher
m.unexpectedDrop = false
if engine != nil {
m.lastStop.Add(1)
}
m.mu.Unlock()
var first error
@@ -143,14 +244,17 @@ func (m *Manager) Disconnect() error {
}
if engine != nil {
done := make(chan error, 1)
go func() { done <- engine.Stop() }()
go func() {
defer m.lastStop.Done()
done <- engine.Stop()
}()
select {
case err := <-done:
if err != nil && first == nil {
first = err
}
case <-time.After(3 * time.Second):
// Engine Stop can block on userspace relays; don't freeze the UI.
// Stop continues in background; Connect will wait on lastStop.
if first == nil {
first = fmt.Errorf("отключение заняло слишком много времени")
}
@@ -236,10 +340,104 @@ func (m *Manager) BinDir() string { return m.binDir }
func (m *Manager) ConfigPath() string { return m.cfgPath }
// Config returns a deep snapshot for UI reads. Mutations must go through Manager methods.
func (m *Manager) Config() *config.Config {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
if m.cfg == nil {
return &config.Config{}
}
return m.cfg.Clone()
}
// ActiveProxyURI returns the active profile proxy without cloning the whole config.
func (m *Manager) ActiveProxyURI() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return "", fmt.Errorf("no config")
}
p, err := m.cfg.ActiveProfile()
if err != nil {
return "", err
}
return p.Proxy, nil
}
// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
type UIPoll struct {
Status Status
Active string
SystemProxy bool
Subscription string
ActiveProxy string
ActiveProtocol config.Protocol
Profiles []config.ProfileInfo
Hy2 Hy2Options
BinDir string
LastError string
ConnectOnLaunch bool
AutoReconnect bool
}
// PollUI gathers status + profile list under one lock without Config().Clone().
func (m *Manager) PollUI(includeSecrets bool) UIPoll {
m.mu.Lock()
defer m.mu.Unlock()
out := UIPoll{
Status: Status{SystemProxy: m.sys.Enabled()},
BinDir: m.binDir,
SystemProxy: false,
Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
LastError: m.lastError,
}
if m.cfg != nil {
out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy
out.Subscription = m.cfg.SubscriptionURL
out.ConnectOnLaunch = m.cfg.ConnectOnLaunch
out.AutoReconnect = m.cfg.AutoReconnect
if includeSecrets {
out.Profiles = m.cfg.ListProfiles()
} else {
out.Profiles = m.cfg.ListProfilesForPoll()
}
if p, err := m.cfg.ActiveProfile(); err == nil {
out.Active = p.Name
out.ActiveProxy = p.Proxy
out.ActiveProtocol = p.Protocol
out.Hy2 = Hy2Options{
Congestion: p.Hy2Congestion,
BBRProfile: p.Hy2BBRProfile,
BandwidthUp: p.Hy2BandwidthUp,
BandwidthDown: p.Hy2BandwidthDown,
Obfs: p.Hy2Obfs,
ObfsPassword: p.Hy2ObfsPassword,
SNI: p.Hy2SNI,
Insecure: p.Hy2Insecure,
PinSHA256: p.Hy2PinSHA256,
FastOpen: p.Hy2FastOpen,
Lazy: p.Hy2Lazy,
HopInterval: p.Hy2HopInterval,
}
}
}
if m.engine != nil && m.engine.Running() {
out.Status.Connected = true
if m.profile != nil {
out.Status.Profile = m.profile.Name
out.Status.Protocol = m.profile.Protocol
}
if hp, ok := m.engine.LocalHTTPProxy(); ok {
out.Status.HTTPProxy = hp
}
if sp, ok := m.engine.LocalSOCKSProxy(); ok {
out.Status.SOCKSProxy = sp
}
}
return out
}
func (m *Manager) SetSystemProxy(enabled bool) {
@@ -248,6 +446,38 @@ func (m *Manager) SetSystemProxy(enabled bool) {
m.cfg.SystemProxy = enabled
}
// Prefs are GUI toggles persisted in config.json.
type Prefs struct {
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
SystemProxy bool `json:"system_proxy"`
}
func (m *Manager) Prefs() Prefs {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return Prefs{}
}
return Prefs{
ConnectOnLaunch: m.cfg.ConnectOnLaunch,
AutoReconnect: m.cfg.AutoReconnect,
SystemProxy: m.cfg.SystemProxy,
}
}
func (m *Manager) SavePrefs(p Prefs) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return fmt.Errorf("no config")
}
m.cfg.ConnectOnLaunch = p.ConnectOnLaunch
m.cfg.AutoReconnect = p.AutoReconnect
m.cfg.SystemProxy = p.SystemProxy
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) UpdateProxyURI(proxy string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -470,20 +700,69 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return 0, fmt.Errorf("сначала отключитесь")
}
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
newNames := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, it := range items {
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
name := strings.TrimSpace(it.Name)
if name == "" {
continue
}
_ = m.cfg.UpsertProfileKeepActive(name, it.URI, it.Protocol)
for i := range m.cfg.Profiles {
if m.cfg.Profiles[i].Name == it.Name {
if m.cfg.Profiles[i].Name == name {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
break
}
}
if _, ok := seen[name]; !ok {
seen[name] = struct{}{}
newNames = append(newNames, name)
}
}
prev := map[string]struct{}{}
for _, n := range m.cfg.SubscriptionNames {
prev[n] = struct{}{}
}
// Prune profiles that came from the previous subscription sync but are gone now.
if len(prev) > 0 {
kept := make([]config.Profile, 0, len(m.cfg.Profiles))
for _, p := range m.cfg.Profiles {
if _, wasSub := prev[p.Name]; wasSub {
if _, still := seen[p.Name]; !still {
continue
}
}
kept = append(kept, p)
}
if len(kept) == 0 {
// Safety: never wipe all profiles.
kept = append([]config.Profile(nil), m.cfg.Profiles...)
} else {
m.cfg.Profiles = kept
}
activeOK := false
for _, p := range m.cfg.Profiles {
if p.Name == m.cfg.Active {
activeOK = true
break
}
}
if !activeOK && len(m.cfg.Profiles) > 0 {
m.cfg.Active = m.cfg.Profiles[0].Name
}
}
m.cfg.SubscriptionNames = newNames
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
return len(items), nil
return len(newNames), nil
}
func (m *Manager) SaveConfig() error {
@@ -528,25 +807,46 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
// EnsureCore downloads the binary required by the active (or given) protocol.
func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
m.mu.Lock()
binDir := m.binDir
if proto == "" {
if p, err := m.cfg.ActiveProfile(); err == nil {
proto = p.Protocol
}
}
m.mu.Unlock()
var (
path string
err error
)
switch proto {
case config.ProtocolHysteria2:
return hysteria2.EnsureBinary(m.binDir)
path, err = hysteria2.EnsureBinary(binDir)
case config.ProtocolAWG:
return awg.EnsureBinary(m.binDir)
path, err = awg.EnsureBinary(binDir)
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
return xray.EnsureBinary(m.binDir)
path, err = xray.EnsureBinary(binDir)
default:
return naive.EnsureBinary(m.binDir)
path, err = naive.EnsureBinary(binDir)
proto = config.ProtocolNaive
}
if err != nil {
return "", err
}
corebin.Set(corebin.CacheKey(binDir, corebin.ProtoKey(string(proto))), path)
return path, nil
}
// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded).
// Downloads run in parallel (one goroutine per core) to use multiple CPU cores / network.
func (m *Manager) EnsureAllCores() (map[string]string, error) {
m.mu.Lock()
binDir := m.binDir
m.mu.Unlock()
corebin.Invalidate()
type item struct {
key string
path string
@@ -556,10 +856,10 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) {
key string
fn func() (string, error)
}{
{"naive", func() (string, error) { return naive.EnsureBinary(m.binDir) }},
{"hysteria2", func() (string, error) { return hysteria2.EnsureBinary(m.binDir) }},
{"awg", func() (string, error) { return awg.EnsureBinary(m.binDir) }},
{"xray", func() (string, error) { return xray.EnsureBinary(m.binDir) }},
{"naive", func() (string, error) { return naive.EnsureBinary(binDir) }},
{"hysteria2", func() (string, error) { return hysteria2.EnsureBinary(binDir) }},
{"awg", func() (string, error) { return awg.EnsureBinary(binDir) }},
{"xray", func() (string, error) { return xray.EnsureBinary(binDir) }},
}
ch := make(chan item, len(jobs))
for _, j := range jobs {
@@ -580,6 +880,7 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) {
continue
}
out[it.key] = it.path
corebin.Set(corebin.CacheKey(binDir, it.key), it.path)
}
return out, first
}
+127
View File
@@ -0,0 +1,127 @@
package core
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"vpnclient/internal/config"
)
type fakeEngine struct {
mu sync.Mutex
running bool
stopDelay time.Duration
stops atomic.Int32
}
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.running = true
return nil
}
func (e *fakeEngine) Stop() error {
e.stops.Add(1)
if e.stopDelay > 0 {
time.Sleep(e.stopDelay)
}
e.mu.Lock()
e.running = false
e.mu.Unlock()
return nil
}
func (e *fakeEngine) Running() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.running
}
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
cfg := &config.Config{
Active: "t",
SystemProxy: false,
BinDir: t.TempDir(),
Profiles: []config.Profile{{
Name: "t",
Protocol: config.ProtocolNaive,
Proxy: "https://u:p@example.com",
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
m.mu.Lock()
m.engine = eng
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
start := time.Now()
_ = m.Disconnect()
// Connect must wait for Stop to finish even if Disconnect returned early...
// our Disconnect waits up to 3s, so with 200ms it should complete.
if time.Since(start) < 150*time.Millisecond {
t.Fatalf("disconnect returned too fast")
}
if eng.Running() {
t.Fatal("engine still running")
}
// Simulate slow stop that outlives Disconnect timeout.
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
m.mu.Lock()
m.engine = eng2
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
// Force short path: call the wait mechanism like Connect does.
m.mu.Lock()
engine := m.engine
m.engine = nil
m.profile = nil
m.lastStop.Add(1)
m.mu.Unlock()
go func() {
defer m.lastStop.Done()
_ = engine.Stop()
}()
waitStart := time.Now()
m.waitEngineStopped()
if time.Since(waitStart) < 400*time.Millisecond {
t.Fatalf("waitEngineStopped returned before stop finished")
}
if eng2.Running() {
t.Fatal("engine2 still running after wait")
}
}
func TestConfigReturnsClone(t *testing.T) {
cfg := &config.Config{
Active: "a",
Profiles: []config.Profile{{
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
snap := m.Config()
snap.Active = "mutated"
snap.Profiles[0].Proxy = "leaked"
if m.cfg.Active != "a" {
t.Fatal("Config() leaked mutable Active")
}
if m.cfg.Profiles[0].Proxy != "https://x" {
t.Fatal("Config() leaked mutable Profile")
}
}
+75
View File
@@ -0,0 +1,75 @@
package corebin
import (
"strings"
"sync"
)
var (
mu sync.Mutex
cache = map[string]string{}
)
// CacheKey identifies a resolved core binary for a binDir + protocol.
func CacheKey(binDir, proto string) string {
return binDir + "\x00" + proto
}
// VirtualPath reports paths that are not on-disk binaries (e.g. embedded AWG).
func VirtualPath(path string) bool {
return strings.HasPrefix(path, "embedded-")
}
// Get returns a cached absolute path if present.
func Get(key string) (string, bool) {
mu.Lock()
defer mu.Unlock()
p, ok := cache[key]
return p, ok
}
// Set stores a resolved path.
func Set(key, path string) {
if key == "" || path == "" {
return
}
mu.Lock()
cache[key] = path
mu.Unlock()
}
// Invalidate clears all cached paths (call after EnsureCore / binDir change / failed Start).
func Invalidate() {
mu.Lock()
cache = map[string]string{}
mu.Unlock()
}
// Resolve caches the result of fn under key.
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
// Embedded/virtual paths never touch the filesystem.
func Resolve(key string, fn func() (string, error)) (string, error) {
if path, ok := Get(key); ok {
return path, nil
}
path, err := fn()
if err != nil {
return "", err
}
Set(key, path)
return path, nil
}
// ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan).
func ProtoKey(proto string) string {
switch proto {
case "hysteria2":
return "hysteria2"
case "awg":
return "awg"
case "vless", "vmess", "trojan", "xray":
return "xray"
default:
return "naive"
}
}
+59
View File
@@ -0,0 +1,59 @@
package corebin
import (
"os"
"path/filepath"
"testing"
)
func TestResolveTrustsCacheWithoutStat(t *testing.T) {
Invalidate()
dir := t.TempDir()
path := filepath.Join(dir, "core")
if err := os.WriteFile(path, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
key := CacheKey(dir, "naive")
calls := 0
fn := func() (string, error) {
calls++
return path, nil
}
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("first: %q %v calls=%d", p, err, calls)
}
_ = os.Remove(path) // missing on disk — cache still trusted until Invalidate
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("cached without Stat: %q %v calls=%d", p, err, calls)
}
Invalidate()
if _, err := Resolve(key, fn); err == nil && calls != 2 {
// fn returns deleted path; Resolve still succeeds
}
if calls != 2 {
t.Fatalf("after Invalidate expected refresh, calls=%d", calls)
}
}
func TestVirtualEmbeddedAWG(t *testing.T) {
Invalidate()
key := CacheKey("/bin", "awg")
calls := 0
p, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p != "embedded-amneziawg-go" || calls != 1 {
t.Fatalf("%q %v calls=%d", p, err, calls)
}
if !VirtualPath(p) {
t.Fatal("expected virtual")
}
p2, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p2 != p || calls != 1 {
t.Fatalf("second: %q calls=%d", p2, calls)
}
}
+214
View File
@@ -0,0 +1,214 @@
package coredl
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
type ReleaseAsset struct {
Name string
BrowserDownloadURL string
SHA256 string // lowercase hex, empty if unknown
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
// FetchLatestAssets loads assets for a repo's latest GitHub release.
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
if err != nil {
return "", nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "navis-vpnclient")
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", nil, err
}
out := make([]ReleaseAsset, 0, len(rel.Assets))
for _, a := range rel.Assets {
out = append(out, ReleaseAsset{
Name: a.Name,
BrowserDownloadURL: a.BrowserDownloadURL,
SHA256: parseDigestSHA256(a.Digest),
})
}
return rel.TagName, out, nil
}
func parseDigestSHA256(digest string) string {
digest = strings.TrimSpace(strings.ToLower(digest))
if digest == "" {
return ""
}
if strings.HasPrefix(digest, "sha256:") {
return strings.TrimPrefix(digest, "sha256:")
}
return ""
}
// DownloadFile writes url to path (size-capped).
func DownloadFile(path, url string, maxBytes int64) error {
if maxBytes <= 0 {
maxBytes = 120 << 20
}
client := &http.Client{Timeout: 15 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
return err
}
// FileSHA256 returns lowercase hex digest of path.
func FileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
func VerifySHA256(path, want string) error {
want = strings.TrimSpace(strings.ToLower(want))
if want == "" {
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
}
got, err := FileSHA256(path)
if err != nil {
return err
}
if got != want {
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
}
return nil
}
// RequireSHA returns sha or an error if the asset has none.
func RequireSHA(a ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
}
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
for _, name := range companions {
for _, o := range all {
if !strings.EqualFold(o.Name, name) {
continue
}
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
if err != nil {
return "", err
}
if sum != "" {
return sum, nil
}
}
}
return RequireSHA(a)
}
func fetchChecksumFile(url, assetName string) (string, error) {
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("checksum download: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
text := string(body)
lowerName := strings.ToLower(assetName)
lines := strings.Split(text, "\n")
var shaLine string
for _, line := range lines {
l := strings.TrimSpace(strings.ToLower(line))
if strings.HasPrefix(l, "sha256:") {
shaLine = strings.TrimSpace(line[len("sha256:"):])
// keep scanning — prefer line that also mentions the asset
if strings.Contains(l, lowerName) {
return strings.ToLower(strings.Fields(shaLine)[0]), nil
}
}
fields := strings.Fields(line)
if len(fields) >= 2 {
sum := strings.ToLower(fields[0])
file := strings.TrimPrefix(fields[len(fields)-1], "*")
if len(sum) == 64 && strings.EqualFold(file, assetName) {
return sum, nil
}
}
}
if shaLine != "" {
parts := strings.Fields(shaLine)
if len(parts) > 0 && len(parts[0]) == 64 {
return strings.ToLower(parts[0]), nil
}
}
return "", nil
}
// DownloadAndVerify downloads url to path and checks sha256.
func DownloadAndVerify(path, url, wantSHA256 string) error {
if err := DownloadFile(path, url, 0); err != nil {
return err
}
if err := VerifySHA256(path, wantSHA256); err != nil {
_ = os.Remove(path)
return err
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
package coredl
import "testing"
func TestParseDigestAndVerify(t *testing.T) {
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
t.Fatalf("parse: %q", got)
}
if parseDigestSHA256("md5:x") != "" {
t.Fatal("expected empty for non-sha256")
}
}
+106
View File
@@ -0,0 +1,106 @@
//go:build darwin
package dockicon
import (
_ "embed"
"log"
"sync"
"unsafe"
"github.com/ebitengine/purego"
"github.com/ebitengine/purego/objc"
)
//go:embed icon_idle.png
var iconIdlePNG []byte
//go:embed icon_connected.png
var iconConnectedPNG []byte
var (
once sync.Once
ready bool
mu sync.Mutex
last *bool
selSet objc.SEL
selPerf objc.SEL
selShared objc.SEL
selData objc.SEL
selAlloc objc.SEL
selInit objc.SEL
selRetain objc.SEL
clsApp objc.Class
clsData objc.Class
clsImage objc.Class
)
func initAppKit() {
once.Do(func() {
if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: Foundation: %v", err)
return
}
if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: AppKit: %v", err)
return
}
clsApp = objc.GetClass("NSApplication")
clsData = objc.GetClass("NSData")
clsImage = objc.GetClass("NSImage")
if clsApp == 0 || clsData == 0 || clsImage == 0 {
log.Printf("dockicon: missing AppKit classes")
return
}
selShared = objc.RegisterName("sharedApplication")
selSet = objc.RegisterName("setApplicationIconImage:")
selPerf = objc.RegisterName("performSelectorOnMainThread:withObject:waitUntilDone:")
selData = objc.RegisterName("dataWithBytes:length:")
selAlloc = objc.RegisterName("alloc")
selInit = objc.RegisterName("initWithData:")
selRetain = objc.RegisterName("retain")
ready = true
})
}
// SetConnected swaps the Dock icon: sea-wave N when connected, original when idle.
func SetConnected(connected bool) {
initAppKit()
if !ready {
return
}
mu.Lock()
if last != nil && *last == connected {
mu.Unlock()
return
}
v := connected
last = &v
mu.Unlock()
png := iconIdlePNG
if connected {
png = iconConnectedPNG
}
if len(png) == 0 {
return
}
data := objc.ID(clsData).Send(selData, unsafe.Pointer(&png[0]), len(png))
if data == 0 {
return
}
img := objc.ID(clsImage).Send(selAlloc)
img = img.Send(selInit, data)
if img == 0 {
return
}
img.Send(selRetain) // keep alive across async main-thread call
app := objc.ID(clsApp).Send(selShared)
if app == 0 {
return
}
// AppKit must run on the main thread (glaze owns it).
app.Send(selPerf, selSet, img, false)
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !darwin
package dockicon
// SetConnected is a no-op outside macOS (Dock icons are a Darwin feature).
func SetConnected(connected bool) {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+59
View File
@@ -0,0 +1,59 @@
package logbuf
import (
"bytes"
"sync"
)
const DefaultMax = 256 << 10 // 256 KiB
// Buffer is a concurrency-safe, size-capped bytes.Buffer replacement for engine stderr.
type Buffer struct {
mu sync.Mutex
buf bytes.Buffer
max int
}
func New(max int) *Buffer {
if max <= 0 {
max = DefaultMax
}
return &Buffer{max: max}
}
func (b *Buffer) Write(p []byte) (int, error) {
if b == nil {
return len(p), nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.buf.Len()+len(p) > b.max {
// Drop oldest half to keep recent diagnostics.
keep := b.buf.Bytes()
if len(keep) > b.max/2 {
keep = keep[len(keep)-b.max/2:]
}
b.buf.Reset()
_, _ = b.buf.Write(keep)
_, _ = b.buf.WriteString("\n… log truncated …\n")
}
return b.buf.Write(p)
}
func (b *Buffer) String() string {
if b == nil {
return ""
}
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func (b *Buffer) Len() int {
if b == nil {
return 0
}
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Len()
}
+29
View File
@@ -0,0 +1,29 @@
package logbuf
import (
"strings"
"sync"
"testing"
)
func TestBufferConcurrentWriteAndCap(t *testing.T) {
b := New(64)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 40; j++ {
_, _ = b.Write([]byte("abcdefghij"))
}
}()
}
wg.Wait()
if b.Len() > 64+40 { // allow truncate marker overhead
t.Fatalf("len=%d exceeds soft cap", b.Len())
}
s := b.String()
if !strings.Contains(s, "abcdefghij") && !strings.Contains(s, "truncated") {
t.Fatalf("unexpected content %q", s)
}
}
+68 -25
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net"
"runtime"
"sort"
"strings"
"sync"
@@ -23,6 +24,7 @@ type Result struct {
Port string `json:"port"`
Ms int64 `json:"ms"`
OK bool `json:"ok"`
Soft bool `json:"soft,omitempty"` // UDP soft-up (timeout, no ICMP refuse)
Error string `json:"error,omitempty"`
}
@@ -44,7 +46,14 @@ func PingAll(ctx context.Context, targets []Target) []Result {
if len(targets) == 0 {
return out
}
workers := 12
// Scale with available OS threads (GOMAXPROCS); keep a useful floor/ceiling.
workers := runtime.GOMAXPROCS(0) * 2
if workers < 8 {
workers = 8
}
if workers > 32 {
workers = 32
}
if len(targets) < workers {
workers = len(targets)
}
@@ -82,12 +91,13 @@ func PingAll(ctx context.Context, targets []Target) []Result {
return sorted
}
// BestOK returns the lowest-latency successful result, if any.
// BestOK returns the lowest-latency hard OK result (excludes UDP soft-up).
// Soft-up nodes stay visible in the list but are not chosen as «Лучший».
func BestOK(results []Result) (Result, bool) {
var best Result
found := false
for _, r := range results {
if !r.OK {
if !r.OK || r.Soft {
continue
}
if !found || r.Ms < best.Ms {
@@ -99,7 +109,8 @@ func BestOK(results []Result) (Result, bool) {
}
// PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — 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).
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
res := Result{Name: name}
if strings.TrimSpace(proxyURI) == "" {
@@ -107,9 +118,22 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res
}
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
var hy2, isAWG, isXray bool
switch proto {
case config.ProtocolHysteria2:
hy2 = true
case config.ProtocolAWG:
isAWG = true
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
isXray = true
case config.ProtocolNaive:
// TCP host parse below
default:
hy2 = hysteria2.Detect(proxyURI)
isAWG = awg.Detect(proxyURI)
isXray = xray.Detect(proxyURI)
}
var host, port string
if isAWG {
h, p, err := awg.HostPort(proxyURI)
@@ -163,67 +187,86 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res
}
start := time.Now()
var err error
var (
rtt time.Duration
soft bool
err error
)
if hy2 || isAWG {
err = probeUDP(ctx, host, port)
rtt, soft, err = probeUDP(ctx, host, port)
} else {
err = probeTCP(ctx, host, port)
rtt, err = probeTCP(ctx, host, port)
}
if err != nil {
res.Error = friendlyDialError(err, hy2 || isAWG)
return res
}
res.Ms = time.Since(start).Milliseconds()
ms := rtt.Milliseconds()
if ms < 1 {
ms = 1
}
res.Ms = ms
res.OK = true
res.Soft = soft
return res
}
func probeTCP(ctx context.Context, host, port string) error {
func probeTCP(ctx context.Context, host, port string) (time.Duration, error) {
start := time.Now()
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil {
return err
return 0, err
}
_ = conn.Close()
return nil
return time.Since(start), nil
}
func probeUDP(ctx context.Context, host, port string) error {
// probeUDP checks Hy2/AWG endpoints.
//
// UDP VPN ports usually ignore a probe datagram (no reply). Treating read-timeout
// as failure marked live nodes as down. Semantics:
// - ICMP / connection refused → down
// - any reply → hard up (RTT to reply)
// - short read-timeout after a successful write → soft-up (display only; not for Best)
func probeUDP(ctx context.Context, host, port string) (time.Duration, bool, error) {
start := time.Now()
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
if err != nil {
return err
return 0, false, err
}
defer conn.Close()
deadline := time.Now().Add(1200 * time.Millisecond)
icmpWait := 350 * time.Millisecond
deadline := time.Now().Add(icmpWait)
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
deadline = dl
}
_ = conn.SetDeadline(deadline)
if _, err := conn.Write([]byte{0}); err != nil {
return err
return 0, false, err
}
afterWrite := time.Now()
buf := make([]byte, 64)
_, err = conn.Read(buf)
if err == nil {
return nil
return time.Since(start), false, nil
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// UDP is connectionless: no reply does NOT mean the port is open.
return fmt.Errorf("udp timeout")
return afterWrite.Sub(start), true, nil
}
return err
return 0, false, err
}
func friendlyDialError(err error, hy2 bool) string {
func friendlyDialError(err error, udp bool) string {
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "refused"):
if hy2 {
if udp {
return "UDP порт недоступен (connection refused)"
}
return "порт закрыт (connection refused)"
+75 -2
View File
@@ -2,7 +2,7 @@ package netcheck
import (
"context"
"strings"
"net"
"testing"
"time"
@@ -12,7 +12,7 @@ import (
func TestFriendlyDialError(t *testing.T) {
err := &netError{s: "dial tcp 1.2.3.4:22514: connectex: No connection could be made because the target machine actively refused it."}
got := friendlyDialError(err, true)
if !strings.Contains(got, "UDP") {
if got == "" || got == err.Error() {
t.Fatalf("got %q", got)
}
}
@@ -31,3 +31,76 @@ func TestPingProfileEmpty(t *testing.T) {
t.Fatalf("%+v", r)
}
}
func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
// Intentionally do not Read — mimics Hy2/AWG ignoring garbage probes.
_, port, err := net.SplitHostPort(pc.LocalAddr().String())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
start := time.Now()
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatalf("expected soft-ok, got err=%v", err)
}
if !soft {
t.Fatal("expected soft=true")
}
if rtt <= 0 {
t.Fatalf("rtt=%v", rtt)
}
if time.Since(start) > 900*time.Millisecond {
t.Fatalf("soft-ok waited too long: %v (should not use long timeout)", time.Since(start))
}
res := PingProfile(ctx, "silent", config.ProtocolHysteria2, "hysteria2://x@127.0.0.1:"+port+"/")
if !res.OK || !res.Soft {
t.Fatalf("PingProfile soft-ok failed: %+v", res)
}
if res.Ms < 1 {
t.Fatalf("ms=%d", res.Ms)
}
if _, ok := BestOK([]Result{res}); ok {
t.Fatal("BestOK must ignore soft-up")
}
}
func TestProbeUDPReplyOK(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
go func() {
buf := make([]byte, 64)
n, addr, err := pc.ReadFrom(buf)
if err != nil || n == 0 {
return
}
_, _ = pc.WriteTo([]byte{1}, addr)
}()
_, port, err := net.SplitHostPort(pc.LocalAddr().String())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatal(err)
}
if soft {
t.Fatal("reply should be hard-ok")
}
if rtt <= 0 {
t.Fatalf("rtt=%v", rtt)
}
}
+65
View File
@@ -454,7 +454,11 @@ func (c *Conf) validate() error {
}
// HostPort returns UDP endpoint host and port for ping.
// Prefers a light Endpoint=/URI extract; falls back to full Parse for JSON/vpn:// blobs.
func HostPort(raw string) (host, port string, err error) {
if h, p, ok := lightEndpoint(raw); ok {
return h, p, nil
}
c, err := Parse(raw)
if err != nil {
return "", "", err
@@ -466,6 +470,67 @@ func HostPort(raw string) (host, port string, err error) {
return h, p, nil
}
func lightEndpoint(raw string) (host, port string, ok bool) {
raw = sanitizeShare(raw)
if raw == "" {
return "", "", false
}
lower := strings.ToLower(raw)
switch {
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
strings.HasPrefix(lower, "wireguard://"):
// URI host:port only — no full config decode.
rest := raw
if i := strings.Index(rest, "://"); i >= 0 {
rest = rest[i+3:]
}
if at := strings.Index(rest, "@"); at >= 0 {
rest = rest[at+1:]
}
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", "", false
}
h, p, err := net.SplitHostPort(rest)
if err != nil {
return rest, "51820", true
}
return h, p, true
}
if strings.Contains(lower, "endpoint") {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
low := strings.ToLower(line)
if !strings.HasPrefix(low, "endpoint") {
continue
}
_, val, cutOK := strings.Cut(line, "=")
if !cutOK {
_, val, cutOK = strings.Cut(line, ":")
}
if !cutOK {
continue
}
val = strings.TrimSpace(val)
if val == "" {
continue
}
h, p, err := net.SplitHostPort(val)
if err != nil {
return val, "51820", true
}
return h, p, true
}
}
return "", "", false
}
// lookupIP is overridable in tests.
var lookupIP = defaultLookupIP
+26
View File
@@ -236,3 +236,29 @@ func indexOf(s, sub string) int {
}
return -1
}
func TestHostPortLightINI(t *testing.T) {
raw := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0
`
h, p, err := HostPort(raw)
if err != nil {
t.Fatal(err)
}
if h != "203.0.113.10" || p != "51820" {
t.Fatalf("got %s:%s", h, p)
}
h2, p2, err := HostPort("awg://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=@9.9.9.9:41421")
if err != nil {
t.Fatal(err)
}
if h2 != "9.9.9.9" || p2 != "41421" {
t.Fatalf("uri got %s:%s", h2, p2)
}
}
+14 -7
View File
@@ -268,17 +268,24 @@ func handleHTTPProxy(client net.Conn, dial DialFunc) error {
func relay(a, b net.Conn) error {
errc := make(chan error, 2)
go func() {
_, err := io.Copy(a, b)
copyOne := func(dst, src net.Conn) {
bufp := copyBufPool.Get().(*[]byte)
defer copyBufPool.Put(bufp)
_, err := io.CopyBuffer(dst, src, *bufp)
errc <- err
}()
go func() {
_, err := io.Copy(b, a)
errc <- err
}()
}
go copyOne(a, b)
go copyOne(b, a)
err := <-errc
_ = a.Close()
_ = b.Close()
<-errc
return err
}
var copyBufPool = sync.Pool{
New: func() any {
b := make([]byte, 32*1024)
return &b
},
}
+21 -65
View File
@@ -1,16 +1,14 @@
package hysteria2
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
@@ -72,7 +70,11 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
name, url, err := findReleaseAsset(want)
asset, all, err := findReleaseAsset(want)
if err != nil {
return "", err
}
sum, err := coredl.ResolveSHA(asset, all)
if err != nil {
return "", err
}
@@ -81,8 +83,8 @@ func EnsureBinary(binDir string) (string, error) {
destName = "hysteria.exe"
}
dest := filepath.Join(binDir, destName)
tmp := filepath.Join(binDir, name+".download")
if err := downloadFile(tmp, url); err != nil {
tmp := filepath.Join(binDir, asset.Name+".download")
if err := coredl.DownloadAndVerify(tmp, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
_ = os.Remove(dest)
@@ -114,73 +116,27 @@ func releaseAssetName() (string, error) {
}
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
return "", "", err
return coredl.ReleaseAsset{}, nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "navis-vpnclient")
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", "", err
}
// Prefer exact non-avx name; fall back to avx variant on Windows.
var fallback string
var fallbackURL string
for _, a := range rel.Assets {
var fallback coredl.ReleaseAsset
for _, a := range assets {
if a.Name == exactName {
return a.Name, a.BrowserDownloadURL, nil
return a, assets, nil
}
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
fallback, fallbackURL = a.Name, a.BrowserDownloadURL
fallback = a
}
}
if fallback != "" {
return fallback, fallbackURL, nil
if fallback.Name != "" {
return fallback, assets, nil
}
// Prefix match for versioned names
for _, a := range rel.Assets {
for _, a := range assets {
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
return a.Name, a.BrowserDownloadURL, nil
return a, assets, nil
}
}
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
}
func downloadFile(path, url string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
return err
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in hysteria release", exactName)
}
+21 -63
View File
@@ -2,16 +2,15 @@ package naive
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
@@ -51,12 +50,16 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
assetName, url, err := findReleaseAsset(pattern)
asset, all, err := findReleaseAsset(pattern)
if err != nil {
return "", err
}
archivePath := filepath.Join(binDir, assetName)
if err := downloadFile(archivePath, url); err != nil {
sum, err := coredl.ResolveSHA(asset, all)
if err != nil {
return "", err
}
archivePath := filepath.Join(binDir, asset.Name+".download")
if err := coredl.DownloadAndVerify(archivePath, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
defer os.Remove(archivePath)
@@ -88,52 +91,26 @@ func assetNameForPlatform() (string, error) {
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
return "naiveproxy-*-linux-x64.zip", nil
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
// Newer releases use mac-x64-x64; older used mac-x64.
return "naiveproxy-*-mac-x64", nil
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
// Newer releases use mac-arm64-arm64; older used mac-arm64.
return "naiveproxy-*-mac-arm64", nil
default:
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
}
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
func findReleaseAsset(pattern string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
return "", "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "vpnclient-go")
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", "", err
return coredl.ReleaseAsset{}, nil, err
}
prefix := strings.Split(pattern, "*")[0]
needle := ""
if parts := strings.SplitN(pattern, "*", 2); len(parts) == 2 {
needle = parts[1]
}
var bestName, bestURL string
for _, a := range rel.Assets {
var best coredl.ReleaseAsset
for _, a := range assets {
name := a.Name
if strings.Contains(name, "plugin") {
continue
@@ -144,41 +121,22 @@ func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
if needle != "" && !strings.Contains(name, needle) {
continue
}
// Prefer desktop archives over openwrt/apk.
if strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".tar.xz") || strings.HasSuffix(name, ".txz") {
return name, a.BrowserDownloadURL, nil
return a, assets, nil
}
if bestName == "" {
bestName, bestURL = name, a.BrowserDownloadURL
if best.Name == "" {
best = a
}
}
if bestName != "" {
return bestName, bestURL, nil
if best.Name != "" {
return best, assets, nil
}
return "", "", fmt.Errorf("no asset matching %s in release %s", pattern, rel.TagName)
}
func downloadFile(path, url string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
return err
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset matching %s in naive release", pattern)
}
func extractNaiveArchive(archivePath, destDir string) error {
lower := strings.ToLower(archivePath)
lower = strings.TrimSuffix(lower, ".download")
switch {
case strings.HasSuffix(lower, ".zip"):
return unzipNaive(archivePath, destDir)
+16 -62
View File
@@ -2,16 +2,15 @@ package xray
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
@@ -56,13 +55,17 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
name, url, err := findReleaseAsset(want)
asset, all, err := findReleaseAsset(want)
if err != nil {
return "", err
}
zipPath := filepath.Join(binDir, name+".download")
sum, err := coredl.ResolveSHA(asset, all)
if err != nil {
return "", err
}
zipPath := filepath.Join(binDir, asset.Name+".download")
_ = os.Remove(zipPath)
if err := downloadFile(zipPath, url); err != nil {
if err := coredl.DownloadAndVerify(zipPath, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
defer os.Remove(zipPath)
@@ -73,7 +76,6 @@ func EnsureBinary(binDir string) (string, error) {
}
dest := filepath.Join(binDir, destName)
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
// Some zips nest the binary; try case-insensitive match.
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
return "", fmt.Errorf("%v; %w", err, err2)
}
@@ -104,65 +106,17 @@ func releaseZipName() (string, error) {
}
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
return "", "", err
return coredl.ReleaseAsset{}, nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "navis-vpnclient")
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", "", err
}
for _, a := range rel.Assets {
if a.Name == exactName {
return a.Name, a.BrowserDownloadURL, nil
for _, a := range assets {
if a.Name == exactName || strings.EqualFold(a.Name, exactName) {
return a, assets, nil
}
}
for _, a := range rel.Assets {
if strings.EqualFold(a.Name, exactName) {
return a.Name, a.BrowserDownloadURL, nil
}
}
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
}
func downloadFile(path, url string) error {
client := &http.Client{Timeout: 15 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
return err
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in xray release", exactName)
}
func extractNamedFromZip(zipPath, wantName, dest string) error {
+40 -40
View File
@@ -13,12 +13,12 @@ import (
)
type darwinController struct {
mu sync.Mutex
enabled bool
host string
httpPort string
mu sync.Mutex
enabled bool
host string
httpPort string
socksPort string
services []string
services []string
}
func newPlatform() Controller { return &darwinController{} }
@@ -47,32 +47,25 @@ func (c *darwinController) Enable(httpHostPort string) error {
return fmt.Errorf("sysproxy: no network services")
}
// One bash process for all networksetup calls (was 6 execs × N services).
// setwebproxy / setsecurewebproxy / setsocksfirewallproxy also turn the proxy on.
var b strings.Builder
b.WriteString("set -e\n")
for _, svc := range services {
if err := runNetworkSetup("-setwebproxy", svc, host, port); err != nil {
return err
}
if err := runNetworkSetup("-setsecurewebproxy", svc, host, port); err != nil {
return err
}
if err := runNetworkSetup("-setsocksfirewallproxy", svc, host, socks); err != nil {
return err
}
if err := runNetworkSetup("-setwebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsecurewebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsocksfirewallproxystate", svc, "on"); err != nil {
return err
}
q := shellQuote(svc)
fmt.Fprintf(&b, "networksetup -setwebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
fmt.Fprintf(&b, "networksetup -setsecurewebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxy %s %s %s\n", q, shellQuote(host), shellQuote(socks))
}
if err := runBash(b.String()); err != nil {
return err
}
c.enabled = true
c.host = host
c.httpPort = port
c.socksPort = socks
c.services = services
c.services = append([]string(nil), services...)
return nil
}
@@ -88,7 +81,8 @@ func (c *darwinController) Disable() error {
func (c *darwinController) ForceDisable() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.disableServices(nil)
svcs := c.services
return c.disableServices(svcs)
}
func (c *darwinController) disableServices(services []string) error {
@@ -99,21 +93,23 @@ func (c *darwinController) disableServices(services []string) error {
return err
}
}
var first error
for _, svc := range services {
for _, args := range [][]string{
{"-setwebproxystate", svc, "off"},
{"-setsecurewebproxystate", svc, "off"},
{"-setsocksfirewallproxystate", svc, "off"},
} {
if err := runNetworkSetup(args...); err != nil && first == nil {
first = err
}
}
if len(services) == 0 {
c.enabled = false
c.services = nil
return nil
}
var b strings.Builder
b.WriteString("set +e\n") // best-effort: turn off what we can
for _, svc := range services {
q := shellQuote(svc)
fmt.Fprintf(&b, "networksetup -setwebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsecurewebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxystate %s off\n", q)
}
err := runBash(b.String())
c.enabled = false
c.services = nil
return first
return err
}
func (c *darwinController) Enabled() bool {
@@ -142,11 +138,15 @@ func listNetworkServices() ([]string, error) {
return services, sc.Err()
}
func runNetworkSetup(args ...string) error {
cmd := exec.Command("networksetup", args...)
func runBash(script string) error {
cmd := exec.Command("/bin/bash", "-c", script)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("networksetup %v: %w (%s)", args, err, strings.TrimSpace(string(out)))
return fmt.Errorf("networksetup batch: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+13 -1
View File
@@ -5,6 +5,7 @@ package sysproxy
import (
"fmt"
"strings"
"sync"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
@@ -16,6 +17,7 @@ const (
)
type windowsController struct {
mu sync.Mutex
enabled bool
hadProxy bool
oldEnable uint64
@@ -27,9 +29,15 @@ func newPlatform() Controller {
return &windowsController{}
}
func (c *windowsController) Enabled() bool { return c.enabled }
func (c *windowsController) Enabled() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.enabled
}
func (c *windowsController) Enable(httpHostPort string) error {
c.mu.Lock()
defer c.mu.Unlock()
if httpHostPort == "" {
return fmt.Errorf("sysproxy: empty http proxy address")
}
@@ -92,6 +100,8 @@ func (c *windowsController) Enable(httpHostPort string) error {
}
func (c *windowsController) Disable() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.enabled {
return nil
}
@@ -99,6 +109,8 @@ func (c *windowsController) Disable() error {
}
func (c *windowsController) ForceDisable() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.disableLocked(false)
}
+14
View File
@@ -0,0 +1,14 @@
package trayhost
// Hooks lets the tray menu drive the VPN app without importing apphost (avoids cycles).
type Hooks struct {
Connect func()
Disconnect func()
Quit func()
IsUp func() bool
}
// Start runs a platform tray if available. Returns false when unsupported.
func Start(appName string, h Hooks) bool {
return start(appName, h)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package trayhost
// macOS/Linux: no CGO tray in default CGO_ENABLED=0 builds.
func start(appName string, h Hooks) bool {
_ = appName
_ = h
return false
}
+194
View File
@@ -0,0 +1,194 @@
//go:build windows
package trayhost
import (
"runtime"
"unsafe"
"golang.org/x/sys/windows"
)
var (
shell32 = windows.NewLazySystemDLL("shell32.dll")
user32 = windows.NewLazySystemDLL("user32.dll")
procShellNotify = shell32.NewProc("Shell_NotifyIconW")
procLoadIcon = user32.NewProc("LoadIconW")
procCreatePopup = user32.NewProc("CreatePopupMenu")
procAppendMenu = user32.NewProc("AppendMenuW")
procTrackPopup = user32.NewProc("TrackPopupMenu")
procDestroyMenu = user32.NewProc("DestroyMenu")
procDefWindowProc = user32.NewProc("DefWindowProcW")
procRegisterClass = user32.NewProc("RegisterClassExW")
procCreateWindow = user32.NewProc("CreateWindowExW")
procGetMessage = user32.NewProc("GetMessageW")
procTranslate = user32.NewProc("TranslateMessage")
procDispatch = user32.NewProc("DispatchMessageW")
procPostQuit = user32.NewProc("PostQuitMessage")
procSetForeground = user32.NewProc("SetForegroundWindow")
procGetCursorPos = user32.NewProc("GetCursorPos")
)
const (
nimAdd = 0x00000000
nimModify = 0x00000001
nimDelete = 0x00000002
nifMessage = 0x00000001
nifIcon = 0x00000002
nifTip = 0x00000004
wmApp = 0x8000
wmTray = wmApp + 1
wmDestroy = 0x0002
wmCommand = 0x0111
wmRButtonUp = 0x0205
wmLButtonUp = 0x0202
mfString = 0x00000000
mfSeparator = 0x00000800
tpmRight = 0x0020
tpmBottom = 0x0020
idiApplication = 32512
idConnect = 1001
idDisconnect = 1002
idQuit = 1003
)
type notifyIconData struct {
CbSize uint32
Hwnd windows.Handle
UID uint32
UFlags uint32
UCallbackMessage uint32
HIcon windows.Handle
SzTip [128]uint16
}
type wndClassEx struct {
CbSize uint32
Style uint32
LpfnWndProc uintptr
CbClsExtra int32
CbWndExtra int32
HInstance windows.Handle
HIcon windows.Handle
HCursor windows.Handle
HbrBackground windows.Handle
LpszMenuName *uint16
LpszClassName *uint16
HIconSm windows.Handle
}
type point struct{ X, Y int32 }
type msg struct {
Hwnd windows.Handle
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Pt point
}
func start(appName string, h Hooks) bool {
if h.Quit == nil {
return false
}
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
runTray(appName, h)
}()
return true
}
func runTray(appName string, h Hooks) {
className, _ := windows.UTF16PtrFromString("NavisTrayClass")
title, _ := windows.UTF16PtrFromString(appName)
var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr {
switch msgU {
case wmTray:
if lParam == wmRButtonUp || lParam == wmLButtonUp {
showMenu(hwnd, h)
}
return 0
case wmCommand:
switch int(wParam & 0xffff) {
case idConnect:
if h.Connect != nil {
go h.Connect()
}
case idDisconnect:
if h.Disconnect != nil {
go h.Disconnect()
}
case idQuit:
go h.Quit()
}
return 0
case wmDestroy:
procPostQuit.Call(0)
return 0
}
r, _, _ := procDefWindowProc.Call(uintptr(hwnd), uintptr(msgU), wParam, lParam)
return r
})
wc := wndClassEx{
CbSize: uint32(unsafe.Sizeof(wndClassEx{})),
LpfnWndProc: wndProc,
LpszClassName: className,
}
procRegisterClass.Call(uintptr(unsafe.Pointer(&wc)))
hwnd, _, _ := procCreateWindow.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, 0, 0)
if hwnd == 0 {
return
}
icon, _, _ := procLoadIcon.Call(0, uintptr(idiApplication))
var nid notifyIconData
nid.CbSize = uint32(unsafe.Sizeof(nid))
nid.Hwnd = windows.Handle(hwnd)
nid.UID = 1
nid.UFlags = nifMessage | nifIcon | nifTip
nid.UCallbackMessage = wmTray
nid.HIcon = windows.Handle(icon)
tip, _ := windows.UTF16FromString(appName)
copy(nid.SzTip[:], tip)
procShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&nid)))
var m msg
for {
ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
if int32(ret) <= 0 {
break
}
procTranslate.Call(uintptr(unsafe.Pointer(&m)))
procDispatch.Call(uintptr(unsafe.Pointer(&m)))
}
procShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&nid)))
}
func showMenu(hwnd windows.Handle, h Hooks) {
menu, _, _ := procCreatePopup.Call()
if menu == 0 {
return
}
defer procDestroyMenu.Call(menu)
add := func(id int, text string) {
p, _ := windows.UTF16PtrFromString(text)
procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p)))
}
up := h.IsUp != nil && h.IsUp()
if up {
add(idDisconnect, "Отключить")
} else {
add(idConnect, "Подключить")
}
procAppendMenu.Call(menu, mfSeparator, 0, 0)
add(idQuit, "Выход")
var pt point
procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
procSetForeground.Call(uintptr(hwnd))
procTrackPopup.Call(menu, tpmRight|tpmBottom, uintptr(pt.X), uintptr(pt.Y), 0, uintptr(hwnd), 0)
}
+128 -33
View File
@@ -18,7 +18,7 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "2.7.3"
const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
@@ -82,21 +82,44 @@ func PlatformKey() string {
return runtime.GOOS + "-" + runtime.GOARCH
}
// AssetFor returns download URL + checksum for the given platform key.
// Asset kinds returned by ResolveAsset.
const (
AssetKindBinary = "bin"
AssetKindAppZip = "appzip"
)
// AssetFor returns Mach-O / exe URL + checksum for the platform key.
func (m Manifest) AssetFor(key string) (url, sha string, ok bool) {
url, sha, _, ok = m.ResolveAsset(key, false)
return url, sha, ok
}
// ResolveAsset picks binary or .app.zip. PreferAppZip when running inside Navis.app.
func (m Manifest) ResolveAsset(key string, preferAppZip bool) (url, sha, kind string, ok bool) {
if key == "" {
key = PlatformKey()
}
if m.Platforms != nil {
if a, found := m.Platforms[key]; found && strings.TrimSpace(a.URL) != "" {
return strings.TrimSpace(a.URL), strings.TrimSpace(a.SHA256), true
if a, found := m.Platforms[key]; found {
if preferAppZip {
zu := strings.TrimSpace(a.ZipURL)
zs := strings.TrimSpace(a.ZipSHA256)
if zu != "" && zs != "" {
return zu, zs, AssetKindAppZip, true
}
}
u := strings.TrimSpace(a.URL)
s := strings.TrimSpace(a.SHA256)
if u != "" {
return u, s, AssetKindBinary, true
}
}
}
// Legacy single-asset manifests are windows-amd64.
if (key == "windows-amd64" || key == "windows-386") && strings.TrimSpace(m.URL) != "" {
return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), true
return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), AssetKindBinary, true
}
return "", "", false
return "", "", "", false
}
// Check fetches the manifest and compares versions for this platform.
@@ -116,7 +139,15 @@ func Check(ctx context.Context, manifestURL string) (Status, error) {
st.Notes = m.Notes
st.Mandatory = m.Mandatory
url, sha, ok := m.AssetFor(key)
preferZip := false
if runtime.GOOS == "darwin" {
if exe, err := os.Executable(); err == nil {
if _, ok := AppBundleRoot(exe); ok {
preferZip = true
}
}
}
url, sha, _, ok := m.ResolveAsset(key, preferZip)
if !ok {
st.Error = fmt.Sprintf("нет сборки для %s", key)
st.Available = false
@@ -247,60 +278,124 @@ func fileSHA256(path string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil
}
// preparedUpdate is a verified download ready for platform Apply.
type preparedUpdate struct {
Latest string
URL string
SHA256 string
ExePath string
TmpPath string
Kind string // AssetKindBinary | AssetKindAppZip
AppRoot string // set when Kind is appzip
}
// AppBundleRoot returns the .app path when exe lives in Contents/MacOS.
func AppBundleRoot(exe string) (string, bool) {
exe = filepath.Clean(exe)
const marker = ".app" + string(filepath.Separator) + "Contents" + string(filepath.Separator) + "MacOS"
idx := strings.Index(exe, marker)
if idx < 0 {
// Also accept forward-slash form after EvalSymlinks on some volumes.
idx = strings.Index(exe, ".app/Contents/MacOS")
if idx < 0 {
return "", false
}
return exe[:idx+len(".app")], true
}
return exe[:idx+len(".app")], true
}
// prepareDownload fetches the update next to the running binary as temp download.
// Refuses when already on/newer than the feed product version (no re-apply loop).
func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, exePath, tmpPath string, err error) {
st, err := Check(ctx, manifestURL)
p, err := prepareUpdate(ctx, manifestURL)
if err != nil {
return "", "", "", "", "", err
}
return p.Latest, p.URL, p.SHA256, p.ExePath, p.TmpPath, nil
}
func prepareUpdate(ctx context.Context, manifestURL string) (preparedUpdate, error) {
st, err := Check(ctx, manifestURL)
if err != nil {
return preparedUpdate{}, err
}
if !st.Available {
if st.Error != "" {
return "", "", "", "", "", fmt.Errorf("%s", st.Error)
return preparedUpdate{}, fmt.Errorf("%s", st.Error)
}
return "", "", "", "", "", fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
}
if !allowedDownloadURL(st.URL) {
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
return preparedUpdate{}, fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
}
exe, err := os.Executable()
if err != nil {
return "", "", "", "", "", err
return preparedUpdate{}, err
}
exe, err = filepath.Abs(exe)
if err != nil {
return "", "", "", "", "", err
return preparedUpdate{}, err
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil && resolved != "" {
exe = resolved
}
tmp := filepath.Join(filepath.Dir(exe), ".navis-update-download")
_ = os.Remove(tmp)
// Also clear legacy leftover from older updaters.
_ = os.Remove(exe + ".new")
if err := downloadFile(ctx, st.URL, tmp); err != nil {
return "", "", "", "", "", err
}
wantSHA := st.SHA256
if wantSHA == "" {
if man, err := fetchManifest(ctx, manifestURL); err == nil {
if _, sha2, ok := man.AssetFor(PlatformKey()); ok {
wantSHA = sha2
}
preferZip := false
appRoot := ""
if runtime.GOOS == "darwin" {
if root, ok := AppBundleRoot(exe); ok {
preferZip = true
appRoot = root
}
}
man, err := fetchManifest(ctx, manifestURL)
if err != nil {
return preparedUpdate{}, err
}
url, wantSHA, kind, ok := man.ResolveAsset(PlatformKey(), preferZip)
if !ok || url == "" {
return preparedUpdate{}, fmt.Errorf("нет ассета обновления для %s", PlatformKey())
}
if !allowedDownloadURL(url) {
return preparedUpdate{}, fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
}
if wantSHA == "" {
_ = os.Remove(tmp)
return "", "", "", "", "", fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
return preparedUpdate{}, fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
}
// Prefer .app.zip when available; otherwise replace Mach-O inside the bundle (legacy).
if kind != AssetKindAppZip {
appRoot = ""
}
dir := filepath.Dir(exe)
if appRoot != "" {
dir = filepath.Dir(appRoot) // sibling of .app
}
tmpName := ".navis-update-download"
if kind == AssetKindAppZip {
tmpName = ".navis-update-app.zip"
}
tmp := filepath.Join(dir, tmpName)
_ = os.Remove(tmp)
_ = os.Remove(exe + ".new")
if err := downloadFile(ctx, url, tmp); err != nil {
return preparedUpdate{}, err
}
sum, err := fileSHA256(tmp)
if err != nil {
_ = os.Remove(tmp)
return "", "", "", "", "", err
return preparedUpdate{}, err
}
if !strings.EqualFold(sum, wantSHA) {
_ = os.Remove(tmp)
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
return preparedUpdate{}, fmt.Errorf("checksum mismatch")
}
return st.Latest, st.URL, wantSHA, exe, tmp, nil
return preparedUpdate{
Latest: st.Latest,
URL: url,
SHA256: wantSHA,
ExePath: exe,
TmpPath: tmp,
Kind: kind,
AppRoot: appRoot,
}, nil
}
+72 -8
View File
@@ -12,21 +12,28 @@ import (
"strings"
)
// Apply downloads the new binary and schedules replacement + relaunch after exit.
// Apply downloads the new binary or .app.zip and schedules replacement + relaunch after exit.
func Apply(ctx context.Context, manifestURL string) (string, error) {
latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL)
p, err := prepareUpdate(ctx, manifestURL)
if err != nil {
return "", err
}
_ = os.Chmod(tmp, 0o755)
dir := filepath.Dir(exe)
if p.Kind == AssetKindAppZip {
return applyAppZip(p)
}
return applyBinary(p)
}
func applyBinary(p preparedUpdate) (string, error) {
_ = os.Chmod(p.TmpPath, 0o755)
dir := filepath.Dir(p.ExePath)
scriptPath := filepath.Join(dir, "navis-update.sh")
pid := os.Getpid()
script := "#!/bin/bash\n" +
"set -e\n" +
"EXE=" + shellQuote(exe) + "\n" +
"NEW=" + shellQuote(tmp) + "\n" +
"EXE=" + shellQuote(p.ExePath) + "\n" +
"NEW=" + shellQuote(p.TmpPath) + "\n" +
"PID=" + strconv.Itoa(pid) + "\n" +
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
"sleep 0.5\n" +
@@ -43,7 +50,56 @@ func Apply(ctx context.Context, manifestURL string) (string, error) {
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start updater: %w", err)
}
return latest, nil
return p.Latest, nil
}
func applyAppZip(p preparedUpdate) (string, error) {
if p.AppRoot == "" {
return "", fmt.Errorf("update: empty app root")
}
parent := filepath.Dir(p.AppRoot)
scriptPath := filepath.Join(parent, "navis-update-app.sh")
pid := os.Getpid()
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
// Never re-sign with ad-hoc "-" after replacing a shipped .app — that strips
// Developer ID / notarization. Only re-sign when a real identity is set.
doSign := identity != "" && identity != "-"
script := "#!/bin/bash\n" +
"set -e\n" +
"APP=" + shellQuote(p.AppRoot) + "\n" +
"ZIP=" + shellQuote(p.TmpPath) + "\n" +
"PID=" + strconv.Itoa(pid) + "\n" +
"PARENT=$(dirname \"$APP\")\n" +
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
"sleep 0.5\n" +
"TMP=$(mktemp -d \"$PARENT/.navis-update-XXXXXX\")\n" +
"unzip -q \"$ZIP\" -d \"$TMP\"\n" +
"NEW=$(find \"$TMP\" -maxdepth 3 -name 'Navis.app' -type d | head -n 1)\n" +
"if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" +
"rm -rf \"$APP.bak\"\n" +
"mv \"$APP\" \"$APP.bak\"\n" +
"mv \"$NEW\" \"$APP\"\n"
if doSign {
script += "ID=" + shellQuote(identity) + "\n" +
"if command -v codesign >/dev/null 2>&1; then\n" +
" codesign -s \"$ID\" --force --deep --options runtime --timestamp \"$APP\"\n" +
" codesign --verify --deep --strict \"$APP\"\n" +
"fi\n"
}
script += "xattr -cr \"$APP\" 2>/dev/null || true\n" +
"rm -rf \"$APP.bak\" \"$TMP\" \"$ZIP\" " + shellQuote(scriptPath) + "\n" +
"open \"$APP\"\n"
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
return "", err
}
cmd := exec.Command("/bin/bash", scriptPath)
cmd.Dir = parent
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start app updater: %w", err)
}
return p.Latest, nil
}
func shellQuote(s string) string {
@@ -58,5 +114,13 @@ func CleanupStaleDownloads() {
}
exe, _ = filepath.Abs(exe)
_ = os.Remove(exe + ".new")
_ = os.Remove(filepath.Join(filepath.Dir(exe), ".navis-update-download"))
dir := filepath.Dir(exe)
_ = os.Remove(filepath.Join(dir, ".navis-update-download"))
_ = os.Remove(filepath.Join(dir, ".navis-update-app.zip"))
if root, ok := AppBundleRoot(exe); ok {
parent := filepath.Dir(root)
_ = os.Remove(filepath.Join(parent, ".navis-update-app.zip"))
_ = os.Remove(filepath.Join(parent, "navis-update-app.sh"))
_ = os.Remove(filepath.Join(parent, "navis-update.sh"))
}
}
+31
View File
@@ -30,6 +30,37 @@ func TestCompare(t *testing.T) {
}
}
func TestResolveAssetPrefersZip(t *testing.T) {
m := Manifest{
Platforms: map[string]PlatformAsset{
"darwin-arm64": {
URL: "https://git.evilfox.cc/bin",
SHA256: "aaa",
ZipURL: "https://git.evilfox.cc/app.zip",
ZipSHA256: "bbb",
},
},
}
u, s, k, ok := m.ResolveAsset("darwin-arm64", true)
if !ok || k != AssetKindAppZip || u == "" || s != "bbb" {
t.Fatalf("zip: ok=%v kind=%s url=%s sha=%s", ok, k, u, s)
}
u, s, k, ok = m.ResolveAsset("darwin-arm64", false)
if !ok || k != AssetKindBinary || s != "aaa" {
t.Fatalf("bin: ok=%v kind=%s sha=%s", ok, k, s)
}
}
func TestAppBundleRoot(t *testing.T) {
root, ok := AppBundleRoot("/Applications/Navis.app/Contents/MacOS/Navis")
if !ok || root != "/Applications/Navis.app" {
t.Fatalf("got %q ok=%v", root, ok)
}
if _, ok := AppBundleRoot("/usr/local/bin/Navis"); ok {
t.Fatal("expected bare binary")
}
}
func TestManifestAssetFor(t *testing.T) {
m := Manifest{
URL: "https://git.evilfox.cc/legacy.exe",
+68 -14
View File
@@ -3,6 +3,11 @@
set -euo pipefail
cd "$(dirname "$0")/.."
# Prefer local toolchain if present.
export PATH="$(pwd)/.tools/go/bin:/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH"
python3 scripts/sync-version.py
VERSION="$(python3 - <<'PY'
import re
from pathlib import Path
@@ -26,7 +31,6 @@ OUT="dist/navis-release/darwin-arm64"
mkdir -p "$OUT"
LDFLAGS="-s -w"
export PATH="/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH"
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
go build -ldflags="$LDFLAGS" -trimpath -o "$OUT/Navis" ./cmd/vpnapp
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
@@ -35,18 +39,26 @@ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
printf '%s\n' "$FULL" > "$OUT/VERSION"
printf '%s\n' "${VERSION}+${BUILD}" > "$OUT/Navis.version"
# Ad-hoc sign CLI/bin; packmac also signs the .app (honors NAVIS_CODESIGN_IDENTITY).
# Set REQUIRE_CODESIGN=1 for release builds (fails without a real Developer ID identity).
if [[ "${REQUIRE_CODESIGN:-}" == "1" ]]; then
if [[ -z "${NAVIS_CODESIGN_IDENTITY:-}" || "${NAVIS_CODESIGN_IDENTITY}" == "-" ]]; then
echo "REQUIRE_CODESIGN=1 requires NAVIS_CODESIGN_IDENTITY (Developer ID Application: …)" >&2
exit 1
fi
fi
if command -v codesign >/dev/null 2>&1; then
codesign -s - --force "$OUT/Navis"
codesign -s - --force "$OUT/Navis-cli"
if [[ -n "${NAVIS_CODESIGN_IDENTITY:-}" && "${NAVIS_CODESIGN_IDENTITY}" != "-" ]]; then
ID="$NAVIS_CODESIGN_IDENTITY"
codesign -s "$ID" --force --options runtime --timestamp "$OUT/Navis"
codesign -s "$ID" --force --options runtime --timestamp "$OUT/Navis-cli"
else
codesign -s - --force "$OUT/Navis" || true
codesign -s - --force "$OUT/Navis-cli" || true
fi
fi
go build -o tools/packmac/packmac ./tools/packmac
tools/packmac/packmac \
-bin "$OUT/Navis" \
-out "$OUT" \
-version "$VERSION" \
-build "$FULL" \
-arch arm64
go run ./tools/packmac -bin "$OUT/Navis" -out "$OUT" -version "$VERSION" -build "$FULL" -arch arm64
cp -f "$OUT/Navis.dmg" "$OUT/Navis-${FULL}-arm64.dmg"
cp -f "$OUT/Navis.app.zip" "$OUT/Navis-${FULL}-arm64.app.zip"
@@ -57,10 +69,23 @@ from pathlib import Path
ver = "${VERSION}"
build = "${BUILD}"
full = "${FULL}"
binp = Path('dist/navis-release/darwin-arm64/Navis')
out = Path('dist/navis-release/darwin-arm64')
binp = out / 'Navis'
zipp = out / 'Navis.app.zip'
dmgp = out / 'Navis.dmg'
h = hashlib.sha256(binp.read_bytes()).hexdigest()
notes = f"Navis {ver}+{build}: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке)."
for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
notes = (
f"Navis {ver}+{build}: меньше I/O на poll (без Stat ядер / кэш host); "
f"macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении."
)
paths = [
Path('dist/update.json'),
Path('dist/navis-release/update.json'),
Path('server/update.json'),
]
for p in paths:
if not p.exists():
continue
d = json.loads(p.read_text())
@@ -69,8 +94,37 @@ for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
plats = d.setdefault('platforms', {})
if 'darwin-arm64' in plats:
plats['darwin-arm64']['sha256'] = h
if zh:
plats['darwin-arm64']['zip_sha256'] = zh
if dh:
plats['darwin-arm64']['dmg_sha256'] = dh
plats['darwin-arm64'].setdefault(
'zip_url',
'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip',
)
plats['darwin-arm64'].setdefault(
'dmg_url',
'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg',
)
# Refresh checksums for any other darwin trees present on disk (amd64/universal).
base = 'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release'
for key in ('darwin-amd64', 'darwin-universal'):
if key not in plats:
continue
plat_dir = Path('dist/navis-release') / key
bin2 = plat_dir / 'Navis'
zip2 = plat_dir / 'Navis.app.zip'
dmg2 = plat_dir / 'Navis.dmg'
if bin2.exists():
plats[key]['sha256'] = hashlib.sha256(bin2.read_bytes()).hexdigest()
if zip2.exists():
plats[key]['zip_sha256'] = hashlib.sha256(zip2.read_bytes()).hexdigest()
plats[key].setdefault('zip_url', f'{base}/{key}/Navis.app.zip')
if dmg2.exists():
plats[key]['dmg_sha256'] = hashlib.sha256(dmg2.read_bytes()).hexdigest()
plats[key].setdefault('dmg_url', f'{base}/{key}/Navis.dmg')
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + '\n')
print('updated', p, '->', ver, 'sha', h[:12])
print('updated', p, '->', ver, 'bin', h[:12], 'zip', zh[:12] if zh else '-')
PY
echo ""
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Optional release signing / notarization for macOS Navis.app / DMG.
# Requires Apple Developer ID + notarytool credentials in the environment.
#
# Usage:
# export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
# export NAVIS_NOTARY_PROFILE="navis-notary" # xcrun notarytool store-credentials
# ./scripts/sign-macos.sh dist/navis-release/darwin-arm64/Navis.dmg
# # or a staged .app:
# ./scripts/sign-macos.sh /path/to/Navis.app
set -euo pipefail
TARGET="${1:-}"
if [[ -z "$TARGET" || ! -e "$TARGET" ]]; then
echo "usage: $0 <Navis.app|Navis.dmg>" >&2
exit 2
fi
IDENTITY="${NAVIS_CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
echo "NAVIS_CODESIGN_IDENTITY is required (Developer ID Application: …)" >&2
exit 1
fi
sign_app() {
local app="$1"
echo "codesign $app as $IDENTITY"
codesign -s "$IDENTITY" --force --deep --options runtime --timestamp "$app"
codesign --verify --deep --strict "$app"
}
if [[ "$TARGET" == *.app || -d "$TARGET" ]]; then
sign_app "$TARGET"
echo "Signed app. Notarize the DMG that contains it via notarytool."
exit 0
fi
if [[ "$TARGET" == *.dmg ]]; then
# Sign nested .app if present after attach is awkward; prefer signing .app before packmac.
# Here we submit the DMG for notarization when credentials exist.
if [[ -n "${NAVIS_NOTARY_PROFILE:-}" ]]; then
echo "notarytool submit $TARGET (profile=$NAVIS_NOTARY_PROFILE)"
xcrun notarytool submit "$TARGET" --keychain-profile "$NAVIS_NOTARY_PROFILE" --wait
xcrun stapler staple "$TARGET"
echo "Notarized + stapled: $TARGET"
else
echo "DMG ready. Set NAVIS_NOTARY_PROFILE and re-run to notarize."
echo "Tip: packmac with NAVIS_CODESIGN_IDENTITY set signs the .app before zip/dmg."
fi
exit 0
fi
echo "unsupported target: $TARGET" >&2
exit 2
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Sync versioninfo.json, build-macos.bat, Android gradle, UI badge from update.go."""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UPDATE_GO = ROOT / "internal" / "update" / "update.go"
VERSIONINFO = ROOT / "versioninfo.json"
BUILD_BAT = ROOT / "build-macos.bat"
ANDROID_GRADLE = ROOT / "android" / "app" / "build.gradle.kts"
INDEX_HTML = ROOT / "internal" / "appui" / "index.html"
def read_consts() -> tuple[str, int]:
text = UPDATE_GO.read_text(encoding="utf-8")
m = re.search(r'CurrentVersion\s*=\s*"([^"]+)"', text)
b = re.search(r"const BuildNumber\s*=\s*(\d+)", text)
if not m or not b:
raise SystemExit("cannot parse CurrentVersion/BuildNumber from update.go")
return m.group(1), int(b.group(1))
def main() -> None:
ver, build = read_consts()
parts = [int(x) for x in ver.split(".")]
while len(parts) < 3:
parts.append(0)
major, minor, patch = parts[0], parts[1], parts[2]
full = f"{ver}.{build}"
display = f"{ver}+{build}"
version_code = major * 1_000_000 + minor * 10_000 + patch * 100 + build
data = json.loads(VERSIONINFO.read_text(encoding="utf-8"))
data["FixedFileInfo"]["FileVersion"] = {
"Major": major,
"Minor": minor,
"Patch": patch,
"Build": build,
}
data["FixedFileInfo"]["ProductVersion"] = dict(data["FixedFileInfo"]["FileVersion"])
data["StringFileInfo"]["FileVersion"] = full
data["StringFileInfo"]["ProductVersion"] = full
VERSIONINFO.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("updated", VERSIONINFO, "->", full)
bat = BUILD_BAT.read_text(encoding="utf-8")
bat2 = re.sub(
r'-version\s+\d+\.\d+\.\d+\s+-build\s+\d+\.\d+\.\d+\.\d+',
f"-version {ver} -build {full}",
bat,
)
if bat2 != bat:
BUILD_BAT.write_text(bat2, encoding="utf-8")
print("updated", BUILD_BAT, "->", ver, full)
else:
print("build-macos.bat already in sync")
if ANDROID_GRADLE.exists():
g = ANDROID_GRADLE.read_text(encoding="utf-8")
g2 = re.sub(r"versionCode\s*=\s*[\d_]+", f"versionCode = {version_code}", g)
g2 = re.sub(r'versionName\s*=\s*"[^"]+"', f'versionName = "{display}"', g2)
if g2 != g:
ANDROID_GRADLE.write_text(g2, encoding="utf-8")
print("updated", ANDROID_GRADLE, "->", display, version_code)
else:
print("android gradle already in sync")
if INDEX_HTML.exists():
h = INDEX_HTML.read_text(encoding="utf-8")
h2 = re.sub(r'(id="badgeVer">)[^<]+', rf"\g<1>{ver}", h, count=1)
if h2 != h:
INDEX_HTML.write_text(h2, encoding="utf-8")
print("updated", INDEX_HTML, "badge ->", ver)
else:
print("index.html badge already in sync")
if __name__ == "__main__":
main()
+12 -6
View File
@@ -1,6 +1,6 @@
{
"version": "2.7.3",
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
"version": "3.8.2",
"notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,11 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
@@ -28,7 +30,9 @@
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
@@ -36,7 +40,9 @@
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
+27 -5
View File
@@ -176,19 +176,41 @@ func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error
func signAppBundle(appRoot string) error {
if _, err := exec.LookPath("codesign"); err != nil {
if os.Getenv("REQUIRE_CODESIGN") == "1" {
return fmt.Errorf("codesign required (REQUIRE_CODESIGN=1) but not found")
}
return nil
}
// Fresh ad-hoc signature; entitlements not required for local/dev builds.
cmd := exec.Command("codesign", "-s", "-", "--force", "--deep", "--options", "runtime", appRoot)
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
require := os.Getenv("REQUIRE_CODESIGN") == "1"
if require && (identity == "" || identity == "-") {
return fmt.Errorf("REQUIRE_CODESIGN=1 requires NAVIS_CODESIGN_IDENTITY")
}
if identity == "" {
identity = "-"
}
args := []string{"-s", identity, "--force", "--deep"}
if identity != "-" {
args = append(args, "--options", "runtime", "--timestamp")
}
args = append(args, appRoot)
cmd := exec.Command("codesign", args...)
out, err := cmd.CombinedOutput()
if err != nil {
// --options runtime can fail without a real Developer ID; retry plain ad-hoc.
cmd = exec.Command("codesign", "-s", "-", "--force", "--deep", appRoot)
out, err = cmd.CombinedOutput()
if identity == "-" {
cmd = exec.Command("codesign", "-s", "-", "--force", "--deep", appRoot)
out, err = cmd.CombinedOutput()
}
if err != nil {
return fmt.Errorf("codesign %s: %w: %s", appRoot, err, strings.TrimSpace(string(out)))
}
}
if identity != "-" {
v := exec.Command("codesign", "--verify", "--deep", "--strict", appRoot)
if out, err := v.CombinedOutput(); err != nil {
return fmt.Errorf("codesign verify: %w: %s", err, strings.TrimSpace(string(out)))
}
}
_ = exec.Command("xattr", "-cr", appRoot).Run()
return nil
}
+15 -5
View File
@@ -1,7 +1,17 @@
{
"FixedFileInfo": {
"FileVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
"ProductVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
"FileVersion": {
"Major": 3,
"Minor": 8,
"Patch": 2,
"Build": 3
},
"ProductVersion": {
"Major": 3,
"Minor": 8,
"Patch": 2,
"Build": 3
},
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "40004",
@@ -10,13 +20,13 @@
},
"StringFileInfo": {
"CompanyName": "EvilFox",
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "2.7.3.3",
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "3.8.2.3",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
"ProductVersion": "2.7.3.3",
"ProductVersion": "3.8.2.3",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {