Files
drover-go/docs/design/v2/drover-shared.jsx
T
root 113616b039
Release / release (push) Successful in 3m19s
docs/design/v2: add 12-variant React design archive
Stash the full claude.ai/design output (12 JSX variants — brutalist,
classic, cli, compact, fluent-live, glass, hero-live, minimal,
sketches, studio, wizard-live, workshop — plus shared hooks and a
standalone HTML preview) for reference when we get to the Wails
frontend in Phase 6/7.

Source archive: C:\Users\root\Downloads\app(1).zip (~1MB).

Not wired into any build target yet — current GUI is the temporary
MessageBox stub. Pulling these in is the goal of the Wails phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 03:12:02 +03:00

309 lines
14 KiB
React

// 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 (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={strokeWidth}/>
<path d="M7 9 L12 14 L17 9" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 14 L12 19" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round"/>
</svg>
);
}
function IconGear({ size=14, color='currentColor' }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.2" stroke={color} strokeWidth="1.2"/>
<path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8L3.4 3.4"
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
</svg>
);
}
function IconMin({ size=14, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M3 8h10" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
}
function IconClose({ size=14, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
}
function IconChevron({ size=12, color='currentColor', dir='down' }) {
const r = { down: 0, up: 180, left: 90, right: -90 }[dir];
return <svg width={size} height={size} viewBox="0 0 12 12" style={{ transform: `rotate(${r}deg)` }}>
<path d="M3 4.5 L6 7.5 L9 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>;
}
function IconCopy({ size=12, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1" stroke={color} strokeWidth="1.2"/>
<path d="M2 8.5V2.5C2 1.95 2.45 1.5 3 1.5h6" stroke={color} strokeWidth="1.2"/>
</svg>;
}
function IconArrowUp({ size=10, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
<path d="M5 8.5V1.5M5 1.5L2 4.5M5 1.5L8 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</svg>;
}
function IconArrowDown({ size=10, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
<path d="M5 1.5V8.5M5 8.5L2 5.5M5 8.5L8 5.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</svg>;
}
// ─── 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 (
<span style={{ display:'inline-block', width:size, height:size, position:'relative' }}>
<svg width={size} height={size} viewBox="0 0 16 16" style={{ animation: 'drv-spin 0.8s linear infinite' }}>
<circle cx="8" cy="8" r="6" stroke={c} strokeOpacity="0.25" strokeWidth="2" fill="none"/>
<path d="M8 2 a6 6 0 0 1 6 6" stroke={c} strokeWidth="2" strokeLinecap="round" fill="none"/>
</svg>
</span>
);
}
if (state === 'passed') {
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill={c}/>
<path d="M5 8.2l2 2 4-4.4" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>;
}
if (state === 'failed') {
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill={c}/>
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
</svg>;
}
if (state === 'skipped') {
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill="none" stroke={c} strokeWidth="1.4" strokeDasharray="2 2"/>
<path d="M5 8h6" stroke={c} strokeWidth="1.4" strokeLinecap="round"/>
</svg>;
}
// pending
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="3" fill="none" stroke={c} strokeWidth="1.4"/>
</svg>;
}
// 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,
});