Release 3.8.2.1: reliability, UI probe/logs, CI, signing gates.
Clear sysproxy on core death; probe+reconnect; subscription prune; Best ignores soft UDP; Windows tray; Android protocol gate; CI workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+138
-46
@@ -41,23 +41,27 @@ type App struct {
|
||||
}
|
||||
|
||||
type UIState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ConnectOnLaunch bool `json:"connect_on_launch"`
|
||||
AutoReconnect bool `json:"auto_reconnect"`
|
||||
LogTail string `json:"log_tail,omitempty"`
|
||||
}
|
||||
|
||||
type PingBestResult struct {
|
||||
@@ -117,23 +121,29 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
|
||||
}
|
||||
|
||||
out := UIState{
|
||||
Connected: st.Connected,
|
||||
Profile: st.Profile,
|
||||
ActiveProfile: poll.Active,
|
||||
Protocol: string(st.Protocol),
|
||||
HTTPProxy: st.HTTPProxy,
|
||||
SOCKSProxy: st.SOCKSProxy,
|
||||
SystemProxy: poll.SystemProxy,
|
||||
Proxy: proxy,
|
||||
CoreReady: coreReady,
|
||||
CorePath: corePath,
|
||||
ConfigPath: cfgPath,
|
||||
Profiles: profiles,
|
||||
Version: update.DisplayVersion(),
|
||||
Update: upd,
|
||||
Pings: pings,
|
||||
Subscription: poll.Subscription,
|
||||
Hy2: hy2,
|
||||
Connected: st.Connected,
|
||||
Profile: st.Profile,
|
||||
ActiveProfile: poll.Active,
|
||||
Protocol: string(st.Protocol),
|
||||
HTTPProxy: st.HTTPProxy,
|
||||
SOCKSProxy: st.SOCKSProxy,
|
||||
SystemProxy: poll.SystemProxy,
|
||||
Proxy: proxy,
|
||||
CoreReady: coreReady,
|
||||
CorePath: corePath,
|
||||
ConfigPath: cfgPath,
|
||||
Profiles: profiles,
|
||||
Version: update.DisplayVersion(),
|
||||
Update: upd,
|
||||
Pings: pings,
|
||||
Subscription: poll.Subscription,
|
||||
Hy2: hy2,
|
||||
LastError: poll.LastError,
|
||||
ConnectOnLaunch: poll.ConnectOnLaunch,
|
||||
AutoReconnect: poll.AutoReconnect,
|
||||
}
|
||||
if includeSecrets && a.LogBuf != nil {
|
||||
out.LogTail = trimLogTail(a.LogBuf.String(), 4000)
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
out.Protocol = string(poll.ActiveProtocol)
|
||||
@@ -144,6 +154,13 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func trimLogTail(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return "…\n" + s[len(s)-max:]
|
||||
}
|
||||
|
||||
func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready bool) {
|
||||
key := corebin.CacheKey(binDir, corebin.ProtoKey(string(proto)))
|
||||
var err error
|
||||
@@ -229,19 +246,93 @@ func (a *App) ConnectProfile(name string) error {
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
|
||||
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
||||
return err
|
||||
var last error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
||||
a.Mgr.SetLastError(err.Error())
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
err := a.Mgr.Connect(ctx, "")
|
||||
cancel()
|
||||
if err != nil {
|
||||
a.Mgr.SetLastError(err.Error())
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
pctx, pcancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
perr := a.Mgr.Probe(pctx, "")
|
||||
pcancel()
|
||||
if perr == nil {
|
||||
return nil
|
||||
}
|
||||
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
|
||||
a.Mgr.SetLastError(last.Error())
|
||||
_ = a.Mgr.Disconnect()
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
return a.Mgr.Connect(ctx, "")
|
||||
return last
|
||||
}
|
||||
|
||||
func (a *App) Disconnect() error {
|
||||
return a.Mgr.Disconnect()
|
||||
}
|
||||
|
||||
func (a *App) GetLogs() string {
|
||||
if a.LogBuf == nil {
|
||||
return ""
|
||||
}
|
||||
return trimLogTail(a.LogBuf.String(), 12000)
|
||||
}
|
||||
|
||||
func (a *App) ProbeTunnel() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
err := a.Mgr.Probe(ctx, "")
|
||||
if err != nil {
|
||||
a.Mgr.SetLastError(err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
|
||||
return a.Mgr.SavePrefs(core.Prefs{
|
||||
ConnectOnLaunch: connectOnLaunch,
|
||||
AutoReconnect: autoReconnect,
|
||||
SystemProxy: systemProxy,
|
||||
})
|
||||
}
|
||||
|
||||
// StartBackground runs reconnect-on-crash and optional connect-on-launch.
|
||||
func (a *App) StartBackground() {
|
||||
go a.watchdogLoop()
|
||||
prefs := a.Mgr.Prefs()
|
||||
if prefs.ConnectOnLaunch {
|
||||
go func() {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
if a.Mgr.Status().Connected {
|
||||
return
|
||||
}
|
||||
_ = a.Connect()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) watchdogLoop() {
|
||||
for {
|
||||
time.Sleep(2 * time.Second)
|
||||
if !a.Mgr.TakeUnexpectedDrop() {
|
||||
continue
|
||||
}
|
||||
prefs := a.Mgr.Prefs()
|
||||
if !prefs.AutoReconnect {
|
||||
continue
|
||||
}
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
_ = a.Connect()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) InstallCore() (string, error) {
|
||||
paths, err := a.Mgr.EnsureAllCores()
|
||||
if err != nil {
|
||||
@@ -316,12 +407,7 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
||||
return out, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Mgr.Connect(ctx, ""); err != nil {
|
||||
if err := a.ConnectProfile(""); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Connected = true
|
||||
@@ -527,6 +613,12 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
||||
return nil, a.SaveHy2(opts)
|
||||
case "importSubscription":
|
||||
return a.ImportSubscription(arg(args, 0, ""))
|
||||
case "getLogs":
|
||||
return a.GetLogs(), nil
|
||||
case "probeTunnel":
|
||||
return nil, a.ProbeTunnel()
|
||||
case "savePrefs":
|
||||
return nil, a.SavePrefs(arg(args, 0, false), arg(args, 1, false), arg(args, 2, true))
|
||||
case "quit":
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
@@ -801,3 +801,18 @@
|
||||
.tools { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
||||
.log-tail {
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
font-size: .72rem;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--panel-bg, rgba(0,0,0,.04));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
|
||||
+40
-4
@@ -3,7 +3,7 @@
|
||||
const methods = [
|
||||
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
||||
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
||||
"checkUpdate","applyUpdate","saveHy2","importSubscription","quit"
|
||||
"checkUpdate","applyUpdate","saveHy2","importSubscription","getLogs","probeTunnel","savePrefs","quit"
|
||||
];
|
||||
if (typeof window.getState === "function") return;
|
||||
window.__navisHttp = true;
|
||||
@@ -36,6 +36,11 @@
|
||||
const proxy = $("proxy");
|
||||
const nameInput = $("name");
|
||||
const sysproxy = $("sysproxy");
|
||||
const bootConnect = $("bootConnect");
|
||||
const autoReconnect = $("autoReconnect");
|
||||
const logTail = $("logTail");
|
||||
const logRefreshBtn = $("logRefreshBtn");
|
||||
const probeBtn = $("probeBtn");
|
||||
const profile = $("profile");
|
||||
const dot = $("dot");
|
||||
const statusText = $("statusText");
|
||||
@@ -213,7 +218,7 @@
|
||||
parts.push(p.name + "|" + (p.protocol || "") + "|" + (p.host || ""));
|
||||
});
|
||||
(state.pings || []).forEach((p) => {
|
||||
parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || ""));
|
||||
parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.soft ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || ""));
|
||||
});
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -282,7 +287,8 @@
|
||||
const pr = pingMap[p.name];
|
||||
if (pr && pr.ok) {
|
||||
ms.className = "ms " + msClass(pr.ms, true);
|
||||
ms.textContent = pr.ms + " ms";
|
||||
ms.textContent = (pr.soft ? "~" : "") + pr.ms + " ms";
|
||||
if (pr.soft) ms.title = "soft-up (UDP без ответа) — не выбирается как «Лучший»";
|
||||
} else if (pr && pr.error) {
|
||||
ms.className = "ms bad";
|
||||
ms.textContent = "—";
|
||||
@@ -363,6 +369,10 @@
|
||||
proxy.disabled = lock;
|
||||
nameInput.disabled = lock;
|
||||
sysproxy.disabled = lock;
|
||||
if (bootConnect) bootConnect.disabled = busy;
|
||||
if (autoReconnect) autoReconnect.disabled = busy;
|
||||
if (probeBtn) probeBtn.disabled = busy || !connected;
|
||||
if (logRefreshBtn) logRefreshBtn.disabled = busy;
|
||||
profile.disabled = lock;
|
||||
addBtn.disabled = lock;
|
||||
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
|
||||
@@ -392,6 +402,8 @@
|
||||
nameInput.value = active.name || state.active_profile || "";
|
||||
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
|
||||
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
|
||||
if (bootConnect && typeof state.connect_on_launch === "boolean") bootConnect.checked = state.connect_on_launch;
|
||||
if (autoReconnect && typeof state.auto_reconnect === "boolean") autoReconnect.checked = state.auto_reconnect;
|
||||
fillHy2(state.hy2);
|
||||
formHydrated = true;
|
||||
if (syncForm) dirty = false;
|
||||
@@ -420,11 +432,17 @@
|
||||
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
|
||||
}
|
||||
if (!busy && Date.now() > metaHoldUntil) {
|
||||
setMeta(detail, state.core_ready === false ? "err" : "");
|
||||
if (!connected && state.last_error) setMeta(state.last_error, "err");
|
||||
else setMeta(detail, state.core_ready === false ? "err" : "");
|
||||
metaHoldUntil = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistPrefs() {
|
||||
if (typeof savePrefs !== "function") return;
|
||||
await savePrefs(!!(bootConnect && bootConnect.checked), !!(autoReconnect && autoReconnect.checked), !!sysproxy.checked);
|
||||
}
|
||||
|
||||
async function refresh(opts) {
|
||||
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
|
||||
let state;
|
||||
@@ -653,6 +671,24 @@
|
||||
setMeta("Версия " + (latest || "?") + " пропущена. Следующую предложим.", "ok");
|
||||
});
|
||||
|
||||
if (sysproxy) sysproxy.addEventListener("change", () => { persistPrefs().catch(() => {}); dirty = true; });
|
||||
if (bootConnect) bootConnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
|
||||
if (autoReconnect) autoReconnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
|
||||
if (logRefreshBtn) logRefreshBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
const t = await getLogs();
|
||||
if (logTail) logTail.textContent = t || "—";
|
||||
setMeta("Лог обновлён", "ok");
|
||||
} catch (e) { setMeta(fmtErr(e), "err"); }
|
||||
}));
|
||||
if (probeBtn) probeBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Проверка туннеля…");
|
||||
await probeTunnel();
|
||||
setMeta("Туннель OK", "ok");
|
||||
} catch (e) { setMeta(fmtErr(e), "err"); }
|
||||
}));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await refresh({ syncForm: true });
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="brand-wrap">
|
||||
<div class="brand-row">
|
||||
<h1 class="brand">Navis</h1>
|
||||
<span class="badge-ver" id="badgeVer">3.8.1</span>
|
||||
<span class="badge-ver" id="badgeVer">3.8.2</span>
|
||||
</div>
|
||||
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
||||
</div>
|
||||
@@ -122,6 +122,29 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Подключать при запуске</span>
|
||||
<label class="switch">
|
||||
<input id="bootConnect" type="checkbox" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Автопереподключение</span>
|
||||
<label class="switch">
|
||||
<input id="autoReconnect" type="checkbox" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="panel" id="logBox">
|
||||
<summary>Логи ядра</summary>
|
||||
<pre class="log-tail" id="logTail">—</pre>
|
||||
<div class="row-actions">
|
||||
<button class="action secondary" id="logRefreshBtn" type="button">Обновить лог</button>
|
||||
<button class="action secondary" id="probeBtn" type="button">Проверить туннель</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel hy2" id="hy2Box">
|
||||
<summary>Hysteria 2 · BBR / obfuscation</summary>
|
||||
|
||||
@@ -35,6 +35,15 @@ type Config struct {
|
||||
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
|
||||
SubscriptionURL string `json:"subscription_url,omitempty"`
|
||||
|
||||
// SubscriptionNames are profile names last imported from SubscriptionURL (for prune).
|
||||
SubscriptionNames []string `json:"subscription_names,omitempty"`
|
||||
|
||||
// ConnectOnLaunch connects the active profile when the GUI starts.
|
||||
ConnectOnLaunch bool `json:"connect_on_launch,omitempty"`
|
||||
|
||||
// AutoReconnect reconnects after an unexpected core crash.
|
||||
AutoReconnect bool `json:"auto_reconnect,omitempty"`
|
||||
|
||||
Profiles []Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
|
||||
+173
-14
@@ -24,7 +24,7 @@ import (
|
||||
"vpnclient/internal/sysproxy"
|
||||
)
|
||||
|
||||
// Manager orchestrates protocol engines and Windows system proxy.
|
||||
// Manager orchestrates protocol engines and OS system proxy.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfgPath string
|
||||
@@ -36,6 +36,10 @@ type Manager struct {
|
||||
binDir string
|
||||
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
|
||||
lastStop sync.WaitGroup
|
||||
|
||||
lastError string
|
||||
unexpectedDrop bool
|
||||
watchGeneration uint64
|
||||
}
|
||||
|
||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||
@@ -113,15 +117,16 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
_ = engine.Stop()
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("already connected; disconnect first")
|
||||
}
|
||||
|
||||
if wantSysProxy {
|
||||
httpProxy, ok := engine.LocalHTTPProxy()
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
|
||||
}
|
||||
@@ -129,6 +134,7 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
if errors.Is(err, sysproxy.ErrUnsupported) {
|
||||
fmt.Fprintf(stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
|
||||
} else {
|
||||
m.mu.Unlock()
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("enable system proxy: %w", err)
|
||||
}
|
||||
@@ -139,15 +145,81 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
|
||||
m.engine = engine
|
||||
m.profile = &profileCopy
|
||||
m.lastError = ""
|
||||
m.unexpectedDrop = false
|
||||
m.watchGeneration++
|
||||
gen := m.watchGeneration
|
||||
m.mu.Unlock()
|
||||
go m.watchEngine(gen, engine)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastError returns the last connection / core failure message.
|
||||
func (m *Manager) LastError() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.lastError
|
||||
}
|
||||
|
||||
// SetLastError stores a UI-facing error string.
|
||||
func (m *Manager) SetLastError(msg string) {
|
||||
m.mu.Lock()
|
||||
m.lastError = strings.TrimSpace(msg)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// TakeUnexpectedDrop reports and clears a crash-driven disconnect.
|
||||
func (m *Manager) TakeUnexpectedDrop() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.unexpectedDrop {
|
||||
return false
|
||||
}
|
||||
m.unexpectedDrop = false
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *Manager) watchEngine(gen uint64, eng Engine) {
|
||||
if eng == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
m.mu.Lock()
|
||||
if m.watchGeneration != gen || m.engine != eng {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if eng.Running() {
|
||||
m.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
// Core died without Disconnect().
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
m.lastError = "процесс ядра завершился неожиданно"
|
||||
m.unexpectedDrop = true
|
||||
sys := m.sys
|
||||
cfgPath := m.cfgPath
|
||||
m.mu.Unlock()
|
||||
|
||||
if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(cfgPath)) {
|
||||
_ = sys.ForceDisable()
|
||||
sysproxy.ClearSentinel(cfgPath)
|
||||
fmt.Fprintf(m.stderr, "core exited: system proxy cleared\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Disconnect() error {
|
||||
m.mu.Lock()
|
||||
sys := m.sys
|
||||
engine := m.engine
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
m.watchGeneration++ // stop watcher
|
||||
m.unexpectedDrop = false
|
||||
if engine != nil {
|
||||
m.lastStop.Add(1)
|
||||
}
|
||||
@@ -280,15 +352,18 @@ func (m *Manager) Config() *config.Config {
|
||||
|
||||
// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
|
||||
type UIPoll struct {
|
||||
Status Status
|
||||
Active string
|
||||
SystemProxy bool
|
||||
Subscription string
|
||||
ActiveProxy string
|
||||
ActiveProtocol config.Protocol
|
||||
Profiles []config.ProfileInfo
|
||||
Hy2 Hy2Options
|
||||
BinDir string
|
||||
Status Status
|
||||
Active string
|
||||
SystemProxy bool
|
||||
Subscription string
|
||||
ActiveProxy string
|
||||
ActiveProtocol config.Protocol
|
||||
Profiles []config.ProfileInfo
|
||||
Hy2 Hy2Options
|
||||
BinDir string
|
||||
LastError string
|
||||
ConnectOnLaunch bool
|
||||
AutoReconnect bool
|
||||
}
|
||||
|
||||
// PollUI gathers status + profile list under one lock without Config().Clone().
|
||||
@@ -302,11 +377,14 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll {
|
||||
SystemProxy: false,
|
||||
Subscription: "",
|
||||
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
|
||||
LastError: m.lastError,
|
||||
}
|
||||
if m.cfg != nil {
|
||||
out.Active = m.cfg.Active
|
||||
out.SystemProxy = m.cfg.SystemProxy
|
||||
out.Subscription = m.cfg.SubscriptionURL
|
||||
out.ConnectOnLaunch = m.cfg.ConnectOnLaunch
|
||||
out.AutoReconnect = m.cfg.AutoReconnect
|
||||
if includeSecrets {
|
||||
out.Profiles = m.cfg.ListProfiles()
|
||||
} else {
|
||||
@@ -354,6 +432,38 @@ func (m *Manager) SetSystemProxy(enabled bool) {
|
||||
m.cfg.SystemProxy = enabled
|
||||
}
|
||||
|
||||
// Prefs are GUI toggles persisted in config.json.
|
||||
type Prefs struct {
|
||||
ConnectOnLaunch bool `json:"connect_on_launch"`
|
||||
AutoReconnect bool `json:"auto_reconnect"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
}
|
||||
|
||||
func (m *Manager) Prefs() Prefs {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cfg == nil {
|
||||
return Prefs{}
|
||||
}
|
||||
return Prefs{
|
||||
ConnectOnLaunch: m.cfg.ConnectOnLaunch,
|
||||
AutoReconnect: m.cfg.AutoReconnect,
|
||||
SystemProxy: m.cfg.SystemProxy,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SavePrefs(p Prefs) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cfg == nil {
|
||||
return fmt.Errorf("no config")
|
||||
}
|
||||
m.cfg.ConnectOnLaunch = p.ConnectOnLaunch
|
||||
m.cfg.AutoReconnect = p.AutoReconnect
|
||||
m.cfg.SystemProxy = p.SystemProxy
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateProxyURI(proxy string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -576,20 +686,69 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return 0, fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
|
||||
|
||||
newNames := make([]string, 0, len(items))
|
||||
seen := map[string]struct{}{}
|
||||
for _, it := range items {
|
||||
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
|
||||
name := strings.TrimSpace(it.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
_ = m.cfg.UpsertProfileKeepActive(name, it.URI, it.Protocol)
|
||||
for i := range m.cfg.Profiles {
|
||||
if m.cfg.Profiles[i].Name == it.Name {
|
||||
if m.cfg.Profiles[i].Name == name {
|
||||
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, ok := seen[name]; !ok {
|
||||
seen[name] = struct{}{}
|
||||
newNames = append(newNames, name)
|
||||
}
|
||||
}
|
||||
|
||||
prev := map[string]struct{}{}
|
||||
for _, n := range m.cfg.SubscriptionNames {
|
||||
prev[n] = struct{}{}
|
||||
}
|
||||
// Prune profiles that came from the previous subscription sync but are gone now.
|
||||
if len(prev) > 0 {
|
||||
kept := make([]config.Profile, 0, len(m.cfg.Profiles))
|
||||
for _, p := range m.cfg.Profiles {
|
||||
if _, wasSub := prev[p.Name]; wasSub {
|
||||
if _, still := seen[p.Name]; !still {
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
// Safety: never wipe all profiles.
|
||||
kept = append([]config.Profile(nil), m.cfg.Profiles...)
|
||||
} else {
|
||||
m.cfg.Profiles = kept
|
||||
}
|
||||
activeOK := false
|
||||
for _, p := range m.cfg.Profiles {
|
||||
if p.Name == m.cfg.Active {
|
||||
activeOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !activeOK && len(m.cfg.Profiles) > 0 {
|
||||
m.cfg.Active = m.cfg.Profiles[0].Name
|
||||
}
|
||||
}
|
||||
m.cfg.SubscriptionNames = newNames
|
||||
|
||||
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(items), nil
|
||||
return len(newNames), nil
|
||||
}
|
||||
|
||||
func (m *Manager) SaveConfig() error {
|
||||
|
||||
+17
-15
@@ -24,6 +24,7 @@ type Result struct {
|
||||
Port string `json:"port"`
|
||||
Ms int64 `json:"ms"`
|
||||
OK bool `json:"ok"`
|
||||
Soft bool `json:"soft,omitempty"` // UDP soft-up (timeout, no ICMP refuse)
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -90,12 +91,13 @@ func PingAll(ctx context.Context, targets []Target) []Result {
|
||||
return sorted
|
||||
}
|
||||
|
||||
// BestOK returns the lowest-latency successful result, if any.
|
||||
// BestOK returns the lowest-latency hard OK result (excludes UDP soft-up).
|
||||
// Soft-up nodes stay visible in the list but are not chosen as «Лучший».
|
||||
func BestOK(results []Result) (Result, bool) {
|
||||
var best Result
|
||||
found := false
|
||||
for _, r := range results {
|
||||
if !r.OK {
|
||||
if !r.OK || r.Soft {
|
||||
continue
|
||||
}
|
||||
if !found || r.Ms < best.Ms {
|
||||
@@ -186,11 +188,12 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
||||
}
|
||||
|
||||
var (
|
||||
rtt time.Duration
|
||||
err error
|
||||
rtt time.Duration
|
||||
soft bool
|
||||
err error
|
||||
)
|
||||
if hy2 || isAWG {
|
||||
rtt, err = probeUDP(ctx, host, port)
|
||||
rtt, soft, err = probeUDP(ctx, host, port)
|
||||
} else {
|
||||
rtt, err = probeTCP(ctx, host, port)
|
||||
}
|
||||
@@ -204,6 +207,7 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
||||
}
|
||||
res.Ms = ms
|
||||
res.OK = true
|
||||
res.Soft = soft
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -223,18 +227,17 @@ func probeTCP(ctx context.Context, host, port string) (time.Duration, error) {
|
||||
// UDP VPN ports usually ignore a probe datagram (no reply). Treating read-timeout
|
||||
// as failure marked live nodes as down. Semantics:
|
||||
// - ICMP / connection refused → down
|
||||
// - any reply → up (RTT to reply)
|
||||
// - short read-timeout after a successful write → soft-up (RTT ≈ DNS+dial+write)
|
||||
func probeUDP(ctx context.Context, host, port string) (time.Duration, error) {
|
||||
// - any reply → hard up (RTT to reply)
|
||||
// - short read-timeout after a successful write → soft-up (display only; not for Best)
|
||||
func probeUDP(ctx context.Context, host, port string) (time.Duration, bool, error) {
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Brief window only to catch ICMP port-unreachable; do not wait for an app reply.
|
||||
icmpWait := 350 * time.Millisecond
|
||||
deadline := time.Now().Add(icmpWait)
|
||||
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
|
||||
@@ -243,20 +246,19 @@ func probeUDP(ctx context.Context, host, port string) (time.Duration, error) {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
if _, err := conn.Write([]byte{0}); err != nil {
|
||||
return 0, err
|
||||
return 0, false, err
|
||||
}
|
||||
afterWrite := time.Now()
|
||||
|
||||
buf := make([]byte, 64)
|
||||
_, err = conn.Read(buf)
|
||||
if err == nil {
|
||||
return time.Since(start), nil
|
||||
return time.Since(start), false, nil
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
// No ICMP refuse → endpoint is plausible; latency without the wait pad.
|
||||
return afterWrite.Sub(start), nil
|
||||
return afterWrite.Sub(start), true, nil
|
||||
}
|
||||
return 0, err
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
func friendlyDialError(err error, udp bool) string {
|
||||
|
||||
@@ -47,10 +47,13 @@ func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
rtt, err := probeUDP(ctx, "127.0.0.1", port)
|
||||
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
|
||||
if err != nil {
|
||||
t.Fatalf("expected soft-ok, got err=%v", err)
|
||||
}
|
||||
if !soft {
|
||||
t.Fatal("expected soft=true")
|
||||
}
|
||||
if rtt <= 0 {
|
||||
t.Fatalf("rtt=%v", rtt)
|
||||
}
|
||||
@@ -59,12 +62,15 @@ func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
|
||||
}
|
||||
|
||||
res := PingProfile(ctx, "silent", config.ProtocolHysteria2, "hysteria2://x@127.0.0.1:"+port+"/")
|
||||
if !res.OK {
|
||||
if !res.OK || !res.Soft {
|
||||
t.Fatalf("PingProfile soft-ok failed: %+v", res)
|
||||
}
|
||||
if res.Ms < 1 {
|
||||
t.Fatalf("ms=%d", res.Ms)
|
||||
}
|
||||
if _, ok := BestOK([]Result{res}); ok {
|
||||
t.Fatal("BestOK must ignore soft-up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeUDPReplyOK(t *testing.T) {
|
||||
@@ -87,10 +93,13 @@ func TestProbeUDPReplyOK(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
rtt, err := probeUDP(ctx, "127.0.0.1", port)
|
||||
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if soft {
|
||||
t.Fatal("reply should be hard-ok")
|
||||
}
|
||||
if rtt <= 0 {
|
||||
t.Fatalf("rtt=%v", rtt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package trayhost
|
||||
|
||||
// Hooks lets the tray menu drive the VPN app without importing apphost (avoids cycles).
|
||||
type Hooks struct {
|
||||
Connect func()
|
||||
Disconnect func()
|
||||
Quit func()
|
||||
IsUp func() bool
|
||||
}
|
||||
|
||||
// Start runs a platform tray if available. Returns false when unsupported.
|
||||
func Start(appName string, h Hooks) bool {
|
||||
return start(appName, h)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package trayhost
|
||||
|
||||
// macOS/Linux: no CGO tray in default CGO_ENABLED=0 builds.
|
||||
func start(appName string, h Hooks) bool {
|
||||
_ = appName
|
||||
_ = h
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//go:build windows
|
||||
|
||||
package trayhost
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
procShellNotify = shell32.NewProc("Shell_NotifyIconW")
|
||||
procLoadIcon = user32.NewProc("LoadIconW")
|
||||
procCreatePopup = user32.NewProc("CreatePopupMenu")
|
||||
procAppendMenu = user32.NewProc("AppendMenuW")
|
||||
procTrackPopup = user32.NewProc("TrackPopupMenu")
|
||||
procDestroyMenu = user32.NewProc("DestroyMenu")
|
||||
procDefWindowProc = user32.NewProc("DefWindowProcW")
|
||||
procRegisterClass = user32.NewProc("RegisterClassExW")
|
||||
procCreateWindow = user32.NewProc("CreateWindowExW")
|
||||
procGetMessage = user32.NewProc("GetMessageW")
|
||||
procTranslate = user32.NewProc("TranslateMessage")
|
||||
procDispatch = user32.NewProc("DispatchMessageW")
|
||||
procPostQuit = user32.NewProc("PostQuitMessage")
|
||||
procSetForeground = user32.NewProc("SetForegroundWindow")
|
||||
procGetCursorPos = user32.NewProc("GetCursorPos")
|
||||
)
|
||||
|
||||
const (
|
||||
nimAdd = 0x00000000
|
||||
nimModify = 0x00000001
|
||||
nimDelete = 0x00000002
|
||||
nifMessage = 0x00000001
|
||||
nifIcon = 0x00000002
|
||||
nifTip = 0x00000004
|
||||
wmApp = 0x8000
|
||||
wmTray = wmApp + 1
|
||||
wmDestroy = 0x0002
|
||||
wmCommand = 0x0111
|
||||
wmRButtonUp = 0x0205
|
||||
wmLButtonUp = 0x0202
|
||||
mfString = 0x00000000
|
||||
mfSeparator = 0x00000800
|
||||
tpmRight = 0x0020
|
||||
tpmBottom = 0x0020
|
||||
idiApplication = 32512
|
||||
idConnect = 1001
|
||||
idDisconnect = 1002
|
||||
idQuit = 1003
|
||||
)
|
||||
|
||||
type notifyIconData struct {
|
||||
CbSize uint32
|
||||
Hwnd windows.Handle
|
||||
UID uint32
|
||||
UFlags uint32
|
||||
UCallbackMessage uint32
|
||||
HIcon windows.Handle
|
||||
SzTip [128]uint16
|
||||
}
|
||||
|
||||
type wndClassEx struct {
|
||||
CbSize uint32
|
||||
Style uint32
|
||||
LpfnWndProc uintptr
|
||||
CbClsExtra int32
|
||||
CbWndExtra int32
|
||||
HInstance windows.Handle
|
||||
HIcon windows.Handle
|
||||
HCursor windows.Handle
|
||||
HbrBackground windows.Handle
|
||||
LpszMenuName *uint16
|
||||
LpszClassName *uint16
|
||||
HIconSm windows.Handle
|
||||
}
|
||||
|
||||
type point struct{ X, Y int32 }
|
||||
|
||||
type msg struct {
|
||||
Hwnd windows.Handle
|
||||
Message uint32
|
||||
WParam uintptr
|
||||
LParam uintptr
|
||||
Time uint32
|
||||
Pt point
|
||||
}
|
||||
|
||||
func start(appName string, h Hooks) bool {
|
||||
if h.Quit == nil {
|
||||
return false
|
||||
}
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
runTray(appName, h)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func runTray(appName string, h Hooks) {
|
||||
className, _ := windows.UTF16PtrFromString("NavisTrayClass")
|
||||
title, _ := windows.UTF16PtrFromString(appName)
|
||||
|
||||
var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr {
|
||||
switch msgU {
|
||||
case wmTray:
|
||||
if lParam == wmRButtonUp || lParam == wmLButtonUp {
|
||||
showMenu(hwnd, h)
|
||||
}
|
||||
return 0
|
||||
case wmCommand:
|
||||
switch int(wParam & 0xffff) {
|
||||
case idConnect:
|
||||
if h.Connect != nil {
|
||||
go h.Connect()
|
||||
}
|
||||
case idDisconnect:
|
||||
if h.Disconnect != nil {
|
||||
go h.Disconnect()
|
||||
}
|
||||
case idQuit:
|
||||
go h.Quit()
|
||||
}
|
||||
return 0
|
||||
case wmDestroy:
|
||||
procPostQuit.Call(0)
|
||||
return 0
|
||||
}
|
||||
r, _, _ := procDefWindowProc.Call(uintptr(hwnd), uintptr(msgU), wParam, lParam)
|
||||
return r
|
||||
})
|
||||
|
||||
wc := wndClassEx{
|
||||
CbSize: uint32(unsafe.Sizeof(wndClassEx{})),
|
||||
LpfnWndProc: wndProc,
|
||||
LpszClassName: className,
|
||||
}
|
||||
procRegisterClass.Call(uintptr(unsafe.Pointer(&wc)))
|
||||
hwnd, _, _ := procCreateWindow.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
if hwnd == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
icon, _, _ := procLoadIcon.Call(0, uintptr(idiApplication))
|
||||
var nid notifyIconData
|
||||
nid.CbSize = uint32(unsafe.Sizeof(nid))
|
||||
nid.Hwnd = windows.Handle(hwnd)
|
||||
nid.UID = 1
|
||||
nid.UFlags = nifMessage | nifIcon | nifTip
|
||||
nid.UCallbackMessage = wmTray
|
||||
nid.HIcon = windows.Handle(icon)
|
||||
tip, _ := windows.UTF16FromString(appName)
|
||||
copy(nid.SzTip[:], tip)
|
||||
procShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&nid)))
|
||||
|
||||
var m msg
|
||||
for {
|
||||
ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||
if int32(ret) <= 0 {
|
||||
break
|
||||
}
|
||||
procTranslate.Call(uintptr(unsafe.Pointer(&m)))
|
||||
procDispatch.Call(uintptr(unsafe.Pointer(&m)))
|
||||
}
|
||||
procShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&nid)))
|
||||
}
|
||||
|
||||
func showMenu(hwnd windows.Handle, h Hooks) {
|
||||
menu, _, _ := procCreatePopup.Call()
|
||||
if menu == 0 {
|
||||
return
|
||||
}
|
||||
defer procDestroyMenu.Call(menu)
|
||||
add := func(id int, text string) {
|
||||
p, _ := windows.UTF16PtrFromString(text)
|
||||
procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p)))
|
||||
}
|
||||
up := h.IsUp != nil && h.IsUp()
|
||||
if up {
|
||||
add(idDisconnect, "Отключить")
|
||||
} else {
|
||||
add(idConnect, "Подключить")
|
||||
}
|
||||
procAppendMenu.Call(menu, mfSeparator, 0, 0)
|
||||
add(idQuit, "Выход")
|
||||
|
||||
var pt point
|
||||
procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
|
||||
procSetForeground.Call(uintptr(hwnd))
|
||||
procTrackPopup.Call(menu, tpmRight|tpmBottom, uintptr(pt.X), uintptr(pt.Y), 0, uintptr(hwnd), 0)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||
// Keep major.minor.patch only — no build suffix here.
|
||||
const CurrentVersion = "3.8.1"
|
||||
const CurrentVersion = "3.8.2"
|
||||
|
||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||
|
||||
@@ -61,16 +61,15 @@ func applyAppZip(p preparedUpdate) (string, error) {
|
||||
scriptPath := filepath.Join(parent, "navis-update-app.sh")
|
||||
pid := os.Getpid()
|
||||
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
|
||||
if identity == "" {
|
||||
identity = "-"
|
||||
}
|
||||
// Never re-sign with ad-hoc "-" after replacing a shipped .app — that strips
|
||||
// Developer ID / notarization. Only re-sign when a real identity is set.
|
||||
doSign := identity != "" && identity != "-"
|
||||
|
||||
script := "#!/bin/bash\n" +
|
||||
"set -e\n" +
|
||||
"APP=" + shellQuote(p.AppRoot) + "\n" +
|
||||
"ZIP=" + shellQuote(p.TmpPath) + "\n" +
|
||||
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||
"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" +
|
||||
@@ -80,11 +79,15 @@ func applyAppZip(p preparedUpdate) (string, error) {
|
||||
"if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" +
|
||||
"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" +
|
||||
"mv \"$NEW\" \"$APP\"\n"
|
||||
if doSign {
|
||||
script += "ID=" + shellQuote(identity) + "\n" +
|
||||
"if command -v codesign >/dev/null 2>&1; then\n" +
|
||||
" codesign -s \"$ID\" --force --deep --options runtime --timestamp \"$APP\"\n" +
|
||||
" codesign --verify --deep --strict \"$APP\"\n" +
|
||||
"fi\n"
|
||||
}
|
||||
script += "xattr -cr \"$APP\" 2>/dev/null || true\n" +
|
||||
"rm -rf \"$APP.bak\" \"$TMP\" \"$ZIP\" " + shellQuote(scriptPath) + "\n" +
|
||||
"open \"$APP\"\n"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user