Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7480dceb | ||
|
|
54b5b87990 |
@@ -25,4 +25,10 @@ android/app/src/main/assets/cores/**/hev-socks5-tunnel
|
||||
*.apk
|
||||
!dist/navis-release/android/*.apk
|
||||
|
||||
# Local backups (pre-release archives)
|
||||
backups/
|
||||
|
||||
# Local Go toolchain
|
||||
.tools/
|
||||
|
||||
|
||||
|
||||
@@ -243,6 +243,13 @@ https://evilfox.win/
|
||||
|
||||
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
||||
|
||||
В 3.8.0:
|
||||
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
|
||||
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
|
||||
- SHA-256 проверка при установке cores (naive / hy2 / xray);
|
||||
- snapshot конфига в UI (`Config().Clone`);
|
||||
- 3.8.0+2: обновление Navis.app через zip+SHA; Start вне mutex; getState без секретов; подпись/notarize scripts; UI CSS/JS split.
|
||||
|
||||
В 2.7.3:
|
||||
- восстановление системного прокси после аварийного завершения;
|
||||
- auth-токен для локального macOS `/api`;
|
||||
|
||||
+3
-3
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
|
||||
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 2.7.3 -build 2.7.3.3 -arch arm64
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.0 -build 3.8.0.2 -arch arm64
|
||||
if errorlevel 1 exit /b 1
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 2.7.3 -build 2.7.3.3 -arch amd64
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.0 -build 3.8.0.2 -arch amd64
|
||||
if errorlevel 1 exit /b 1
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 2.7.3 -build 2.7.3.3 -arch universal
|
||||
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.0 -build 3.8.0.2 -arch universal
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo Built Mac GUI + CLI:
|
||||
|
||||
@@ -89,7 +89,7 @@ func main() {
|
||||
w.Destroy()
|
||||
}()
|
||||
|
||||
w.SetTitle("Navis 2")
|
||||
w.SetTitle("Navis")
|
||||
w.SetSize(500, 900, glaze.HintNone)
|
||||
w.Navigate(uiURL)
|
||||
log.Printf("Navis UI: %s", uiURL)
|
||||
|
||||
+29
-402
@@ -4,60 +4,23 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/jchv/go-webview2"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"vpnclient/internal/apphost"
|
||||
"vpnclient/internal/appui"
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
"vpnclient/internal/netcheck"
|
||||
"vpnclient/internal/protocols/awg"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/protocols/xray"
|
||||
"vpnclient/internal/update"
|
||||
)
|
||||
|
||||
type app struct {
|
||||
mu sync.Mutex
|
||||
mgr *core.Manager
|
||||
cfgPath string
|
||||
logBuf *bytes.Buffer
|
||||
updateStatus update.Status
|
||||
pings []netcheck.Result
|
||||
webview webview2.WebView
|
||||
}
|
||||
|
||||
type uiState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if update.MaybeFinishUpdate(os.Args[1:]) {
|
||||
return
|
||||
@@ -87,27 +50,25 @@ func main() {
|
||||
fatalDialog("%v", err)
|
||||
}
|
||||
|
||||
a := &app{
|
||||
mgr: mgr,
|
||||
cfgPath: cfgPath,
|
||||
logBuf: logBuf,
|
||||
updateStatus: update.Status{
|
||||
Current: update.DisplayVersion(),
|
||||
ManifestURL: update.DefaultManifestURL,
|
||||
},
|
||||
a := apphost.New(mgr, cfgPath, logBuf)
|
||||
a.OpenURL = shellOpen
|
||||
a.OnAfterUpdate = func() {
|
||||
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
|
||||
_ = os.MkdirAll(dataPath, 0o755)
|
||||
|
||||
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
|
||||
|
||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||
Debug: false,
|
||||
AutoFocus: true,
|
||||
DataPath: dataPath,
|
||||
WindowOptions: webview2.WindowOptions{
|
||||
Title: "Navis 2",
|
||||
Title: "Navis",
|
||||
Width: 500,
|
||||
Height: 900,
|
||||
Center: true,
|
||||
@@ -117,33 +78,33 @@ func main() {
|
||||
if w == nil {
|
||||
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
|
||||
}
|
||||
a.webview = w
|
||||
defer func() {
|
||||
_ = a.mgr.Disconnect()
|
||||
_ = mgr.Disconnect()
|
||||
w.Destroy()
|
||||
}()
|
||||
|
||||
applyWindowIcon(w.Window())
|
||||
w.SetSize(500, 900, webview2.HintNone)
|
||||
|
||||
mustBind(w, "getState", a.getState)
|
||||
mustBind(w, "connect", a.connect)
|
||||
mustBind(w, "disconnect", a.disconnect)
|
||||
mustBind(w, "connectProfile", a.connectProfile)
|
||||
mustBind(w, "saveProfile", a.saveProfile)
|
||||
mustBind(w, "createProfile", a.createProfile)
|
||||
mustBind(w, "selectProfile", a.selectProfile)
|
||||
mustBind(w, "deleteProfile", a.deleteProfile)
|
||||
mustBind(w, "installCore", a.installCore)
|
||||
mustBind(w, "openURL", openURL)
|
||||
mustBind(w, "pingServers", a.pingServers)
|
||||
mustBind(w, "pingBest", a.pingBest)
|
||||
mustBind(w, "checkUpdate", a.checkUpdate)
|
||||
mustBind(w, "applyUpdate", a.applyUpdate)
|
||||
mustBind(w, "saveHy2", a.saveHy2)
|
||||
mustBind(w, "importSubscription", a.importSubscription)
|
||||
mustBind(w, "getState", a.GetState)
|
||||
mustBind(w, "getEditState", a.GetEditState)
|
||||
mustBind(w, "connect", a.Connect)
|
||||
mustBind(w, "disconnect", a.Disconnect)
|
||||
mustBind(w, "connectProfile", a.ConnectProfile)
|
||||
mustBind(w, "saveProfile", a.SaveProfile)
|
||||
mustBind(w, "createProfile", a.CreateProfile)
|
||||
mustBind(w, "selectProfile", a.SelectProfile)
|
||||
mustBind(w, "deleteProfile", a.DeleteProfile)
|
||||
mustBind(w, "installCore", a.InstallCore)
|
||||
mustBind(w, "openURL", a.OpenShopURL)
|
||||
mustBind(w, "pingServers", a.PingServers)
|
||||
mustBind(w, "pingBest", a.PingBest)
|
||||
mustBind(w, "checkUpdate", a.CheckUpdate)
|
||||
mustBind(w, "applyUpdate", a.ApplyUpdate)
|
||||
mustBind(w, "saveHy2", a.SaveHy2)
|
||||
mustBind(w, "importSubscription", a.ImportSubscription)
|
||||
|
||||
go a.autoCheckUpdate()
|
||||
go a.AutoCheckUpdate()
|
||||
|
||||
w.SetHtml(appui.IndexHTML)
|
||||
w.Run()
|
||||
@@ -155,340 +116,6 @@ func mustBind(w webview2.WebView, name string, fn interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) getState() (uiState, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
st := a.mgr.Status()
|
||||
cfg := a.mgr.Config()
|
||||
proxy := ""
|
||||
active := cfg.Active
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
proxy = p.Proxy
|
||||
active = p.Name
|
||||
}
|
||||
corePath := ""
|
||||
coreReady := false
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
switch p.Protocol {
|
||||
case config.ProtocolHysteria2:
|
||||
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
case config.ProtocolAWG:
|
||||
if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||
if path, err := xray.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
default:
|
||||
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
}
|
||||
} else if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
out := uiState{
|
||||
Connected: st.Connected,
|
||||
Profile: st.Profile,
|
||||
ActiveProfile: active,
|
||||
Protocol: string(st.Protocol),
|
||||
HTTPProxy: st.HTTPProxy,
|
||||
SOCKSProxy: st.SOCKSProxy,
|
||||
SystemProxy: cfg.SystemProxy,
|
||||
Proxy: proxy,
|
||||
CoreReady: coreReady,
|
||||
CorePath: corePath,
|
||||
ConfigPath: a.cfgPath,
|
||||
Profiles: config.RedactProfileList(cfg.ListProfiles()),
|
||||
Version: update.DisplayVersion(),
|
||||
Update: a.updateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
Hy2: a.mgr.ActiveHy2Options(),
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
out.Protocol = string(p.Protocol)
|
||||
}
|
||||
}
|
||||
if st.Connected {
|
||||
out.SystemProxy = st.SystemProxy
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *app) saveProfile(name, proxy string, systemProxy bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("укажите название профиля")
|
||||
}
|
||||
a.mgr.SetSystemProxy(systemProxy)
|
||||
return a.mgr.SaveActiveProfile(name, proxy)
|
||||
}
|
||||
|
||||
func (a *app) createProfile(name, proxy string, systemProxy bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("укажите название профиля")
|
||||
}
|
||||
a.mgr.SetSystemProxy(systemProxy)
|
||||
return a.mgr.SaveProfile(name, proxy)
|
||||
}
|
||||
|
||||
func (a *app) selectProfile(name string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mgr.SetActiveProfile(name)
|
||||
}
|
||||
|
||||
func (a *app) deleteProfile(name string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mgr.DeleteProfile(name)
|
||||
}
|
||||
|
||||
func (a *app) connect() error {
|
||||
return a.connectProfile("")
|
||||
}
|
||||
|
||||
// connectProfile switches to the named profile (if set) and connects.
|
||||
// If already connected to another profile, disconnects first.
|
||||
func (a *app) connectProfile(name string) error {
|
||||
a.mu.Lock()
|
||||
name = strings.TrimSpace(name)
|
||||
st := a.mgr.Status()
|
||||
if st.Connected {
|
||||
if name == "" || st.Profile == name {
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if err := a.mgr.Disconnect(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.mu.Lock()
|
||||
}
|
||||
if name != "" {
|
||||
if err := a.mgr.SetActiveProfile(name); err != nil {
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("сначала вставьте ссылку сервера")
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
|
||||
if _, err := a.mgr.EnsureCore(""); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
return a.mgr.Connect(ctx, "")
|
||||
}
|
||||
|
||||
func (a *app) disconnect() error {
|
||||
// Do not hold a.mu across engine teardown — getState polling would freeze the UI.
|
||||
return a.mgr.Disconnect()
|
||||
}
|
||||
|
||||
func (a *app) installCore() (string, error) {
|
||||
paths, err := a.mgr.EnsureAllCores()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := make([]string, 0, len(paths))
|
||||
for k, v := range paths {
|
||||
parts = append(parts, k+": "+v)
|
||||
}
|
||||
return strings.Join(parts, " | "), nil
|
||||
}
|
||||
|
||||
func (a *app) saveHy2(opts core.Hy2Options) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mgr.SaveHy2Options(opts)
|
||||
}
|
||||
|
||||
func (a *app) importSubscription(rawURL string) (int, error) {
|
||||
return a.mgr.ImportSubscription(rawURL)
|
||||
}
|
||||
|
||||
type pingBestResult struct {
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
BestName string `json:"best_name,omitempty"`
|
||||
BestMs int64 `json:"best_ms,omitempty"`
|
||||
Selected bool `json:"selected"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
func (a *app) pingServers() ([]netcheck.Result, error) {
|
||||
res, err := a.runPings()
|
||||
return res, err
|
||||
}
|
||||
|
||||
// pingBest measures all nodes, activates the fastest OK one, and optionally connects.
|
||||
func (a *app) pingBest(autoConnect bool) (pingBestResult, error) {
|
||||
pings, err := a.runPings()
|
||||
out := pingBestResult{Pings: pings}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
best, ok := netcheck.BestOK(pings)
|
||||
if !ok {
|
||||
return out, fmt.Errorf("нет доступных серверов")
|
||||
}
|
||||
out.BestName = best.Name
|
||||
out.BestMs = best.Ms
|
||||
|
||||
a.mu.Lock()
|
||||
st := a.mgr.Status()
|
||||
alreadyBest := st.Connected && st.Profile == best.Name
|
||||
if st.Connected && !alreadyBest {
|
||||
a.mu.Unlock()
|
||||
if err := a.mgr.Disconnect(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
a.mu.Lock()
|
||||
}
|
||||
if !alreadyBest {
|
||||
if err := a.mgr.SetActiveProfile(best.Name); err != nil {
|
||||
a.mu.Unlock()
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
out.Selected = true
|
||||
if !autoConnect {
|
||||
a.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
if alreadyBest {
|
||||
a.mu.Unlock()
|
||||
out.Connected = true
|
||||
return out, nil
|
||||
}
|
||||
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
|
||||
a.mu.Unlock()
|
||||
return out, err
|
||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
||||
a.mu.Unlock()
|
||||
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if _, err := a.mgr.EnsureCore(""); err != nil {
|
||||
return out, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := a.mgr.Connect(ctx, ""); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Connected = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *app) runPings() ([]netcheck.Result, error) {
|
||||
a.mu.Lock()
|
||||
list := a.mgr.Profiles()
|
||||
a.mu.Unlock()
|
||||
|
||||
targets := make([]netcheck.Target, 0, len(list))
|
||||
for _, p := range list {
|
||||
targets = append(targets, netcheck.Target{
|
||||
Name: p.Name,
|
||||
Protocol: config.Protocol(p.Protocol),
|
||||
Proxy: p.Proxy,
|
||||
})
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
out := netcheck.PingAll(ctx, targets)
|
||||
a.mu.Lock()
|
||||
a.pings = out
|
||||
a.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *app) checkUpdate() (update.Status, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
st, err := update.Check(ctx, update.DefaultManifestURL)
|
||||
a.mu.Lock()
|
||||
if err != nil {
|
||||
st.Current = update.DisplayVersion()
|
||||
st.ManifestURL = update.DefaultManifestURL
|
||||
st.Error = err.Error()
|
||||
}
|
||||
a.updateStatus = st
|
||||
a.mu.Unlock()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (a *app) applyUpdate() (string, error) {
|
||||
// Explicit user action only — never called from autoCheckUpdate.
|
||||
st, err := a.checkUpdate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !st.Available {
|
||||
return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
latest, err := update.Apply(ctx, update.DefaultManifestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = a.mgr.Disconnect()
|
||||
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate (can hang).
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
|
||||
}
|
||||
|
||||
func (a *app) autoCheckUpdate() {
|
||||
// Only refresh update status for the UI banner — never auto-install.
|
||||
// (Previously auto-apply caused an infinite update loop when the feed version
|
||||
// stayed ahead of the shipped CurrentVersion in the downloaded binary.)
|
||||
time.Sleep(3 * time.Second)
|
||||
_, _ = a.checkUpdate()
|
||||
}
|
||||
|
||||
func openURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("пустая ссылка")
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
|
||||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
|
||||
raw = "https://evilfox.win/"
|
||||
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
|
||||
// allow shop deep-links on evilfox.win only
|
||||
default:
|
||||
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
||||
}
|
||||
return shellOpen(raw)
|
||||
}
|
||||
|
||||
func shellOpen(raw string) error {
|
||||
verb, err := windows.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2.7.3+3
|
||||
3.8.0+2
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.7.3.3
|
||||
3.8.0.2
|
||||
|
||||
Vendored
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "2.7.3",
|
||||
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
|
||||
"version": "3.8.0",
|
||||
"notes": "Navis 3.8.0+2: Mac .app zip update; EnsureCore lock; getState без секретов; подпись/notarize pipeline; UI split.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
@@ -16,11 +16,13 @@
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
|
||||
"sha256": "6e15ad79e5ec1a1f74c48bc8f189ab8694b8f3f67f4be204420a12cf0fd622fd",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
|
||||
"zip_sha256": "65041e154356a33525b9769f851bcccfc79d0c2a1200fb9a47bc22dd95cdf2ef",
|
||||
"dmg_sha256": "46617b7cbf22e92a5ef85740ed9473cd1bc9219c9de5841ce0ac46b8eb946912"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
|
||||
Vendored
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "2.7.3",
|
||||
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
|
||||
"version": "3.8.0",
|
||||
"notes": "Navis 3.8.0+2: Mac .app zip update; EnsureCore lock; getState без секретов; подпись/notarize pipeline; UI split.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
@@ -16,11 +16,13 @@
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
|
||||
"sha256": "6e15ad79e5ec1a1f74c48bc8f189ab8694b8f3f67f4be204420a12cf0fd622fd",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
|
||||
"zip_sha256": "65041e154356a33525b9769f851bcccfc79d0c2a1200fb9a47bc22dd95cdf2ef",
|
||||
"dmg_sha256": "46617b7cbf22e92a5ef85740ed9473cd1bc9219c9de5841ce0ac46b8eb946912"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Code signing (release)
|
||||
|
||||
## macOS
|
||||
|
||||
1. Create a **Developer ID Application** certificate in Apple Developer.
|
||||
2. Store notary credentials once:
|
||||
|
||||
```bash
|
||||
xcrun notarytool store-credentials "navis-notary" \
|
||||
--apple-id "you@example.com" \
|
||||
--team-id "TEAMID" \
|
||||
--password "app-specific-password"
|
||||
```
|
||||
|
||||
3. Build with identity (packmac picks it up):
|
||||
|
||||
```bash
|
||||
export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
|
||||
./scripts/build-macos-arm64.sh
|
||||
```
|
||||
|
||||
4. Notarize the DMG:
|
||||
|
||||
```bash
|
||||
export NAVIS_NOTARY_PROFILE="navis-notary"
|
||||
./scripts/sign-macos.sh dist/navis-release/darwin-arm64/Navis.dmg
|
||||
```
|
||||
|
||||
Without these env vars, packmac uses **ad-hoc** signing (`-`) — fine for local/dev.
|
||||
|
||||
## Windows
|
||||
|
||||
Sign `Navis.exe` with Authenticode (EV or standard code signing cert):
|
||||
|
||||
```bat
|
||||
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /a dist\navis-release\Navis.exe
|
||||
signtool verify /pa dist\navis-release\Navis.exe
|
||||
```
|
||||
|
||||
Then refresh `sha256` in `dist/update.json` (or re-run the Windows release script that stamps it).
|
||||
+19
-1
@@ -80,6 +80,15 @@ func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
|
||||
}
|
||||
|
||||
func (a *App) GetState() (UIState, error) {
|
||||
return a.getState(false)
|
||||
}
|
||||
|
||||
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
|
||||
func (a *App) GetEditState() (UIState, error) {
|
||||
return a.getState(true)
|
||||
}
|
||||
|
||||
func (a *App) getState(includeSecrets bool) (UIState, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
@@ -91,6 +100,9 @@ func (a *App) GetState() (UIState, error) {
|
||||
proxy = p.Proxy
|
||||
active = p.Name
|
||||
}
|
||||
if !includeSecrets {
|
||||
proxy = config.RedactProxyURI(proxy)
|
||||
}
|
||||
corePath := ""
|
||||
coreReady := false
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
@@ -115,6 +127,10 @@ func (a *App) GetState() (UIState, error) {
|
||||
} else if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
hy2 := a.Mgr.ActiveHy2Options()
|
||||
if !includeSecrets {
|
||||
hy2.ObfsPassword = ""
|
||||
}
|
||||
out := UIState{
|
||||
Connected: st.Connected,
|
||||
Profile: st.Profile,
|
||||
@@ -132,7 +148,7 @@ func (a *App) GetState() (UIState, error) {
|
||||
Update: a.UpdateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
Hy2: a.Mgr.ActiveHy2Options(),
|
||||
Hy2: hy2,
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
@@ -473,6 +489,8 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
||||
switch name {
|
||||
case "getState":
|
||||
return a.GetState()
|
||||
case "getEditState":
|
||||
return a.GetEditState()
|
||||
case "connect":
|
||||
return nil, a.Connect()
|
||||
case "disconnect":
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
:root {
|
||||
--ink: #0b1c18;
|
||||
--muted: #5a736b;
|
||||
--line: rgba(11, 28, 24, 0.10);
|
||||
--accent: #0d8a66;
|
||||
--accent-deep: #086b4f;
|
||||
--accent-soft: #d8f3e9;
|
||||
--danger: #c0362c;
|
||||
--ok: #0d8a66;
|
||||
--surface: rgba(255, 255, 255, 0.82);
|
||||
--surface-2: rgba(255, 255, 255, 0.62);
|
||||
--shadow: 0 18px 50px rgba(8, 40, 32, 0.12);
|
||||
--radius: 22px;
|
||||
--ease: cubic-bezier(.22,.8,.24,1);
|
||||
--page-bg:
|
||||
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
|
||||
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
|
||||
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
|
||||
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
|
||||
--hero-bg: linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
|
||||
--hero-on-bg: linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
|
||||
--input-bg: rgba(255,255,255,.94);
|
||||
--chip-bg: rgba(255,255,255,.75);
|
||||
--row-bg: rgba(255,255,255,.72);
|
||||
--row-active-bg: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
|
||||
--modal-bg: #f5fbf8;
|
||||
--modal-scrim: rgba(8, 28, 24, .42);
|
||||
--soft-btn: linear-gradient(180deg, #d2efe4, #bfe6d6);
|
||||
--soft-btn-hover: linear-gradient(180deg, #c5e9db, #aedfcb);
|
||||
--danger-btn: linear-gradient(180deg, #f8d8d4, #efc4bf);
|
||||
--danger-btn-hover: linear-gradient(180deg, #f3c5bf, #e8aea7);
|
||||
--danger-ink: #9e2a22;
|
||||
--danger-ink-hover: #7f1f19;
|
||||
--meta-bg: rgba(255,255,255,.35);
|
||||
--meta-ok-bg: rgba(216,243,233,.65);
|
||||
--meta-err-bg: rgba(255,236,234,.75);
|
||||
--update-bg: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
|
||||
--shop-bg: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
|
||||
--switch-track: #c7d5cf;
|
||||
--switch-knob: #fff;
|
||||
--ms-good: #0a7a3e;
|
||||
--ms-mid: #b8860b;
|
||||
--focus-ring: rgba(13,138,102,.12);
|
||||
--theme-btn-bg: rgba(255,255,255,.7);
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--ink: #e6f4ee;
|
||||
--muted: #8eaaa0;
|
||||
--line: rgba(230, 244, 238, 0.12);
|
||||
--accent: #2bbf8a;
|
||||
--accent-deep: #7ee0b8;
|
||||
--accent-soft: rgba(43, 191, 138, 0.16);
|
||||
--danger: #f07167;
|
||||
--ok: #2bbf8a;
|
||||
--surface: rgba(18, 28, 26, 0.88);
|
||||
--surface-2: rgba(28, 42, 38, 0.72);
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||
--page-bg:
|
||||
radial-gradient(720px 400px at 6% -12%, rgba(20, 90, 70, .55) 0%, transparent 55%),
|
||||
radial-gradient(560px 340px at 100% 0%, rgba(30, 55, 80, .45) 0%, transparent 50%),
|
||||
radial-gradient(480px 300px at 50% 110%, rgba(18, 70, 55, .4) 0%, transparent 45%),
|
||||
linear-gradient(165deg, #0c1412 0%, #121c1a 48%, #101820 100%);
|
||||
--hero-bg: linear-gradient(160deg, rgba(28, 42, 38, .95), rgba(22, 34, 31, .9));
|
||||
--hero-on-bg: linear-gradient(160deg, rgba(24, 70, 55, .55), rgba(28, 42, 38, .95));
|
||||
--input-bg: rgba(12, 20, 18, .92);
|
||||
--chip-bg: rgba(12, 20, 18, .75);
|
||||
--row-bg: rgba(22, 34, 31, .78);
|
||||
--row-active-bg: linear-gradient(135deg, rgba(43, 191, 138, .18), rgba(28, 42, 38, .95));
|
||||
--modal-bg: #182420;
|
||||
--modal-scrim: rgba(0, 0, 0, .58);
|
||||
--soft-btn: linear-gradient(180deg, #1f3d34, #18332b);
|
||||
--soft-btn-hover: linear-gradient(180deg, #264d42, #1d3d34);
|
||||
--danger-btn: linear-gradient(180deg, #4a2a28, #3d2220);
|
||||
--danger-btn-hover: linear-gradient(180deg, #5a3230, #4a2826);
|
||||
--danger-ink: #f0a39c;
|
||||
--danger-ink-hover: #ffc4be;
|
||||
--meta-bg: rgba(12, 20, 18, .45);
|
||||
--meta-ok-bg: rgba(43, 191, 138, .14);
|
||||
--meta-err-bg: rgba(240, 113, 103, .14);
|
||||
--update-bg: linear-gradient(135deg, rgba(43,191,138,.16), rgba(22,34,31,.85));
|
||||
--shop-bg: linear-gradient(135deg, rgba(43,191,138,.12), rgba(22,34,31,.7));
|
||||
--switch-track: #3a4f48;
|
||||
--switch-knob: #e6f4ee;
|
||||
--ms-good: #4ad69a;
|
||||
--ms-mid: #e0b84a;
|
||||
--focus-ring: rgba(43,191,138,.2);
|
||||
--theme-btn-bg: rgba(22, 34, 31, .85);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: Figtree, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--page-bg);
|
||||
transition: color .2s var(--ease), background .25s var(--ease);
|
||||
color-scheme: light;
|
||||
}
|
||||
html[data-theme="dark"], html[data-theme="dark"] body {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding: 16px 12px 20px;
|
||||
}
|
||||
.shell {
|
||||
width: min(480px, 100%);
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(18px) saturate(1.15);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 28px;
|
||||
padding: 18px 18px 16px;
|
||||
animation: rise .55s var(--ease) both;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(14px) scale(.985); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
display: none;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(13, 138, 102, 0.28);
|
||||
background: var(--update-bg);
|
||||
animation: rise .4s var(--ease) both;
|
||||
}
|
||||
.update-banner.show { display: block; }
|
||||
.update-banner strong {
|
||||
font-family: Sora, sans-serif;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: .95rem;
|
||||
}
|
||||
.update-banner p {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.update-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.update-actions .action {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.logo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
box-shadow: 0 10px 24px rgba(13, 138, 102, 0.28);
|
||||
background: linear-gradient(145deg, #0d8a66, #1bb887);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.logo svg { width: 30px; height: 30px; }
|
||||
.brand-wrap { min-width: 0; flex: 1; }
|
||||
.theme-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--theme-btn-bg);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: background .15s, border-color .15s, transform .15s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.06);
|
||||
}
|
||||
.theme-btn:hover {
|
||||
border-color: rgba(13,138,102,.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.theme-btn svg { width: 18px; height: 18px; display: block; }
|
||||
.theme-btn .icon-sun { display: none; }
|
||||
.theme-btn .icon-moon { display: block; }
|
||||
html[data-theme="dark"] .theme-btn .icon-sun { display: block; }
|
||||
html[data-theme="dark"] .theme-btn .icon-moon { display: none; }
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand {
|
||||
font-family: Sora, sans-serif;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.badge-ver {
|
||||
font-size: .68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: none;
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid rgba(13,138,102,.18);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.tagline {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--hero-bg);
|
||||
padding: 16px 16px 14px;
|
||||
margin-bottom: 14px;
|
||||
transition: border-color .25s, box-shadow .25s, background .25s;
|
||||
}
|
||||
.hero.on {
|
||||
border-color: rgba(13,138,102,.35);
|
||||
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
|
||||
background: var(--hero-on-bg);
|
||||
}
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 160px; height: 160px;
|
||||
right: -40px; top: -50px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(13,138,102,.18), transparent 70%);
|
||||
pointer-events: none;
|
||||
transition: opacity .3s;
|
||||
opacity: .55;
|
||||
}
|
||||
.hero.on::after { opacity: 1; }
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: .98rem;
|
||||
}
|
||||
.dot {
|
||||
width: 11px; height: 11px; border-radius: 50%;
|
||||
background: #9aaba4;
|
||||
transition: background .2s;
|
||||
}
|
||||
.dot.on {
|
||||
background: var(--ok);
|
||||
animation: pulse 1.8s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(13,138,102,.4); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(13,138,102,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(13,138,102,0); }
|
||||
}
|
||||
.proto-chip {
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: var(--chip-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
max-width: 46%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.proto-chip.active {
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
border-color: rgba(13,138,102,.22);
|
||||
}
|
||||
.hero-actions { position: relative; z-index: 1; display: grid; gap: 8px; }
|
||||
.hero-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: .8rem;
|
||||
color: var(--muted);
|
||||
min-height: 1.2em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-family: Sora, sans-serif;
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
label.field {
|
||||
display: block;
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.profile-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sub-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
select, input[type="text"], textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--input-bg);
|
||||
border-radius: 14px;
|
||||
padding: 11px 13px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||
}
|
||||
textarea {
|
||||
min-height: 78px;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
select:focus, input[type="text"]:focus, textarea:focus {
|
||||
border-color: rgba(13,138,102,.5);
|
||||
box-shadow: 0 0 0 4px var(--focus-ring);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(8, 90, 68, 0.22);
|
||||
background: var(--soft-btn);
|
||||
color: var(--accent-deep);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
|
||||
box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--soft-btn-hover);
|
||||
border-color: rgba(8, 90, 68, 0.38);
|
||||
color: var(--accent-deep);
|
||||
box-shadow: 0 6px 14px rgba(8, 90, 68, 0.14);
|
||||
}
|
||||
.icon-btn.danger {
|
||||
color: var(--danger-ink);
|
||||
background: var(--danger-btn);
|
||||
border-color: rgba(158, 42, 34, 0.28);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--danger-btn-hover);
|
||||
color: var(--danger-ink-hover);
|
||||
}
|
||||
.icon-btn.wide {
|
||||
width: auto;
|
||||
padding: 0 14px;
|
||||
font-size: .82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stack { display: grid; gap: 10px; }
|
||||
|
||||
details.panel {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 8px 12px 10px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
details.panel summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: .86rem;
|
||||
color: var(--ink);
|
||||
list-style: none;
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
details.panel summary::-webkit-details-marker { display: none; }
|
||||
details.panel summary::after {
|
||||
content: "+";
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
transition: transform .2s;
|
||||
}
|
||||
details.panel[open] summary::after { content: "–"; }
|
||||
details.panel .grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
details.panel label.field { margin-top: 4px; }
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: .86rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-2);
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute; inset: 0; background: var(--switch-track);
|
||||
border-radius: 999px; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 20px; height: 20px;
|
||||
left: 3px; top: 3px;
|
||||
background: var(--switch-knob);
|
||||
border-radius: 50%;
|
||||
transition: transform .15s;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.12);
|
||||
}
|
||||
.switch input:checked + .slider { background: var(--accent); }
|
||||
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||
|
||||
button.action {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 15px;
|
||||
padding: 13px 15px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform .12s, background .15s, opacity .15s, box-shadow .15s, border-color .15s, color .15s;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
button.action:active { transform: translateY(1px); }
|
||||
button.action:disabled { opacity: .55; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
.primary {
|
||||
background: linear-gradient(145deg, var(--accent-deep), var(--accent));
|
||||
color: #f3fff9;
|
||||
box-shadow: 0 12px 28px rgba(13, 138, 102, 0.28);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: linear-gradient(145deg, #0a7a59, #12a078);
|
||||
box-shadow: 0 14px 30px rgba(13, 138, 102, 0.34);
|
||||
}
|
||||
.primary.danger {
|
||||
background: linear-gradient(145deg, #a52c24, #d14a3f);
|
||||
box-shadow: 0 12px 28px rgba(192, 54, 44, 0.24);
|
||||
}
|
||||
.primary.danger:hover:not(:disabled) {
|
||||
background: linear-gradient(145deg, #b8342b, #e0574b);
|
||||
}
|
||||
.secondary {
|
||||
background: linear-gradient(180deg, #2f6f5f, #25584b);
|
||||
color: #f2fff9;
|
||||
border: 1px solid rgba(12, 48, 40, 0.35);
|
||||
box-shadow: 0 8px 18px rgba(20, 70, 56, 0.18);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: linear-gradient(180deg, #37806d, #2c6757);
|
||||
color: #ffffff;
|
||||
border-color: rgba(12, 48, 40, 0.45);
|
||||
box-shadow: 0 10px 22px rgba(20, 70, 56, 0.24);
|
||||
}
|
||||
.cta {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
font-size: 1.02rem;
|
||||
font-family: Sora, sans-serif;
|
||||
}
|
||||
|
||||
.actions { display: grid; gap: 8px; }
|
||||
.tools {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tools .action { min-height: 44px; font-size: .9rem; }
|
||||
|
||||
.server-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.server-toolbar .section-title { margin: 0; }
|
||||
.server-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.server-actions .mini {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(8, 90, 68, 0.22);
|
||||
background: var(--soft-btn);
|
||||
color: var(--accent-deep);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.server-actions .mini:hover { filter: brightness(.97); }
|
||||
.server-actions .mini.accent {
|
||||
background: linear-gradient(180deg, #2f6f5f, #25584b);
|
||||
color: #f2fff9;
|
||||
border-color: rgba(12, 48, 40, 0.35);
|
||||
}
|
||||
.auto-best {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.server-list {
|
||||
max-height: min(42vh, 360px);
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.server-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--row-bg);
|
||||
cursor: pointer;
|
||||
transition: background .12s, border-color .12s, box-shadow .12s;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.server-row:hover {
|
||||
border-color: rgba(13,138,102,.25);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
.server-row.active {
|
||||
border-color: rgba(13,138,102,.4);
|
||||
background: var(--row-active-bg);
|
||||
box-shadow: 0 4px 14px rgba(13,138,102,.1);
|
||||
}
|
||||
.server-row .left { min-width: 0; }
|
||||
.server-row .name {
|
||||
font-weight: 700;
|
||||
font-size: .84rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.server-row .sub {
|
||||
margin-top: 2px;
|
||||
font-size: .72rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.server-row .right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.server-row .proto {
|
||||
font-size: .62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 999px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.server-row .ms {
|
||||
font-size: .78rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
}
|
||||
.server-row .ms.ok { color: var(--ok); }
|
||||
.server-row .ms.good { color: var(--ms-good); }
|
||||
.server-row .ms.mid { color: var(--ms-mid); }
|
||||
.server-row .ms.bad { color: var(--danger); }
|
||||
.server-empty {
|
||||
padding: 14px 10px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: .82rem;
|
||||
}
|
||||
.profile-edit summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.profile-edit summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.meta {
|
||||
margin: 10px 0 0;
|
||||
font-size: .82rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
min-height: 2.4em;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--meta-bg);
|
||||
border: 1px solid transparent;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.meta.ok {
|
||||
color: var(--ok);
|
||||
background: var(--meta-ok-bg);
|
||||
border-color: rgba(13,138,102,.18);
|
||||
}
|
||||
.meta.err {
|
||||
color: var(--danger);
|
||||
background: var(--meta-err-bg);
|
||||
border-color: rgba(192,54,44,.16);
|
||||
}
|
||||
|
||||
.ping-list {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.ping-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--row-bg);
|
||||
font-size: .82rem;
|
||||
}
|
||||
.ping-item .ms { font-weight: 700; }
|
||||
.ping-item .ms.ok { color: var(--ok); }
|
||||
.ping-item .ms.bad { color: var(--danger); }
|
||||
|
||||
.shop {
|
||||
margin-top: 12px;
|
||||
padding: 13px 14px 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(13, 138, 102, 0.16);
|
||||
background: var(--shop-bg);
|
||||
}
|
||||
.shop h3 {
|
||||
font-family: Sora, sans-serif;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
.shop p {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: .82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.shop .action { width: 100%; }
|
||||
.shop-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
font-size: .76rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
.shop-link:hover { text-decoration: underline; }
|
||||
|
||||
.ver {
|
||||
margin-top: 8px;
|
||||
font-size: .72rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--modal-scrim);
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
z-index: 20;
|
||||
}
|
||||
.modal-backdrop.open { display: grid; }
|
||||
.modal {
|
||||
width: min(400px, 100%);
|
||||
background: var(--modal-bg);
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
animation: rise .35s var(--ease) both;
|
||||
color: var(--ink);
|
||||
}
|
||||
.modal h2 {
|
||||
font-family: Sora, sans-serif;
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.modal .actions {
|
||||
margin-top: 12px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.shell { padding: 14px 12px 12px; border-radius: 22px; }
|
||||
.brand { font-size: 1.45rem; }
|
||||
details.panel .grid2 { grid-template-columns: 1fr; }
|
||||
.tools { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives).
|
||||
(function () {
|
||||
const methods = [
|
||||
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
||||
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
||||
"checkUpdate","applyUpdate","saveHy2","importSubscription","quit"
|
||||
];
|
||||
if (typeof window.getState === "function") return;
|
||||
window.__navisHttp = true;
|
||||
async function call(name, args) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (window.__NAVIS_TOKEN__) headers["X-Navis-Token"] = window.__NAVIS_TOKEN__;
|
||||
const r = await fetch("/api/" + name, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify({ args: args || [] })
|
||||
});
|
||||
if (r.status === 401) throw "unauthorized";
|
||||
const j = await r.json();
|
||||
if (j && j.error) throw j.error;
|
||||
return j ? j.result : null;
|
||||
}
|
||||
methods.forEach((name) => {
|
||||
window[name] = function () {
|
||||
return call(name, Array.prototype.slice.call(arguments));
|
||||
};
|
||||
});
|
||||
})();
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const meta = $("meta");
|
||||
const btn = $("toggleBtn");
|
||||
const coreBtn = $("coreBtn");
|
||||
const saveBtn = $("saveBtn");
|
||||
const addBtn = $("addBtn");
|
||||
const delBtn = $("delBtn");
|
||||
const proxy = $("proxy");
|
||||
const nameInput = $("name");
|
||||
const sysproxy = $("sysproxy");
|
||||
const profile = $("profile");
|
||||
const dot = $("dot");
|
||||
const statusText = $("statusText");
|
||||
const modal = $("modal");
|
||||
const shopBtn = $("shopBtn");
|
||||
const shopLink = $("shopLink");
|
||||
const pingBtn = $("pingBtn");
|
||||
const bestBtn = $("bestBtn");
|
||||
const autoBest = $("autoBest");
|
||||
const serverList = $("serverList");
|
||||
const updCheckBtn = $("updCheckBtn");
|
||||
const updateBanner = $("updateBanner");
|
||||
const updateBtn = $("updateBtn");
|
||||
const skipUpdateBtn = $("skipUpdateBtn");
|
||||
const verLabel = $("verLabel");
|
||||
const SKIP_UPDATE_KEY = "navis.skipUpdateVersion";
|
||||
function skippedVersion() {
|
||||
try { return localStorage.getItem(SKIP_UPDATE_KEY) || ""; } catch (_) { return ""; }
|
||||
}
|
||||
function setSkippedVersion(v) {
|
||||
try {
|
||||
if (v) localStorage.setItem(SKIP_UPDATE_KEY, String(v));
|
||||
else localStorage.removeItem(SKIP_UPDATE_KEY);
|
||||
} catch (_) {}
|
||||
}
|
||||
const quitBtn = $("quitBtn");
|
||||
if (window.__navisHttp && quitBtn) {
|
||||
quitBtn.hidden = false;
|
||||
quitBtn.style.display = "block";
|
||||
quitBtn.addEventListener("click", () => {
|
||||
withBusy(async () => {
|
||||
setMeta("Выход…");
|
||||
try { await quit(); } catch (_) {}
|
||||
});
|
||||
});
|
||||
}
|
||||
const subUrl = $("subUrl");
|
||||
const subBtn = $("subBtn");
|
||||
const hero = $("hero");
|
||||
const protoChip = $("protoChip");
|
||||
const heroHint = $("heroHint");
|
||||
const SHOP_URL = "https://evilfox.win/";
|
||||
const AUTO_BEST_KEY = "navis.autoBest";
|
||||
const THEME_KEY = "navis.theme";
|
||||
|
||||
(function initThemeToggle() {
|
||||
const btn = $("themeBtn");
|
||||
if (!btn) return;
|
||||
const apply = (theme) => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
|
||||
btn.title = theme === "dark" ? "Светлая тема" : "Тёмная тема";
|
||||
btn.setAttribute("aria-label", btn.title);
|
||||
};
|
||||
btn.addEventListener("click", () => {
|
||||
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
apply(cur === "dark" ? "light" : "dark");
|
||||
});
|
||||
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
btn.title = cur === "dark" ? "Светлая тема" : "Тёмная тема";
|
||||
btn.setAttribute("aria-label", btn.title);
|
||||
})();
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(AUTO_BEST_KEY);
|
||||
autoBest.checked = saved === "1";
|
||||
} catch (_) {}
|
||||
autoBest.addEventListener("change", () => {
|
||||
try { localStorage.setItem(AUTO_BEST_KEY, autoBest.checked ? "1" : "0"); } catch (_) {}
|
||||
});
|
||||
|
||||
function readHy2() {
|
||||
return {
|
||||
congestion: $("hy2Congestion").value,
|
||||
bbr_profile: $("hy2Bbr").value,
|
||||
bandwidth_up: $("hy2Up").value.trim(),
|
||||
bandwidth_down: $("hy2Down").value.trim(),
|
||||
obfs: $("hy2Obfs").value,
|
||||
obfs_password: $("hy2ObfsPass").value.trim(),
|
||||
sni: $("hy2Sni").value.trim(),
|
||||
insecure: !!$("hy2Insecure").checked,
|
||||
pin_sha256: $("hy2Pin").value.trim(),
|
||||
fast_open: !!$("hy2Fast").checked,
|
||||
lazy: !!$("hy2Lazy").checked,
|
||||
hop_interval: $("hy2Hop").value.trim()
|
||||
};
|
||||
}
|
||||
|
||||
function fillHy2(h) {
|
||||
if (!h) return;
|
||||
if (h.congestion) $("hy2Congestion").value = h.congestion;
|
||||
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
|
||||
$("hy2Up").value = h.bandwidth_up || "";
|
||||
$("hy2Down").value = h.bandwidth_down || "";
|
||||
$("hy2Obfs").value = h.obfs || "";
|
||||
$("hy2ObfsPass").value = h.obfs_password || "";
|
||||
$("hy2Sni").value = h.sni || "";
|
||||
$("hy2Pin").value = h.pin_sha256 || "";
|
||||
$("hy2Hop").value = h.hop_interval || "";
|
||||
$("hy2Insecure").checked = !!h.insecure;
|
||||
$("hy2Fast").checked = !!h.fast_open;
|
||||
$("hy2Lazy").checked = !!h.lazy;
|
||||
}
|
||||
|
||||
async function persistHy2IfNeeded(protocol) {
|
||||
const p = (protocol || "").toLowerCase();
|
||||
const link = proxy.value.trim().toLowerCase();
|
||||
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
|
||||
await saveHy2(readHy2());
|
||||
}
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
let connected = false;
|
||||
let formHydrated = false;
|
||||
let dirty = false;
|
||||
let profiles = [];
|
||||
let pingMap = {};
|
||||
let metaHoldUntil = 0;
|
||||
|
||||
function markDirty() { dirty = true; }
|
||||
[
|
||||
proxy, nameInput, sysproxy, subUrl,
|
||||
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
|
||||
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
|
||||
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
|
||||
].forEach((el) => {
|
||||
if (!el) return;
|
||||
el.addEventListener("input", markDirty);
|
||||
el.addEventListener("change", markDirty);
|
||||
});
|
||||
|
||||
function setMeta(text, kind) {
|
||||
meta.textContent = text || "";
|
||||
meta.classList.remove("ok", "err");
|
||||
if (kind === "ok") meta.classList.add("ok");
|
||||
if (kind === "err") meta.classList.add("err");
|
||||
if (kind === "ok" || kind === "err") metaHoldUntil = Date.now() + 8000;
|
||||
}
|
||||
|
||||
function detectProtoLabel(state, active) {
|
||||
const p = (state.protocol || active.protocol || "").toLowerCase();
|
||||
const link = (typeof state.proxy === "string" ? state.proxy : (active.proxy || "")).trim().toLowerCase();
|
||||
if (p) return p;
|
||||
if (link.startsWith("vless://")) return "vless";
|
||||
if (link.startsWith("vmess://")) return "vmess";
|
||||
if (link.startsWith("trojan://")) return "trojan";
|
||||
if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) return "hysteria2";
|
||||
if (link.startsWith("awg://") || link.includes("[interface]")) return "awg";
|
||||
if (link.startsWith("naive") || link.startsWith("https://") || link.startsWith("quic://")) return "naive";
|
||||
return "протокол";
|
||||
}
|
||||
|
||||
function msClass(ms, ok) {
|
||||
if (!ok) return "bad";
|
||||
if (ms < 80) return "good";
|
||||
if (ms < 180) return "ok";
|
||||
if (ms < 350) return "mid";
|
||||
return "bad";
|
||||
}
|
||||
|
||||
function rememberPings(pings) {
|
||||
pingMap = {};
|
||||
(pings || []).forEach((p) => { pingMap[p.name] = p; });
|
||||
}
|
||||
|
||||
function fillProfiles(list, active) {
|
||||
profiles = list || [];
|
||||
const cur = profile.value;
|
||||
profile.innerHTML = "";
|
||||
profiles.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.name;
|
||||
opt.textContent = p.name;
|
||||
profile.appendChild(opt);
|
||||
});
|
||||
const want = active || cur || (profiles[0] && profiles[0].name);
|
||||
if (want) profile.value = want;
|
||||
renderServerList(want);
|
||||
}
|
||||
|
||||
function orderedProfiles() {
|
||||
const list = profiles.slice();
|
||||
list.sort((a, b) => {
|
||||
const pa = pingMap[a.name], pb = pingMap[b.name];
|
||||
const oa = pa && pa.ok, ob = pb && pb.ok;
|
||||
if (oa !== ob) return oa ? -1 : 1;
|
||||
if (oa && ob && pa.ms !== pb.ms) return pa.ms - pb.ms;
|
||||
return String(a.name).localeCompare(String(b.name));
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderServerList(activeName) {
|
||||
const active = activeName || profile.value;
|
||||
serverList.innerHTML = "";
|
||||
const list = orderedProfiles();
|
||||
if (!list.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "server-empty";
|
||||
empty.textContent = "Нет серверов — добавьте профиль или подписку";
|
||||
serverList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
list.forEach((p) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "server-row" + (p.name === active ? " active" : "");
|
||||
row.dataset.name = p.name;
|
||||
|
||||
const left = document.createElement("div");
|
||||
left.className = "left";
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "name";
|
||||
nameEl.textContent = p.name;
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "sub";
|
||||
sub.textContent = p.host || "нет хоста";
|
||||
left.appendChild(nameEl);
|
||||
left.appendChild(sub);
|
||||
|
||||
const right = document.createElement("div");
|
||||
right.className = "right";
|
||||
const proto = document.createElement("span");
|
||||
proto.className = "proto";
|
||||
proto.textContent = (p.protocol || "?").toString();
|
||||
const ms = document.createElement("span");
|
||||
const pr = pingMap[p.name];
|
||||
if (pr && pr.ok) {
|
||||
ms.className = "ms " + msClass(pr.ms, true);
|
||||
ms.textContent = pr.ms + " ms";
|
||||
} else if (pr && pr.error) {
|
||||
ms.className = "ms bad";
|
||||
ms.textContent = "—";
|
||||
ms.title = pr.error;
|
||||
} else {
|
||||
ms.className = "ms";
|
||||
ms.textContent = "…";
|
||||
}
|
||||
right.appendChild(proto);
|
||||
right.appendChild(ms);
|
||||
|
||||
row.appendChild(left);
|
||||
row.appendChild(right);
|
||||
row.addEventListener("click", () => onServerClick(p.name));
|
||||
row.addEventListener("dblclick", () => onServerConnect(p.name));
|
||||
serverList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function onServerClick(name) {
|
||||
if (busy) return;
|
||||
if (connected) {
|
||||
if (name !== profile.value) await onServerConnect(name);
|
||||
return;
|
||||
}
|
||||
await withBusy(async () => {
|
||||
await selectProfile(name);
|
||||
dirty = false;
|
||||
formHydrated = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function onServerConnect(name) {
|
||||
if (busy) return;
|
||||
await withBusy(async () => {
|
||||
setMeta("Подключение к «" + name + "»…");
|
||||
await connectProfile(name);
|
||||
setMeta("Подключено: " + name, "ok");
|
||||
});
|
||||
}
|
||||
|
||||
function renderUpdate(u, version) {
|
||||
verLabel.textContent = "Navis v" + (version || "?");
|
||||
const badge = $("badgeVer");
|
||||
if (badge && version) badge.textContent = version;
|
||||
if (!u) {
|
||||
updateBanner.classList.remove("show");
|
||||
return;
|
||||
}
|
||||
const latest = (u.latest || "").toString();
|
||||
const skip = skippedVersion();
|
||||
if (u.available && latest && skip === latest) {
|
||||
updateBanner.classList.remove("show");
|
||||
return;
|
||||
}
|
||||
if (u.available) {
|
||||
updateBanner.classList.add("show");
|
||||
$("updateTitle").textContent = "Доступно обновление " + latest;
|
||||
$("updateNotes").textContent = u.notes || ("У вас " + (u.current || version || "?") + ". Обновление только по кнопке.");
|
||||
updateBanner.dataset.latest = latest;
|
||||
} else {
|
||||
updateBanner.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
function paint(state, opts) {
|
||||
const syncForm = opts && opts.syncForm;
|
||||
connected = !!state.connected;
|
||||
dot.classList.toggle("on", connected);
|
||||
hero.classList.toggle("on", connected);
|
||||
statusText.textContent = connected
|
||||
? ("Подключено" + (state.profile ? " · " + state.profile : ""))
|
||||
: "Отключено";
|
||||
btn.textContent = connected ? "Отключить" : "Подключить";
|
||||
btn.classList.toggle("danger", connected);
|
||||
|
||||
const lock = connected || busy;
|
||||
proxy.disabled = lock;
|
||||
nameInput.disabled = lock;
|
||||
sysproxy.disabled = lock;
|
||||
profile.disabled = lock;
|
||||
addBtn.disabled = lock;
|
||||
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
|
||||
saveBtn.disabled = lock;
|
||||
coreBtn.disabled = busy;
|
||||
pingBtn.disabled = busy;
|
||||
bestBtn.disabled = busy;
|
||||
updCheckBtn.disabled = busy;
|
||||
updateBtn.disabled = busy;
|
||||
subBtn.disabled = busy;
|
||||
subUrl.disabled = busy;
|
||||
btn.disabled = busy;
|
||||
|
||||
rememberPings(state.pings || []);
|
||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||
renderUpdate(state.update, state.version);
|
||||
if (typeof state.subscription_url === "string" && !dirty) {
|
||||
subUrl.value = state.subscription_url;
|
||||
}
|
||||
|
||||
if (syncForm || (!formHydrated && !dirty)) {
|
||||
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
|
||||
nameInput.value = active.name || state.active_profile || "";
|
||||
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
|
||||
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
|
||||
fillHy2(state.hy2);
|
||||
formHydrated = true;
|
||||
if (syncForm) dirty = false;
|
||||
}
|
||||
|
||||
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
|
||||
const label = detectProtoLabel(state, active);
|
||||
protoChip.textContent = label;
|
||||
protoChip.classList.toggle("active", !!state.connected || label !== "протокол");
|
||||
const hy2 = (label === "hysteria2" || label === "hy2");
|
||||
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
|
||||
|
||||
let detail = "";
|
||||
if (connected) {
|
||||
const parts = [];
|
||||
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
|
||||
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
|
||||
detail = parts.join(" · ") || "Туннель активен";
|
||||
heroHint.textContent = detail;
|
||||
} else if (state.core_ready === false) {
|
||||
detail = "Сначала установите cores в разделе «Сервис»";
|
||||
heroHint.textContent = "Нужны cores для выбранного протокола";
|
||||
} else {
|
||||
const n = (state.profiles || []).length;
|
||||
detail = n > 1 ? ("Серверов: " + n + " · нажмите Пинг или Лучший") : "Готово к подключению";
|
||||
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
|
||||
}
|
||||
if (!busy && Date.now() > metaHoldUntil) {
|
||||
setMeta(detail, state.core_ready === false ? "err" : "");
|
||||
metaHoldUntil = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(opts) {
|
||||
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
|
||||
let state;
|
||||
if (needSecrets && typeof getEditState === "function") {
|
||||
state = await getEditState();
|
||||
} else {
|
||||
state = await getState();
|
||||
}
|
||||
paint(state, opts);
|
||||
return state;
|
||||
}
|
||||
|
||||
function fmtErr(e) {
|
||||
if (e == null) return "ошибка";
|
||||
if (typeof e === "string") return e;
|
||||
if (typeof e === "object" && e.message) return String(e.message);
|
||||
return String(e);
|
||||
}
|
||||
|
||||
async function withBusy(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
paintButtonsLocked(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setMeta(fmtErr(e), "err");
|
||||
} finally {
|
||||
busy = false;
|
||||
try {
|
||||
await refresh({ syncForm: true });
|
||||
} catch (e) {
|
||||
setMeta(fmtErr(e), "err");
|
||||
paintButtonsLocked(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintButtonsLocked(locked) {
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
|
||||
if (b) b.disabled = locked;
|
||||
});
|
||||
}
|
||||
|
||||
async function runBest(autoConnect) {
|
||||
setMeta(autoConnect ? "Пинг и автоподключение…" : "Пинг и выбор лучшего…");
|
||||
const res = await pingBest(!!autoConnect);
|
||||
rememberPings(res.pings || []);
|
||||
renderServerList(res.best_name || profile.value);
|
||||
if (res.best_name) {
|
||||
setMeta("Лучший: " + res.best_name + " · " + res.best_ms + " ms" + (res.connected ? " · подключено" : ""), "ok");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
profile.addEventListener("change", () => withBusy(async () => {
|
||||
await selectProfile(profile.value);
|
||||
dirty = false;
|
||||
formHydrated = false;
|
||||
}));
|
||||
|
||||
btn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
if (connected) {
|
||||
setMeta("Отключение…");
|
||||
await disconnect();
|
||||
setMeta("Отключено", "ok");
|
||||
} else {
|
||||
setMeta("Подключение…");
|
||||
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
|
||||
await persistHy2IfNeeded();
|
||||
await connect();
|
||||
setMeta("Туннель активен", "ok");
|
||||
}
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
saveBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
|
||||
await persistHy2IfNeeded();
|
||||
setMeta("Профиль сохранён", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
coreBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Скачивание official cores…");
|
||||
const path = await installCore();
|
||||
setMeta("Cores готовы: " + path, "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
delBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
if (!confirm("Удалить профиль «" + profile.value + "»?")) return;
|
||||
await deleteProfile(profile.value);
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Профиль удалён", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
$("newName").value = "";
|
||||
$("newProxy").value = "";
|
||||
const me = $("modalErr");
|
||||
if (me) { me.style.display = "none"; me.textContent = ""; me.className = "meta"; }
|
||||
modal.classList.add("open");
|
||||
$("newName").focus();
|
||||
});
|
||||
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) modal.classList.remove("open");
|
||||
});
|
||||
function sanitizeShare(s) {
|
||||
return String(s || "")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF\u2066-\u2069\u200E\u200F\u202A-\u202E]/g, "")
|
||||
.trim();
|
||||
}
|
||||
$("createNew").addEventListener("click", () => withBusy(async () => {
|
||||
const me = $("modalErr");
|
||||
const showModalErr = (msg) => {
|
||||
if (!me) { setMeta(String(msg), "err"); return; }
|
||||
me.style.display = "block";
|
||||
me.className = "meta err";
|
||||
me.textContent = String(msg);
|
||||
};
|
||||
try {
|
||||
const n = $("newName").value.trim();
|
||||
const p = sanitizeShare($("newProxy").value);
|
||||
if (!n) throw "Укажите название профиля";
|
||||
if (!p) throw "Вставьте ссылку или конфиг Amnezia/AWG";
|
||||
$("newProxy").value = p;
|
||||
await createProfile(n, p, !!sysproxy.checked);
|
||||
modal.classList.remove("open");
|
||||
if (me) { me.style.display = "none"; me.textContent = ""; }
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Профиль создан", "ok");
|
||||
} catch (e) { showModalErr(e); }
|
||||
}));
|
||||
|
||||
async function openShop(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
try {
|
||||
setMeta("Открываю evilfox.win…");
|
||||
await openURL(SHOP_URL);
|
||||
setMeta("Открыто в браузере", "ok");
|
||||
} catch (err) {
|
||||
setMeta("Не удалось открыть ссылку: " + String(err), "err");
|
||||
}
|
||||
}
|
||||
shopBtn.addEventListener("click", openShop);
|
||||
shopLink.addEventListener("click", openShop);
|
||||
|
||||
async function runImportSubscription() {
|
||||
const url = subUrl.value.trim();
|
||||
if (!url) {
|
||||
setMeta("Вставьте URL подписки", "err");
|
||||
return;
|
||||
}
|
||||
setMeta("Загрузка подписки…");
|
||||
const n = await importSubscription(url);
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Импортировано: " + n + " · измеряю пинг…", "ok");
|
||||
await runBest(!!autoBest.checked);
|
||||
}
|
||||
|
||||
subBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
await runImportSubscription();
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
subUrl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
subBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
pingBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Пинг серверов…");
|
||||
const rows = await pingServers();
|
||||
rememberPings(rows);
|
||||
renderServerList(profile.value);
|
||||
const ok = (rows || []).filter((r) => r.ok).length;
|
||||
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
bestBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
await runBest(!!autoBest.checked);
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
updCheckBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Проверка обновлений…");
|
||||
const st = await checkUpdate();
|
||||
renderUpdate(st, st.current);
|
||||
if (st.available) setMeta("Доступна версия " + st.latest, "ok");
|
||||
else if (st.error) setMeta("Обновление: " + st.error, "err");
|
||||
else setMeta("У вас актуальная версия " + st.current, "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
updateBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Скачивание и установка обновления…");
|
||||
const msg = await applyUpdate();
|
||||
setMeta(String(msg || "Перезапуск…"), "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
skipUpdateBtn.addEventListener("click", () => {
|
||||
const latest = updateBanner.dataset.latest || "";
|
||||
if (latest) setSkippedVersion(latest);
|
||||
updateBanner.classList.remove("show");
|
||||
setMeta("Версия " + (latest || "?") + " пропущена. Следующую предложим.", "ok");
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await refresh({ syncForm: true });
|
||||
// Auto-best connect is opt-in via checkbox only — never on first paint.
|
||||
} catch (e) {
|
||||
setMeta(String(e), "err");
|
||||
}
|
||||
})();
|
||||
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
|
||||
|
||||
+21
-3
@@ -1,7 +1,25 @@
|
||||
package appui
|
||||
|
||||
import _ "embed"
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var IndexHTML string
|
||||
var indexHTML string
|
||||
|
||||
//go:embed app.css
|
||||
var appCSS string
|
||||
|
||||
//go:embed app.js
|
||||
var appJS string
|
||||
|
||||
// IndexHTML is the full GUI document (CSS/JS inlined for WebView2 / glaze).
|
||||
var IndexHTML = buildIndexHTML()
|
||||
|
||||
func buildIndexHTML() string {
|
||||
s := indexHTML
|
||||
s = strings.Replace(s, `<link rel="stylesheet" href="app.css" />`, "<style>\n"+appCSS+"\n</style>", 1)
|
||||
s = strings.Replace(s, `<script src="app.js"></script>`, "<script>\n"+appJS+"\n</script>", 1)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexHTMLInlinesAssets(t *testing.T) {
|
||||
if !strings.Contains(IndexHTML, "--accent:") {
|
||||
t.Fatal("css missing")
|
||||
}
|
||||
if !strings.Contains(IndexHTML, "getEditState") {
|
||||
t.Fatal("js missing")
|
||||
}
|
||||
if strings.Contains(IndexHTML, `href="app.css"`) {
|
||||
t.Fatal("css link not replaced")
|
||||
}
|
||||
if strings.Contains(IndexHTML, `src="app.js"`) {
|
||||
t.Fatal("js src not replaced")
|
||||
}
|
||||
}
|
||||
+3
-1435
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,26 @@ func lastIndex(s, substr string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the config (JSON round-trip).
|
||||
func (c *Config) Clone() *Config {
|
||||
if c == nil {
|
||||
return &Config{}
|
||||
}
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
out := *c
|
||||
out.Profiles = append([]Profile(nil), c.Profiles...)
|
||||
return &out
|
||||
}
|
||||
var out Config
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
cp := *c
|
||||
cp.Profiles = append([]Profile(nil), c.Profiles...)
|
||||
return &cp
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// Load reads and validates a config file.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -40,6 +40,29 @@ func RedactProfileList(in []ProfileInfo) []ProfileInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
func RedactProxyURI(proxy string) string {
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(proxy)
|
||||
if strings.Contains(lower, "[interface]") || strings.Contains(lower, "privatekey") {
|
||||
return "[конфиг скрыт — откройте редактор профиля]"
|
||||
}
|
||||
if i := strings.Index(proxy, "://"); i >= 0 {
|
||||
scheme := proxy[:i+3]
|
||||
rest := proxy[i+3:]
|
||||
if at := strings.LastIndex(rest, "@"); at >= 0 {
|
||||
return scheme + "***@" + rest[at+1:]
|
||||
}
|
||||
return scheme + rest
|
||||
}
|
||||
if at := strings.LastIndex(proxy, "@"); at >= 0 {
|
||||
return "***@" + proxy[at+1:]
|
||||
}
|
||||
return proxyHost(proxy)
|
||||
}
|
||||
|
||||
func proxyHost(proxy string) string {
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactProxyURI(t *testing.T) {
|
||||
got := RedactProxyURI("https://user:pass@example.com:443/?x=1")
|
||||
if got != "https://***@example.com:443/?x=1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if RedactProxyURI("") != "" {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
r := RedactProxyURI("[Interface]\nPrivateKey = abc\n")
|
||||
if !strings.Contains(r, "скрыт") {
|
||||
t.Fatalf("awg redact: %q", r)
|
||||
}
|
||||
}
|
||||
+66
-23
@@ -33,6 +33,8 @@ type Manager struct {
|
||||
profile *config.Profile
|
||||
stderr io.Writer
|
||||
binDir string
|
||||
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
|
||||
lastStop sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||
@@ -68,51 +70,74 @@ func (m *Manager) RecoverSystemProxy() {
|
||||
sysproxy.ClearSentinel(m.cfgPath)
|
||||
}
|
||||
|
||||
func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
func (m *Manager) waitEngineStopped() {
|
||||
m.lastStop.Wait()
|
||||
}
|
||||
|
||||
func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
// Wait outside the lock so a slow Disconnect can finish freeing :1080/:1081.
|
||||
m.waitEngineStopped()
|
||||
|
||||
m.mu.Lock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("already connected; disconnect first")
|
||||
}
|
||||
|
||||
if m.engine != nil {
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
}
|
||||
if profileName != "" {
|
||||
m.cfg.Active = profileName
|
||||
}
|
||||
profile, err := m.cfg.ActiveProfile()
|
||||
if err != nil {
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
profileCopy := *profile
|
||||
binDir := m.binDir
|
||||
wantSysProxy := m.cfg.SystemProxy
|
||||
cfgPath := m.cfgPath
|
||||
sys := m.sys
|
||||
stderr := m.stderr
|
||||
m.mu.Unlock()
|
||||
|
||||
engine, err := m.newEngine(profile.Protocol)
|
||||
engine, err := m.newEngine(profileCopy.Protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := engine.Start(ctx, *profile, m.binDir); err != nil {
|
||||
if err := engine.Start(ctx, profileCopy, binDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if m.cfg.SystemProxy {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("already connected; disconnect first")
|
||||
}
|
||||
|
||||
if wantSysProxy {
|
||||
httpProxy, ok := engine.LocalHTTPProxy()
|
||||
if !ok {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
|
||||
}
|
||||
if err := m.sys.Enable(httpProxy); err != nil {
|
||||
if err := sys.Enable(httpProxy); err != nil {
|
||||
if errors.Is(err, sysproxy.ErrUnsupported) {
|
||||
fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
|
||||
fmt.Fprintf(stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
|
||||
} else {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("enable system proxy: %w", err)
|
||||
}
|
||||
} else {
|
||||
sysproxy.WriteSentinel(m.cfgPath, httpProxy)
|
||||
sysproxy.WriteSentinel(cfgPath, httpProxy)
|
||||
}
|
||||
}
|
||||
|
||||
m.engine = engine
|
||||
m.profile = profile
|
||||
m.profile = &profileCopy
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,6 +147,9 @@ func (m *Manager) Disconnect() error {
|
||||
engine := m.engine
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
if engine != nil {
|
||||
m.lastStop.Add(1)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
var first error
|
||||
@@ -143,14 +171,17 @@ func (m *Manager) Disconnect() error {
|
||||
}
|
||||
if engine != nil {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- engine.Stop() }()
|
||||
go func() {
|
||||
defer m.lastStop.Done()
|
||||
done <- engine.Stop()
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
// Engine Stop can block on userspace relays; don't freeze the UI.
|
||||
// Stop continues in background; Connect will wait on lastStop.
|
||||
if first == nil {
|
||||
first = fmt.Errorf("отключение заняло слишком много времени")
|
||||
}
|
||||
@@ -236,10 +267,14 @@ func (m *Manager) BinDir() string { return m.binDir }
|
||||
|
||||
func (m *Manager) ConfigPath() string { return m.cfgPath }
|
||||
|
||||
// Config returns a deep snapshot for UI reads. Mutations must go through Manager methods.
|
||||
func (m *Manager) Config() *config.Config {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg
|
||||
if m.cfg == nil {
|
||||
return &config.Config{}
|
||||
}
|
||||
return m.cfg.Clone()
|
||||
}
|
||||
|
||||
func (m *Manager) SetSystemProxy(enabled bool) {
|
||||
@@ -528,25 +563,33 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
|
||||
|
||||
// EnsureCore downloads the binary required by the active (or given) protocol.
|
||||
func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
|
||||
m.mu.Lock()
|
||||
binDir := m.binDir
|
||||
if proto == "" {
|
||||
if p, err := m.cfg.ActiveProfile(); err == nil {
|
||||
proto = p.Protocol
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
switch proto {
|
||||
case config.ProtocolHysteria2:
|
||||
return hysteria2.EnsureBinary(m.binDir)
|
||||
return hysteria2.EnsureBinary(binDir)
|
||||
case config.ProtocolAWG:
|
||||
return awg.EnsureBinary(m.binDir)
|
||||
return awg.EnsureBinary(binDir)
|
||||
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||
return xray.EnsureBinary(m.binDir)
|
||||
return xray.EnsureBinary(binDir)
|
||||
default:
|
||||
return naive.EnsureBinary(m.binDir)
|
||||
return naive.EnsureBinary(binDir)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded).
|
||||
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||
m.mu.Lock()
|
||||
binDir := m.binDir
|
||||
m.mu.Unlock()
|
||||
|
||||
type item struct {
|
||||
key string
|
||||
path string
|
||||
@@ -556,10 +599,10 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||
key string
|
||||
fn func() (string, error)
|
||||
}{
|
||||
{"naive", func() (string, error) { return naive.EnsureBinary(m.binDir) }},
|
||||
{"hysteria2", func() (string, error) { return hysteria2.EnsureBinary(m.binDir) }},
|
||||
{"awg", func() (string, error) { return awg.EnsureBinary(m.binDir) }},
|
||||
{"xray", func() (string, error) { return xray.EnsureBinary(m.binDir) }},
|
||||
{"naive", func() (string, error) { return naive.EnsureBinary(binDir) }},
|
||||
{"hysteria2", func() (string, error) { return hysteria2.EnsureBinary(binDir) }},
|
||||
{"awg", func() (string, error) { return awg.EnsureBinary(binDir) }},
|
||||
{"xray", func() (string, error) { return xray.EnsureBinary(binDir) }},
|
||||
}
|
||||
ch := make(chan item, len(jobs))
|
||||
for _, j := range jobs {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
type fakeEngine struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopDelay time.Duration
|
||||
stops atomic.Int32
|
||||
}
|
||||
|
||||
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
|
||||
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.running = true
|
||||
return nil
|
||||
}
|
||||
func (e *fakeEngine) Stop() error {
|
||||
e.stops.Add(1)
|
||||
if e.stopDelay > 0 {
|
||||
time.Sleep(e.stopDelay)
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.running = false
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (e *fakeEngine) Running() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.running
|
||||
}
|
||||
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
|
||||
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
|
||||
|
||||
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "t",
|
||||
SystemProxy: false,
|
||||
BinDir: t.TempDir(),
|
||||
Profiles: []config.Profile{{
|
||||
Name: "t",
|
||||
Protocol: config.ProtocolNaive,
|
||||
Proxy: "https://u:p@example.com",
|
||||
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
|
||||
}},
|
||||
}
|
||||
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
|
||||
m.mu.Lock()
|
||||
m.engine = eng
|
||||
m.profile = &cfg.Profiles[0]
|
||||
m.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
_ = m.Disconnect()
|
||||
// Connect must wait for Stop to finish even if Disconnect returned early...
|
||||
// our Disconnect waits up to 3s, so with 200ms it should complete.
|
||||
if time.Since(start) < 150*time.Millisecond {
|
||||
t.Fatalf("disconnect returned too fast")
|
||||
}
|
||||
if eng.Running() {
|
||||
t.Fatal("engine still running")
|
||||
}
|
||||
|
||||
// Simulate slow stop that outlives Disconnect timeout.
|
||||
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
|
||||
m.mu.Lock()
|
||||
m.engine = eng2
|
||||
m.profile = &cfg.Profiles[0]
|
||||
m.mu.Unlock()
|
||||
|
||||
// Force short path: call the wait mechanism like Connect does.
|
||||
m.mu.Lock()
|
||||
engine := m.engine
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
m.lastStop.Add(1)
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
defer m.lastStop.Done()
|
||||
_ = engine.Stop()
|
||||
}()
|
||||
|
||||
waitStart := time.Now()
|
||||
m.waitEngineStopped()
|
||||
if time.Since(waitStart) < 400*time.Millisecond {
|
||||
t.Fatalf("waitEngineStopped returned before stop finished")
|
||||
}
|
||||
if eng2.Running() {
|
||||
t.Fatal("engine2 still running after wait")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigReturnsClone(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "a",
|
||||
Profiles: []config.Profile{{
|
||||
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
|
||||
}},
|
||||
}
|
||||
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := m.Config()
|
||||
snap.Active = "mutated"
|
||||
snap.Profiles[0].Proxy = "leaked"
|
||||
if m.cfg.Active != "a" {
|
||||
t.Fatal("Config() leaked mutable Active")
|
||||
}
|
||||
if m.cfg.Profiles[0].Proxy != "https://x" {
|
||||
t.Fatal("Config() leaked mutable Profile")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package coredl
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
|
||||
type ReleaseAsset struct {
|
||||
Name string
|
||||
BrowserDownloadURL string
|
||||
SHA256 string // lowercase hex, empty if unknown
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// FetchLatestAssets loads assets for a repo's latest GitHub release.
|
||||
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
out := make([]ReleaseAsset, 0, len(rel.Assets))
|
||||
for _, a := range rel.Assets {
|
||||
out = append(out, ReleaseAsset{
|
||||
Name: a.Name,
|
||||
BrowserDownloadURL: a.BrowserDownloadURL,
|
||||
SHA256: parseDigestSHA256(a.Digest),
|
||||
})
|
||||
}
|
||||
return rel.TagName, out, nil
|
||||
}
|
||||
|
||||
func parseDigestSHA256(digest string) string {
|
||||
digest = strings.TrimSpace(strings.ToLower(digest))
|
||||
if digest == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(digest, "sha256:") {
|
||||
return strings.TrimPrefix(digest, "sha256:")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DownloadFile writes url to path (size-capped).
|
||||
func DownloadFile(path, url string, maxBytes int64) error {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 120 << 20
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
|
||||
return err
|
||||
}
|
||||
|
||||
// FileSHA256 returns lowercase hex digest of path.
|
||||
func FileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
|
||||
func VerifySHA256(path, want string) error {
|
||||
want = strings.TrimSpace(strings.ToLower(want))
|
||||
if want == "" {
|
||||
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
|
||||
}
|
||||
got, err := FileSHA256(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got != want {
|
||||
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireSHA returns sha or an error if the asset has none.
|
||||
func RequireSHA(a ReleaseAsset) (string, error) {
|
||||
if a.SHA256 != "" {
|
||||
return a.SHA256, nil
|
||||
}
|
||||
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
|
||||
}
|
||||
|
||||
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
|
||||
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
|
||||
if a.SHA256 != "" {
|
||||
return a.SHA256, nil
|
||||
}
|
||||
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
|
||||
for _, name := range companions {
|
||||
for _, o := range all {
|
||||
if !strings.EqualFold(o.Name, name) {
|
||||
continue
|
||||
}
|
||||
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sum != "" {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return RequireSHA(a)
|
||||
}
|
||||
|
||||
func fetchChecksumFile(url, assetName string) (string, error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("checksum download: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := string(body)
|
||||
lowerName := strings.ToLower(assetName)
|
||||
lines := strings.Split(text, "\n")
|
||||
var shaLine string
|
||||
for _, line := range lines {
|
||||
l := strings.TrimSpace(strings.ToLower(line))
|
||||
if strings.HasPrefix(l, "sha256:") {
|
||||
shaLine = strings.TrimSpace(line[len("sha256:"):])
|
||||
// keep scanning — prefer line that also mentions the asset
|
||||
if strings.Contains(l, lowerName) {
|
||||
return strings.ToLower(strings.Fields(shaLine)[0]), nil
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
sum := strings.ToLower(fields[0])
|
||||
file := strings.TrimPrefix(fields[len(fields)-1], "*")
|
||||
if len(sum) == 64 && strings.EqualFold(file, assetName) {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if shaLine != "" {
|
||||
parts := strings.Fields(shaLine)
|
||||
if len(parts) > 0 && len(parts[0]) == 64 {
|
||||
return strings.ToLower(parts[0]), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// DownloadAndVerify downloads url to path and checks sha256.
|
||||
func DownloadAndVerify(path, url, wantSHA256 string) error {
|
||||
if err := DownloadFile(path, url, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := VerifySHA256(path, wantSHA256); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package coredl
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDigestAndVerify(t *testing.T) {
|
||||
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
|
||||
t.Fatalf("parse: %q", got)
|
||||
}
|
||||
if parseDigestSHA256("md5:x") != "" {
|
||||
t.Fatal("expected empty for non-sha256")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/coredl"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
|
||||
@@ -72,7 +70,11 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
|
||||
name, url, err := findReleaseAsset(want)
|
||||
asset, all, err := findReleaseAsset(want)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum, err := coredl.ResolveSHA(asset, all)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -81,8 +83,8 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
destName = "hysteria.exe"
|
||||
}
|
||||
dest := filepath.Join(binDir, destName)
|
||||
tmp := filepath.Join(binDir, name+".download")
|
||||
if err := downloadFile(tmp, url); err != nil {
|
||||
tmp := filepath.Join(binDir, asset.Name+".download")
|
||||
if err := coredl.DownloadAndVerify(tmp, asset.BrowserDownloadURL, sum); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Remove(dest)
|
||||
@@ -114,73 +116,27 @@ func releaseAssetName() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return coredl.ReleaseAsset{}, nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Prefer exact non-avx name; fall back to avx variant on Windows.
|
||||
var fallback string
|
||||
var fallbackURL string
|
||||
for _, a := range rel.Assets {
|
||||
var fallback coredl.ReleaseAsset
|
||||
for _, a := range assets {
|
||||
if a.Name == exactName {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
return a, assets, nil
|
||||
}
|
||||
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
|
||||
fallback, fallbackURL = a.Name, a.BrowserDownloadURL
|
||||
fallback = a
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback, fallbackURL, nil
|
||||
if fallback.Name != "" {
|
||||
return fallback, assets, nil
|
||||
}
|
||||
// Prefix match for versioned names
|
||||
for _, a := range rel.Assets {
|
||||
for _, a := range assets {
|
||||
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
return a, assets, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
||||
return err
|
||||
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in hysteria release", exactName)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,15 @@ package naive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/coredl"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
|
||||
@@ -51,12 +50,16 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
|
||||
assetName, url, err := findReleaseAsset(pattern)
|
||||
asset, all, err := findReleaseAsset(pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
archivePath := filepath.Join(binDir, assetName)
|
||||
if err := downloadFile(archivePath, url); err != nil {
|
||||
sum, err := coredl.ResolveSHA(asset, all)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
archivePath := filepath.Join(binDir, asset.Name+".download")
|
||||
if err := coredl.DownloadAndVerify(archivePath, asset.BrowserDownloadURL, sum); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(archivePath)
|
||||
@@ -88,52 +91,26 @@ func assetNameForPlatform() (string, error) {
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
||||
return "naiveproxy-*-linux-x64.zip", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
|
||||
// Newer releases use mac-x64-x64; older used mac-x64.
|
||||
return "naiveproxy-*-mac-x64", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
|
||||
// Newer releases use mac-arm64-arm64; older used mac-arm64.
|
||||
return "naiveproxy-*-mac-arm64", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
func findReleaseAsset(pattern string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "vpnclient-go")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
return coredl.ReleaseAsset{}, nil, err
|
||||
}
|
||||
prefix := strings.Split(pattern, "*")[0]
|
||||
needle := ""
|
||||
if parts := strings.SplitN(pattern, "*", 2); len(parts) == 2 {
|
||||
needle = parts[1]
|
||||
}
|
||||
var bestName, bestURL string
|
||||
for _, a := range rel.Assets {
|
||||
var best coredl.ReleaseAsset
|
||||
for _, a := range assets {
|
||||
name := a.Name
|
||||
if strings.Contains(name, "plugin") {
|
||||
continue
|
||||
@@ -144,41 +121,22 @@ func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
|
||||
if needle != "" && !strings.Contains(name, needle) {
|
||||
continue
|
||||
}
|
||||
// Prefer desktop archives over openwrt/apk.
|
||||
if strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".tar.xz") || strings.HasSuffix(name, ".txz") {
|
||||
return name, a.BrowserDownloadURL, nil
|
||||
return a, assets, nil
|
||||
}
|
||||
if bestName == "" {
|
||||
bestName, bestURL = name, a.BrowserDownloadURL
|
||||
if best.Name == "" {
|
||||
best = a
|
||||
}
|
||||
}
|
||||
if bestName != "" {
|
||||
return bestName, bestURL, nil
|
||||
if best.Name != "" {
|
||||
return best, assets, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset matching %s in release %s", pattern, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
||||
return err
|
||||
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset matching %s in naive release", pattern)
|
||||
}
|
||||
|
||||
func extractNaiveArchive(archivePath, destDir string) error {
|
||||
lower := strings.ToLower(archivePath)
|
||||
lower = strings.TrimSuffix(lower, ".download")
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
return unzipNaive(archivePath, destDir)
|
||||
|
||||
@@ -2,16 +2,15 @@ package xray
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/coredl"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
|
||||
@@ -56,13 +55,17 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
|
||||
name, url, err := findReleaseAsset(want)
|
||||
asset, all, err := findReleaseAsset(want)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zipPath := filepath.Join(binDir, name+".download")
|
||||
sum, err := coredl.ResolveSHA(asset, all)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zipPath := filepath.Join(binDir, asset.Name+".download")
|
||||
_ = os.Remove(zipPath)
|
||||
if err := downloadFile(zipPath, url); err != nil {
|
||||
if err := coredl.DownloadAndVerify(zipPath, asset.BrowserDownloadURL, sum); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(zipPath)
|
||||
@@ -73,7 +76,6 @@ func EnsureBinary(binDir string) (string, error) {
|
||||
}
|
||||
dest := filepath.Join(binDir, destName)
|
||||
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
|
||||
// Some zips nest the binary; try case-insensitive match.
|
||||
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
|
||||
return "", fmt.Errorf("%v; %w", err, err2)
|
||||
}
|
||||
@@ -104,65 +106,17 @@ func releaseZipName() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return coredl.ReleaseAsset{}, nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, a := range rel.Assets {
|
||||
if a.Name == exactName {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
for _, a := range assets {
|
||||
if a.Name == exactName || strings.EqualFold(a.Name, exactName) {
|
||||
return a, assets, nil
|
||||
}
|
||||
}
|
||||
for _, a := range rel.Assets {
|
||||
if strings.EqualFold(a.Name, exactName) {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 15 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
||||
return err
|
||||
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in xray release", exactName)
|
||||
}
|
||||
|
||||
func extractNamedFromZip(zipPath, wantName, dest string) error {
|
||||
|
||||
@@ -5,6 +5,7 @@ package sysproxy
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
@@ -16,6 +17,7 @@ const (
|
||||
)
|
||||
|
||||
type windowsController struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
hadProxy bool
|
||||
oldEnable uint64
|
||||
@@ -27,9 +29,15 @@ func newPlatform() Controller {
|
||||
return &windowsController{}
|
||||
}
|
||||
|
||||
func (c *windowsController) Enabled() bool { return c.enabled }
|
||||
func (c *windowsController) Enabled() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.enabled
|
||||
}
|
||||
|
||||
func (c *windowsController) Enable(httpHostPort string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if httpHostPort == "" {
|
||||
return fmt.Errorf("sysproxy: empty http proxy address")
|
||||
}
|
||||
@@ -92,6 +100,8 @@ func (c *windowsController) Enable(httpHostPort string) error {
|
||||
}
|
||||
|
||||
func (c *windowsController) Disable() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
@@ -99,6 +109,8 @@ func (c *windowsController) Disable() error {
|
||||
}
|
||||
|
||||
func (c *windowsController) ForceDisable() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.disableLocked(false)
|
||||
}
|
||||
|
||||
|
||||
+129
-34
@@ -18,11 +18,11 @@ import (
|
||||
|
||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||
// Keep major.minor.patch only — no build suffix here.
|
||||
const CurrentVersion = "2.7.3"
|
||||
const CurrentVersion = "3.8.0"
|
||||
|
||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||
const BuildNumber = 3
|
||||
const BuildNumber = 2
|
||||
|
||||
// DefaultManifestURL is the update feed (hosted in the project git repo).
|
||||
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||
@@ -82,21 +82,44 @@ func PlatformKey() string {
|
||||
return runtime.GOOS + "-" + runtime.GOARCH
|
||||
}
|
||||
|
||||
// AssetFor returns download URL + checksum for the given platform key.
|
||||
// Asset kinds returned by ResolveAsset.
|
||||
const (
|
||||
AssetKindBinary = "bin"
|
||||
AssetKindAppZip = "appzip"
|
||||
)
|
||||
|
||||
// AssetFor returns Mach-O / exe URL + checksum for the platform key.
|
||||
func (m Manifest) AssetFor(key string) (url, sha string, ok bool) {
|
||||
url, sha, _, ok = m.ResolveAsset(key, false)
|
||||
return url, sha, ok
|
||||
}
|
||||
|
||||
// ResolveAsset picks binary or .app.zip. PreferAppZip when running inside Navis.app.
|
||||
func (m Manifest) ResolveAsset(key string, preferAppZip bool) (url, sha, kind string, ok bool) {
|
||||
if key == "" {
|
||||
key = PlatformKey()
|
||||
}
|
||||
if m.Platforms != nil {
|
||||
if a, found := m.Platforms[key]; found && strings.TrimSpace(a.URL) != "" {
|
||||
return strings.TrimSpace(a.URL), strings.TrimSpace(a.SHA256), true
|
||||
if a, found := m.Platforms[key]; found {
|
||||
if preferAppZip {
|
||||
zu := strings.TrimSpace(a.ZipURL)
|
||||
zs := strings.TrimSpace(a.ZipSHA256)
|
||||
if zu != "" && zs != "" {
|
||||
return zu, zs, AssetKindAppZip, true
|
||||
}
|
||||
}
|
||||
u := strings.TrimSpace(a.URL)
|
||||
s := strings.TrimSpace(a.SHA256)
|
||||
if u != "" {
|
||||
return u, s, AssetKindBinary, true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy single-asset manifests are windows-amd64.
|
||||
if (key == "windows-amd64" || key == "windows-386") && strings.TrimSpace(m.URL) != "" {
|
||||
return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), true
|
||||
return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), AssetKindBinary, true
|
||||
}
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
// Check fetches the manifest and compares versions for this platform.
|
||||
@@ -116,7 +139,15 @@ func Check(ctx context.Context, manifestURL string) (Status, error) {
|
||||
st.Notes = m.Notes
|
||||
st.Mandatory = m.Mandatory
|
||||
|
||||
url, sha, ok := m.AssetFor(key)
|
||||
preferZip := false
|
||||
if runtime.GOOS == "darwin" {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, ok := AppBundleRoot(exe); ok {
|
||||
preferZip = true
|
||||
}
|
||||
}
|
||||
}
|
||||
url, sha, _, ok := m.ResolveAsset(key, preferZip)
|
||||
if !ok {
|
||||
st.Error = fmt.Sprintf("нет сборки для %s", key)
|
||||
st.Available = false
|
||||
@@ -247,60 +278,124 @@ func fileSHA256(path string) (string, error) {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// preparedUpdate is a verified download ready for platform Apply.
|
||||
type preparedUpdate struct {
|
||||
Latest string
|
||||
URL string
|
||||
SHA256 string
|
||||
ExePath string
|
||||
TmpPath string
|
||||
Kind string // AssetKindBinary | AssetKindAppZip
|
||||
AppRoot string // set when Kind is appzip
|
||||
}
|
||||
|
||||
// AppBundleRoot returns the .app path when exe lives in Contents/MacOS.
|
||||
func AppBundleRoot(exe string) (string, bool) {
|
||||
exe = filepath.Clean(exe)
|
||||
const marker = ".app" + string(filepath.Separator) + "Contents" + string(filepath.Separator) + "MacOS"
|
||||
idx := strings.Index(exe, marker)
|
||||
if idx < 0 {
|
||||
// Also accept forward-slash form after EvalSymlinks on some volumes.
|
||||
idx = strings.Index(exe, ".app/Contents/MacOS")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return exe[:idx+len(".app")], true
|
||||
}
|
||||
return exe[:idx+len(".app")], true
|
||||
}
|
||||
|
||||
// prepareDownload fetches the update next to the running binary as temp download.
|
||||
// Refuses when already on/newer than the feed product version (no re-apply loop).
|
||||
func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, exePath, tmpPath string, err error) {
|
||||
st, err := Check(ctx, manifestURL)
|
||||
p, err := prepareUpdate(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
return p.Latest, p.URL, p.SHA256, p.ExePath, p.TmpPath, nil
|
||||
}
|
||||
|
||||
func prepareUpdate(ctx context.Context, manifestURL string) (preparedUpdate, error) {
|
||||
st, err := Check(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
if !st.Available {
|
||||
if st.Error != "" {
|
||||
return "", "", "", "", "", fmt.Errorf("%s", st.Error)
|
||||
return preparedUpdate{}, fmt.Errorf("%s", st.Error)
|
||||
}
|
||||
return "", "", "", "", "", fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
|
||||
}
|
||||
if !allowedDownloadURL(st.URL) {
|
||||
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
return preparedUpdate{}, fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
exe, err = filepath.Abs(exe)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil && resolved != "" {
|
||||
exe = resolved
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(exe), ".navis-update-download")
|
||||
_ = os.Remove(tmp)
|
||||
// Also clear legacy leftover from older updaters.
|
||||
_ = os.Remove(exe + ".new")
|
||||
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
wantSHA := st.SHA256
|
||||
if wantSHA == "" {
|
||||
if man, err := fetchManifest(ctx, manifestURL); err == nil {
|
||||
if _, sha2, ok := man.AssetFor(PlatformKey()); ok {
|
||||
wantSHA = sha2
|
||||
}
|
||||
|
||||
preferZip := false
|
||||
appRoot := ""
|
||||
if runtime.GOOS == "darwin" {
|
||||
if root, ok := AppBundleRoot(exe); ok {
|
||||
preferZip = true
|
||||
appRoot = root
|
||||
}
|
||||
}
|
||||
|
||||
man, err := fetchManifest(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
url, wantSHA, kind, ok := man.ResolveAsset(PlatformKey(), preferZip)
|
||||
if !ok || url == "" {
|
||||
return preparedUpdate{}, fmt.Errorf("нет ассета обновления для %s", PlatformKey())
|
||||
}
|
||||
if !allowedDownloadURL(url) {
|
||||
return preparedUpdate{}, fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
}
|
||||
if wantSHA == "" {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
|
||||
return preparedUpdate{}, fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
|
||||
}
|
||||
// Prefer .app.zip when available; otherwise replace Mach-O inside the bundle (legacy).
|
||||
if kind != AssetKindAppZip {
|
||||
appRoot = ""
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
if appRoot != "" {
|
||||
dir = filepath.Dir(appRoot) // sibling of .app
|
||||
}
|
||||
tmpName := ".navis-update-download"
|
||||
if kind == AssetKindAppZip {
|
||||
tmpName = ".navis-update-app.zip"
|
||||
}
|
||||
tmp := filepath.Join(dir, tmpName)
|
||||
_ = os.Remove(tmp)
|
||||
_ = os.Remove(exe + ".new")
|
||||
if err := downloadFile(ctx, url, tmp); err != nil {
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
sum, err := fileSHA256(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", err
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
if !strings.EqualFold(sum, wantSHA) {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
||||
return preparedUpdate{}, fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
return st.Latest, st.URL, wantSHA, exe, tmp, nil
|
||||
return preparedUpdate{
|
||||
Latest: st.Latest,
|
||||
URL: url,
|
||||
SHA256: wantSHA,
|
||||
ExePath: exe,
|
||||
TmpPath: tmp,
|
||||
Kind: kind,
|
||||
AppRoot: appRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -12,21 +12,28 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Apply downloads the new binary and schedules replacement + relaunch after exit.
|
||||
// Apply downloads the new binary or .app.zip and schedules replacement + relaunch after exit.
|
||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL)
|
||||
p, err := prepareUpdate(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(tmp, 0o755)
|
||||
dir := filepath.Dir(exe)
|
||||
if p.Kind == AssetKindAppZip {
|
||||
return applyAppZip(p)
|
||||
}
|
||||
return applyBinary(p)
|
||||
}
|
||||
|
||||
func applyBinary(p preparedUpdate) (string, error) {
|
||||
_ = os.Chmod(p.TmpPath, 0o755)
|
||||
dir := filepath.Dir(p.ExePath)
|
||||
scriptPath := filepath.Join(dir, "navis-update.sh")
|
||||
pid := os.Getpid()
|
||||
|
||||
script := "#!/bin/bash\n" +
|
||||
"set -e\n" +
|
||||
"EXE=" + shellQuote(exe) + "\n" +
|
||||
"NEW=" + shellQuote(tmp) + "\n" +
|
||||
"EXE=" + shellQuote(p.ExePath) + "\n" +
|
||||
"NEW=" + shellQuote(p.TmpPath) + "\n" +
|
||||
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
||||
"sleep 0.5\n" +
|
||||
@@ -43,7 +50,53 @@ func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("start updater: %w", err)
|
||||
}
|
||||
return latest, nil
|
||||
return p.Latest, nil
|
||||
}
|
||||
|
||||
func applyAppZip(p preparedUpdate) (string, error) {
|
||||
if p.AppRoot == "" {
|
||||
return "", fmt.Errorf("update: empty app root")
|
||||
}
|
||||
parent := filepath.Dir(p.AppRoot)
|
||||
scriptPath := filepath.Join(parent, "navis-update-app.sh")
|
||||
pid := os.Getpid()
|
||||
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
|
||||
if identity == "" {
|
||||
identity = "-"
|
||||
}
|
||||
|
||||
script := "#!/bin/bash\n" +
|
||||
"set -e\n" +
|
||||
"APP=" + shellQuote(p.AppRoot) + "\n" +
|
||||
"ZIP=" + shellQuote(p.TmpPath) + "\n" +
|
||||
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||
"ID=" + shellQuote(identity) + "\n" +
|
||||
"PARENT=$(dirname \"$APP\")\n" +
|
||||
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
||||
"sleep 0.5\n" +
|
||||
"TMP=$(mktemp -d \"$PARENT/.navis-update-XXXXXX\")\n" +
|
||||
"unzip -q \"$ZIP\" -d \"$TMP\"\n" +
|
||||
"NEW=$(find \"$TMP\" -maxdepth 3 -name 'Navis.app' -type d | head -n 1)\n" +
|
||||
"if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" +
|
||||
"rm -rf \"$APP.bak\"\n" +
|
||||
"mv \"$APP\" \"$APP.bak\"\n" +
|
||||
"mv \"$NEW\" \"$APP\"\n" +
|
||||
"if command -v codesign >/dev/null 2>&1; then\n" +
|
||||
" codesign -s \"$ID\" --force --deep --options runtime \"$APP\" 2>/dev/null || codesign -s \"$ID\" --force --deep \"$APP\" || true\n" +
|
||||
" xattr -cr \"$APP\" 2>/dev/null || true\n" +
|
||||
"fi\n" +
|
||||
"rm -rf \"$APP.bak\" \"$TMP\" \"$ZIP\" " + shellQuote(scriptPath) + "\n" +
|
||||
"open \"$APP\"\n"
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command("/bin/bash", scriptPath)
|
||||
cmd.Dir = parent
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("start app updater: %w", err)
|
||||
}
|
||||
return p.Latest, nil
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
@@ -58,5 +111,13 @@ func CleanupStaleDownloads() {
|
||||
}
|
||||
exe, _ = filepath.Abs(exe)
|
||||
_ = os.Remove(exe + ".new")
|
||||
_ = os.Remove(filepath.Join(filepath.Dir(exe), ".navis-update-download"))
|
||||
dir := filepath.Dir(exe)
|
||||
_ = os.Remove(filepath.Join(dir, ".navis-update-download"))
|
||||
_ = os.Remove(filepath.Join(dir, ".navis-update-app.zip"))
|
||||
if root, ok := AppBundleRoot(exe); ok {
|
||||
parent := filepath.Dir(root)
|
||||
_ = os.Remove(filepath.Join(parent, ".navis-update-app.zip"))
|
||||
_ = os.Remove(filepath.Join(parent, "navis-update-app.sh"))
|
||||
_ = os.Remove(filepath.Join(parent, "navis-update.sh"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,37 @@ func TestCompare(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAssetPrefersZip(t *testing.T) {
|
||||
m := Manifest{
|
||||
Platforms: map[string]PlatformAsset{
|
||||
"darwin-arm64": {
|
||||
URL: "https://git.evilfox.cc/bin",
|
||||
SHA256: "aaa",
|
||||
ZipURL: "https://git.evilfox.cc/app.zip",
|
||||
ZipSHA256: "bbb",
|
||||
},
|
||||
},
|
||||
}
|
||||
u, s, k, ok := m.ResolveAsset("darwin-arm64", true)
|
||||
if !ok || k != AssetKindAppZip || u == "" || s != "bbb" {
|
||||
t.Fatalf("zip: ok=%v kind=%s url=%s sha=%s", ok, k, u, s)
|
||||
}
|
||||
u, s, k, ok = m.ResolveAsset("darwin-arm64", false)
|
||||
if !ok || k != AssetKindBinary || s != "aaa" {
|
||||
t.Fatalf("bin: ok=%v kind=%s sha=%s", ok, k, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppBundleRoot(t *testing.T) {
|
||||
root, ok := AppBundleRoot("/Applications/Navis.app/Contents/MacOS/Navis")
|
||||
if !ok || root != "/Applications/Navis.app" {
|
||||
t.Fatalf("got %q ok=%v", root, ok)
|
||||
}
|
||||
if _, ok := AppBundleRoot("/usr/local/bin/Navis"); ok {
|
||||
t.Fatal("expected bare binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestAssetFor(t *testing.T) {
|
||||
m := Manifest{
|
||||
URL: "https://git.evilfox.cc/legacy.exe",
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Prefer local toolchain if present.
|
||||
export PATH="$(pwd)/.tools/go/bin:/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH"
|
||||
|
||||
python3 scripts/sync-version.py
|
||||
|
||||
VERSION="$(python3 - <<'PY'
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -26,7 +31,6 @@ OUT="dist/navis-release/darwin-arm64"
|
||||
mkdir -p "$OUT"
|
||||
LDFLAGS="-s -w"
|
||||
|
||||
export PATH="/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
|
||||
go build -ldflags="$LDFLAGS" -trimpath -o "$OUT/Navis" ./cmd/vpnapp
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
|
||||
@@ -35,18 +39,14 @@ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
|
||||
printf '%s\n' "$FULL" > "$OUT/VERSION"
|
||||
printf '%s\n' "${VERSION}+${BUILD}" > "$OUT/Navis.version"
|
||||
|
||||
# Ad-hoc sign CLI/bin; packmac also signs the .app (honors NAVIS_CODESIGN_IDENTITY).
|
||||
if command -v codesign >/dev/null 2>&1; then
|
||||
codesign -s - --force "$OUT/Navis"
|
||||
codesign -s - --force "$OUT/Navis-cli"
|
||||
ID="${NAVIS_CODESIGN_IDENTITY:--}"
|
||||
codesign -s "$ID" --force "$OUT/Navis" || codesign -s - --force "$OUT/Navis"
|
||||
codesign -s "$ID" --force "$OUT/Navis-cli" || codesign -s - --force "$OUT/Navis-cli"
|
||||
fi
|
||||
|
||||
go build -o tools/packmac/packmac ./tools/packmac
|
||||
tools/packmac/packmac \
|
||||
-bin "$OUT/Navis" \
|
||||
-out "$OUT" \
|
||||
-version "$VERSION" \
|
||||
-build "$FULL" \
|
||||
-arch arm64
|
||||
go run ./tools/packmac -bin "$OUT/Navis" -out "$OUT" -version "$VERSION" -build "$FULL" -arch arm64
|
||||
|
||||
cp -f "$OUT/Navis.dmg" "$OUT/Navis-${FULL}-arm64.dmg"
|
||||
cp -f "$OUT/Navis.app.zip" "$OUT/Navis-${FULL}-arm64.app.zip"
|
||||
@@ -57,10 +57,23 @@ from pathlib import Path
|
||||
ver = "${VERSION}"
|
||||
build = "${BUILD}"
|
||||
full = "${FULL}"
|
||||
binp = Path('dist/navis-release/darwin-arm64/Navis')
|
||||
out = Path('dist/navis-release/darwin-arm64')
|
||||
binp = out / 'Navis'
|
||||
zipp = out / 'Navis.app.zip'
|
||||
dmgp = out / 'Navis.dmg'
|
||||
h = hashlib.sha256(binp.read_bytes()).hexdigest()
|
||||
notes = f"Navis {ver}+{build}: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке)."
|
||||
for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
|
||||
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
|
||||
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
|
||||
notes = (
|
||||
f"Navis {ver}+{build}: Mac .app zip update; EnsureCore lock; "
|
||||
f"getState без секретов; подпись/notarize pipeline; UI split."
|
||||
)
|
||||
paths = [
|
||||
Path('dist/update.json'),
|
||||
Path('dist/navis-release/update.json'),
|
||||
Path('server/update.json'),
|
||||
]
|
||||
for p in paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
d = json.loads(p.read_text())
|
||||
@@ -69,8 +82,20 @@ for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
|
||||
plats = d.setdefault('platforms', {})
|
||||
if 'darwin-arm64' in plats:
|
||||
plats['darwin-arm64']['sha256'] = h
|
||||
if zh:
|
||||
plats['darwin-arm64']['zip_sha256'] = zh
|
||||
if dh:
|
||||
plats['darwin-arm64']['dmg_sha256'] = dh
|
||||
plats['darwin-arm64'].setdefault(
|
||||
'zip_url',
|
||||
'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip',
|
||||
)
|
||||
plats['darwin-arm64'].setdefault(
|
||||
'dmg_url',
|
||||
'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg',
|
||||
)
|
||||
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + '\n')
|
||||
print('updated', p, '->', ver, 'sha', h[:12])
|
||||
print('updated', p, '->', ver, 'bin', h[:12], 'zip', zh[:12] if zh else '-')
|
||||
PY
|
||||
|
||||
echo ""
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Optional release signing / notarization for macOS Navis.app / DMG.
|
||||
# Requires Apple Developer ID + notarytool credentials in the environment.
|
||||
#
|
||||
# Usage:
|
||||
# export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
|
||||
# export NAVIS_NOTARY_PROFILE="navis-notary" # xcrun notarytool store-credentials
|
||||
# ./scripts/sign-macos.sh dist/navis-release/darwin-arm64/Navis.dmg
|
||||
# # or a staged .app:
|
||||
# ./scripts/sign-macos.sh /path/to/Navis.app
|
||||
set -euo pipefail
|
||||
|
||||
TARGET="${1:-}"
|
||||
if [[ -z "$TARGET" || ! -e "$TARGET" ]]; then
|
||||
echo "usage: $0 <Navis.app|Navis.dmg>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
IDENTITY="${NAVIS_CODESIGN_IDENTITY:-}"
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
echo "NAVIS_CODESIGN_IDENTITY is required (Developer ID Application: …)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sign_app() {
|
||||
local app="$1"
|
||||
echo "codesign $app as $IDENTITY"
|
||||
codesign -s "$IDENTITY" --force --deep --options runtime --timestamp "$app"
|
||||
codesign --verify --deep --strict "$app"
|
||||
}
|
||||
|
||||
if [[ "$TARGET" == *.app || -d "$TARGET" ]]; then
|
||||
sign_app "$TARGET"
|
||||
echo "Signed app. Notarize the DMG that contains it via notarytool."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == *.dmg ]]; then
|
||||
# Sign nested .app if present after attach is awkward; prefer signing .app before packmac.
|
||||
# Here we submit the DMG for notarization when credentials exist.
|
||||
if [[ -n "${NAVIS_NOTARY_PROFILE:-}" ]]; then
|
||||
echo "notarytool submit $TARGET (profile=$NAVIS_NOTARY_PROFILE)"
|
||||
xcrun notarytool submit "$TARGET" --keychain-profile "$NAVIS_NOTARY_PROFILE" --wait
|
||||
xcrun stapler staple "$TARGET"
|
||||
echo "Notarized + stapled: $TARGET"
|
||||
else
|
||||
echo "DMG ready. Set NAVIS_NOTARY_PROFILE and re-run to notarize."
|
||||
echo "Tip: packmac with NAVIS_CODESIGN_IDENTITY set signs the .app before zip/dmg."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unsupported target: $TARGET" >&2
|
||||
exit 2
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync versioninfo.json + build-macos.bat from internal/update/update.go constants."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UPDATE_GO = ROOT / "internal" / "update" / "update.go"
|
||||
VERSIONINFO = ROOT / "versioninfo.json"
|
||||
BUILD_BAT = ROOT / "build-macos.bat"
|
||||
|
||||
|
||||
def read_consts() -> tuple[str, int]:
|
||||
text = UPDATE_GO.read_text(encoding="utf-8")
|
||||
m = re.search(r'CurrentVersion\s*=\s*"([^"]+)"', text)
|
||||
b = re.search(r"const BuildNumber\s*=\s*(\d+)", text)
|
||||
if not m or not b:
|
||||
raise SystemExit("cannot parse CurrentVersion/BuildNumber from update.go")
|
||||
return m.group(1), int(b.group(1))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ver, build = read_consts()
|
||||
parts = [int(x) for x in ver.split(".")]
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
major, minor, patch = parts[0], parts[1], parts[2]
|
||||
full = f"{ver}.{build}"
|
||||
|
||||
data = json.loads(VERSIONINFO.read_text(encoding="utf-8"))
|
||||
data["FixedFileInfo"]["FileVersion"] = {
|
||||
"Major": major,
|
||||
"Minor": minor,
|
||||
"Patch": patch,
|
||||
"Build": build,
|
||||
}
|
||||
data["FixedFileInfo"]["ProductVersion"] = dict(data["FixedFileInfo"]["FileVersion"])
|
||||
data["StringFileInfo"]["FileVersion"] = full
|
||||
data["StringFileInfo"]["ProductVersion"] = full
|
||||
VERSIONINFO.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print("updated", VERSIONINFO, "->", full)
|
||||
|
||||
bat = BUILD_BAT.read_text(encoding="utf-8")
|
||||
bat2 = re.sub(
|
||||
r'-version\s+\d+\.\d+\.\d+\s+-build\s+\d+\.\d+\.\d+\.\d+',
|
||||
f"-version {ver} -build {full}",
|
||||
bat,
|
||||
)
|
||||
if bat2 != bat:
|
||||
BUILD_BAT.write_text(bat2, encoding="utf-8")
|
||||
print("updated", BUILD_BAT, "->", ver, full)
|
||||
else:
|
||||
print("build-macos.bat already in sync")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "2.7.3",
|
||||
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
|
||||
"version": "3.8.0",
|
||||
"notes": "Navis 3.8.0+2: Mac .app zip update; EnsureCore lock; getState без секретов; подпись/notarize pipeline; UI split.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
@@ -16,11 +16,13 @@
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
|
||||
"sha256": "6e15ad79e5ec1a1f74c48bc8f189ab8694b8f3f67f4be204420a12cf0fd622fd",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
|
||||
"zip_sha256": "65041e154356a33525b9769f851bcccfc79d0c2a1200fb9a47bc22dd95cdf2ef",
|
||||
"dmg_sha256": "46617b7cbf22e92a5ef85740ed9473cd1bc9219c9de5841ce0ac46b8eb946912"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
|
||||
@@ -178,12 +178,15 @@ func signAppBundle(appRoot string) error {
|
||||
if _, err := exec.LookPath("codesign"); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Fresh ad-hoc signature; entitlements not required for local/dev builds.
|
||||
cmd := exec.Command("codesign", "-s", "-", "--force", "--deep", "--options", "runtime", appRoot)
|
||||
// NAVIS_CODESIGN_IDENTITY=Developer ID Application: … for release; default ad-hoc "-".
|
||||
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
|
||||
if identity == "" {
|
||||
identity = "-"
|
||||
}
|
||||
cmd := exec.Command("codesign", "-s", identity, "--force", "--deep", "--options", "runtime", appRoot)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// --options runtime can fail without a real Developer ID; retry plain ad-hoc.
|
||||
cmd = exec.Command("codesign", "-s", "-", "--force", "--deep", appRoot)
|
||||
cmd = exec.Command("codesign", "-s", identity, "--force", "--deep", appRoot)
|
||||
out, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("codesign %s: %w: %s", appRoot, err, strings.TrimSpace(string(out)))
|
||||
|
||||
+15
-5
@@ -1,7 +1,17 @@
|
||||
{
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
|
||||
"ProductVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
|
||||
"FileVersion": {
|
||||
"Major": 3,
|
||||
"Minor": 8,
|
||||
"Patch": 0,
|
||||
"Build": 2
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 3,
|
||||
"Minor": 8,
|
||||
"Patch": 0,
|
||||
"Build": 2
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
"FileFlags": "00",
|
||||
"FileOS": "40004",
|
||||
@@ -10,13 +20,13 @@
|
||||
},
|
||||
"StringFileInfo": {
|
||||
"CompanyName": "EvilFox",
|
||||
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||
"FileVersion": "2.7.3.3",
|
||||
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||
"FileVersion": "3.8.0.2",
|
||||
"InternalName": "Navis",
|
||||
"LegalCopyright": "Copyright (c) EvilFox",
|
||||
"OriginalFilename": "Navis.exe",
|
||||
"ProductName": "Navis",
|
||||
"ProductVersion": "2.7.3.3",
|
||||
"ProductVersion": "3.8.0.2",
|
||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
Reference in New Issue
Block a user