diff --git a/README.md b/README.md
index e9d61a5..51f7c9f 100644
--- a/README.md
+++ b/README.md
@@ -247,7 +247,8 @@ https://evilfox.win/
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
- SHA-256 проверка при установке cores (naive / hy2 / xray);
-- snapshot конфига в UI (`Config().Clone`).
+- snapshot конфига в UI (`Config().Clone`);
+- 3.8.0+2: обновление Navis.app через zip+SHA; Start вне mutex; getState без секретов; подпись/notarize scripts; UI CSS/JS split.
В 2.7.3:
- восстановление системного прокси после аварийного завершения;
diff --git a/build-macos.bat b/build-macos.bat
index 151b17a..683724c 100644
--- a/build-macos.bat
+++ b/build-macos.bat
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.0 -build 3.8.0.1 -arch arm64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.0 -build 3.8.0.2 -arch arm64
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.0 -build 3.8.0.1 -arch amd64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.0 -build 3.8.0.2 -arch amd64
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.0 -build 3.8.0.1 -arch universal
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.0 -build 3.8.0.2 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
diff --git a/cmd/vpnapp/main_darwin.go b/cmd/vpnapp/main_darwin.go
index d5d4fba..50d0ccc 100644
--- a/cmd/vpnapp/main_darwin.go
+++ b/cmd/vpnapp/main_darwin.go
@@ -89,7 +89,7 @@ func main() {
w.Destroy()
}()
- w.SetTitle("Navis 2")
+ w.SetTitle("Navis")
w.SetSize(500, 900, glaze.HintNone)
w.Navigate(uiURL)
log.Printf("Navis UI: %s", uiURL)
diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go
index cb985f6..30ce66f 100644
--- a/cmd/vpnapp/main_windows.go
+++ b/cmd/vpnapp/main_windows.go
@@ -87,6 +87,7 @@ func main() {
w.SetSize(500, 900, webview2.HintNone)
mustBind(w, "getState", a.GetState)
+ mustBind(w, "getEditState", a.GetEditState)
mustBind(w, "connect", a.Connect)
mustBind(w, "disconnect", a.Disconnect)
mustBind(w, "connectProfile", a.ConnectProfile)
diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis
index 2575ff8..f157b2d 100755
Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ
diff --git a/dist/navis-release/darwin-arm64/Navis-cli b/dist/navis-release/darwin-arm64/Navis-cli
index a706f31..b740710 100755
Binary files a/dist/navis-release/darwin-arm64/Navis-cli and b/dist/navis-release/darwin-arm64/Navis-cli differ
diff --git a/dist/navis-release/darwin-arm64/Navis.app.zip b/dist/navis-release/darwin-arm64/Navis.app.zip
index 12a5d31..94c65cf 100644
Binary files a/dist/navis-release/darwin-arm64/Navis.app.zip and b/dist/navis-release/darwin-arm64/Navis.app.zip differ
diff --git a/dist/navis-release/darwin-arm64/Navis.dmg b/dist/navis-release/darwin-arm64/Navis.dmg
index 0e5909e..6de1f01 100644
Binary files a/dist/navis-release/darwin-arm64/Navis.dmg and b/dist/navis-release/darwin-arm64/Navis.dmg differ
diff --git a/dist/navis-release/darwin-arm64/Navis.version b/dist/navis-release/darwin-arm64/Navis.version
index 4c28a6f..181a7cf 100644
--- a/dist/navis-release/darwin-arm64/Navis.version
+++ b/dist/navis-release/darwin-arm64/Navis.version
@@ -1 +1 @@
-3.8.0+1
+3.8.0+2
diff --git a/dist/navis-release/darwin-arm64/VERSION b/dist/navis-release/darwin-arm64/VERSION
index 846fe72..9ad370d 100644
--- a/dist/navis-release/darwin-arm64/VERSION
+++ b/dist/navis-release/darwin-arm64/VERSION
@@ -1 +1 @@
-3.8.0.1
+3.8.0.2
diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json
index d586bb9..0bde978 100644
--- a/dist/navis-release/update.json
+++ b/dist/navis-release/update.json
@@ -1,6 +1,6 @@
{
"version": "3.8.0",
- "notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
+ "notes": "Navis 3.8.0+2: Mac .app zip update; EnsureCore lock; getState без секретов; подпись/notarize pipeline; UI split.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,11 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
+ "sha256": "6e15ad79e5ec1a1f74c48bc8f189ab8694b8f3f67f4be204420a12cf0fd622fd",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
+ "zip_sha256": "65041e154356a33525b9769f851bcccfc79d0c2a1200fb9a47bc22dd95cdf2ef",
+ "dmg_sha256": "46617b7cbf22e92a5ef85740ed9473cd1bc9219c9de5841ce0ac46b8eb946912"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
diff --git a/dist/update.json b/dist/update.json
index d586bb9..0bde978 100644
--- a/dist/update.json
+++ b/dist/update.json
@@ -1,6 +1,6 @@
{
"version": "3.8.0",
- "notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
+ "notes": "Navis 3.8.0+2: Mac .app zip update; EnsureCore lock; getState без секретов; подпись/notarize pipeline; UI split.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,11 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
+ "sha256": "6e15ad79e5ec1a1f74c48bc8f189ab8694b8f3f67f4be204420a12cf0fd622fd",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
+ "zip_sha256": "65041e154356a33525b9769f851bcccfc79d0c2a1200fb9a47bc22dd95cdf2ef",
+ "dmg_sha256": "46617b7cbf22e92a5ef85740ed9473cd1bc9219c9de5841ce0ac46b8eb946912"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
diff --git a/docs/signing.md b/docs/signing.md
new file mode 100644
index 0000000..8654774
--- /dev/null
+++ b/docs/signing.md
@@ -0,0 +1,40 @@
+# Code signing (release)
+
+## macOS
+
+1. Create a **Developer ID Application** certificate in Apple Developer.
+2. Store notary credentials once:
+
+```bash
+xcrun notarytool store-credentials "navis-notary" \
+ --apple-id "you@example.com" \
+ --team-id "TEAMID" \
+ --password "app-specific-password"
+```
+
+3. Build with identity (packmac picks it up):
+
+```bash
+export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
+./scripts/build-macos-arm64.sh
+```
+
+4. Notarize the DMG:
+
+```bash
+export NAVIS_NOTARY_PROFILE="navis-notary"
+./scripts/sign-macos.sh dist/navis-release/darwin-arm64/Navis.dmg
+```
+
+Without these env vars, packmac uses **ad-hoc** signing (`-`) — fine for local/dev.
+
+## Windows
+
+Sign `Navis.exe` with Authenticode (EV or standard code signing cert):
+
+```bat
+signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /a dist\navis-release\Navis.exe
+signtool verify /pa dist\navis-release\Navis.exe
+```
+
+Then refresh `sha256` in `dist/update.json` (or re-run the Windows release script that stamps it).
diff --git a/internal/apphost/app.go b/internal/apphost/app.go
index 7141991..faa38a2 100644
--- a/internal/apphost/app.go
+++ b/internal/apphost/app.go
@@ -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":
diff --git a/internal/appui/app.css b/internal/appui/app.css
new file mode 100644
index 0000000..c2f0e79
--- /dev/null
+++ b/internal/appui/app.css
@@ -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; }
+ }
+
diff --git a/internal/appui/app.js b/internal/appui/app.js
new file mode 100644
index 0000000..a6bf07c
--- /dev/null
+++ b/internal/appui/app.js
@@ -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);
+
diff --git a/internal/appui/embed.go b/internal/appui/embed.go
index 16cdd7e..2b1b215 100644
--- a/internal/appui/embed.go
+++ b/internal/appui/embed.go
@@ -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, ``, "", 1)
+ s = strings.Replace(s, ``, "", 1)
+ return s
+}
diff --git a/internal/appui/embed_test.go b/internal/appui/embed_test.go
new file mode 100644
index 0000000..9faf5fe
--- /dev/null
+++ b/internal/appui/embed_test.go
@@ -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")
+ }
+}
diff --git a/internal/appui/index.html b/internal/appui/index.html
index 2c74ee1..030769d 100644
--- a/internal/appui/index.html
+++ b/internal/appui/index.html
@@ -7,810 +7,8 @@
-
+