Compare commits
4
Commits
release/3.8.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1570b23d3 | ||
|
|
838f9e340b | ||
|
|
621c847cb3 | ||
|
|
6b6c13c933 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -243,6 +243,21 @@ https://evilfox.win/
|
|||||||
|
|
||||||
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
Некоторые 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:
|
В 3.8.0:
|
||||||
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
|
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
|
||||||
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
|
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
# Navis Android
|
# 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 |
|
| VLESS / VMess / Trojan (Xray) | SOCKS core + VpnService |
|
||||||
| Hysteria 2 | SOCKS core + VpnService |
|
| Hysteria 2 | SOCKS core + VpnService |
|
||||||
| NaiveProxy | импорт в UI; Android Chromium-бинарник не бандлится |
|
| NaiveProxy | импорт в UI; подключение отключено (только Desktop) |
|
||||||
| AmneziaWG | импорт в UI; полный Tun — следующий релиз |
|
| AmneziaWG | импорт в UI; подключение отключено (только Desktop) |
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
||||||
versionCode = 2_070_201
|
versionCode = 3080203
|
||||||
versionName = "2.7.3+1"
|
versionName = "3.8.2+3"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
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(
|
data class Profile(
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
vm.setMeta("Выберите сервер", true)
|
vm.setMeta("Выберите сервер", true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!p.protocol.supportedOnAndroid()) {
|
||||||
|
vm.setMeta("${p.protocol.label}: на Android недоступен — используйте VLESS/VMess/Trojan/Hy2", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
prepareVpnThen {
|
prepareVpnThen {
|
||||||
startService(
|
startService(
|
||||||
Intent(this, NavisVpnService::class.java)
|
Intent(this, NavisVpnService::class.java)
|
||||||
|
|||||||
@@ -139,12 +139,18 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
val rows = store.pingAll(profiles)
|
val rows = store.pingAll(profiles)
|
||||||
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
|
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
|
||||||
val best = store.bestOf(rows) ?: run {
|
val supported = profiles.filter { it.protocol.supportedOnAndroid() }
|
||||||
setMeta("нет доступных серверов", true)
|
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
|
return
|
||||||
}
|
}
|
||||||
store.setActiveId(best.id)
|
store.setActiveId(best.id)
|
||||||
val profile = profiles.first { it.id == 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) }
|
_state.update { it.copy(activeId = best.id) }
|
||||||
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
|
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
|
||||||
if (connect && prepare != null) {
|
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("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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Surface(shape = RoundedCornerShape(22.dp), color = Color.White.copy(alpha = 0.85f), tonalElevation = 2.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
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
|
|||||||
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.0 -build 3.8.0.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
|
if errorlevel 1 exit /b 1
|
||||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.0 -build 3.8.0.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
|
if errorlevel 1 exit /b 1
|
||||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.0 -build 3.8.0.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
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
echo Built Mac GUI + CLI:
|
echo Built Mac GUI + CLI:
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"vpnclient/internal/apphost"
|
"vpnclient/internal/apphost"
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
|
"vpnclient/internal/logbuf"
|
||||||
|
"vpnclient/internal/trayhost"
|
||||||
"vpnclient/internal/update"
|
"vpnclient/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
update.CleanupStaleDownloads()
|
update.CleanupStaleDownloads()
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
|
||||||
|
|
||||||
cfgPath, err := config.LocateConfig()
|
cfgPath, err := config.LocateConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,7 +44,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("Ошибка конфига %s: %v", cfgPath, err)
|
fatalf("Ошибка конфига %s: %v", cfgPath, err)
|
||||||
}
|
}
|
||||||
logBuf := &bytes.Buffer{}
|
logBuf := logbuf.New(0)
|
||||||
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
|
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
@@ -54,6 +57,7 @@ func main() {
|
|||||||
a.OnAfterUpdate = func() {
|
a.OnAfterUpdate = func() {
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
_ = a.Disconnect()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,16 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
_ = srv.Serve(ln)
|
_ = 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()
|
go a.AutoCheckUpdate()
|
||||||
|
|
||||||
w, err := glaze.New(false)
|
w, err := glaze.New(false)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"vpnclient/internal/appui"
|
"vpnclient/internal/appui"
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
|
"vpnclient/internal/logbuf"
|
||||||
|
"vpnclient/internal/trayhost"
|
||||||
"vpnclient/internal/update"
|
"vpnclient/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ func main() {
|
|||||||
update.CleanupStaleDownloads()
|
update.CleanupStaleDownloads()
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
|
||||||
|
|
||||||
cfgPath, err := config.LocateConfig()
|
cfgPath, err := config.LocateConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,7 +47,7 @@ func main() {
|
|||||||
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
|
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logBuf := &bytes.Buffer{}
|
logBuf := logbuf.New(0)
|
||||||
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
|
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalDialog("%v", err)
|
fatalDialog("%v", err)
|
||||||
@@ -56,6 +59,7 @@ func main() {
|
|||||||
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
|
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
_ = a.Disconnect()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,20 @@ func main() {
|
|||||||
mustBind(w, "applyUpdate", a.ApplyUpdate)
|
mustBind(w, "applyUpdate", a.ApplyUpdate)
|
||||||
mustBind(w, "saveHy2", a.SaveHy2)
|
mustBind(w, "saveHy2", a.SaveHy2)
|
||||||
mustBind(w, "importSubscription", a.ImportSubscription)
|
mustBind(w, "importSubscription", a.ImportSubscription)
|
||||||
|
mustBind(w, "getLogs", a.GetLogs)
|
||||||
|
mustBind(w, "probeTunnel", a.ProbeTunnel)
|
||||||
|
mustBind(w, "savePrefs", a.SavePrefs)
|
||||||
|
|
||||||
|
a.StartBackground()
|
||||||
|
trayhost.Start("Navis", trayhost.Hooks{
|
||||||
|
Connect: func() { _ = a.Connect() },
|
||||||
|
Disconnect: func() { _ = a.Disconnect() },
|
||||||
|
Quit: func() {
|
||||||
|
_ = a.Disconnect()
|
||||||
|
os.Exit(0)
|
||||||
|
},
|
||||||
|
IsUp: func() bool { return a.Mgr.Status().Connected },
|
||||||
|
})
|
||||||
|
|
||||||
go a.AutoCheckUpdate()
|
go a.AutoCheckUpdate()
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
|||||||
3.8.0+3
|
3.8.2+3
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.8.0.3
|
3.8.2.3
|
||||||
|
|||||||
Vendored
+11
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.0",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.",
|
"notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f",
|
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
"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": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4",
|
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||||
"dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee"
|
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
@@ -30,7 +30,9 @@
|
|||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
"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": {
|
"darwin-universal": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||||
@@ -38,7 +40,9 @@
|
|||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "universal",
|
"arch": "universal",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.0",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.",
|
"notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f",
|
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
"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": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4",
|
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||||
"dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee"
|
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
@@ -30,7 +30,9 @@
|
|||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
"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": {
|
"darwin-universal": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||||
@@ -38,7 +40,9 @@
|
|||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "universal",
|
"arch": "universal",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export NAVIS_NOTARY_PROFILE="navis-notary"
|
|||||||
|
|
||||||
Without these env vars, packmac uses **ad-hoc** signing (`-`) — fine for local/dev.
|
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
|
## Windows
|
||||||
|
|
||||||
Sign `Navis.exe` with Authenticode (EV or standard code signing cert):
|
Sign `Navis.exe` with Authenticode (EV or standard code signing cert):
|
||||||
|
|||||||
+211
-91
@@ -1,7 +1,6 @@
|
|||||||
package apphost
|
package apphost
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -18,6 +17,9 @@ import (
|
|||||||
"vpnclient/internal/appui"
|
"vpnclient/internal/appui"
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
|
"vpnclient/internal/corebin"
|
||||||
|
"vpnclient/internal/dockicon"
|
||||||
|
"vpnclient/internal/logbuf"
|
||||||
"vpnclient/internal/netcheck"
|
"vpnclient/internal/netcheck"
|
||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
@@ -31,7 +33,7 @@ type App struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
Mgr *core.Manager
|
Mgr *core.Manager
|
||||||
CfgPath string
|
CfgPath string
|
||||||
LogBuf *bytes.Buffer
|
LogBuf *logbuf.Buffer
|
||||||
UpdateStatus update.Status
|
UpdateStatus update.Status
|
||||||
Pings []netcheck.Result
|
Pings []netcheck.Result
|
||||||
OpenURL func(string) error
|
OpenURL func(string) error
|
||||||
@@ -40,23 +42,27 @@ type App struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UIState struct {
|
type UIState struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
Profile string `json:"profile,omitempty"`
|
Profile string `json:"profile,omitempty"`
|
||||||
ActiveProfile string `json:"active_profile,omitempty"`
|
ActiveProfile string `json:"active_profile,omitempty"`
|
||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||||
SystemProxy bool `json:"system_proxy"`
|
SystemProxy bool `json:"system_proxy"`
|
||||||
Proxy string `json:"proxy"`
|
Proxy string `json:"proxy"`
|
||||||
CoreReady bool `json:"core_ready"`
|
CoreReady bool `json:"core_ready"`
|
||||||
CorePath string `json:"core_path,omitempty"`
|
CorePath string `json:"core_path,omitempty"`
|
||||||
ConfigPath string `json:"config_path"`
|
ConfigPath string `json:"config_path"`
|
||||||
Profiles []config.ProfileInfo `json:"profiles"`
|
Profiles []config.ProfileInfo `json:"profiles"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Update update.Status `json:"update"`
|
Update update.Status `json:"update"`
|
||||||
Pings []netcheck.Result `json:"pings"`
|
Pings []netcheck.Result `json:"pings"`
|
||||||
Subscription string `json:"subscription_url"`
|
Subscription string `json:"subscription_url"`
|
||||||
Hy2 core.Hy2Options `json:"hy2"`
|
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 {
|
type PingBestResult struct {
|
||||||
@@ -67,7 +73,7 @@ type PingBestResult struct {
|
|||||||
Connected bool `json:"connected"`
|
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{
|
return &App{
|
||||||
Mgr: mgr,
|
Mgr: mgr,
|
||||||
CfgPath: cfgPath,
|
CfgPath: cfgPath,
|
||||||
@@ -89,71 +95,59 @@ func (a *App) GetEditState() (UIState, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) getState(includeSecrets bool) (UIState, error) {
|
func (a *App) getState(includeSecrets bool) (UIState, error) {
|
||||||
|
// Hold App.mu only for fields mutated by update/ping handlers.
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
upd := a.UpdateStatus
|
||||||
|
pings := append([]netcheck.Result(nil), a.Pings...)
|
||||||
|
cfgPath := a.CfgPath
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
st := a.Mgr.Status()
|
poll := a.Mgr.PollUI(includeSecrets)
|
||||||
cfg := a.Mgr.Config()
|
st := poll.Status
|
||||||
proxy := ""
|
proxy := poll.ActiveProxy
|
||||||
active := cfg.Active
|
|
||||||
if p, err := cfg.ActiveProfile(); err == nil {
|
|
||||||
proxy = p.Proxy
|
|
||||||
active = p.Name
|
|
||||||
}
|
|
||||||
if !includeSecrets {
|
if !includeSecrets {
|
||||||
proxy = config.RedactProxyURI(proxy)
|
proxy = config.RedactProxyURI(proxy)
|
||||||
}
|
}
|
||||||
corePath := ""
|
hy2 := poll.Hy2
|
||||||
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 := a.Mgr.ActiveHy2Options()
|
|
||||||
if !includeSecrets {
|
if !includeSecrets {
|
||||||
hy2.ObfsPassword = ""
|
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{
|
out := UIState{
|
||||||
Connected: st.Connected,
|
Connected: st.Connected,
|
||||||
Profile: st.Profile,
|
Profile: st.Profile,
|
||||||
ActiveProfile: active,
|
ActiveProfile: poll.Active,
|
||||||
Protocol: string(st.Protocol),
|
Protocol: string(st.Protocol),
|
||||||
HTTPProxy: st.HTTPProxy,
|
HTTPProxy: st.HTTPProxy,
|
||||||
SOCKSProxy: st.SOCKSProxy,
|
SOCKSProxy: st.SOCKSProxy,
|
||||||
SystemProxy: cfg.SystemProxy,
|
SystemProxy: poll.SystemProxy,
|
||||||
Proxy: proxy,
|
Proxy: proxy,
|
||||||
CoreReady: coreReady,
|
CoreReady: coreReady,
|
||||||
CorePath: corePath,
|
CorePath: corePath,
|
||||||
ConfigPath: a.CfgPath,
|
ConfigPath: cfgPath,
|
||||||
Profiles: config.RedactProfileList(cfg.ListProfiles()),
|
Profiles: profiles,
|
||||||
Version: update.DisplayVersion(),
|
Version: update.DisplayVersion(),
|
||||||
Update: a.UpdateStatus,
|
Update: upd,
|
||||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
Pings: pings,
|
||||||
Subscription: cfg.SubscriptionURL,
|
Subscription: poll.Subscription,
|
||||||
Hy2: hy2,
|
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 out.Protocol == "" {
|
||||||
if p, err := cfg.ActiveProfile(); err == nil {
|
out.Protocol = string(poll.ActiveProtocol)
|
||||||
out.Protocol = string(p.Protocol)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if st.Connected {
|
if st.Connected {
|
||||||
out.SystemProxy = st.SystemProxy
|
out.SystemProxy = st.SystemProxy
|
||||||
@@ -161,6 +155,32 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
|
|||||||
return out, nil
|
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 {
|
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
@@ -218,26 +238,123 @@ func (a *App) ConnectProfile(name string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
|
proxy, err := a.Mgr.ActiveProxyURI()
|
||||||
|
if err != nil {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
}
|
||||||
|
if strings.TrimSpace(proxy) == "" {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return fmt.Errorf("сначала вставьте ссылку сервера")
|
return fmt.Errorf("сначала вставьте ссылку сервера")
|
||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
|
var last error
|
||||||
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
return err
|
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)
|
dockicon.SetConnected(false)
|
||||||
defer cancel()
|
return last
|
||||||
return a.Mgr.Connect(ctx, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Disconnect() error {
|
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) {
|
func (a *App) InstallCore() (string, error) {
|
||||||
@@ -305,21 +422,18 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
|
|||||||
out.Connected = true
|
out.Connected = true
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
|
proxy, err := a.Mgr.ActiveProxyURI()
|
||||||
|
if err != nil {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return out, err
|
return out, err
|
||||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
}
|
||||||
|
if strings.TrimSpace(proxy) == "" {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
|
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
|
||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
if err := a.ConnectProfile(""); 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
|
return out, err
|
||||||
}
|
}
|
||||||
out.Connected = true
|
out.Connected = true
|
||||||
@@ -525,6 +639,12 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
|||||||
return nil, a.SaveHy2(opts)
|
return nil, a.SaveHy2(opts)
|
||||||
case "importSubscription":
|
case "importSubscription":
|
||||||
return a.ImportSubscription(arg(args, 0, ""))
|
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":
|
case "quit":
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|||||||
@@ -801,3 +801,18 @@
|
|||||||
.tools { 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; }
|
||||||
|
|||||||
+61
-5
@@ -3,7 +3,7 @@
|
|||||||
const methods = [
|
const methods = [
|
||||||
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
||||||
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
||||||
"checkUpdate","applyUpdate","saveHy2","importSubscription","quit"
|
"checkUpdate","applyUpdate","saveHy2","importSubscription","getLogs","probeTunnel","savePrefs","quit"
|
||||||
];
|
];
|
||||||
if (typeof window.getState === "function") return;
|
if (typeof window.getState === "function") return;
|
||||||
window.__navisHttp = true;
|
window.__navisHttp = true;
|
||||||
@@ -36,6 +36,11 @@
|
|||||||
const proxy = $("proxy");
|
const proxy = $("proxy");
|
||||||
const nameInput = $("name");
|
const nameInput = $("name");
|
||||||
const sysproxy = $("sysproxy");
|
const sysproxy = $("sysproxy");
|
||||||
|
const bootConnect = $("bootConnect");
|
||||||
|
const autoReconnect = $("autoReconnect");
|
||||||
|
const logTail = $("logTail");
|
||||||
|
const logRefreshBtn = $("logRefreshBtn");
|
||||||
|
const probeBtn = $("probeBtn");
|
||||||
const profile = $("profile");
|
const profile = $("profile");
|
||||||
const dot = $("dot");
|
const dot = $("dot");
|
||||||
const statusText = $("statusText");
|
const statusText = $("statusText");
|
||||||
@@ -202,6 +207,22 @@
|
|||||||
(pings || []).forEach((p) => { pingMap[p.name] = p; });
|
(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) {
|
function fillProfiles(list, active) {
|
||||||
profiles = list || [];
|
profiles = list || [];
|
||||||
const cur = profile.value;
|
const cur = profile.value;
|
||||||
@@ -266,7 +287,8 @@
|
|||||||
const pr = pingMap[p.name];
|
const pr = pingMap[p.name];
|
||||||
if (pr && pr.ok) {
|
if (pr && pr.ok) {
|
||||||
ms.className = "ms " + msClass(pr.ms, true);
|
ms.className = "ms " + msClass(pr.ms, true);
|
||||||
ms.textContent = pr.ms + " ms";
|
ms.textContent = (pr.soft ? "~" : "") + pr.ms + " ms";
|
||||||
|
if (pr.soft) ms.title = "soft-up (UDP без ответа) — не выбирается как «Лучший»";
|
||||||
} else if (pr && pr.error) {
|
} else if (pr && pr.error) {
|
||||||
ms.className = "ms bad";
|
ms.className = "ms bad";
|
||||||
ms.textContent = "—";
|
ms.textContent = "—";
|
||||||
@@ -347,6 +369,10 @@
|
|||||||
proxy.disabled = lock;
|
proxy.disabled = lock;
|
||||||
nameInput.disabled = lock;
|
nameInput.disabled = lock;
|
||||||
sysproxy.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;
|
profile.disabled = lock;
|
||||||
addBtn.disabled = lock;
|
addBtn.disabled = lock;
|
||||||
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
|
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
|
||||||
@@ -361,7 +387,11 @@
|
|||||||
btn.disabled = busy;
|
btn.disabled = busy;
|
||||||
|
|
||||||
rememberPings(state.pings || []);
|
rememberPings(state.pings || []);
|
||||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
const fp = profilesFingerprint(state);
|
||||||
|
if (syncForm || fp !== listFP) {
|
||||||
|
listFP = fp;
|
||||||
|
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||||
|
}
|
||||||
renderUpdate(state.update, state.version);
|
renderUpdate(state.update, state.version);
|
||||||
if (typeof state.subscription_url === "string" && !dirty) {
|
if (typeof state.subscription_url === "string" && !dirty) {
|
||||||
subUrl.value = state.subscription_url;
|
subUrl.value = state.subscription_url;
|
||||||
@@ -372,6 +402,8 @@
|
|||||||
nameInput.value = active.name || state.active_profile || "";
|
nameInput.value = active.name || state.active_profile || "";
|
||||||
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
|
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
|
||||||
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_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);
|
fillHy2(state.hy2);
|
||||||
formHydrated = true;
|
formHydrated = true;
|
||||||
if (syncForm) dirty = false;
|
if (syncForm) dirty = false;
|
||||||
@@ -400,11 +432,17 @@
|
|||||||
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
|
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
|
||||||
}
|
}
|
||||||
if (!busy && Date.now() > metaHoldUntil) {
|
if (!busy && Date.now() > metaHoldUntil) {
|
||||||
setMeta(detail, state.core_ready === false ? "err" : "");
|
if (!connected && state.last_error) setMeta(state.last_error, "err");
|
||||||
|
else setMeta(detail, state.core_ready === false ? "err" : "");
|
||||||
metaHoldUntil = 0;
|
metaHoldUntil = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistPrefs() {
|
||||||
|
if (typeof savePrefs !== "function") return;
|
||||||
|
await savePrefs(!!(bootConnect && bootConnect.checked), !!(autoReconnect && autoReconnect.checked), !!sysproxy.checked);
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh(opts) {
|
async function refresh(opts) {
|
||||||
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
|
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
|
||||||
let state;
|
let state;
|
||||||
@@ -633,6 +671,24 @@
|
|||||||
setMeta("Версия " + (latest || "?") + " пропущена. Следующую предложим.", "ok");
|
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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await refresh({ syncForm: true });
|
await refresh({ syncForm: true });
|
||||||
@@ -641,5 +697,5 @@
|
|||||||
setMeta(String(e), "err");
|
setMeta(String(e), "err");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
|
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="brand-wrap">
|
<div class="brand-wrap">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<h1 class="brand">Navis</h1>
|
<h1 class="brand">Navis</h1>
|
||||||
<span class="badge-ver" id="badgeVer">3.8.0</span>
|
<span class="badge-ver" id="badgeVer">3.8.2</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +122,29 @@
|
|||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span>Подключать при запуске</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input id="bootConnect" type="checkbox" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span>Автопереподключение</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input id="autoReconnect" type="checkbox" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="panel" id="logBox">
|
||||||
|
<summary>Логи ядра</summary>
|
||||||
|
<pre class="log-tail" id="logTail">—</pre>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="action secondary" id="logRefreshBtn" type="button">Обновить лог</button>
|
||||||
|
<button class="action secondary" id="probeBtn" type="button">Проверить туннель</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="panel hy2" id="hy2Box">
|
<details class="panel hy2" id="hy2Box">
|
||||||
<summary>Hysteria 2 · BBR / obfuscation</summary>
|
<summary>Hysteria 2 · BBR / obfuscation</summary>
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ type Config struct {
|
|||||||
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
|
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
|
||||||
SubscriptionURL string `json:"subscription_url,omitempty"`
|
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"`
|
Profiles []Profile `json:"profiles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +325,7 @@ func WriteExample(path string) error {
|
|||||||
return Save(path, Example())
|
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 {
|
func Save(path string, cfg Config) error {
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -332,7 +341,15 @@ func Save(path string, cfg Config) error {
|
|||||||
return err
|
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.
|
// SetActiveProxy updates the active profile proxy URI.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
|
// 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"`
|
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.
|
// ListProfiles returns UI-friendly profile summaries.
|
||||||
func (c *Config) ListProfiles() []ProfileInfo {
|
func (c *Config) ListProfiles() []ProfileInfo {
|
||||||
out := make([]ProfileInfo, 0, len(c.Profiles))
|
out := make([]ProfileInfo, 0, len(c.Profiles))
|
||||||
@@ -22,7 +39,21 @@ func (c *Config) ListProfiles() []ProfileInfo {
|
|||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Protocol: string(p.Protocol),
|
Protocol: string(p.Protocol),
|
||||||
Proxy: p.Proxy,
|
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,
|
Active: p.Name == c.Active,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-9
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
|
"vpnclient/internal/corebin"
|
||||||
"vpnclient/internal/linknorm"
|
"vpnclient/internal/linknorm"
|
||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
@@ -23,7 +24,7 @@ import (
|
|||||||
"vpnclient/internal/sysproxy"
|
"vpnclient/internal/sysproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager orchestrates protocol engines and Windows system proxy.
|
// Manager orchestrates protocol engines and OS system proxy.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfgPath string
|
cfgPath string
|
||||||
@@ -35,6 +36,10 @@ type Manager struct {
|
|||||||
binDir string
|
binDir string
|
||||||
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
|
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
|
||||||
lastStop sync.WaitGroup
|
lastStop sync.WaitGroup
|
||||||
|
|
||||||
|
lastError string
|
||||||
|
unexpectedDrop bool
|
||||||
|
watchGeneration uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||||
@@ -112,15 +117,16 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.engine != nil && m.engine.Running() {
|
if m.engine != nil && m.engine.Running() {
|
||||||
_ = engine.Stop()
|
_ = engine.Stop()
|
||||||
|
m.mu.Unlock()
|
||||||
return fmt.Errorf("already connected; disconnect first")
|
return fmt.Errorf("already connected; disconnect first")
|
||||||
}
|
}
|
||||||
|
|
||||||
if wantSysProxy {
|
if wantSysProxy {
|
||||||
httpProxy, ok := engine.LocalHTTPProxy()
|
httpProxy, ok := engine.LocalHTTPProxy()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
m.mu.Unlock()
|
||||||
_ = engine.Stop()
|
_ = engine.Stop()
|
||||||
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
|
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
|
||||||
}
|
}
|
||||||
@@ -128,6 +134,7 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
|||||||
if errors.Is(err, sysproxy.ErrUnsupported) {
|
if errors.Is(err, sysproxy.ErrUnsupported) {
|
||||||
fmt.Fprintf(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 {
|
} else {
|
||||||
|
m.mu.Unlock()
|
||||||
_ = engine.Stop()
|
_ = engine.Stop()
|
||||||
return fmt.Errorf("enable system proxy: %w", err)
|
return fmt.Errorf("enable system proxy: %w", err)
|
||||||
}
|
}
|
||||||
@@ -138,15 +145,81 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
|||||||
|
|
||||||
m.engine = engine
|
m.engine = engine
|
||||||
m.profile = &profileCopy
|
m.profile = &profileCopy
|
||||||
|
m.lastError = ""
|
||||||
|
m.unexpectedDrop = false
|
||||||
|
m.watchGeneration++
|
||||||
|
gen := m.watchGeneration
|
||||||
|
m.mu.Unlock()
|
||||||
|
go m.watchEngine(gen, engine)
|
||||||
return nil
|
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 {
|
func (m *Manager) Disconnect() error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
sys := m.sys
|
sys := m.sys
|
||||||
engine := m.engine
|
engine := m.engine
|
||||||
m.engine = nil
|
m.engine = nil
|
||||||
m.profile = nil
|
m.profile = nil
|
||||||
|
m.watchGeneration++ // stop watcher
|
||||||
|
m.unexpectedDrop = false
|
||||||
if engine != nil {
|
if engine != nil {
|
||||||
m.lastStop.Add(1)
|
m.lastStop.Add(1)
|
||||||
}
|
}
|
||||||
@@ -277,12 +350,134 @@ func (m *Manager) Config() *config.Config {
|
|||||||
return m.cfg.Clone()
|
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) {
|
func (m *Manager) SetSystemProxy(enabled bool) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
m.cfg.SystemProxy = enabled
|
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 {
|
func (m *Manager) UpdateProxyURI(proxy string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -505,20 +700,69 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
|||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
if m.engine != nil && m.engine.Running() {
|
||||||
|
return 0, fmt.Errorf("сначала отключитесь")
|
||||||
|
}
|
||||||
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
|
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
|
||||||
|
|
||||||
|
newNames := make([]string, 0, len(items))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
for _, it := range items {
|
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 {
|
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])
|
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
|
||||||
break
|
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 {
|
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return len(items), nil
|
return len(newNames), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SaveConfig() error {
|
func (m *Manager) SaveConfig() error {
|
||||||
@@ -572,24 +816,37 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
|
|||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
var (
|
||||||
|
path string
|
||||||
|
err error
|
||||||
|
)
|
||||||
switch proto {
|
switch proto {
|
||||||
case config.ProtocolHysteria2:
|
case config.ProtocolHysteria2:
|
||||||
return hysteria2.EnsureBinary(binDir)
|
path, err = hysteria2.EnsureBinary(binDir)
|
||||||
case config.ProtocolAWG:
|
case config.ProtocolAWG:
|
||||||
return awg.EnsureBinary(binDir)
|
path, err = awg.EnsureBinary(binDir)
|
||||||
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||||
return xray.EnsureBinary(binDir)
|
path, err = xray.EnsureBinary(binDir)
|
||||||
default:
|
default:
|
||||||
return naive.EnsureBinary(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).
|
// 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) {
|
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
binDir := m.binDir
|
binDir := m.binDir
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
corebin.Invalidate()
|
||||||
|
|
||||||
type item struct {
|
type item struct {
|
||||||
key string
|
key string
|
||||||
path string
|
path string
|
||||||
@@ -623,6 +880,7 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out[it.key] = it.path
|
out[it.key] = it.path
|
||||||
|
corebin.Set(corebin.CacheKey(binDir, it.key), it.path)
|
||||||
}
|
}
|
||||||
return out, first
|
return out, first
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package dockicon
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/ebitengine/purego"
|
||||||
|
"github.com/ebitengine/purego/objc"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed icon_idle.png
|
||||||
|
var iconIdlePNG []byte
|
||||||
|
|
||||||
|
//go:embed icon_connected.png
|
||||||
|
var iconConnectedPNG []byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
ready bool
|
||||||
|
mu sync.Mutex
|
||||||
|
last *bool
|
||||||
|
selSet objc.SEL
|
||||||
|
selPerf objc.SEL
|
||||||
|
selShared objc.SEL
|
||||||
|
selData objc.SEL
|
||||||
|
selAlloc objc.SEL
|
||||||
|
selInit objc.SEL
|
||||||
|
selRetain objc.SEL
|
||||||
|
clsApp objc.Class
|
||||||
|
clsData objc.Class
|
||||||
|
clsImage objc.Class
|
||||||
|
)
|
||||||
|
|
||||||
|
func initAppKit() {
|
||||||
|
once.Do(func() {
|
||||||
|
if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
|
||||||
|
log.Printf("dockicon: Foundation: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
|
||||||
|
log.Printf("dockicon: AppKit: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clsApp = objc.GetClass("NSApplication")
|
||||||
|
clsData = objc.GetClass("NSData")
|
||||||
|
clsImage = objc.GetClass("NSImage")
|
||||||
|
if clsApp == 0 || clsData == 0 || clsImage == 0 {
|
||||||
|
log.Printf("dockicon: missing AppKit classes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selShared = objc.RegisterName("sharedApplication")
|
||||||
|
selSet = objc.RegisterName("setApplicationIconImage:")
|
||||||
|
selPerf = objc.RegisterName("performSelectorOnMainThread:withObject:waitUntilDone:")
|
||||||
|
selData = objc.RegisterName("dataWithBytes:length:")
|
||||||
|
selAlloc = objc.RegisterName("alloc")
|
||||||
|
selInit = objc.RegisterName("initWithData:")
|
||||||
|
selRetain = objc.RegisterName("retain")
|
||||||
|
ready = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConnected swaps the Dock icon: sea-wave N when connected, original when idle.
|
||||||
|
func SetConnected(connected bool) {
|
||||||
|
initAppKit()
|
||||||
|
if !ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if last != nil && *last == connected {
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := connected
|
||||||
|
last = &v
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
png := iconIdlePNG
|
||||||
|
if connected {
|
||||||
|
png = iconConnectedPNG
|
||||||
|
}
|
||||||
|
if len(png) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := objc.ID(clsData).Send(selData, unsafe.Pointer(&png[0]), len(png))
|
||||||
|
if data == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
img := objc.ID(clsImage).Send(selAlloc)
|
||||||
|
img = img.Send(selInit, data)
|
||||||
|
if img == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
img.Send(selRetain) // keep alive across async main-thread call
|
||||||
|
|
||||||
|
app := objc.ID(clsApp).Send(selShared)
|
||||||
|
if app == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// AppKit must run on the main thread (glaze owns it).
|
||||||
|
app.Send(selPerf, selSet, img, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package dockicon
|
||||||
|
|
||||||
|
// SetConnected is a no-op outside macOS (Dock icons are a Darwin feature).
|
||||||
|
func SetConnected(connected bool) {}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-20
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,6 +24,7 @@ type Result struct {
|
|||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
Ms int64 `json:"ms"`
|
Ms int64 `json:"ms"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
|
Soft bool `json:"soft,omitempty"` // UDP soft-up (timeout, no ICMP refuse)
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,14 @@ func PingAll(ctx context.Context, targets []Target) []Result {
|
|||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
return out
|
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 {
|
if len(targets) < workers {
|
||||||
workers = len(targets)
|
workers = len(targets)
|
||||||
}
|
}
|
||||||
@@ -82,12 +91,13 @@ func PingAll(ctx context.Context, targets []Target) []Result {
|
|||||||
return sorted
|
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) {
|
func BestOK(results []Result) (Result, bool) {
|
||||||
var best Result
|
var best Result
|
||||||
found := false
|
found := false
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if !r.OK {
|
if !r.OK || r.Soft {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !found || r.Ms < best.Ms {
|
if !found || r.Ms < best.Ms {
|
||||||
@@ -99,7 +109,8 @@ func BestOK(results []Result) (Result, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PingProfile pings using protocol hints when available.
|
// 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 {
|
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
|
||||||
res := Result{Name: name}
|
res := Result{Name: name}
|
||||||
if strings.TrimSpace(proxyURI) == "" {
|
if strings.TrimSpace(proxyURI) == "" {
|
||||||
@@ -107,9 +118,22 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
var hy2, isAWG, isXray bool
|
||||||
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
|
switch proto {
|
||||||
isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
|
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
|
var host, port string
|
||||||
if isAWG {
|
if isAWG {
|
||||||
h, p, err := awg.HostPort(proxyURI)
|
h, p, err := awg.HostPort(proxyURI)
|
||||||
@@ -164,11 +188,12 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
rtt time.Duration
|
rtt time.Duration
|
||||||
err error
|
soft bool
|
||||||
|
err error
|
||||||
)
|
)
|
||||||
if hy2 || isAWG {
|
if hy2 || isAWG {
|
||||||
rtt, err = probeUDP(ctx, host, port)
|
rtt, soft, err = probeUDP(ctx, host, port)
|
||||||
} else {
|
} else {
|
||||||
rtt, err = probeTCP(ctx, host, port)
|
rtt, err = probeTCP(ctx, host, port)
|
||||||
}
|
}
|
||||||
@@ -182,6 +207,7 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
}
|
}
|
||||||
res.Ms = ms
|
res.Ms = ms
|
||||||
res.OK = true
|
res.OK = true
|
||||||
|
res.Soft = soft
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,18 +227,17 @@ func probeTCP(ctx context.Context, host, port string) (time.Duration, error) {
|
|||||||
// UDP VPN ports usually ignore a probe datagram (no reply). Treating read-timeout
|
// UDP VPN ports usually ignore a probe datagram (no reply). Treating read-timeout
|
||||||
// as failure marked live nodes as down. Semantics:
|
// as failure marked live nodes as down. Semantics:
|
||||||
// - ICMP / connection refused → down
|
// - ICMP / connection refused → down
|
||||||
// - any reply → up (RTT to reply)
|
// - any reply → hard up (RTT to reply)
|
||||||
// - short read-timeout after a successful write → soft-up (RTT ≈ DNS+dial+write)
|
// - short read-timeout after a successful write → soft-up (display only; not for Best)
|
||||||
func probeUDP(ctx context.Context, host, port string) (time.Duration, error) {
|
func probeUDP(ctx context.Context, host, port string) (time.Duration, bool, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
d := net.Dialer{Timeout: 3 * time.Second}
|
d := net.Dialer{Timeout: 3 * time.Second}
|
||||||
conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
|
conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// Brief window only to catch ICMP port-unreachable; do not wait for an app reply.
|
|
||||||
icmpWait := 350 * time.Millisecond
|
icmpWait := 350 * time.Millisecond
|
||||||
deadline := time.Now().Add(icmpWait)
|
deadline := time.Now().Add(icmpWait)
|
||||||
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
|
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
|
||||||
@@ -221,20 +246,19 @@ func probeUDP(ctx context.Context, host, port string) (time.Duration, error) {
|
|||||||
_ = conn.SetDeadline(deadline)
|
_ = conn.SetDeadline(deadline)
|
||||||
|
|
||||||
if _, err := conn.Write([]byte{0}); err != nil {
|
if _, err := conn.Write([]byte{0}); err != nil {
|
||||||
return 0, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
afterWrite := time.Now()
|
afterWrite := time.Now()
|
||||||
|
|
||||||
buf := make([]byte, 64)
|
buf := make([]byte, 64)
|
||||||
_, err = conn.Read(buf)
|
_, err = conn.Read(buf)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return time.Since(start), nil
|
return time.Since(start), false, nil
|
||||||
}
|
}
|
||||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
// No ICMP refuse → endpoint is plausible; latency without the wait pad.
|
return afterWrite.Sub(start), true, nil
|
||||||
return afterWrite.Sub(start), nil
|
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyDialError(err error, udp bool) string {
|
func friendlyDialError(err error, udp bool) string {
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
rtt, err := probeUDP(ctx, "127.0.0.1", port)
|
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected soft-ok, got err=%v", err)
|
t.Fatalf("expected soft-ok, got err=%v", err)
|
||||||
}
|
}
|
||||||
|
if !soft {
|
||||||
|
t.Fatal("expected soft=true")
|
||||||
|
}
|
||||||
if rtt <= 0 {
|
if rtt <= 0 {
|
||||||
t.Fatalf("rtt=%v", rtt)
|
t.Fatalf("rtt=%v", rtt)
|
||||||
}
|
}
|
||||||
@@ -59,12 +62,15 @@ func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res := PingProfile(ctx, "silent", config.ProtocolHysteria2, "hysteria2://x@127.0.0.1:"+port+"/")
|
res := PingProfile(ctx, "silent", config.ProtocolHysteria2, "hysteria2://x@127.0.0.1:"+port+"/")
|
||||||
if !res.OK {
|
if !res.OK || !res.Soft {
|
||||||
t.Fatalf("PingProfile soft-ok failed: %+v", res)
|
t.Fatalf("PingProfile soft-ok failed: %+v", res)
|
||||||
}
|
}
|
||||||
if res.Ms < 1 {
|
if res.Ms < 1 {
|
||||||
t.Fatalf("ms=%d", res.Ms)
|
t.Fatalf("ms=%d", res.Ms)
|
||||||
}
|
}
|
||||||
|
if _, ok := BestOK([]Result{res}); ok {
|
||||||
|
t.Fatal("BestOK must ignore soft-up")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProbeUDPReplyOK(t *testing.T) {
|
func TestProbeUDPReplyOK(t *testing.T) {
|
||||||
@@ -87,10 +93,13 @@ func TestProbeUDPReplyOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
rtt, err := probeUDP(ctx, "127.0.0.1", port)
|
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if soft {
|
||||||
|
t.Fatal("reply should be hard-ok")
|
||||||
|
}
|
||||||
if rtt <= 0 {
|
if rtt <= 0 {
|
||||||
t.Fatalf("rtt=%v", rtt)
|
t.Fatalf("rtt=%v", rtt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,7 +454,11 @@ func (c *Conf) validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HostPort returns UDP endpoint host and port for ping.
|
// 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) {
|
func HostPort(raw string) (host, port string, err error) {
|
||||||
|
if h, p, ok := lightEndpoint(raw); ok {
|
||||||
|
return h, p, nil
|
||||||
|
}
|
||||||
c, err := Parse(raw)
|
c, err := Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
@@ -466,6 +470,67 @@ func HostPort(raw string) (host, port string, err error) {
|
|||||||
return h, p, nil
|
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.
|
// lookupIP is overridable in tests.
|
||||||
var lookupIP = defaultLookupIP
|
var lookupIP = defaultLookupIP
|
||||||
|
|
||||||
|
|||||||
@@ -236,3 +236,29 @@ func indexOf(s, sub string) int {
|
|||||||
}
|
}
|
||||||
return -1
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,17 +268,24 @@ func handleHTTPProxy(client net.Conn, dial DialFunc) error {
|
|||||||
|
|
||||||
func relay(a, b net.Conn) error {
|
func relay(a, b net.Conn) error {
|
||||||
errc := make(chan error, 2)
|
errc := make(chan error, 2)
|
||||||
go func() {
|
copyOne := func(dst, src net.Conn) {
|
||||||
_, err := io.Copy(a, b)
|
bufp := copyBufPool.Get().(*[]byte)
|
||||||
|
defer copyBufPool.Put(bufp)
|
||||||
|
_, err := io.CopyBuffer(dst, src, *bufp)
|
||||||
errc <- err
|
errc <- err
|
||||||
}()
|
}
|
||||||
go func() {
|
go copyOne(a, b)
|
||||||
_, err := io.Copy(b, a)
|
go copyOne(b, a)
|
||||||
errc <- err
|
|
||||||
}()
|
|
||||||
err := <-errc
|
err := <-errc
|
||||||
_ = a.Close()
|
_ = a.Close()
|
||||||
_ = b.Close()
|
_ = b.Close()
|
||||||
<-errc
|
<-errc
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var copyBufPool = sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
b := make([]byte, 32*1024)
|
||||||
|
return &b
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
+40
-40
@@ -13,12 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type darwinController struct {
|
type darwinController struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
enabled bool
|
enabled bool
|
||||||
host string
|
host string
|
||||||
httpPort string
|
httpPort string
|
||||||
socksPort string
|
socksPort string
|
||||||
services []string
|
services []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPlatform() Controller { return &darwinController{} }
|
func newPlatform() Controller { return &darwinController{} }
|
||||||
@@ -47,32 +47,25 @@ func (c *darwinController) Enable(httpHostPort string) error {
|
|||||||
return fmt.Errorf("sysproxy: no network services")
|
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 {
|
for _, svc := range services {
|
||||||
if err := runNetworkSetup("-setwebproxy", svc, host, port); err != nil {
|
q := shellQuote(svc)
|
||||||
return err
|
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))
|
||||||
if err := runNetworkSetup("-setsecurewebproxy", svc, host, port); err != nil {
|
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxy %s %s %s\n", q, shellQuote(host), shellQuote(socks))
|
||||||
return err
|
}
|
||||||
}
|
if err := runBash(b.String()); err != nil {
|
||||||
if err := runNetworkSetup("-setsocksfirewallproxy", svc, host, socks); err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := runNetworkSetup("-setwebproxystate", svc, "on"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := runNetworkSetup("-setsecurewebproxystate", svc, "on"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := runNetworkSetup("-setsocksfirewallproxystate", svc, "on"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.enabled = true
|
c.enabled = true
|
||||||
c.host = host
|
c.host = host
|
||||||
c.httpPort = port
|
c.httpPort = port
|
||||||
c.socksPort = socks
|
c.socksPort = socks
|
||||||
c.services = services
|
c.services = append([]string(nil), services...)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +81,8 @@ func (c *darwinController) Disable() error {
|
|||||||
func (c *darwinController) ForceDisable() error {
|
func (c *darwinController) ForceDisable() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return c.disableServices(nil)
|
svcs := c.services
|
||||||
|
return c.disableServices(svcs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *darwinController) disableServices(services []string) error {
|
func (c *darwinController) disableServices(services []string) error {
|
||||||
@@ -99,21 +93,23 @@ func (c *darwinController) disableServices(services []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var first error
|
if len(services) == 0 {
|
||||||
for _, svc := range services {
|
c.enabled = false
|
||||||
for _, args := range [][]string{
|
c.services = nil
|
||||||
{"-setwebproxystate", svc, "off"},
|
return nil
|
||||||
{"-setsecurewebproxystate", svc, "off"},
|
|
||||||
{"-setsocksfirewallproxystate", svc, "off"},
|
|
||||||
} {
|
|
||||||
if err := runNetworkSetup(args...); err != nil && first == nil {
|
|
||||||
first = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.enabled = false
|
||||||
c.services = nil
|
c.services = nil
|
||||||
return first
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *darwinController) Enabled() bool {
|
func (c *darwinController) Enabled() bool {
|
||||||
@@ -142,11 +138,15 @@ func listNetworkServices() ([]string, error) {
|
|||||||
return services, sc.Err()
|
return services, sc.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func runNetworkSetup(args ...string) error {
|
func runBash(script string) error {
|
||||||
cmd := exec.Command("networksetup", args...)
|
cmd := exec.Command("/bin/bash", "-c", script)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||||
// Keep major.minor.patch only — no build suffix here.
|
// Keep major.minor.patch only — no build suffix here.
|
||||||
const CurrentVersion = "3.8.0"
|
const CurrentVersion = "3.8.2"
|
||||||
|
|
||||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||||
|
|||||||
@@ -61,16 +61,15 @@ func applyAppZip(p preparedUpdate) (string, error) {
|
|||||||
scriptPath := filepath.Join(parent, "navis-update-app.sh")
|
scriptPath := filepath.Join(parent, "navis-update-app.sh")
|
||||||
pid := os.Getpid()
|
pid := os.Getpid()
|
||||||
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
|
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
|
||||||
if identity == "" {
|
// Never re-sign with ad-hoc "-" after replacing a shipped .app — that strips
|
||||||
identity = "-"
|
// Developer ID / notarization. Only re-sign when a real identity is set.
|
||||||
}
|
doSign := identity != "" && identity != "-"
|
||||||
|
|
||||||
script := "#!/bin/bash\n" +
|
script := "#!/bin/bash\n" +
|
||||||
"set -e\n" +
|
"set -e\n" +
|
||||||
"APP=" + shellQuote(p.AppRoot) + "\n" +
|
"APP=" + shellQuote(p.AppRoot) + "\n" +
|
||||||
"ZIP=" + shellQuote(p.TmpPath) + "\n" +
|
"ZIP=" + shellQuote(p.TmpPath) + "\n" +
|
||||||
"PID=" + strconv.Itoa(pid) + "\n" +
|
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||||
"ID=" + shellQuote(identity) + "\n" +
|
|
||||||
"PARENT=$(dirname \"$APP\")\n" +
|
"PARENT=$(dirname \"$APP\")\n" +
|
||||||
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
||||||
"sleep 0.5\n" +
|
"sleep 0.5\n" +
|
||||||
@@ -80,11 +79,15 @@ func applyAppZip(p preparedUpdate) (string, error) {
|
|||||||
"if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" +
|
"if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" +
|
||||||
"rm -rf \"$APP.bak\"\n" +
|
"rm -rf \"$APP.bak\"\n" +
|
||||||
"mv \"$APP\" \"$APP.bak\"\n" +
|
"mv \"$APP\" \"$APP.bak\"\n" +
|
||||||
"mv \"$NEW\" \"$APP\"\n" +
|
"mv \"$NEW\" \"$APP\"\n"
|
||||||
"if command -v codesign >/dev/null 2>&1; then\n" +
|
if doSign {
|
||||||
" codesign -s \"$ID\" --force --deep --options runtime \"$APP\" 2>/dev/null || codesign -s \"$ID\" --force --deep \"$APP\" || true\n" +
|
script += "ID=" + shellQuote(identity) + "\n" +
|
||||||
" xattr -cr \"$APP\" 2>/dev/null || true\n" +
|
"if command -v codesign >/dev/null 2>&1; then\n" +
|
||||||
"fi\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" +
|
"rm -rf \"$APP.bak\" \"$TMP\" \"$ZIP\" " + shellQuote(scriptPath) + "\n" +
|
||||||
"open \"$APP\"\n"
|
"open \"$APP\"\n"
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,22 @@ printf '%s\n' "$FULL" > "$OUT/VERSION"
|
|||||||
printf '%s\n' "${VERSION}+${BUILD}" > "$OUT/Navis.version"
|
printf '%s\n' "${VERSION}+${BUILD}" > "$OUT/Navis.version"
|
||||||
|
|
||||||
# Ad-hoc sign CLI/bin; packmac also signs the .app (honors NAVIS_CODESIGN_IDENTITY).
|
# 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
|
if command -v codesign >/dev/null 2>&1; then
|
||||||
ID="${NAVIS_CODESIGN_IDENTITY:--}"
|
if [[ -n "${NAVIS_CODESIGN_IDENTITY:-}" && "${NAVIS_CODESIGN_IDENTITY}" != "-" ]]; then
|
||||||
codesign -s "$ID" --force "$OUT/Navis" || codesign -s - --force "$OUT/Navis"
|
ID="$NAVIS_CODESIGN_IDENTITY"
|
||||||
codesign -s "$ID" --force "$OUT/Navis-cli" || codesign -s - --force "$OUT/Navis-cli"
|
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
|
fi
|
||||||
|
|
||||||
go run ./tools/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
|
||||||
@@ -65,8 +77,8 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest()
|
|||||||
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
|
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
|
||||||
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
|
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
|
||||||
notes = (
|
notes = (
|
||||||
f"Navis {ver}+{build}: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); "
|
f"Navis {ver}+{build}: меньше I/O на poll (без Stat ядер / кэш host); "
|
||||||
f"Mac .app zip update; EnsureCore lock; getState без секретов."
|
f"macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении."
|
||||||
)
|
)
|
||||||
paths = [
|
paths = [
|
||||||
Path('dist/update.json'),
|
Path('dist/update.json'),
|
||||||
@@ -94,6 +106,23 @@ for p in paths:
|
|||||||
'dmg_url',
|
'dmg_url',
|
||||||
'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg',
|
'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')
|
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + '\n')
|
||||||
print('updated', p, '->', ver, 'bin', h[:12], 'zip', zh[:12] if zh else '-')
|
print('updated', p, '->', ver, 'bin', h[:12], 'zip', zh[:12] if zh else '-')
|
||||||
PY
|
PY
|
||||||
|
|||||||
+24
-1
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Sync versioninfo.json + build-macos.bat from internal/update/update.go constants."""
|
"""Sync versioninfo.json, build-macos.bat, Android gradle, UI badge from update.go."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -10,6 +10,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
UPDATE_GO = ROOT / "internal" / "update" / "update.go"
|
UPDATE_GO = ROOT / "internal" / "update" / "update.go"
|
||||||
VERSIONINFO = ROOT / "versioninfo.json"
|
VERSIONINFO = ROOT / "versioninfo.json"
|
||||||
BUILD_BAT = ROOT / "build-macos.bat"
|
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]:
|
def read_consts() -> tuple[str, int]:
|
||||||
@@ -28,6 +30,8 @@ def main() -> None:
|
|||||||
parts.append(0)
|
parts.append(0)
|
||||||
major, minor, patch = parts[0], parts[1], parts[2]
|
major, minor, patch = parts[0], parts[1], parts[2]
|
||||||
full = f"{ver}.{build}"
|
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 = json.loads(VERSIONINFO.read_text(encoding="utf-8"))
|
||||||
data["FixedFileInfo"]["FileVersion"] = {
|
data["FixedFileInfo"]["FileVersion"] = {
|
||||||
@@ -54,6 +58,25 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
print("build-macos.bat already in sync")
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+11
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.0",
|
"version": "3.8.2",
|
||||||
"notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.",
|
"notes": "Navis 3.8.2+3: меньше I/O на poll (без Stat ядер / кэш host); macOS sysproxy одним bash-batch; Connect без JSON Clone; Dock-иконка при подключении.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f",
|
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
"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": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4",
|
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||||
"dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee"
|
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
@@ -30,7 +30,9 @@
|
|||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
"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": {
|
"darwin-universal": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||||
@@ -38,7 +40,9 @@
|
|||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "universal",
|
"arch": "universal",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -176,22 +176,41 @@ func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error
|
|||||||
|
|
||||||
func signAppBundle(appRoot string) error {
|
func signAppBundle(appRoot string) error {
|
||||||
if _, err := exec.LookPath("codesign"); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
// NAVIS_CODESIGN_IDENTITY=Developer ID Application: … for release; default ad-hoc "-".
|
|
||||||
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
|
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 == "" {
|
if identity == "" {
|
||||||
identity = "-"
|
identity = "-"
|
||||||
}
|
}
|
||||||
cmd := exec.Command("codesign", "-s", identity, "--force", "--deep", "--options", "runtime", appRoot)
|
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()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cmd = exec.Command("codesign", "-s", identity, "--force", "--deep", appRoot)
|
if identity == "-" {
|
||||||
out, err = cmd.CombinedOutput()
|
cmd = exec.Command("codesign", "-s", "-", "--force", "--deep", appRoot)
|
||||||
|
out, err = cmd.CombinedOutput()
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("codesign %s: %w: %s", appRoot, err, strings.TrimSpace(string(out)))
|
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()
|
_ = exec.Command("xattr", "-cr", appRoot).Run()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -3,13 +3,13 @@
|
|||||||
"FileVersion": {
|
"FileVersion": {
|
||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 0,
|
"Patch": 2,
|
||||||
"Build": 3
|
"Build": 3
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 0,
|
"Patch": 2,
|
||||||
"Build": 3
|
"Build": 3
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "3.8.0.3",
|
"FileVersion": "3.8.2.3",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "3.8.0.3",
|
"ProductVersion": "3.8.2.3",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user