internal/gui: Wails app with Classic React variant + theme toggle
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped

- app.go: App struct with stub bindings (RunCheck/StartEngine/
  StopEngine/GetStatus/Version) — emits check:result, check:done,
  engine:status, stats:update events. Real backend lands in Phase 1.
- run.go: wails.Run() with frameless 480x640 fixed window, Classic
  dark bg matching theme.
- embed.go: //go:embed all:frontend/dist for the Vite build output.
- frontend/: Vite + React project derived from `wails init -t react`.
  Removed default template assets and wired Classic variant from
  docs/design/v2/.
  - components/Classic.jsx: variant 1 with custom title bar
    (drag region, sun/moon theme toggle, min/close hooked to
    Wails WindowMinimise/Quit).
  - components/shared.jsx: useDrover hook adapted to call Wails
    bindings and listen on backend events instead of mock SCENARIOS.
    Added IconSun + IconMoon for the title-bar toggle.
  - App.jsx: owns mode state, wraps setMode in
    document.startViewTransition so the title-bar toggle gives a
    circle-reveal sweep from the cursor.
  - style.css: clean reset (overflow hidden, no scrollbars, brand
    background) — replaces the wails-react-template defaults.
  - wailsjs/go/gui/App.js: hand-written bindings since our App
    struct lives in package gui rather than the standard top-level
    main; `wails generate module` would have written package main
    bindings here.
- build/: standard wails artifacts (icon, manifest); will be
  consumed by `wails build` once we wire it through CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 15:17:19 +03:00
parent 13c32c90d5
commit b6619ef53b
29 changed files with 3792 additions and 0 deletions
@@ -0,0 +1,469 @@
// Classic.jsx — Variant 1: Classic devtool.
// Information-dense. Mono metrics. Plain rectangles, hairline borders.
// Sober palette: neutral grays + one teal accent for primary action / success.
import * as React from 'react'
import {
useDrover,
BrandMark, IconGear, IconMin, IconClose, IconChevron, IconCopy,
IconArrowUp, IconArrowDown, IconSun, IconMoon, StatusDot,
fmtBytes, fmtUptime, fmtTime,
} from './shared.jsx'
import { WindowMinimise, Quit } from '../../wailsjs/runtime/runtime'
import { Version as GoVersion } from '../../wailsjs/go/gui/App'
const ClassicTheme = {
// dark
d: {
bg: '#1c1d20',
chrome: '#15161a',
panel: '#22242a',
panelAlt: '#1a1c20',
border: '#34373d',
borderSoft:'#2a2c32',
text: '#dde0e6',
dim: '#8a8f99',
dimmer: '#5b6068',
accent: '#3ea99f', // teal
accentDim: '#2a7d76',
danger: '#d96565',
warn: '#d9a155',
pass: '#5cba8b',
skip: '#7c8088',
inputBg: '#15161a',
btnBg: '#2c2f36',
btnBgH: '#373b43',
primaryBg: '#3ea99f',
primaryFg: '#0c1a18',
},
l: {
bg: '#f3f4f6',
chrome: '#e8eaef',
panel: '#ffffff',
panelAlt: '#f8f9fb',
border: '#d8dbe1',
borderSoft:'#e6e8ec',
text: '#1c1f24',
dim: '#5c6168',
dimmer: '#8a8f97',
accent: '#2a7d76',
accentDim: '#bdded9',
danger: '#c0463f',
warn: '#a8731e',
pass: '#2f8c5a',
skip: '#7c8088',
inputBg: '#ffffff',
btnBg: '#ffffff',
btnBgH: '#f1f2f5',
primaryBg: '#2a7d76',
primaryFg: '#ffffff',
},
};
export function ClassicWindow({ mode = 'dark', initial, onToggleMode }) {
const t = ClassicTheme[mode === 'dark' ? 'd' : 'l'];
const D = useDrover(initial);
const [version, setVersion] = React.useState('');
React.useEffect(() => { GoVersion().then(setVersion).catch(() => {}); }, []);
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace';
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
const isActive = D.phase === 'active';
const allChecked = D.phase === 'checked' || D.phase === 'active';
const failed = D.lastSummary?.failed ?? 0;
return (
<div style={{
width: '100vw', height: '100vh', background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
fontFamily: fontUI, fontSize: 13, lineHeight: 1.4, overflow: 'hidden',
border: mode === 'dark' ? '1px solid #000' : '1px solid #c0c3c9',
}}>
{/* ─── title bar ─── */}
<ClassicTitleBar t={t} version={version} mode={mode} onToggleMode={onToggleMode} />
{/* ─── content ─── */}
<div style={{ flex: 1, overflow: 'auto', padding: '14px 16px 0' }}>
{/* Form */}
<SectionLabel t={t}>SOCKS5 Proxy</SectionLabel>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Field t={t} label="Host" style={{ flex: 1 }}>
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
placeholder="95.165.72.59 или example.com"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono)} />
</Field>
<Field t={t} label="Port" style={{ width: 92 }}>
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
placeholder="12334" inputMode="numeric"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono)} />
</Field>
</div>
<Checkbox t={t} checked={D.form.auth}
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
Authentication
</Checkbox>
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: 12, opacity: D.form.auth ? 1 : 0.45 }}>
<Field t={t} label="Login" style={{ flex: 1 }}>
<input id="cls-login" disabled={!D.form.auth} value={D.form.login}
onChange={e => D.update({ login: e.target.value })} placeholder="user"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono, !D.form.auth)} />
</Field>
<Field t={t} label="Password" style={{ flex: 1 }}>
<input disabled={!D.form.auth} type="password" value={D.form.password}
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono, !D.form.auth)} />
</Field>
</div>
<PrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
</PrimaryBtn>
{/* Status */}
<div style={{ height: 18 }} />
<SectionLabel t={t}>Status</SectionLabel>
<ClassicStatus t={t} D={D} palette={palette} fontMono={fontMono} />
{/* Action buttons */}
<div style={{ height: 14 }} />
<div style={{ display: 'flex', gap: 8 }}>
<ClassicStartBtn t={t} D={D} fontMono={fontMono} />
<ClassicStopBtn t={t} D={D} />
</div>
{isActive && <ClassicLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
<div style={{ height: 14 }} />
</div>
{/* Logs collapsible */}
<ClassicLogs t={t} D={D} fontMono={fontMono} />
</div>
);
}
// ─── pieces ─────────────────────────────────────────────────────────────────
function ClassicTitleBar({ t, version, mode, onToggleMode }) {
// Cell height = full title-bar height so the hover background fills
// edge-to-edge (no thin sliver of chrome above the red close highlight).
// alignSelf:'stretch' on the wrapper keeps it pinned to the top/bottom
// of the flex row even though parent uses alignItems:'center' for text.
const cellStyle = {
width: 38, height: '100%', display:'flex', alignItems:'center', justifyContent:'center',
color: t.dim, cursor:'pointer',
};
return (
<div style={{
height: 32, background: t.chrome, borderBottom: `1px solid ${t.borderSoft}`,
display:'flex', alignItems:'stretch',
// CSS Wails recognises for the OS title-bar drag region. The
// close/min cells below override it with --wails-draggable: no-drag
// so clicks land on the buttons, not the drag handler.
['--wails-draggable']: 'drag',
userSelect:'none',
}}>
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 12px', flex:1 }}>
<BrandMark size={14} color={t.accent}/>
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.1 }}>Drover-Go</span>
{version && <span style={{ fontSize: 11, color: t.dimmer, fontFamily:'ui-monospace,monospace' }}>v{version}</span>}
</div>
<div style={{ display:'flex', alignItems:'stretch', ['--wails-draggable']: 'no-drag' }}>
<div style={cellStyle}
title={mode === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
onClick={(e) => onToggleMode && onToggleMode(e)}>
{mode === 'dark' ? <IconSun color={t.dim}/> : <IconMoon color={t.dim}/>}
</div>
<div style={cellStyle} title="Minimize" onClick={() => WindowMinimise()}>
<IconMin color={t.dim}/>
</div>
<div style={cellStyle} title="Close"
onClick={() => Quit()}
onMouseEnter={e => e.currentTarget.style.background = '#c0463f'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<IconClose color={t.dim}/>
</div>
</div>
</div>
);
}
function SectionLabel({ children, t }) {
return <div style={{
fontSize: 10.5, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase',
color: t.dim, marginBottom: 8,
}}>{children}</div>;
}
function Field({ children, label, t, style }) {
return (
<label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500 }}>{label}</span>
{children}
</label>
);
}
function inputStyle(t, fontMono, disabled) {
return {
background: t.inputBg, color: disabled ? t.dimmer : t.text,
border: `1px solid ${t.border}`, borderRadius: 3, padding: '7px 9px',
fontFamily: fontMono, fontSize: 12, outline: 'none', width: '100%', boxSizing: 'border-box',
transition: 'border-color .12s, box-shadow .12s',
};
}
function Checkbox({ checked, onChange, children, t }) {
return (
<label style={{ display:'inline-flex', alignItems:'center', gap: 7, cursor:'pointer', userSelect:'none', fontSize: 12 }}>
<span style={{
width: 14, height: 14, borderRadius: 2, border: `1px solid ${checked ? t.accent : t.border}`,
background: checked ? t.accent : 'transparent', display:'flex', alignItems:'center', justifyContent:'center',
transition: 'background .12s, border-color .12s',
}}>
{checked && <svg width="9" height="9" viewBox="0 0 9 9"><path d="M1.5 4.5l2 2 4-4" stroke={t.primaryFg} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
{children}
</label>
);
}
function PrimaryBtn({ t, onClick, disabled, children, style }) {
return (
<button onClick={onClick} disabled={disabled}
style={{
width:'100%', padding:'9px 12px', border:'none', borderRadius: 3,
background: disabled ? t.btnBg : t.primaryBg, color: disabled ? t.dimmer : t.primaryFg,
fontWeight: 600, fontSize: 12.5, letterSpacing: 0.1, cursor: disabled ? 'not-allowed' : 'pointer',
boxShadow: disabled ? 'none' : `inset 0 -1px 0 rgba(0,0,0,.18)`,
transition: 'background .12s', ...style,
}}>
{children}
</button>
);
}
// ─── status panel ──────────────────────────────────────────────────────────
function ClassicStatus({ t, D, palette, fontMono }) {
const idle = D.phase === 'idle';
if (idle) {
return (
<div style={{
background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4,
padding: '14px 14px', display:'flex', alignItems:'center', gap: 10,
}}>
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
<span style={{ color: t.dim, fontSize: 12.5 }}>Ready to check</span>
</div>
);
}
return (
<div style={{ background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4, overflow:'hidden' }}>
{/* header */}
<div style={{
padding: '8px 12px', display:'flex', alignItems:'center', gap: 8,
borderBottom: `1px solid ${t.borderSoft}`, background: t.panelAlt, fontSize: 12,
}}>
{D.phase === 'checking'
? <>
<StatusDot state="running" palette={palette} size={12}/>
<span>Running diagnostics</span>
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
{Object.keys(D.results).length}/{D.tests.length}
</span>
</>
: D.lastSummary?.failed === 0
? <span style={{ color: t.pass, fontWeight: 600 }}>All checks passed. Ready to start.</span>
: <span style={{ color: t.warn, fontWeight: 600 }}>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
</div>
{/* tests */}
<div>
{D.tests.map((test, i) => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
const isLast = i === D.tests.length - 1;
return (
<div key={test.id} style={{
borderBottom: !isLast ? `1px solid ${t.borderSoft}` : 'none',
padding: '6px 12px',
}}>
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 22 }}>
<StatusDot state={state} palette={palette} size={12}/>
<span style={{ fontSize: 12, color: state === 'pending' ? t.dim : t.text }} title={test.desc}>
{test.label}
</span>
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
{r?.metric || (state === 'running' ? '...' : '')}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
<IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
</button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '4px 0 6px 21px', padding: '8px 10px', borderRadius: 3,
background: mode_mix(t.danger, t.panel, 0.9), border: `1px solid ${mode_mix(t.danger, t.panel, 0.78)}`,
fontSize: 11.5, color: t.text,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
<div style={{ color: t.dim }}>{r.hint}</div>
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
style={smallBtn(t, fontMono)}>
<IconCopy color={t.dim}/> copy
</button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function iconBtnStyle(t) {
return {
width: 20, height: 20, padding: 0, border:'none', background:'transparent',
cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
borderRadius: 2,
};
}
function smallBtn(t, fontMono) {
return {
display:'inline-flex', alignItems:'center', gap: 4, padding: '3px 7px',
background: t.btnBg, border: `1px solid ${t.border}`, color: t.dim,
borderRadius: 3, fontFamily: fontMono, fontSize: 10.5, cursor:'pointer',
};
}
// crude color mix for dark/light. expects hex (#rrggbb), bg can be hex too. amount=share-of-bg.
function mode_mix(fg, bg, amt) {
const a = hexToRgb(fg), b = hexToRgb(bg);
return `rgb(${Math.round(a.r*(1-amt)+b.r*amt)},${Math.round(a.g*(1-amt)+b.g*amt)},${Math.round(a.b*(1-amt)+b.b*amt)})`;
}
function hexToRgb(h) {
const v = h.replace('#','');
return { r: parseInt(v.slice(0,2),16), g: parseInt(v.slice(2,4),16), b: parseInt(v.slice(4,6),16) };
}
// ─── start/stop ────────────────────────────────────────────────────────────
function ClassicStartBtn({ t, D, fontMono }) {
const phase = D.phase;
const summary = D.lastSummary;
const allFailed = summary && summary.failed === D.tests.length;
const checkedOk = phase === 'checked' && !allFailed;
const active = phase === 'active';
const warning = active && (summary?.failed || 0) > 0;
if (active) {
return (
<div style={{
flex:1, padding:'9px 12px', borderRadius: 3, display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
background: warning ? mode_mix(t.warn, t.panel, 0.85) : mode_mix(t.pass, t.panel, 0.85),
border: `1px solid ${warning ? t.warn : t.pass}`,
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5, fontFamily: fontMono,
}}>
<span className="drv-pulsedot" style={{
width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass,
}}/>
Active{warning ? ' · UDP fallback' : ''}
</div>
);
}
return (
<PrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1 }}>
Start proxying
</PrimaryBtn>
);
}
function ClassicStopBtn({ t, D }) {
const enabled = D.phase === 'active';
return (
<button onClick={D.stopProxy} disabled={!enabled}
style={{
flex:1, padding:'9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
background: t.btnBg, color: enabled ? t.text : t.dimmer,
border: `1px solid ${t.border}`, cursor: enabled ? 'pointer':'not-allowed',
}}>
Stop
</button>
);
}
function ClassicLiveStats({ t, stats, fontMono }) {
const cell = (icon, val) => (
<div style={{ display:'flex', alignItems:'center', gap: 4, color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
{icon}<span>{val}</span>
</div>
);
return (
<div style={{
marginTop: 8, padding: '6px 10px', borderRadius: 3,
background: t.panel, border: `1px solid ${t.borderSoft}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
}}>
{cell(<IconArrowUp color={t.dim}/>, fmtBytes(stats.up))}
{cell(<IconArrowDown color={t.dim}/>, fmtBytes(stats.down))}
{cell(<span style={{fontSize:9, color:t.dimmer}}>TCP</span>, stats.tcp)}
{cell(<span style={{fontSize:9, color:t.dimmer}}>UDP</span>, stats.udp)}
{cell(<span style={{fontSize:9, color:t.dimmer}}>↑t</span>, fmtUptime(stats.uptimeS))}
</div>
);
}
// ─── logs ──────────────────────────────────────────────────────────────────
function ClassicLogs({ t, D, fontMono }) {
return (
<div style={{ borderTop: `1px solid ${t.borderSoft}`, background: t.chrome, flexShrink: 0 }}>
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
width:'100%', padding: '8px 14px', display:'flex', alignItems:'center', gap:8,
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
fontSize: 11, fontFamily: fontMono, letterSpacing: 0.3,
}}>
<IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
<span style={{ textTransform:'uppercase' }}>Logs</span>
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
</button>
{D.logsOpen && (
<div style={{ borderTop: `1px solid ${t.borderSoft}` }}>
<div style={{ display:'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.borderSoft}` }}>
<button style={smallBtn(t, fontMono)}
onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all</button>
<button style={smallBtn(t, fontMono)} onClick={D.clearLogs}>clear</button>
<button style={smallBtn(t, fontMono)}>open log file</button>
</div>
<div className="drv-log" style={{
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
background: t.panelAlt,
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
{D.logs.map((l, i) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{fmtTime(l.t)}</span>
{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 600 }}>[{l.level}]</span>
{' '}
<span>{l.msg}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
// Default export so App.jsx can `import ClassicWindow from './components/Classic'`.
export default ClassicWindow;
@@ -0,0 +1,369 @@
// shared.jsx — state machine + shared icons/utilities for all Drover-Go variants.
//
// Original prototype loaded everything via window globals (babel script-tag
// build). For Wails + Vite we use real ESM imports/exports — additions:
// - `import * as React from 'react'` so `React.useState/useMemo/useEffect`
// keep working unchanged.
// - `export` on everything the variant components need.
// - `useDrover` no longer simulates with `SCENARIOS`; it calls the Wails
// bindings on `window.go.main.App` and listens for the events the Go
// side emits (`check:result`, `check:done`, `stats:update`, ...).
//
// The state surface (form/phase/results/stats/logs) is unchanged, so the
// UI components don't need to be rewritten — only their imports.
import * as React from 'react'
import { RunCheck, StartEngine, StopEngine, GetStatus } from '../../wailsjs/go/gui/App'
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
// ─── Test catalog ──────────────────────────────────────────────────────────
export 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' },
},
};
export 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'
export 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'); // kept for compat with prototype, unused with real backend
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 }]);
}
// Subscribe to backend events once. The Go side emits:
// check:result → one test result (id, status, metric, error, hint)
// check:done → diagnostic finished, summary {total, passed, failed}
// engine:status → {running: bool}
// stats:update → {up, down, tcp, udp, uptimeS}
React.useEffect(() => {
const offResult = EventsOn('check:result', (r) => {
if (r.status === 'running') {
setRunning(r.id);
return;
}
// Convert backend "status" field to the frontend's "result" field used
// by the Classic/Fluent/etc components.
setResults(prev => ({
...prev,
[r.id]: {
result: r.status,
metric: r.metric,
error: r.error,
hint: r.hint,
expanded: r.status === 'failed',
},
}));
pushLog(r.status === 'failed' ? 'ERROR' : r.status === 'skipped' ? 'WARN' : 'INFO',
`${r.id}: ${r.status}${r.metric ? ' · ' + r.metric : ''}`);
});
const offDone = EventsOn('check:done', (s) => {
setRunning(null);
setPhase('checked');
pushLog('INFO', `check finished — ${s.passed}/${s.total} passed`);
});
const offStatus = EventsOn('engine:status', (s) => {
setPhase(s.running ? 'active' : 'checked');
pushLog('INFO', s.running ? 'engine: started' : 'engine: stopped');
});
const offStats = EventsOn('stats:update', (s) => setStats(s));
return () => {
offResult();
offDone();
offStatus();
offStats();
};
}, []);
async function runCheck() {
if (phase === 'checking') return;
setPhase('checking');
setResults({});
setRunning(null);
pushLog('INFO', `connect ${form.host}:${form.port}${form.auth ? ' (auth)' : ''}`);
await RunCheck({
host: form.host,
port: parseInt(form.port, 10) || 0,
auth: form.auth,
login: form.login,
password: form.password,
});
// The rest is event-driven (check:result, check:done) — see useEffect above.
}
async function startProxy() {
if (phase !== 'checked') return;
if (lastSummary?.failed === tests.length) return;
await StartEngine();
// engine:status event will flip phase to 'active'.
}
async function stopProxy() {
if (phase !== 'active') return;
await StopEngine();
setStats({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
}
// Reflect initial backend state (in case the engine was already running
// when the GUI was opened — e.g. via service mode).
React.useEffect(() => {
GetStatus().then((s) => {
if (s?.running) setPhase('active');
}).catch(() => {});
}, []);
// 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)); }
// Sun + moon icons for the theme-toggle button in the title bar. Style
// matches the rest (1.2 stroke, 14px square viewBox).
export function IconSun({ size=14, color='currentColor' }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="3" stroke={color} strokeWidth="1.2"/>
<path d="M8 1.5v1.5M8 13v1.5M14.5 8H13M3 8H1.5M12.6 3.4l-1 1M4.4 11.6l-1 1M12.6 12.6l-1-1M4.4 4.4l-1-1"
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
</svg>
);
}
export function IconMoon({ size=14, color='currentColor' }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
<path d="M13.5 9.5A5.5 5.5 0 1 1 6.5 2.5a4 4 0 0 0 7 7z"
stroke={color} strokeWidth="1.2" strokeLinejoin="round"/>
</svg>
);
}
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' },
];
}
export 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';
}
export 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`;
}
export 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".
export 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>
);
}
export 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>
);
}
export 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>;
}
export 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>;
}
export 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>;
}
export 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>;
}
export 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>;
}
export 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) ─────
export 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,
});