// drover-shared.jsx — shared hooks, state machine, types for all Drover-Go variants. // Each variant imports these via window globals and renders them in its own visual language. // ─── Test catalog ────────────────────────────────────────────────────────── const ALL_TESTS = [ { id: 'tcp', label: 'TCP reachability', desc: 'TCP-соединение до прокси установлено' }, { id: 'greet', label: 'SOCKS5 greeting', desc: 'Прокси отвечает по протоколу SOCKS5' }, { id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true }, { id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' }, { id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' }, { id: 'stun', label: 'UDP round-trip via STUN', desc: 'UDP-пакеты ходят туда-обратно' }, { id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' }, ]; // Pre-baked scenarios so the prototype feels alive. Each entry per test: // { result: 'passed'|'failed'|'skipped', metric: '12 ms' | 'ok' | …, error?: 'short msg', hint?: 'what to try' } const SCENARIOS = { // Default happy path (no auth) happy: { tcp: { result: 'passed', metric: '14 ms' }, greet: { result: 'passed', metric: 'SOCKS5/0x05' }, connect: { result: 'passed', metric: 'gateway.discord.gg' }, udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' }, stun: { result: 'passed', metric: '38 ms RTT' }, api: { result: 'passed', metric: '204 OK · 89 ms' }, }, // With auth happyAuth: { tcp: { result: 'passed', metric: '14 ms' }, greet: { result: 'passed', metric: 'SOCKS5/0x05' }, auth: { result: 'passed', metric: 'user/pass · ok' }, connect: { result: 'passed', metric: 'gateway.discord.gg' }, udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' }, stun: { result: 'passed', metric: '38 ms RTT' }, api: { result: 'passed', metric: '204 OK · 89 ms' }, }, // UDP fails — common Discord scenario udpFail: { tcp: { result: 'passed', metric: '17 ms' }, greet: { result: 'passed', metric: 'SOCKS5/0x05' }, connect: { result: 'passed', metric: 'gateway.discord.gg' }, udp: { result: 'failed', metric: 'X\'07 cmd not supported', error: 'Прокси не поддерживает UDP ASSOCIATE.', hint: 'Голос и демонстрация экрана работать не будут. Текст и API — будут. Попробуйте другой SOCKS5-сервер с поддержкой UDP.' }, stun: { result: 'skipped', metric: 'требует UDP ASSOCIATE' }, api: { result: 'passed', metric: '204 OK · 92 ms' }, }, }; function getTests(authEnabled) { return ALL_TESTS.filter(t => !t.authOnly || authEnabled); } // ─── Drover state hook ───────────────────────────────────────────────────── // Owns: form values, diagnostic phase, per-test results, drover-active state, // live stats counter, log buffer. // phase: 'idle' | 'checking' | 'checked' | 'active' function useDrover(initial = {}) { const [form, setForm] = React.useState({ host: '95.165.72.59', port: '12334', auth: false, login: '', password: '', ...initial, }); const [phase, setPhase] = React.useState('idle'); const [results, setResults] = React.useState({}); // testId -> {result, metric, error, hint, expanded} const [running, setRunning] = React.useState(null); // currently-running test id const [scenario, setScenario] = React.useState('happy'); // for tweaks const [stats, setStats] = React.useState({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 }); const [logs, setLogs] = React.useState(() => seedLogs()); const [logsOpen, setLogsOpen] = React.useState(false); const tests = getTests(form.auth); const lastSummary = React.useMemo(() => { if (phase !== 'checked' && phase !== 'active') return null; const ids = tests.map(t => t.id); const failed = ids.filter(id => results[id]?.result === 'failed').length; return { total: ids.length, failed }; }, [phase, results, tests]); // ── actions ──────────────────────────────────────────────────────────── function update(patch) { setForm(f => ({ ...f, ...patch })); } function pushLog(level, msg) { setLogs(l => [...l.slice(-499), { t: Date.now(), level, msg }]); } async function runCheck() { if (phase === 'checking') return; setPhase('checking'); setResults({}); setRunning(null); pushLog('INFO', `connect ${form.host}:${form.port}${form.auth ? ' (auth)' : ''}`); const list = getTests(form.auth); const sc = form.auth && scenario === 'happy' ? SCENARIOS.happyAuth : SCENARIOS[scenario]; for (const t of list) { setRunning(t.id); // shorter when scenario is failing past skipped tests const r = sc[t.id]; if (r?.result === 'skipped') { await sleep(120); } else { await sleep(380 + Math.random()*220); } setResults(prev => ({ ...prev, [t.id]: { ...r, expanded: r?.result === 'failed' } })); pushLog(r?.result === 'failed' ? 'ERROR' : r?.result === 'skipped' ? 'WARN' : 'INFO', `${t.label}: ${r?.result}${r?.metric ? ' · ' + r.metric : ''}`); } setRunning(null); setPhase('checked'); } function startProxy() { if (phase !== 'checked') return; if (lastSummary?.failed === tests.length) return; setPhase('active'); pushLog('INFO', 'drover: bound 127.0.0.1:1080 · routing discord traffic'); } function stopProxy() { if (phase !== 'active') return; setPhase('checked'); setStats({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 }); pushLog('INFO', 'drover: stopped'); } // Live stats while active React.useEffect(() => { if (phase !== 'active') return; const id = setInterval(() => { setStats(s => ({ up: Math.max(0, s.up + (Math.random()*40000 - 18000)), down: Math.max(0, s.down + (Math.random()*120000 - 50000)), tcp: Math.max(2, Math.min(28, s.tcp + (Math.random() < 0.3 ? (Math.random()<.5?-1:1) : 0))), udp: Math.max(0, Math.min(8, s.udp + (Math.random() < 0.2 ? (Math.random()<.5?-1:1) : 0))), uptimeS: s.uptimeS + 1, })); }, 1000); // initial values setStats({ up: 42000, down: 180000, tcp: 6, udp: 2, uptimeS: 0 }); return () => clearInterval(id); }, [phase]); // Toggle a test's expanded explanation function toggleExpand(id) { setResults(r => ({ ...r, [id]: { ...r[id], expanded: !r[id]?.expanded } })); } return { form, update, phase, setPhase, tests, results, running, scenario, setScenario, stats, logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]), lastSummary, runCheck, startProxy, stopProxy, toggleExpand, }; } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function seedLogs() { const t = Date.now(); return [ { t: t-9200, level: 'INFO', msg: 'drover-go v0.4.2 starting' }, { t: t-9100, level: 'INFO', msg: 'config: ~/.drover/config.toml' }, { t: t-9000, level: 'INFO', msg: 'no active session' }, ]; } function fmtBytes(n) { if (n < 1024) return n.toFixed(0) + ' B/s'; if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB/s'; return (n/1024/1024).toFixed(2) + ' MB/s'; } function fmtUptime(s) { const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60; if (h) return `${h}h ${m}m`; if (m) return `${m}m ${ss}s`; return `${ss}s`; } function fmtTime(t) { const d = new Date(t); return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0'); } // ─── Shared icons (small, original) ──────────────────────────────────────── // Drover-Go mark: a downward chevron through a ring — "tunneled traffic". function BrandMark({ size = 16, color = 'currentColor', strokeWidth = 1.6 }) { const s = size; return ( ); } function IconGear({ size=14, color='currentColor' }) { return ( ); } function IconMin({ size=14, color='currentColor' }) { return ; } function IconClose({ size=14, color='currentColor' }) { return ; } function IconChevron({ size=12, color='currentColor', dir='down' }) { const r = { down: 0, up: 180, left: 90, right: -90 }[dir]; return ; } function IconCopy({ size=12, color='currentColor' }) { return ; } function IconArrowUp({ size=10, color='currentColor' }) { return ; } function IconArrowDown({ size=10, color='currentColor' }) { return ; } // ─── Test row state icons (per visual variant supplies its own colors) ───── function StatusDot({ state, palette, size = 12 }) { // state: 'pending' | 'running' | 'passed' | 'failed' | 'skipped' const c = palette[state] || palette.pending; if (state === 'running') { return ( ); } if (state === 'passed') { return ; } if (state === 'failed') { return ; } if (state === 'skipped') { return ; } // pending return ; } // CSS for the spinner — injected once. if (typeof document !== 'undefined' && !document.getElementById('drv-shared-css')) { const s = document.createElement('style'); s.id = 'drv-shared-css'; s.textContent = ` @keyframes drv-spin { to { transform: rotate(360deg); } } @keyframes drv-pulse { 0%,100% { opacity:1; transform:scale(1);} 50% { opacity:.55; transform:scale(0.7);} } @keyframes drv-blink { 0%,100% { opacity:1;} 50% { opacity:.35;} } @keyframes drv-fadein { from { opacity:0; transform:translateY(-2px);} to { opacity:1; transform:none;} } .drv-fadein { animation: drv-fadein .18s ease-out; } .drv-pulsedot { animation: drv-pulse 1.4s ease-in-out infinite; } .drv-shimmer::after { content:''; position:absolute; inset:0; background: linear-gradient(90deg,transparent,rgba(255,255,255,.25),transparent); transform:translateX(-100%); animation: drv-shim 1.6s linear infinite; } @keyframes drv-shim { to { transform: translateX(100%); } } /* Hide scrollbars for log panes inside artboards */ .drv-log::-webkit-scrollbar { width:6px; } .drv-log::-webkit-scrollbar-thumb { background: rgba(127,127,127,.35); border-radius: 3px; } `; document.head.appendChild(s); } // Expose globals Object.assign(window, { useDrover, getTests, ALL_TESTS, SCENARIOS, fmtBytes, fmtUptime, fmtTime, BrandMark, StatusDot, IconGear, IconMin, IconClose, IconChevron, IconCopy, IconArrowUp, IconArrowDown, });