Release 3.8.2.11: idle rev-cache, port-safe Connect, single-instance tray UX.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

Faster unchanged polls; orphan-core cleanup on listen ports; clearer busy-port errors; cores only from binDir; incremental server list; CSP without Google Fonts; single-instance activates existing window; close-to-tray without kill-others or balloon.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 17:27:47 +03:00
co-authored by Cursor
parent fed1ace33e
commit 57d719f02f
45 changed files with 876 additions and 285 deletions
+9 -7
View File
@@ -42,6 +42,8 @@
--ms-mid: #b8860b;
--focus-ring: rgba(13,138,102,.12);
--theme-btn-bg: rgba(255,255,255,.7);
--font-ui: "Segoe UI Variable Text", "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
--font-display: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", "Helvetica Neue", sans-serif;
}
html[data-theme="dark"] {
--ink: #e6f4ee;
@@ -90,7 +92,7 @@
html, body {
margin: 0;
min-height: 100%;
font-family: Figtree, sans-serif;
font-family: var(--font-ui);
color: var(--ink);
background: var(--page-bg);
transition: color .2s var(--ease), background .25s var(--ease);
@@ -130,7 +132,7 @@
}
.update-banner.show { display: block; }
.update-banner strong {
font-family: Sora, sans-serif;
font-family: var(--font-display);
display: block;
margin-bottom: 4px;
font-size: .95rem;
@@ -199,7 +201,7 @@
flex-wrap: wrap;
}
.brand {
font-family: Sora, sans-serif;
font-family: var(--font-display);
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.045em;
@@ -319,7 +321,7 @@
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
font-family: Sora, sans-serif;
font-family: var(--font-display);
font-size: .78rem;
font-weight: 700;
letter-spacing: .04em;
@@ -528,7 +530,7 @@
width: 100%;
min-height: 52px;
font-size: 1.02rem;
font-family: Sora, sans-serif;
font-family: var(--font-display);
}
.actions { display: grid; gap: 8px; }
@@ -749,7 +751,7 @@
background: var(--shop-bg);
}
.shop h3 {
font-family: Sora, sans-serif;
font-family: var(--font-display);
font-size: 1rem;
letter-spacing: -0.02em;
margin: 0 0 5px;
@@ -806,7 +808,7 @@
color: var(--ink);
}
.modal h2 {
font-family: Sora, sans-serif;
font-family: var(--font-display);
margin: 0 0 12px;
font-size: 1.2rem;
letter-spacing: -.02em;
+75 -50
View File
@@ -262,69 +262,94 @@
});
}
function pingLabel(pr) {
if (pr && pr.ok) {
return {
cls: "ms " + msClass(pr.ms, true),
text: (pr.soft ? "~" : "") + pr.ms + " ms",
title: pr.soft ? "soft-up (UDP без ответа) — не выбирается как «Лучший»" : ""
};
}
if (pr && pr.error) {
return { cls: "ms bad", text: "—", title: pr.error };
}
return { cls: "ms", text: "…", title: "" };
}
function ensureServerRow(p) {
let row = serverList.querySelector('.server-row[data-name="' + CSS.escape(p.name) + '"]');
if (row) return row;
row = document.createElement("button");
row.type = "button";
row.className = "server-row";
row.dataset.name = p.name;
const left = document.createElement("div");
left.className = "left";
const nameEl = document.createElement("div");
nameEl.className = "name";
const sub = document.createElement("div");
sub.className = "sub";
left.appendChild(nameEl);
left.appendChild(sub);
const right = document.createElement("div");
right.className = "right";
const proto = document.createElement("span");
proto.className = "proto";
const ms = document.createElement("span");
ms.className = "ms";
right.appendChild(proto);
right.appendChild(ms);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
return row;
}
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const all = orderedProfiles();
const list = filteredProfiles();
if (!all.length) {
const keep = new Set();
if (!all.length || !list.length) {
serverList.innerHTML = "";
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — добавьте профиль или подписку";
serverList.appendChild(empty);
return;
}
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Ничего не найдено";
empty.textContent = !all.length
? "Нет серверов — добавьте профиль или подписку"
: "Ничего не найдено";
serverList.appendChild(empty);
return;
}
// Drop empty-state nodes from prior renders.
serverList.querySelectorAll(".server-empty").forEach((el) => el.remove());
list.forEach((p) => {
const row = document.createElement("button");
row.type = "button";
keep.add(p.name);
const row = ensureServerRow(p);
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.soft ? "~" : "") + pr.ms + " ms";
if (pr.soft) ms.title = "soft-up (UDP без ответа) — не выбирается как «Лучший»";
} else if (pr && pr.error) {
ms.className = "ms bad";
ms.textContent = "—";
ms.title = pr.error;
} else {
ms.className = "ms";
ms.textContent = "…";
const nameEl = row.querySelector(".name");
const sub = row.querySelector(".sub");
const proto = row.querySelector(".proto");
const ms = row.querySelector(".ms");
if (nameEl && nameEl.textContent !== p.name) nameEl.textContent = p.name;
const hostText = p.host || "нет хоста";
if (sub && sub.textContent !== hostText) sub.textContent = hostText;
const protoText = (p.protocol || "?").toString();
if (proto && proto.textContent !== protoText) proto.textContent = protoText;
const pl = pingLabel(pingMap[p.name]);
if (ms) {
if (ms.className !== pl.cls) ms.className = pl.cls;
if (ms.textContent !== pl.text) ms.textContent = pl.text;
if ((ms.title || "") !== (pl.title || "")) ms.title = pl.title || "";
}
right.appendChild(proto);
right.appendChild(ms);
serverList.appendChild(row); // re-order if needed
});
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
serverList.querySelectorAll(".server-row").forEach((row) => {
if (!keep.has(row.dataset.name)) row.remove();
});
}
+1 -3
View File
@@ -3,10 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'none';" />
<title>Navis</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Figtree:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="app.css" />
<script>