Files
navi/internal/appui/app.js
T

646 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);