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 @@ - + diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 30e6e20..07e9926 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -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 == "" { diff --git a/internal/config/redact_test.go b/internal/config/redact_test.go new file mode 100644 index 0000000..8cf2688 --- /dev/null +++ b/internal/config/redact_test.go @@ -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) + } +} diff --git a/internal/core/manager.go b/internal/core/manager.go index 4060e37..074be4f 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -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 { diff --git a/internal/update/update.go b/internal/update/update.go index 862938f..5a1539e 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 } diff --git a/internal/update/update_darwin.go b/internal/update/update_darwin.go index 0790282..e535a94 100644 --- a/internal/update/update_darwin.go +++ b/internal/update/update_darwin.go @@ -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")) + } } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 66664d2..65f3b1a 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -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", diff --git a/scripts/build-macos-arm64.sh b/scripts/build-macos-arm64.sh index 55a1e35..417276e 100755 --- a/scripts/build-macos-arm64.sh +++ b/scripts/build-macos-arm64.sh @@ -3,6 +3,11 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Prefer local toolchain if present. +export PATH="$(pwd)/.tools/go/bin:/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH" + +python3 scripts/sync-version.py + VERSION="$(python3 - <<'PY' import re from pathlib import Path @@ -26,7 +31,6 @@ OUT="dist/navis-release/darwin-arm64" mkdir -p "$OUT" LDFLAGS="-s -w" -export PATH="/tmp/go-sdk/go/bin:/usr/local/go/bin:$PATH" CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ go build -ldflags="$LDFLAGS" -trimpath -o "$OUT/Navis" ./cmd/vpnapp CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ @@ -35,18 +39,14 @@ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ printf '%s\n' "$FULL" > "$OUT/VERSION" printf '%s\n' "${VERSION}+${BUILD}" > "$OUT/Navis.version" +# Ad-hoc sign CLI/bin; packmac also signs the .app (honors NAVIS_CODESIGN_IDENTITY). if command -v codesign >/dev/null 2>&1; then - codesign -s - --force "$OUT/Navis" - codesign -s - --force "$OUT/Navis-cli" + ID="${NAVIS_CODESIGN_IDENTITY:--}" + codesign -s "$ID" --force "$OUT/Navis" || codesign -s - --force "$OUT/Navis" + codesign -s "$ID" --force "$OUT/Navis-cli" || codesign -s - --force "$OUT/Navis-cli" fi -go build -o tools/packmac/packmac ./tools/packmac -tools/packmac/packmac \ - -bin "$OUT/Navis" \ - -out "$OUT" \ - -version "$VERSION" \ - -build "$FULL" \ - -arch arm64 +go run ./tools/packmac -bin "$OUT/Navis" -out "$OUT" -version "$VERSION" -build "$FULL" -arch arm64 cp -f "$OUT/Navis.dmg" "$OUT/Navis-${FULL}-arm64.dmg" cp -f "$OUT/Navis.app.zip" "$OUT/Navis-${FULL}-arm64.app.zip" @@ -57,10 +57,23 @@ from pathlib import Path ver = "${VERSION}" build = "${BUILD}" full = "${FULL}" -binp = Path('dist/navis-release/darwin-arm64/Navis') +out = Path('dist/navis-release/darwin-arm64') +binp = out / 'Navis' +zipp = out / 'Navis.app.zip' +dmgp = out / 'Navis.dmg' h = hashlib.sha256(binp.read_bytes()).hexdigest() -notes = f"Navis {ver}+{build}: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot." -for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]: +zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else "" +dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else "" +notes = ( + f"Navis {ver}+{build}: Mac .app zip update; EnsureCore lock; " + f"getState без секретов; подпись/notarize pipeline; UI split." +) +paths = [ + Path('dist/update.json'), + Path('dist/navis-release/update.json'), + Path('server/update.json'), +] +for p in paths: if not p.exists(): continue d = json.loads(p.read_text()) @@ -69,8 +82,20 @@ for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]: plats = d.setdefault('platforms', {}) if 'darwin-arm64' in plats: plats['darwin-arm64']['sha256'] = h + if zh: + plats['darwin-arm64']['zip_sha256'] = zh + if dh: + plats['darwin-arm64']['dmg_sha256'] = dh + plats['darwin-arm64'].setdefault( + 'zip_url', + 'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip', + ) + plats['darwin-arm64'].setdefault( + 'dmg_url', + 'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg', + ) p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + '\n') - print('updated', p, '->', ver, 'sha', h[:12]) + print('updated', p, '->', ver, 'bin', h[:12], 'zip', zh[:12] if zh else '-') PY echo "" diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh new file mode 100755 index 0000000..49cc04b --- /dev/null +++ b/scripts/sign-macos.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Optional release signing / notarization for macOS Navis.app / DMG. +# Requires Apple Developer ID + notarytool credentials in the environment. +# +# Usage: +# export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)" +# export NAVIS_NOTARY_PROFILE="navis-notary" # xcrun notarytool store-credentials +# ./scripts/sign-macos.sh dist/navis-release/darwin-arm64/Navis.dmg +# # or a staged .app: +# ./scripts/sign-macos.sh /path/to/Navis.app +set -euo pipefail + +TARGET="${1:-}" +if [[ -z "$TARGET" || ! -e "$TARGET" ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +IDENTITY="${NAVIS_CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + echo "NAVIS_CODESIGN_IDENTITY is required (Developer ID Application: …)" >&2 + exit 1 +fi + +sign_app() { + local app="$1" + echo "codesign $app as $IDENTITY" + codesign -s "$IDENTITY" --force --deep --options runtime --timestamp "$app" + codesign --verify --deep --strict "$app" +} + +if [[ "$TARGET" == *.app || -d "$TARGET" ]]; then + sign_app "$TARGET" + echo "Signed app. Notarize the DMG that contains it via notarytool." + exit 0 +fi + +if [[ "$TARGET" == *.dmg ]]; then + # Sign nested .app if present after attach is awkward; prefer signing .app before packmac. + # Here we submit the DMG for notarization when credentials exist. + if [[ -n "${NAVIS_NOTARY_PROFILE:-}" ]]; then + echo "notarytool submit $TARGET (profile=$NAVIS_NOTARY_PROFILE)" + xcrun notarytool submit "$TARGET" --keychain-profile "$NAVIS_NOTARY_PROFILE" --wait + xcrun stapler staple "$TARGET" + echo "Notarized + stapled: $TARGET" + else + echo "DMG ready. Set NAVIS_NOTARY_PROFILE and re-run to notarize." + echo "Tip: packmac with NAVIS_CODESIGN_IDENTITY set signs the .app before zip/dmg." + fi + exit 0 +fi + +echo "unsupported target: $TARGET" >&2 +exit 2 diff --git a/scripts/sync-version.py b/scripts/sync-version.py new file mode 100755 index 0000000..2843206 --- /dev/null +++ b/scripts/sync-version.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Sync versioninfo.json + build-macos.bat from internal/update/update.go constants.""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +UPDATE_GO = ROOT / "internal" / "update" / "update.go" +VERSIONINFO = ROOT / "versioninfo.json" +BUILD_BAT = ROOT / "build-macos.bat" + + +def read_consts() -> tuple[str, int]: + text = UPDATE_GO.read_text(encoding="utf-8") + m = re.search(r'CurrentVersion\s*=\s*"([^"]+)"', text) + b = re.search(r"const BuildNumber\s*=\s*(\d+)", text) + if not m or not b: + raise SystemExit("cannot parse CurrentVersion/BuildNumber from update.go") + return m.group(1), int(b.group(1)) + + +def main() -> None: + ver, build = read_consts() + parts = [int(x) for x in ver.split(".")] + while len(parts) < 3: + parts.append(0) + major, minor, patch = parts[0], parts[1], parts[2] + full = f"{ver}.{build}" + + data = json.loads(VERSIONINFO.read_text(encoding="utf-8")) + data["FixedFileInfo"]["FileVersion"] = { + "Major": major, + "Minor": minor, + "Patch": patch, + "Build": build, + } + data["FixedFileInfo"]["ProductVersion"] = dict(data["FixedFileInfo"]["FileVersion"]) + data["StringFileInfo"]["FileVersion"] = full + data["StringFileInfo"]["ProductVersion"] = full + VERSIONINFO.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print("updated", VERSIONINFO, "->", full) + + bat = BUILD_BAT.read_text(encoding="utf-8") + bat2 = re.sub( + r'-version\s+\d+\.\d+\.\d+\s+-build\s+\d+\.\d+\.\d+\.\d+', + f"-version {ver} -build {full}", + bat, + ) + if bat2 != bat: + BUILD_BAT.write_text(bat2, encoding="utf-8") + print("updated", BUILD_BAT, "->", ver, full) + else: + print("build-macos.bat already in sync") + + +if __name__ == "__main__": + main() diff --git a/server/update.json b/server/update.json index d586bb9..0bde978 100644 --- a/server/update.json +++ b/server/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/tools/packmac/main.go b/tools/packmac/main.go index f238f72..768a326 100644 --- a/tools/packmac/main.go +++ b/tools/packmac/main.go @@ -178,12 +178,15 @@ func signAppBundle(appRoot string) error { if _, err := exec.LookPath("codesign"); err != nil { return nil } - // Fresh ad-hoc signature; entitlements not required for local/dev builds. - cmd := exec.Command("codesign", "-s", "-", "--force", "--deep", "--options", "runtime", appRoot) + // NAVIS_CODESIGN_IDENTITY=Developer ID Application: … for release; default ad-hoc "-". + identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY")) + if identity == "" { + identity = "-" + } + cmd := exec.Command("codesign", "-s", identity, "--force", "--deep", "--options", "runtime", appRoot) out, err := cmd.CombinedOutput() if err != nil { - // --options runtime can fail without a real Developer ID; retry plain ad-hoc. - cmd = exec.Command("codesign", "-s", "-", "--force", "--deep", appRoot) + cmd = exec.Command("codesign", "-s", identity, "--force", "--deep", appRoot) out, err = cmd.CombinedOutput() if err != nil { return fmt.Errorf("codesign %s: %w: %s", appRoot, err, strings.TrimSpace(string(out))) diff --git a/versioninfo.json b/versioninfo.json index 6d46594..d18c22a 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,17 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 }, - "ProductVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 }, + "FileVersion": { + "Major": 3, + "Minor": 8, + "Patch": 0, + "Build": 2 + }, + "ProductVersion": { + "Major": 3, + "Minor": 8, + "Patch": 0, + "Build": 2 + }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -11,12 +21,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "3.8.0.1", + "FileVersion": "3.8.0.2", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "3.8.0.1", + "ProductVersion": "3.8.0.2", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {