mirror of
https://github.com/by-sonic/tglock.git
synced 2026-07-31 07:45:13 +03:00
9bacc488a5
Аудит репозитория после #25 и #26. Расхождения между тем, что написано, и тем, что есть: - README обещал `tglock-cli-*` в таблице загрузок, но в релизе v2.0.0-beta.1 такого артефакта нет: задача `cli` в release.yml сработает только на следующем теге. Заменено на честную формулировку с командой сборки из main. - ui/main.ts держал начальным значением маршрута строку «Автоматический маршрут», которую бэкенд больше не отдаёт: при коде 0 возвращается «Маршрут ещё не выбран». Иначе до первого опроса статуса интерфейс показывал название несуществующего маршрута. - ARCHITECTURE_V2.md не упоминал ни разделения на библиотеку и два бинаря, ни фичи `gui`, ни правила различения протоколов, ни политики прямого релея, ни того, что GUI не запускается без WebView. Добавлены разделы, а последнее внесено в Current limitations вместе с отсутствием подписи бинарей. - ISSUE_AUDIT.md описывал состояние на 29 июля. Сам аудит оставлен как фиксация на дату, сверху добавлен статус на 30 июля: что закрыто, что осталось открытым и почему, и что исправлено вне списка issues. - HABR.md — черновик статьи, а не документация, но был указан в README как «подробный технический разбор». В нём «два файла, 350 строк, четыре платформы», тогда как сейчас 2872 строки Rust, семь файлов и три платформы плюс headless. Добавлена шапка с поправкой, ссылка в README переписана так, чтобы читателя не отправляли к устаревшим числам за документацией. Проверено: fmt, clippy в обоих вариантах сборки, 47 + 12 тестов, npm run build, все ссылки на файлы в .md существуют. Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
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;
|
|
uptimeSeconds: number;
|
|
port: number;
|
|
logs: LogLine[];
|
|
};
|
|
|
|
type LogLine = {
|
|
timestamp: string;
|
|
message: string;
|
|
error: boolean;
|
|
};
|
|
|
|
type Settings = {
|
|
lanMode: boolean;
|
|
port: number;
|
|
workerDomain: string;
|
|
};
|
|
|
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
|
|
|
let view: View = "home";
|
|
let status: Status = {
|
|
running: false,
|
|
activeConnections: 0,
|
|
tunnels: 0,
|
|
dataCenter: null,
|
|
route: "Маршрут ещё не выбран",
|
|
failures: 0,
|
|
uptimeSeconds: 0,
|
|
port: 1080,
|
|
logs: [],
|
|
};
|
|
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
|
|
let busy = false;
|
|
let toastTimer: number | undefined;
|
|
|
|
const icons = {
|
|
arrowLeft: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`,
|
|
settings: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.86 2.86-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21H9.6v-.1A1.7 1.7 0 0 0 8.5 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06L3.7 16.94l.06-.06A1.7 1.7 0 0 0 4.1 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H2.3V9.6h.1A1.7 1.7 0 0 0 4.1 8.5a1.7 1.7 0 0 0-.34-1.88l-.06-.06L6.56 3.7l.06.06A1.7 1.7 0 0 0 8.5 4.1a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V2.3h4v.1A1.7 1.7 0 0 0 15 4.1a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.86 2.86-.06.06A1.7 1.7 0 0 0 19.4 8.5a1.7 1.7 0 0 0 .6 1 1.7 1.7 0 0 0 1.1.4h.1v4h-.1a1.7 1.7 0 0 0-1.7 1.1Z"/></svg>`,
|
|
activity: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l2.4-7 5.1 14 2.4-7H21"/></svg>`,
|
|
plane: `<svg viewBox="0 0 64 64" aria-hidden="true"><path fill="currentColor" d="M52.4 11.7 8.2 28.8c-3 1.2-3 2.8-.6 3.5l11.3 3.5 4.4 13.4c.5 1.5.3 2.1 1.8 2.1 1.2 0 1.7-.5 2.4-1.2l5.5-5.3 11.5 8.5c2.1 1.2 3.7.6 4.2-2l7.6-35.8c.8-3.1-1.2-4.5-3.9-3.8ZM22.7 35l22.1-13.9c1.1-.7 2.1-.3 1.3.4L27.9 38l-.7 7.4L22.7 35Z"/></svg>`,
|
|
};
|
|
|
|
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",
|
|
};
|
|
}
|
|
|
|
function shell(content: string, pageClass = ""): string {
|
|
return `
|
|
<main class="app-shell ${pageClass}">
|
|
<div class="drag-region" data-tauri-drag-region></div>
|
|
${content}
|
|
<div id="toast" class="toast" role="status"></div>
|
|
</main>
|
|
`;
|
|
}
|
|
|
|
function renderHome(): void {
|
|
const connection = connectionState();
|
|
root.innerHTML = shell(`
|
|
<section class="home">
|
|
<header class="brand">
|
|
<div class="wordmark"><span>TG</span>Lock</div>
|
|
<p>Свободный Telegram. В один клик.</p>
|
|
</header>
|
|
|
|
<div class="hero ${connection.className}">
|
|
<div class="orb-field">
|
|
<div class="orbit orbit-one"></div>
|
|
<div class="orbit orbit-two"></div>
|
|
<div class="orb">
|
|
<div class="orb-shine"></div>
|
|
${icons.plane}
|
|
</div>
|
|
</div>
|
|
<div class="connection-copy">
|
|
<div class="status-line">
|
|
<span class="status-dot"></span>
|
|
<h1>${connection.title}</h1>
|
|
</div>
|
|
<p>${connection.subtitle}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button id="power" class="power-button ${status.running ? "stop" : ""}" ${busy ? "disabled" : ""}>
|
|
<span>${busy ? "Подождите…" : status.running ? "Выключить" : "Включить защиту"}</span>
|
|
<span class="button-arrow">→</span>
|
|
</button>
|
|
|
|
<div class="route-pill">
|
|
<span class="route-pulse"></span>
|
|
<span>${escapeHtml(status.route)}</span>
|
|
<span class="route-separator">·</span>
|
|
<span>порт ${status.port}</span>
|
|
</div>
|
|
|
|
<nav class="bottom-nav" aria-label="Разделы">
|
|
<button id="settings-nav" class="nav-button">
|
|
<span class="nav-icon">${icons.settings}</span>
|
|
<span>Настройки</span>
|
|
</button>
|
|
<div class="nav-divider"></div>
|
|
<button id="diagnostics-nav" class="nav-button">
|
|
<span class="nav-icon">${icons.activity}</span>
|
|
<span>Диагностика</span>
|
|
</button>
|
|
</nav>
|
|
</section>
|
|
`, "home-page");
|
|
|
|
document.querySelector("#power")?.addEventListener("click", toggleProtection);
|
|
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
|
|
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
|
|
}
|
|
|
|
function pageHeader(title: string, subtitle: string): string {
|
|
return `
|
|
<header class="page-header">
|
|
<button class="back-button" id="back" aria-label="Назад">${icons.arrowLeft}</button>
|
|
<div>
|
|
<h1>${title}</h1>
|
|
<p>${subtitle}</p>
|
|
</div>
|
|
</header>
|
|
`;
|
|
}
|
|
|
|
function renderSettings(): void {
|
|
root.innerHTML = shell(`
|
|
<section class="subpage">
|
|
${pageHeader("Настройки", "Подключение и локальная сеть")}
|
|
|
|
<form id="settings-form" class="settings-form">
|
|
<div class="setting-card">
|
|
<label class="field-label" for="worker">Cloudflare Worker</label>
|
|
<input
|
|
id="worker"
|
|
name="worker"
|
|
type="text"
|
|
value="${escapeHtml(settings.workerDomain)}"
|
|
placeholder="example.workers.dev"
|
|
autocomplete="off"
|
|
${status.running ? "disabled" : ""}
|
|
/>
|
|
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
|
|
</div>
|
|
|
|
<div class="setting-list">
|
|
<label class="setting-row" for="lan-mode">
|
|
<span>
|
|
<strong>Доступ из локальной сети</strong>
|
|
<small>Подключение других устройств</small>
|
|
</span>
|
|
<span class="switch">
|
|
<input id="lan-mode" type="checkbox" ${settings.lanMode ? "checked" : ""} ${status.running ? "disabled" : ""}/>
|
|
<span class="switch-track"></span>
|
|
</span>
|
|
</label>
|
|
<div class="setting-rule"></div>
|
|
<label class="setting-row compact" for="port">
|
|
<span>
|
|
<strong>Локальный порт</strong>
|
|
<small>SOCKS5 и MTProto</small>
|
|
</span>
|
|
<input id="port" class="port-input" type="number" min="1" max="65535" value="${settings.port}" ${status.running ? "disabled" : ""}/>
|
|
</label>
|
|
</div>
|
|
|
|
${status.running ? `<div class="locked-note"><span></span>Выключите защиту, чтобы изменить настройки</div>` : ""}
|
|
|
|
<button class="save-button" type="submit" ${status.running || busy ? "disabled" : ""}>
|
|
${busy ? "Сохраняю…" : "Сохранить настройки"}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
`, "subpage-shell");
|
|
|
|
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
document.querySelector("#settings-form")?.addEventListener("submit", saveSettings);
|
|
}
|
|
|
|
function renderDiagnostics(): void {
|
|
const recentLogs = status.logs.slice(-4).reverse();
|
|
root.innerHTML = shell(`
|
|
<section class="subpage">
|
|
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
|
|
|
|
<div class="connection-banner ${status.running ? "active" : ""}">
|
|
<span class="banner-icon">${icons.activity}</span>
|
|
<div>
|
|
<strong>${status.running ? "Сервис работает" : "Сервис остановлен"}</strong>
|
|
<small>${status.running ? "Локальный прокси принимает подключения" : "Включите защиту на главном экране"}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="metrics-grid">
|
|
<article class="metric-card">
|
|
<span>Соединения</span>
|
|
<strong>${status.activeConnections}</strong>
|
|
</article>
|
|
<article class="metric-card">
|
|
<span>Туннели</span>
|
|
<strong>${status.tunnels}</strong>
|
|
</article>
|
|
<article class="metric-card">
|
|
<span>Дата-центр</span>
|
|
<strong>${status.dataCenter ? `DC${status.dataCenter}` : "—"}</strong>
|
|
</article>
|
|
<article class="metric-card">
|
|
<span>Время работы</span>
|
|
<strong class="time">${formatUptime(status.uptimeSeconds)}</strong>
|
|
</article>
|
|
</div>
|
|
|
|
<div class="log-panel">
|
|
<div class="log-heading">
|
|
<span>Последние события</span>
|
|
<span class="live-label"><i></i>LIVE</span>
|
|
</div>
|
|
<div class="logs">
|
|
${recentLogs.length
|
|
? recentLogs
|
|
.map(
|
|
(line) => `
|
|
<div class="log-line ${line.error ? "error" : ""}">
|
|
<time>${line.timestamp}</time>
|
|
<span>${escapeHtml(line.message)}</span>
|
|
</div>`,
|
|
)
|
|
.join("")
|
|
: `<div class="empty-log">События появятся после включения защиты</div>`}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`, "subpage-shell");
|
|
|
|
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
}
|
|
|
|
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<void> {
|
|
try {
|
|
status = await invoke<Status>("get_status");
|
|
if (view !== "settings") render();
|
|
} catch (error) {
|
|
showToast(String(error), true);
|
|
}
|
|
}
|
|
|
|
async function toggleProtection(): Promise<void> {
|
|
if (busy) return;
|
|
busy = true;
|
|
renderHome();
|
|
try {
|
|
status = status.running
|
|
? await invoke<Status>("stop_proxy")
|
|
: await invoke<Status>("start_proxy");
|
|
} catch (error) {
|
|
showToast(String(error), true);
|
|
} finally {
|
|
busy = false;
|
|
await refreshStatus();
|
|
}
|
|
}
|
|
|
|
async function saveSettings(event: Event): Promise<void> {
|
|
event.preventDefault();
|
|
if (busy || status.running) return;
|
|
const worker = document.querySelector<HTMLInputElement>("#worker");
|
|
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
|
|
const port = document.querySelector<HTMLInputElement>("#port");
|
|
if (!worker || !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(),
|
|
lanMode: lanMode.checked,
|
|
port: parsedPort,
|
|
};
|
|
renderSettings();
|
|
try {
|
|
settings = await invoke<Settings>("save_settings", { settings });
|
|
showToast("Настройки сохранены");
|
|
window.setTimeout(() => navigate("home"), 350);
|
|
} catch (error) {
|
|
showToast(String(error), true);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function showToast(message: string, error = false): void {
|
|
window.clearTimeout(toastTimer);
|
|
window.requestAnimationFrame(() => {
|
|
const toast = document.querySelector<HTMLDivElement>("#toast");
|
|
if (!toast) return;
|
|
toast.textContent = message.replace(/^["']|["']$/g, "");
|
|
toast.className = `toast visible${error ? " error" : ""}`;
|
|
toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2800);
|
|
});
|
|
}
|
|
|
|
async function bootstrap(): Promise<void> {
|
|
try {
|
|
[status, settings] = await Promise.all([
|
|
invoke<Status>("get_status"),
|
|
invoke<Settings>("get_settings"),
|
|
]);
|
|
} catch {
|
|
// The browser preview intentionally uses the initial local state.
|
|
}
|
|
render();
|
|
window.setInterval(refreshStatus, 1000);
|
|
}
|
|
|
|
void bootstrap();
|