import { invoke } from "@tauri-apps/api/core"; import "./styles.css"; type View = "home" | "settings" | "diagnostics"; type Status = { running: boolean; activeConnections: number; tunnels: number; dataCenter: number | null; route: string; failures: number; routeFailures: number; /// Отклонено политикой «в LAN-режиме только Telegram». blocked: number; /// Клиенты, которые дошли, но не сумели договориться о рукопожатии. unknownClients: number; /// Соединения, которые открылись и ничего не прислали до таймаута. silentClients: number; uptimeSeconds: number; port: number; /// Адрес для других устройств. Приходит только в LAN-режиме. shareAddress: string | null; logs: LogLine[]; }; type LogLine = { timestamp: string; message: string; error: boolean; }; type Settings = { lanMode: boolean; port: number; workerDomain: string; cfProxyDomain: string; }; const root = document.querySelector("#app")!; let view: View = "home"; let status: Status = { running: false, activeConnections: 0, tunnels: 0, dataCenter: null, route: "Маршрут ещё не выбран", failures: 0, routeFailures: 0, blocked: 0, unknownClients: 0, silentClients: 0, uptimeSeconds: 0, port: 1080, shareAddress: null, logs: [], }; let settings: Settings = { lanMode: false, port: 1080, workerDomain: "", cfProxyDomain: "" }; let busy = false; let toastTimer: number | undefined; let toastMessage = ""; let toastError = false; const icons = { arrowLeft: ``, settings: ``, activity: ``, plane: ``, }; function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function formatUptime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const rest = seconds % 60; return [hours, minutes, rest].map((value) => value.toString().padStart(2, "0")).join(":"); } function connectionState(): { title: string; subtitle: string; className: string } { if (!status.running) { return { title: "Защита выключена", subtitle: "Нажмите кнопку, чтобы подключить Telegram", className: "offline", }; } if (status.failures > 0 && status.tunnels === 0) { return { title: "Ищем новый маршрут", subtitle: "Переключаемся на резервное подключение", className: "warning", }; } if (status.tunnels > 0) { return { title: "Telegram на связи", subtitle: "Соединение защищено и работает", className: "online", }; } return { title: "Защита включена", subtitle: "Откройте Telegram — маршрут готов", className: "online", }; } // Статус приходит раз в секунду. Если каждый раз переписывать root.innerHTML, // кнопки становятся новыми узлами, и тап, начатый на старой кнопке и // законченный на новой, Chromium не превращает в click. Поэтому разметка // накладывается на уже существующие узлы: меняются только текст и атрибуты, // а элемент заменяется лишь там, где на его месте теперь другой тег или id. function patchRoot(html: string): void { const next = document.createElement("template"); next.innerHTML = html; patchChildren(root, next.content); } function patchChildren(current: Node, next: Node): void { const nextChildren = Array.from(next.childNodes); nextChildren.forEach((nextChild, index) => { const currentChild = current.childNodes[index]; if (!currentChild) current.appendChild(nextChild); else if (!sameSlot(currentChild, nextChild)) current.replaceChild(nextChild, currentChild); else patchNode(currentChild, nextChild); }); while (current.childNodes.length > nextChildren.length) current.lastChild?.remove(); } function sameSlot(current: Node, next: Node): boolean { if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) return false; return !(current instanceof Element && next instanceof Element) || current.id === next.id; } function patchNode(current: Node, next: Node): void { if (!(current instanceof Element && next instanceof Element)) { if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue; return; } for (const { name } of Array.from(current.attributes)) { if (!next.hasAttribute(name)) current.removeAttribute(name); } for (const { name, value } of Array.from(next.attributes)) { if (current.getAttribute(name) !== value) current.setAttribute(name, value); } patchChildren(current, next); } function shell(content: string, pageClass = ""): string { return `
${content}
${escapeHtml(toastMessage)}
`; } function renderHome(): void { const connection = connectionState(); patchRoot(shell(`
TGLock

Свободный Telegram. В один клик.

${icons.plane}

${connection.title}

${connection.subtitle}

${status.running ? `
` : ""}
${escapeHtml(status.route)} · порт ${status.port}
${status.shareAddress ? ` ` : ""}
`, "home-page")); } function pageHeader(title: string, subtitle: string): string { return ` `; } function renderSettings(): void { patchRoot(shell(`
${pageHeader("Настройки", "Подключение и локальная сеть")}

Необязательно. Используется как резервный маршрут.

Необязательно. CF-прокси без Worker: записи kws1…kws5 и kws203 на адреса Telegram.

${status.running ? `
Выключите защиту, чтобы изменить настройки
` : ""}
`, "subpage-shell")); } function renderDiagnostics(): void { const recentLogs = status.logs.slice(-4).reverse(); patchRoot(shell(`
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
${status.running ? "Сервис работает" : "Сервис остановлен"} ${status.running ? "Локальный прокси принимает подключения" : "Включите защиту на главном экране"}
Соединения ${status.activeConnections}
Туннели ${status.tunnels}
Дата-центр ${status.dataCenter ? `DC${status.dataCenter}` : "—"}
Время работы ${formatUptime(status.uptimeSeconds)}
Падений маршрутов ${status.routeFailures}
Отклонено ${status.blocked}
Не опознаны ${status.unknownClients}
Промолчали ${status.silentClients}

«Падений маршрутов» больше нуля при работающем Telegram — это норма: значит закреплённый адрес недоступен и подключение идёт через запасной. Число в багрепорте помогает понять, что именно перебиралось.

«Отклонено» — запросы, которые LAN-режим не пропустил: он ходит только по адресам Telegram. Если с телефона ничего не работает, а здесь ноль и соединений тоже ноль, значит телефон до этого компьютера не дошёл — дело в сети или брандмауэре. Какие именно адреса отклонены, видно ниже.

«Не опознаны» — клиенты, которые дошли до прокси, но договориться с ними не удалось. Почти всегда это старая ссылка: секрет в Telegram остался от прошлого запуска и больше не совпадает. Тогда Telegram пишет «прокси настроен неверно», а адрес такого клиента появится в журнале ниже.

Последние события LIVE
${recentLogs.length ? recentLogs .map( (line) => `
${escapeHtml(line.message)}
`, ) .join("") : `
События появятся после включения защиты
`}
`, "subpage-shell")); } // Обработчики висят на #app, а не на кнопках: patchRoot сохраняет кнопки, // но новые (например, «Открыть Telegram» после включения) создаёт сам. const clickActions: Record void> = { power: toggleProtection, "open-telegram": openTelegram, "copy-link": copyTelegramLink, "copy-address": copyShareAddress, "settings-nav": () => navigate("settings"), "diagnostics-nav": () => navigate("diagnostics"), "copy-diagnostics": copyDiagnostics, back: () => navigate("home"), }; root.addEventListener("click", (event) => { const button = (event.target as Element).closest("button[id]"); if (button && !button.disabled) clickActions[button.id]?.(); }); root.addEventListener("submit", (event) => { if ((event.target as Element).id === "settings-form") void saveSettings(event); }); function render(): void { if (view === "settings") renderSettings(); else if (view === "diagnostics") renderDiagnostics(); else renderHome(); } function navigate(next: View): void { view = next; render(); } async function refreshStatus(): Promise { try { status = await invoke("get_status"); if (view !== "settings") render(); } catch (error) { showToast(String(error), true); } } async function toggleProtection(): Promise { if (busy) return; busy = true; renderHome(); try { status = status.running ? await invoke("stop_proxy") : await invoke("start_proxy"); } catch (error) { showToast(String(error), true); } finally { busy = false; await refreshStatus(); } } async function saveSettings(event: Event): Promise { event.preventDefault(); if (busy || status.running) return; const worker = document.querySelector("#worker"); const cfProxy = document.querySelector("#cf-proxy"); const lanMode = document.querySelector("#lan-mode"); const port = document.querySelector("#port"); if (!worker || !cfProxy || !lanMode || !port) return; const parsedPort = Number(port.value); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { showToast("Порт должен быть от 1 до 65535", true); return; } busy = true; settings = { workerDomain: worker.value.trim(), cfProxyDomain: cfProxy.value.trim(), lanMode: lanMode.checked, port: parsedPort, }; renderSettings(); try { settings = await invoke("save_settings", { settings }); showToast("Настройки сохранены"); window.setTimeout(() => navigate("home"), 350); } catch (error) { showToast(String(error), true); } finally { busy = false; } } async function openTelegram(): Promise { try { await invoke("open_telegram"); } catch (error) { showToast(String(error), true); } } async function copyTelegramLink(): Promise { try { const link = await invoke("get_telegram_link"); await navigator.clipboard.writeText(link); showToast("Ссылка скопирована; она содержит секрет прокси"); } catch (error) { showToast(`Не удалось скопировать ссылку: ${String(error)}`, true); } } async function copyDiagnostics(): Promise { try { // Public reports exclude proxy links, worker domains and free-form logs. const report = { application: "TGLock", version: "2.0.0-beta.17", running: status.running, activeConnections: status.activeConnections, tunnels: status.tunnels, dataCenter: status.dataCenter, route: status.route, failures: status.failures, routeFailures: status.routeFailures, blocked: status.blocked, unknownClients: status.unknownClients, silentClients: status.silentClients, uptimeSeconds: status.uptimeSeconds, port: status.port, lanMode: settings.lanMode, }; await navigator.clipboard.writeText(JSON.stringify(report, null, 2)); showToast("Диагностика скопирована без адресов и секретов"); } catch { showToast("Буфер обмена недоступен", true); } } async function copyShareAddress(): Promise { const address = status.shareAddress; if (!address) return; try { await navigator.clipboard.writeText(address); showToast("Адрес скопирован"); } catch { // Буфер обмена может быть недоступен — адрес и так виден на экране. showToast("Скопируйте адрес вручную", true); } } function showToast(message: string, error = false): void { window.clearTimeout(toastTimer); toastMessage = message.replace(/^["']|["']$/g, ""); toastError = error; window.requestAnimationFrame(() => { const toast = document.querySelector("#toast"); if (!toast) return; toast.textContent = message.replace(/^["']|["']$/g, ""); toast.className = `toast visible${error ? " error" : ""}`; toastTimer = window.setTimeout(() => { toastMessage = ""; document.querySelector("#toast")?.classList.remove("visible"); }, 4500); }); } async function bootstrap(): Promise { try { [status, settings] = await Promise.all([ invoke("get_status"), invoke("get_settings"), ]); } catch { // The browser preview intentionally uses the initial local state. } render(); window.setInterval(refreshStatus, 1000); } void bootstrap();