diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b2c717e..1ac7087 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { minSdk = 26 targetSdk = 35 // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build - versionCode = 4_010_201 - versionName = "4.1.2+1" + versionCode = 4_020_001 + versionName = "4.2.0+1" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index a555a5e..b03bf45 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -64,6 +64,10 @@ type uiState struct { Mode string `json:"mode"` VPNSupported bool `json:"vpn_mode_supported"` VPNActive bool `json:"vpn_active"` + // KillSwitch — persisted setting; KillSwitchActive — tunnel died and + // traffic is intentionally blocked until disconnect / reconnect. + KillSwitch bool `json:"kill_switch"` + KillSwitchActive bool `json:"kill_switch_active,omitempty"` } func main() { @@ -146,7 +150,9 @@ func main() { mustBind(w, "installCore", a.installCore) mustBind(w, "openURL", openURL) mustBind(w, "pingServers", a.pingServers) + mustBind(w, "pingServer", a.pingServer) mustBind(w, "pingBest", a.pingBest) + mustBind(w, "setKillSwitch", a.setKillSwitch) mustBind(w, "checkUpdate", a.checkUpdate) mustBind(w, "applyUpdate", a.applyUpdate) mustBind(w, "saveHy2", a.saveHy2) @@ -225,9 +231,11 @@ func (a *app) getState() (uiState, error) { Remnawave: a.mgr.RemnawaveSettings(), Hy2: a.mgr.ActiveHy2Options(), StorePackaged: update.IsStorePackaged(), - Mode: a.mgr.Mode(), - VPNSupported: a.mgr.VPNModeSupported(), - VPNActive: st.VPNActive, + Mode: a.mgr.Mode(), + VPNSupported: a.mgr.VPNModeSupported(), + VPNActive: st.VPNActive, + KillSwitch: cfg.KillSwitch, + KillSwitchActive: st.KillSwitchActive, } if out.Protocol == "" { if p, err := cfg.ActiveProfile(); err == nil { @@ -380,6 +388,50 @@ func (a *app) pingServers() ([]netcheck.Result, error) { return res, err } +// pingServer measures latency of one server (📊 button on a server row) +// and merges the result into the cached ping list. +func (a *app) pingServer(name string) (netcheck.Result, error) { + name = strings.TrimSpace(name) + a.mu.Lock() + list := a.mgr.Profiles() + a.mu.Unlock() + var target *netcheck.Target + for _, p := range list { + if p.Name == name { + target = &netcheck.Target{Name: p.Name, Protocol: config.Protocol(p.Protocol), Proxy: p.Proxy} + break + } + } + if target == nil { + return netcheck.Result{}, fmt.Errorf("сервер %q не найден", name) + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + out := netcheck.PingAll(ctx, []netcheck.Target{*target}) + if len(out) == 0 { + return netcheck.Result{}, fmt.Errorf("не удалось измерить пинг") + } + res := out[0] + a.mu.Lock() + replaced := false + for i := range a.pings { + if a.pings[i].Name == res.Name { + a.pings[i] = res + replaced = true + break + } + } + if !replaced { + a.pings = append(a.pings, res) + } + a.mu.Unlock() + return res, nil +} + +func (a *app) setKillSwitch(enabled bool) error { + return a.mgr.SetKillSwitch(enabled) +} + // pingBest measures all nodes, activates the fastest OK one, and optionally connects. func (a *app) pingBest(autoConnect bool) (pingBestResult, error) { pings, err := a.runPings() diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 8e44cd3..0f07365 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,16 +1,16 @@ { - "version": "4.1.2", - "notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются", + "version": "4.2.0", + "notes": "4.2.0: обновлённые формулировки, Kill Switch, статистика, кнопка подключения", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee", + "sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee", + "sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d", "os": "windows", "arch": "amd64" }, diff --git a/dist/navis-release/windows/EvilFox.exe b/dist/navis-release/windows/EvilFox.exe index 9a860d1..d600464 100644 Binary files a/dist/navis-release/windows/EvilFox.exe and b/dist/navis-release/windows/EvilFox.exe differ diff --git a/dist/navis-release/windows/Navis.exe b/dist/navis-release/windows/Navis.exe index 9a860d1..d600464 100644 Binary files a/dist/navis-release/windows/Navis.exe and b/dist/navis-release/windows/Navis.exe differ diff --git a/dist/navis-release/windows/evilfox-cli.exe b/dist/navis-release/windows/evilfox-cli.exe index 3852d8f..5d34e93 100644 Binary files a/dist/navis-release/windows/evilfox-cli.exe and b/dist/navis-release/windows/evilfox-cli.exe differ diff --git a/dist/navis-release/windows/vpnclient.exe b/dist/navis-release/windows/vpnclient.exe index 3852d8f..5d34e93 100644 Binary files a/dist/navis-release/windows/vpnclient.exe and b/dist/navis-release/windows/vpnclient.exe differ diff --git a/dist/update.json b/dist/update.json index 8e44cd3..0f07365 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,16 +1,16 @@ { - "version": "4.1.2", - "notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются", + "version": "4.2.0", + "notes": "4.2.0: обновлённые формулировки, Kill Switch, статистика, кнопка подключения", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee", + "sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee", + "sha256": "64da79ddf7cd5694bf93c4c110bf0e1281a20651e1e868a960562d5c1a15bd0d", "os": "windows", "arch": "amd64" }, diff --git a/internal/apphost/app.go b/internal/apphost/app.go index 14fa87d..72ae0b8 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -62,6 +62,10 @@ type UIState struct { Mode string `json:"mode"` VPNSupported bool `json:"vpn_mode_supported"` VPNActive bool `json:"vpn_active"` + // KillSwitch is the persisted setting; KillSwitchActive means the tunnel + // died unexpectedly and traffic is blocked until disconnect/reconnect. + KillSwitch bool `json:"kill_switch"` + KillSwitchActive bool `json:"kill_switch_active,omitempty"` } type PingBestResult struct { @@ -141,9 +145,11 @@ func (a *App) GetState() (UIState, error) { Remnawave: a.Mgr.RemnawaveSettings(), Hy2: a.Mgr.ActiveHy2Options(), StorePackaged: update.IsStorePackaged(), - Mode: a.Mgr.Mode(), - VPNSupported: a.Mgr.VPNModeSupported(), - VPNActive: st.VPNActive, + Mode: a.Mgr.Mode(), + VPNSupported: a.Mgr.VPNModeSupported(), + VPNActive: st.VPNActive, + KillSwitch: cfg.KillSwitch, + KillSwitchActive: st.KillSwitchActive, } if out.Protocol == "" { if p, err := cfg.ActiveProfile(); err == nil { @@ -281,6 +287,51 @@ func (a *App) PingServers() ([]netcheck.Result, error) { return a.runPings() } +// PingServer measures latency of a single server (📊 button on a server row) +// and merges the result into the cached ping list. +func (a *App) PingServer(name string) (netcheck.Result, error) { + name = strings.TrimSpace(name) + a.mu.Lock() + list := a.Mgr.Profiles() + a.mu.Unlock() + var target *netcheck.Target + for _, p := range list { + if p.Name == name { + target = &netcheck.Target{Name: p.Name, Protocol: config.Protocol(p.Protocol), Proxy: p.Proxy} + break + } + } + if target == nil { + return netcheck.Result{}, fmt.Errorf("сервер %q не найден", name) + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + out := netcheck.PingAll(ctx, []netcheck.Target{*target}) + if len(out) == 0 { + return netcheck.Result{}, fmt.Errorf("не удалось измерить пинг") + } + res := out[0] + a.mu.Lock() + replaced := false + for i := range a.Pings { + if a.Pings[i].Name == res.Name { + a.Pings[i] = res + replaced = true + break + } + } + if !replaced { + a.Pings = append(a.Pings, res) + } + a.mu.Unlock() + return res, nil +} + +// SetKillSwitch persists the leak-protection setting. +func (a *App) SetKillSwitch(enabled bool) error { + return a.Mgr.SetKillSwitch(enabled) +} + func (a *App) PingBest(autoConnect bool) (PingBestResult, error) { pings, err := a.runPings() out := PingBestResult{Pings: pings} @@ -533,6 +584,10 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { return nil, a.OpenShopURL(arg(args, 0, "")) case "pingServers": return a.PingServers() + case "pingServer": + return a.PingServer(arg(args, 0, "")) + case "setKillSwitch": + return nil, a.SetKillSwitch(arg(args, 0, false)) case "pingBest": return a.PingBest(arg(args, 0, false)) case "checkUpdate": diff --git a/internal/appui/index.html b/internal/appui/index.html index d2407e5..73de266 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -76,8 +76,10 @@ --side-bg: rgba(255, 255, 255, 0.65); --side-active: rgba(8, 145, 178, 0.12); --side-active-fg: #0e7490; - --power-off: linear-gradient(160deg, #e2e8f0, #cbd5e1); - --power-on: linear-gradient(160deg, #0d9488, #0891b2); + /* Кнопка-выключатель: акцентная в выключенном состоянии («нажми меня»), + красноватая — когда подключено (действие = отключить). */ + --power-off: linear-gradient(160deg, #0891b2, #22d3ee); + --power-on: linear-gradient(160deg, #b91c1c, #ef4444); --log-bg: rgba(15, 23, 42, 0.04); } html[data-theme="dark"] { @@ -131,8 +133,8 @@ --side-bg: rgba(12, 18, 30, 0.72); --side-active: rgba(34, 211, 238, 0.14); --side-active-fg: #22d3ee; - --power-off: linear-gradient(160deg, #243247, #16202f); - --power-on: linear-gradient(160deg, #0d9488, #0891b2); + --power-off: linear-gradient(160deg, #0e7490, #06b6d4); + --power-on: linear-gradient(160deg, #b91c1c, #ef4444); --log-bg: rgba(2, 6, 14, 0.6); } * { box-sizing: border-box; } @@ -323,35 +325,45 @@ background: var(--hero-bg-on); } .power-btn { - width: 132px; - height: 132px; + width: 148px; + height: 148px; border-radius: 50%; border: 0; cursor: pointer; background: var(--power-off); - color: var(--muted); - display: grid; - place-items: center; + color: #fff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; margin: 4px auto 14px; transition: background .25s, color .25s, transform .12s, box-shadow .25s; - box-shadow: 0 14px 34px rgba(15, 23, 42, .18); + box-shadow: 0 14px 34px rgba(8, 145, 178, .3); position: relative; } .power-btn:hover { transform: translateY(-2px); } .power-btn:active { transform: none; } .power-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; } - .power-btn svg { width: 46px; height: 46px; } + .power-btn svg { width: 40px; height: 40px; } + .power-btn .power-label { + font-family: Outfit, sans-serif; + font-size: .64rem; + font-weight: 800; + letter-spacing: .09em; + line-height: 1; + } .power-btn.on { background: var(--power-on); - color: #f0fdfa; - box-shadow: 0 16px 44px rgba(13, 148, 136, .45); + color: #fff5f5; + box-shadow: 0 16px 44px rgba(220, 38, 38, .4); } .power-btn.on::after { content: ""; position: absolute; inset: -9px; border-radius: 50%; - border: 2px solid rgba(45, 212, 191, .45); + border: 2px solid rgba(248, 113, 113, .5); animation: ring 2s ease-out infinite; } @keyframes ring { @@ -853,7 +865,40 @@ 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 .right { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; } + /* Протокол — крошечная буква-иконка с подсказкой (не повторяем «vless» под каждой строкой). */ + .proto-badge { + flex: 0 0 auto; + width: 18px; + height: 18px; + display: grid; + place-items: center; + border-radius: 6px; + font-size: .6rem; + font-weight: 800; + color: var(--muted); + background: var(--meta-bg); + border: 1px solid var(--line); + cursor: default; + } + /* Проверка скорости одного сервера — маленькая кнопка [📊] в строке. */ + .row-ping { + appearance: none; + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + display: grid; + place-items: center; + border-radius: 8px; + border: 1px solid var(--line); + background: transparent; + font-size: .78rem; + cursor: pointer; + transition: border-color .15s; + } + .row-ping:hover { border-color: rgba(8,145,178,.45); } + .row-ping:disabled { opacity: .5; cursor: default; } .server-row .name-row { display: flex; align-items: center; gap: 6px; min-width: 0; } /* Пилюля «АКТИВЕН» вместо строчного текста «активный сервер» */ .badge-active { @@ -934,6 +979,58 @@ .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); } + /* ==== Статистика: карточки за сегодня + недельный график ==== */ + .stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 12px; + } + .stat-card { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--row-bg); + padding: 12px 10px; + text-align: center; + } + .stat-val { + font-family: Outfit, sans-serif; + font-weight: 800; + font-size: 1.02rem; + letter-spacing: -.01em; + font-variant-numeric: tabular-nums; + } + .stat-k { font-size: .68rem; color: var(--muted); margin-top: 3px; } + .week-chart { + display: flex; + align-items: stretch; + gap: 6px; + height: 118px; + padding: 10px 12px 8px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--row-bg); + margin-bottom: 12px; + } + .week-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 4px; + min-width: 0; + } + .week-bar { + width: 100%; + max-width: 34px; + border-radius: 6px 6px 2px 2px; + background: linear-gradient(180deg, var(--accent), var(--accent-deep)); + min-height: 2px; + } + .week-day { font-size: .62rem; color: var(--muted); white-space: nowrap; } + .week-empty { width: 100%; align-self: center; text-align: center; color: var(--muted); font-size: .8rem; } + /* Logs */ .log-view { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; @@ -1034,6 +1131,8 @@ font-size: .84rem; font-weight: 700; } + /* «📊 ДОСТУПНО X ГБ» — заголовок блока трафика капсом */ + .traffic-row > span:first-child { text-transform: uppercase; letter-spacing: .03em; font-size: .8rem; } .traffic-row .pct { color: var(--muted); font-weight: 600; font-size: .78rem; } .traffic-bar { height: 9px; @@ -1083,11 +1182,9 @@ html[data-theme="dark"] .prov-card.ours { background: linear-gradient(150deg, rgba(34,211,238,.16), var(--surface-2) 70%); } - /* Бейдж «Рекомендуем» лежит НА карточке EvilFox (верхний угол), а не между карточками. */ + /* Бейдж «Рекомендуемый сервис» — первая строка карточки EvilFox. */ .prov-badge { - position: absolute; - top: 10px; - right: 10px; + align-self: flex-start; font-size: .64rem; font-weight: 800; letter-spacing: .03em; @@ -1098,7 +1195,6 @@ padding: 3px 9px; box-shadow: 0 4px 10px rgba(251, 191, 36, .35); } - .prov-card.ours .prov-name { padding-right: 96px; } /* Приглушённая подпись «Сторонний сервис» на карточках конкурентов */ .prov-tag { font-size: .62rem; @@ -1144,7 +1240,7 @@ .prov-ping.mid .val { color: var(--ms-mid); } @media (max-width: 560px) { .prov-grid { grid-template-columns: 1fr; } } - /* ==== Активация ключа ==== */ + /* ==== Добавление конфигурации ==== */ .key-box { border: 1px solid var(--shop-border); border-radius: var(--radius); @@ -1156,6 +1252,8 @@ } .key-box h3 { font-family: Outfit, sans-serif; font-size: .95rem; letter-spacing: -.02em; margin: 0; } .key-hint { margin: 0; font-size: .76rem; color: var(--muted); line-height: 1.4; } + /* Статус конфигурации: 🟢 добавлена / 🔴 не добавлена (сам URL не показываем). */ + .key-status { margin: 0; font-size: .8rem; font-weight: 700; } .key-link { border: 0; background: transparent; @@ -1238,7 +1336,7 @@
Нажмите на кнопку, чтобы включить защиту
+Нажмите, чтобы включить защиту