Release 3.8.0.2: app.zip updates, lock hygiene, secret-safe getState.
This commit is contained in:
+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")
|
||||
}
|
||||
}
|
||||
+2
-1434
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
-20
@@ -79,54 +79,65 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
m.waitEngineStopped()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("already connected; disconnect first")
|
||||
}
|
||||
if m.engine != nil {
|
||||
// Defunct reference (stop finished or engine died) — drop before start.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -552,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
|
||||
@@ -580,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 {
|
||||
|
||||
+128
-33
@@ -22,7 +22,7 @@ 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 = 1
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user