ostp/ostp-gui/src/main.js

414 lines
15 KiB
JavaScript

import { t, toggleLang, applyTranslations } from './i18n.js';
// ── Tauri invoke shim ────────────────────────────────────────────────────────
let invoke = () => Promise.resolve(null);
if (window.__TAURI__?.core) {
invoke = window.__TAURI__.core.invoke;
}
// ── State ────────────────────────────────────────────────────────────────────
let appState = 'disconnected'; // 'disconnected' | 'connecting' | 'connected'
let pollTimer = null;
let uptimeTimer = null;
let uptimeSecs = 0;
let rawConfig = null; // parsed config.json object
let serverAddr = ''; // current server address (for badge)
// ── DOM refs ─────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const homeScreen = $('home-screen');
const settingsScreen = $('settings-screen');
const btnConnect = $('btn-connect');
const orbitWrap = $('orbit-wrap');
const brandDot = $('brand-dot');
const statusLabel = $('status-text');
const statusSub = $('uptime-text');
const serverBadge = $('server-badge');
const serverBadgeTxt = $('server-badge-text');
const metricDown = $('metric-down');
const metricUp = $('metric-up');
const metricMode = $('metric-mode');
const metricPing = $('metric-ping');
const pingIconBlock = $('ping-icon-block');
const toast = $('toast');
const btnGoSettings = $('btn-go-settings');
const btnBack = $('btn-back');
const btnLang = $('btn-lang');
const btnImport = $('btn-import-url');
const btnPeekKey = $('btn-peek-key');
const btnSave = $('btn-save-config');
const importInput = $('in-import-url');
const inServer = $('in-server');
const inKey = $('in-key');
const inSocks = $('in-socks');
const inDns = $('in-dns');
const inTransport = $('in-transport');
const inSni = $('in-stealth-sni');
const inPbk = $('in-pbk');
const inSid = $('in-sid');
const inMtu = $('in-mtu');
const inTun = $('in-tun-mode');
const inMux = $('in-mux-mode');
const inMuxSessions = $('in-mux-sessions');
const inDebug = $('in-debug');
const inDomains = $('in-ex-domains');
const inIps = $('in-ex-ips');
const inProcesses = $('in-ex-processes');
// ── Utilities ────────────────────────────────────────────────────────────────
function fmtBytes(b) {
if (!b || b === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log2(b) / 10), 4);
return (b / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function fmtTime(s) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = n => String(n).padStart(2, '0');
return h > 0
? `${h}:${pad(m)}:${pad(sec)}`
: `${pad(m)}:${pad(sec)}`;
}
function splitLines(val) {
return val.split('\n').map(l => l.trim()).filter(Boolean);
}
// ── Toast ────────────────────────────────────────────────────────────────────
let toastTimer = null;
function showToast(msg, variant = '') {
toast.textContent = msg;
toast.className = 'toast show' + (variant ? ' is-' + variant : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('show');
}, 2400);
}
// ── State machine ────────────────────────────────────────────────────────────
function setState(next) {
if (appState === next) return;
appState = next;
// Reset all dynamic classes
btnConnect.className = 'power-btn';
orbitWrap.className = 'orbit-wrap';
brandDot.className = 'brand-dot';
statusLabel.className = 'status-label';
if (next === 'disconnected') {
statusLabel.textContent = t('status_disconnected');
statusSub.textContent = t('hint_tap');
statusLabel.classList.add('');
serverBadge.classList.add('hidden');
metricDown.textContent = '0 B';
metricUp.textContent = '0 B';
metricMode.textContent = '—';
clearInterval(pollTimer);
clearInterval(uptimeTimer);
pollTimer = uptimeTimer = null;
uptimeSecs = 0;
} else if (next === 'connecting') {
btnConnect.classList.add('connecting');
orbitWrap.classList.add('connecting');
brandDot.classList.add('connecting');
statusLabel.classList.add('is-connecting');
statusLabel.textContent = t('status_connecting');
statusSub.textContent = t('hint_connecting');
serverBadge.classList.add('hidden');
clearInterval(uptimeTimer);
uptimeSecs = 0;
} else if (next === 'connected') {
btnConnect.classList.add('connected');
orbitWrap.classList.add('connected');
brandDot.classList.add('connected');
statusLabel.classList.add('is-connected');
statusLabel.textContent = t('status_connected');
// Show server badge
if (serverAddr) {
serverBadgeTxt.textContent = serverAddr;
serverBadge.classList.remove('hidden');
}
// Start uptime counter
if (!uptimeTimer) {
uptimeSecs = 0;
uptimeTimer = setInterval(() => {
uptimeSecs++;
statusSub.textContent = fmtTime(uptimeSecs);
}, 1000);
}
}
}
// ── Polling ──────────────────────────────────────────────────────────────────
async function poll() {
try {
const code = await invoke('get_tunnel_status');
if (code === 0) { setState('disconnected'); return; }
else if (code === 1) setState('connecting');
else if (code === 2) setState('connected');
const metrics = await invoke('get_metrics');
if (metrics) {
metricDown.textContent = fmtBytes(metrics.bytes_recv);
metricUp.textContent = fmtBytes(metrics.bytes_sent);
const isTun = rawConfig?.tun?.enable;
metricMode.textContent = isTun ? 'TUN' : 'SOCKS5';
const rtt = metrics.rtt_ms || 0;
if (rtt > 0) {
metricPing.textContent = rtt + ' ms';
// Color code: green < 80ms, yellow < 200ms, red >= 200ms
if (rtt < 80) {
metricPing.style.color = 'var(--ping-good, #4ade80)';
pingIconBlock.style.color = 'var(--ping-good, #4ade80)';
} else if (rtt < 200) {
metricPing.style.color = 'var(--ping-mid, #facc15)';
pingIconBlock.style.color = 'var(--ping-mid, #facc15)';
} else {
metricPing.style.color = 'var(--ping-bad, #f87171)';
pingIconBlock.style.color = 'var(--ping-bad, #f87171)';
}
} else {
metricPing.textContent = '— ms';
metricPing.style.color = '';
pingIconBlock.style.color = '';
}
}
} catch {
setState('disconnected');
}
}
function startPolling() {
clearInterval(pollTimer);
poll();
pollTimer = setInterval(poll, 1000);
}
// ── Connect / Disconnect ─────────────────────────────────────────────────────
async function handleToggle() {
if (appState === 'disconnected') {
// Read server address for badge before connecting
try {
const raw = await invoke('get_config');
const cfg = JSON.parse(raw);
serverAddr = cfg.server || '';
// Determine mode label
const isTun = cfg.tun?.enable;
metricMode.textContent = isTun ? 'TUN' : 'SOCKS5';
} catch { serverAddr = ''; }
setState('connecting');
try {
const ok = await invoke('start_tunnel');
if (ok) {
startPolling();
} else {
setState('disconnected');
showToast(t('toast_error') || 'Failed to connect', 'error');
}
} catch (err) {
setState('disconnected');
showToast(String(err), 'error');
}
} else {
try { await invoke('stop_tunnel'); } catch { /* ignore */ }
setState('disconnected');
showToast(t('toast_disconnected') || 'Disconnected');
}
}
// ── Screen navigation ────────────────────────────────────────────────────────
function showScreen(name) {
if (name === 'settings') {
loadConfigIntoForm();
homeScreen.classList.remove('active');
settingsScreen.classList.add('active');
} else {
settingsScreen.classList.remove('active');
homeScreen.classList.add('active');
}
}
// ── Config — load ─────────────────────────────────────────────────────────────
async function loadConfigIntoForm() {
try {
const raw = await invoke('get_config');
rawConfig = JSON.parse(raw);
const c = rawConfig.mode === 'client' ? rawConfig : null;
if (!c) return;
inServer.value = c.server || '';
inKey.value = c.access_key || '';
inSocks.value = c.socks5_bind || '127.0.0.1:1088';
inTransport.value = c.transport?.mode || 'udp';
inSni.value = c.transport?.stealth_sni || '';
inPbk.value = c.reality?.pbk || '';
inSid.value = c.reality?.sid || '';
inMtu.value = c.mtu || '';
inTun.checked = !!c.tun?.enable;
inMux.checked = !!c.mux?.enabled;
inMuxSessions.value = c.mux?.sessions || '';
inDns.value = c.tun?.dns || '';
inDebug.checked = !!c.debug;
const ex = c.exclude || {};
inDomains.value = (ex.domains || []).join('\n');
inIps.value = (ex.ips || []).join('\n');
inProcesses.value = (ex.processes || []).join('\n');
} catch (err) {
showToast(String(err), 'error');
}
}
// ── Config — save ─────────────────────────────────────────────────────────────
async function handleSave() {
if (!rawConfig) rawConfig = { mode: 'client', log_level: 'info' };
const server = inServer.value.trim();
const key = inKey.value.trim();
if (!server) { showToast(t('err_server_req') || 'Server address required', 'error'); return; }
if (!key) { showToast(t('err_key_req') || 'Access key required', 'error'); return; }
rawConfig.mode = 'client';
rawConfig.server = server;
rawConfig.access_key = key;
rawConfig.socks5_bind = inSocks.value.trim() || null;
rawConfig.debug = inDebug.checked;
rawConfig.transport = rawConfig.transport || {};
rawConfig.transport.mode = inTransport.value;
rawConfig.transport.stealth_sni = inSni.value.trim() || undefined;
const pbk = inPbk.value.trim();
if (pbk) {
rawConfig.reality = {
enabled: true,
dest: '',
private_key: '',
pbk: pbk,
sid: inSid.value.trim(),
sni_list: []
};
} else {
delete rawConfig.reality;
}
const mtuStr = inMtu.value.trim();
if (mtuStr) rawConfig.mtu = parseInt(mtuStr, 10);
else delete rawConfig.mtu;
if (inMux.checked) {
const s = parseInt(inMuxSessions.value.trim(), 10);
rawConfig.mux = { enabled: true, sessions: isNaN(s) ? 1 : s };
} else {
delete rawConfig.mux;
}
if (!rawConfig.tun) {
rawConfig.tun = { wintun_path: './wintun.dll', ipv4_address: '10.1.0.2/24' };
}
rawConfig.tun.enable = inTun.checked;
rawConfig.tun.dns = inDns.value.trim() || null;
rawConfig.exclude = {
domains: splitLines(inDomains.value),
ips: splitLines(inIps.value),
processes: splitLines(inProcesses.value),
};
try {
const ok = await invoke('save_config', { jsonContent: JSON.stringify(rawConfig, null, 2) });
if (ok) {
showToast(t('toast_saved'), 'ok');
setTimeout(() => showScreen('home'), 700);
} else {
showToast(t('toast_error'), 'error');
}
} catch (err) {
showToast(String(err), 'error');
}
}
// ── Import share link ─────────────────────────────────────────────────────────
function handleImport() {
const raw = importInput.value.trim();
if (!raw) return;
try {
if (!raw.startsWith('ostp://')) throw new Error('Link must start with ostp://');
const url = new URL(raw);
const key = decodeURIComponent(url.username);
const host = url.host;
if (!key || !host) throw new Error('Incomplete link parameters');
inServer.value = host;
inKey.value = key;
inSni.value = url.searchParams.get('sni') || '';
inPbk.value = url.searchParams.get('pbk') || '';
inSid.value = url.searchParams.get('sid') || '';
const type = url.searchParams.get('type');
if (type === 'tcp' || type === 'http') inTransport.value = 'uot';
else inTransport.value = 'udp';
importInput.value = '';
showToast(t('toast_imported'), 'ok');
} catch (err) {
showToast(err.message, 'error');
}
}
// ── Peek key ──────────────────────────────────────────────────────────────────
let peeking = false;
function togglePeek() {
peeking = !peeking;
inKey.type = peeking ? 'text' : 'password';
btnPeekKey.style.color = peeking
? 'var(--c-accent)'
: 'var(--c-txt-3)';
}
// ── Init ──────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', async () => {
applyTranslations();
setState('disconnected');
// Event wiring
btnConnect.addEventListener('click', handleToggle);
btnGoSettings.addEventListener('click', () => showScreen('settings'));
btnBack.addEventListener('click', () => showScreen('home'));
btnSave.addEventListener('click', handleSave);
btnImport.addEventListener('click', handleImport);
btnPeekKey.addEventListener('click', togglePeek);
importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); });
btnLang.addEventListener('click', () => {
toggleLang();
// Refresh dynamic text without losing state
const cur = appState;
appState = '';
setState(cur);
document.getElementById('lang-label').textContent =
localStorage.getItem('ostp_lang') === 'ru' ? 'RU' : 'EN';
});
// Restore status on app open
try {
const code = await invoke('get_tunnel_status');
if (code > 0) startPolling();
} catch { /* not in Tauri context */ }
});