internal/gui: Wails app with Classic React variant + theme toggle
- 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:
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user