// 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","getLogs","probeTunnel","savePrefs","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 bootConnect = $("bootConnect"); const autoReconnect = $("autoReconnect"); const logTail = $("logTail"); const logRefreshBtn = $("logRefreshBtn"); const probeBtn = $("probeBtn"); 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; }); } let listFP = ""; function profilesFingerprint(state) { const parts = [ state.connected ? "1" : "0", state.active_profile || state.profile || "", ]; (state.profiles || []).forEach((p) => { parts.push(p.name + "|" + (p.protocol || "") + "|" + (p.host || "")); }); (state.pings || []).forEach((p) => { parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.soft ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || "")); }); return parts.join("\n"); } 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.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 = "…"; } 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; if (bootConnect) bootConnect.disabled = busy; if (autoReconnect) autoReconnect.disabled = busy; if (probeBtn) probeBtn.disabled = busy || !connected; if (logRefreshBtn) logRefreshBtn.disabled = busy; 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 || []); const fp = profilesFingerprint(state); if (syncForm || fp !== listFP) { listFP = fp; 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; if (bootConnect && typeof state.connect_on_launch === "boolean") bootConnect.checked = state.connect_on_launch; if (autoReconnect && typeof state.auto_reconnect === "boolean") autoReconnect.checked = state.auto_reconnect; 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) { if (!connected && state.last_error) setMeta(state.last_error, "err"); else setMeta(detail, state.core_ready === false ? "err" : ""); metaHoldUntil = 0; } } async function persistPrefs() { if (typeof savePrefs !== "function") return; await savePrefs(!!(bootConnect && bootConnect.checked), !!(autoReconnect && autoReconnect.checked), !!sysproxy.checked); } 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"); }); if (sysproxy) sysproxy.addEventListener("change", () => { persistPrefs().catch(() => {}); dirty = true; }); if (bootConnect) bootConnect.addEventListener("change", () => { persistPrefs().catch(() => {}); }); if (autoReconnect) autoReconnect.addEventListener("change", () => { persistPrefs().catch(() => {}); }); if (logRefreshBtn) logRefreshBtn.addEventListener("click", () => withBusy(async () => { try { const t = await getLogs(); if (logTail) logTail.textContent = t || "—"; setMeta("Лог обновлён", "ok"); } catch (e) { setMeta(fmtErr(e), "err"); } })); if (probeBtn) probeBtn.addEventListener("click", () => withBusy(async () => { try { setMeta("Проверка туннеля…"); await probeTunnel(); setMeta("Туннель OK", "ok"); } catch (e) { setMeta(fmtErr(e), "err"); } })); (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(() => {}); }, 4000);