internal/gui: wire real checker.Run + CancelCheck binding + RawHex display
- RunCheck now drives internal/checker.Run instead of the fake 7-step sleep loop. Streams checker.Result events as "check:result" with Duration converted to milliseconds; emits "check:done" summary on channel close. Re-running while a check is in flight cancels the previous run and waits for its goroutine to drain so two emitter goroutines never race on event order. - New CancelCheck method (no-op when nothing is running) is bound through wailsjs/go/gui/App.js and surfaced in useDrover as cancelCheck() with a "check cancelled by user" log line. - Classic.jsx: while phase==='checking', the Check button collapses to a disabled "Checking…" pill side-by-side with a Cancel button (uses Stop's secondary visual weight, t.danger on hover). The expanded failure row now renders r.rawHex (truncated to 64 chars) on its own mono line and the copy button includes raw=<hex> when present. - CheckResult struct gains RawHex (json:"rawHex") and Attempt fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+98
-37
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.okcu.io/root/drover-go/internal/checker"
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +28,15 @@ type App struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
|
|
||||||
|
// muCheck guards cancelCheck and checkDone.
|
||||||
|
// cancelCheck is the cancel func of the in-flight checker.Run context (nil
|
||||||
|
// when no check is running). checkDone is closed by the runner goroutine
|
||||||
|
// once it has drained the result channel — RunCheck waits on it before
|
||||||
|
// starting a new run, so we never have two emitter goroutines alive.
|
||||||
|
muCheck sync.Mutex
|
||||||
|
cancelCheck context.CancelFunc
|
||||||
|
checkDone chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp returns a fresh App stamped with the binary's build version
|
// NewApp returns a fresh App stamped with the binary's build version
|
||||||
@@ -55,58 +65,109 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckResult is one row in the diagnostic table; the frontend listens
|
// CheckResult is one row in the diagnostic table; the frontend listens
|
||||||
// for them on the "check:result" event.
|
// for them on the "check:result" event. Mirrors checker.Result but with
|
||||||
|
// Duration converted to milliseconds (int) for the JS side.
|
||||||
type CheckResult struct {
|
type CheckResult struct {
|
||||||
ID string `json:"id"` // tcp / greet / auth / connect / udp / stun / api
|
ID string `json:"id"` // tcp / greet / auth / connect / udp / stun / api
|
||||||
Status string `json:"status"` // running | passed | failed | skipped
|
Status string `json:"status"` // running | passed | failed | skipped
|
||||||
Metric string `json:"metric"`
|
Metric string `json:"metric,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Hint string `json:"hint,omitempty"`
|
Hint string `json:"hint,omitempty"`
|
||||||
|
RawHex string `json:"rawHex,omitempty"`
|
||||||
Duration int64 `json:"duration_ms,omitempty"`
|
Duration int64 `json:"duration_ms,omitempty"`
|
||||||
|
Attempt int `json:"attempt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunCheck plays out a fake 7-step diagnostic. Each result is pushed to
|
// RunCheck runs a real 7-step SOCKS5 diagnostic via internal/checker. Each
|
||||||
// the JS side as a "check:result" event; when the run finishes we emit
|
// Result from the checker channel is forwarded to the frontend as a
|
||||||
// "check:done" with the overall summary.
|
// "check:result" event; when the channel closes (run finished, or context
|
||||||
|
// cancelled) we emit "check:done" with the {total, passed, failed} summary.
|
||||||
//
|
//
|
||||||
// Phase 1+: replace this with internal/checker.Run() and stream its
|
// If a previous check is still in flight, its context is cancelled and we
|
||||||
// real results.
|
// wait for the previous goroutine to finish before launching the new one
|
||||||
|
// — this guarantees event ordering (no two emitters alive simultaneously).
|
||||||
func (a *App) RunCheck(cfg Config) {
|
func (a *App) RunCheck(cfg Config) {
|
||||||
|
// Cancel any in-flight check and wait for its goroutine to drain.
|
||||||
|
a.muCheck.Lock()
|
||||||
|
prevCancel := a.cancelCheck
|
||||||
|
prevDone := a.checkDone
|
||||||
|
a.muCheck.Unlock()
|
||||||
|
if prevCancel != nil {
|
||||||
|
prevCancel()
|
||||||
|
}
|
||||||
|
if prevDone != nil {
|
||||||
|
<-prevDone
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(a.ctx)
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
a.muCheck.Lock()
|
||||||
|
a.cancelCheck = cancel
|
||||||
|
a.checkDone = done
|
||||||
|
a.muCheck.Unlock()
|
||||||
|
|
||||||
|
ckCfg := checker.Config{
|
||||||
|
ProxyHost: cfg.Host,
|
||||||
|
ProxyPort: cfg.Port,
|
||||||
|
UseAuth: cfg.Auth,
|
||||||
|
ProxyLogin: cfg.Login,
|
||||||
|
ProxyPassword: cfg.Password,
|
||||||
|
// Leave PerTestTimeout / MaxRetries / RetryBackoff /
|
||||||
|
// DiscordGateway / DiscordAPI / StunServer at zero so the
|
||||||
|
// checker package applies its own defaults.
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ids := []string{"tcp", "greet"}
|
defer close(done)
|
||||||
if cfg.Auth {
|
var passed, failed int
|
||||||
ids = append(ids, "auth")
|
for r := range checker.Run(ctx, ckCfg) {
|
||||||
}
|
// Always emit on a.ctx, never on the per-check ctx — the
|
||||||
ids = append(ids, "connect", "udp", "stun", "api")
|
// per-check ctx may already be cancelled when the final
|
||||||
|
// "cancelled" result arrives, which would silently drop it.
|
||||||
fakeMetric := map[string]string{
|
|
||||||
"tcp": "14 ms", "greet": "SOCKS5/0x05", "auth": "user/pass · ok",
|
|
||||||
"connect": "gateway.discord.gg", "udp": "relay 1.2.3.4:54321",
|
|
||||||
"stun": "38 ms RTT", "api": "204 OK · 89 ms",
|
|
||||||
}
|
|
||||||
|
|
||||||
passedCount := 0
|
|
||||||
for _, id := range ids {
|
|
||||||
runtime.EventsEmit(a.ctx, "check:result", CheckResult{ID: id, Status: "running"})
|
|
||||||
time.Sleep(350 * time.Millisecond)
|
|
||||||
|
|
||||||
// Stub: everything passes. Real checker will fail UDP if proxy
|
|
||||||
// rejects ASSOCIATE, etc.
|
|
||||||
runtime.EventsEmit(a.ctx, "check:result", CheckResult{
|
runtime.EventsEmit(a.ctx, "check:result", CheckResult{
|
||||||
ID: id,
|
ID: r.ID,
|
||||||
Status: "passed",
|
Status: string(r.Status),
|
||||||
Metric: fakeMetric[id],
|
Metric: r.Metric,
|
||||||
Duration: 350,
|
Error: r.Error,
|
||||||
|
Hint: r.Hint,
|
||||||
|
RawHex: r.RawHex,
|
||||||
|
Duration: r.Duration.Milliseconds(),
|
||||||
|
Attempt: r.Attempt,
|
||||||
})
|
})
|
||||||
passedCount++
|
switch r.Status {
|
||||||
|
case checker.StatusPassed:
|
||||||
|
passed++
|
||||||
|
case checker.StatusFailed:
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.EventsEmit(a.ctx, "check:done", map[string]int{
|
||||||
|
"total": passed + failed,
|
||||||
|
"passed": passed,
|
||||||
|
"failed": failed,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear cancel/done if we're still the current run (RunCheck may
|
||||||
|
// have already replaced them with a newer run by the time we get
|
||||||
|
// here, in which case leave those alone).
|
||||||
|
a.muCheck.Lock()
|
||||||
|
if a.checkDone == done {
|
||||||
|
a.cancelCheck = nil
|
||||||
|
a.checkDone = nil
|
||||||
|
}
|
||||||
|
a.muCheck.Unlock()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.EventsEmit(a.ctx, "check:done", map[string]int{
|
// CancelCheck cancels the currently-running diagnostic, if any. Safe to
|
||||||
"total": len(ids),
|
// call when no check is running (no-op).
|
||||||
"passed": passedCount,
|
func (a *App) CancelCheck() {
|
||||||
"failed": len(ids) - passedCount,
|
a.muCheck.Lock()
|
||||||
})
|
defer a.muCheck.Unlock()
|
||||||
}()
|
if a.cancelCheck != nil {
|
||||||
|
a.cancelCheck()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartEngine flips the proxy on. In the stub we just toggle the flag and
|
// StartEngine flips the proxy on. In the stub we just toggle the flag and
|
||||||
|
|||||||
@@ -121,9 +121,18 @@ export function ClassicWindow({ mode = 'dark', initial, onToggleMode }) {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
{D.phase === 'checking' ? (
|
||||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<PrimaryBtn t={t} onClick={D.runCheck} disabled style={{ flex: 1 }}>
|
||||||
|
Checking…
|
||||||
</PrimaryBtn>
|
</PrimaryBtn>
|
||||||
|
<ClassicCancelBtn t={t} onClick={D.cancelCheck} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PrimaryBtn t={t} onClick={D.runCheck} disabled={isActive}>
|
||||||
|
Check connection
|
||||||
|
</PrimaryBtn>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div style={{ height: 18 }} />
|
<div style={{ height: 18 }} />
|
||||||
@@ -316,8 +325,19 @@ function ClassicStatus({ t, D, palette, fontMono }) {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||||
|
{r.rawHex && (
|
||||||
|
<div style={{
|
||||||
|
fontFamily: fontMono, fontSize: 10.5, color: t.dimmer,
|
||||||
|
marginTop: 4, padding: '4px 6px',
|
||||||
|
background: t.panelAlt, borderRadius: 2,
|
||||||
|
overflowX: 'auto', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{r.rawHex.length > 64 ? r.rawHex.slice(0, 64) + '…' : r.rawHex}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
||||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
|
<button onClick={() => navigator.clipboard?.writeText(
|
||||||
|
`[${test.label}] ${r.error} — ${r.metric}` + (r.rawHex ? ` — raw=${r.rawHex}` : ''))}
|
||||||
style={smallBtn(t, fontMono)}>
|
style={smallBtn(t, fontMono)}>
|
||||||
<IconCopy color={t.dim}/> copy
|
<IconCopy color={t.dim}/> copy
|
||||||
</button>
|
</button>
|
||||||
@@ -388,6 +408,22 @@ function ClassicStartBtn({ t, D, fontMono }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ClassicCancelBtn({ t, onClick }) {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<button onClick={onClick}
|
||||||
|
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
width: 92, padding: '9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
||||||
|
background: t.btnBg, color: hover ? t.danger : t.text,
|
||||||
|
border: `1px solid ${hover ? t.danger : t.border}`, cursor: 'pointer',
|
||||||
|
transition: 'color .12s, border-color .12s',
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ClassicStopBtn({ t, D }) {
|
function ClassicStopBtn({ t, D }) {
|
||||||
const enabled = D.phase === 'active';
|
const enabled = D.phase === 'active';
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// UI components don't need to be rewritten — only their imports.
|
// UI components don't need to be rewritten — only their imports.
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { RunCheck, StartEngine, StopEngine, GetStatus } from '../../wailsjs/go/gui/App'
|
import { RunCheck, CancelCheck, StartEngine, StopEngine, GetStatus } from '../../wailsjs/go/gui/App'
|
||||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
|
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
|
||||||
|
|
||||||
// ─── Test catalog ──────────────────────────────────────────────────────────
|
// ─── Test catalog ──────────────────────────────────────────────────────────
|
||||||
@@ -121,6 +121,8 @@ export function useDrover(initial = {}) {
|
|||||||
metric: r.metric,
|
metric: r.metric,
|
||||||
error: r.error,
|
error: r.error,
|
||||||
hint: r.hint,
|
hint: r.hint,
|
||||||
|
rawHex: r.rawHex,
|
||||||
|
attempt: r.attempt,
|
||||||
expanded: r.status === 'failed',
|
expanded: r.status === 'failed',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -162,6 +164,11 @@ export function useDrover(initial = {}) {
|
|||||||
// The rest is event-driven (check:result, check:done) — see useEffect above.
|
// The rest is event-driven (check:result, check:done) — see useEffect above.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelCheck() {
|
||||||
|
CancelCheck();
|
||||||
|
pushLog('WARN', 'check cancelled by user');
|
||||||
|
}
|
||||||
|
|
||||||
async function startProxy() {
|
async function startProxy() {
|
||||||
if (phase !== 'checked') return;
|
if (phase !== 'checked') return;
|
||||||
if (lastSummary?.failed === tests.length) return;
|
if (lastSummary?.failed === tests.length) return;
|
||||||
@@ -196,7 +203,7 @@ export function useDrover(initial = {}) {
|
|||||||
stats,
|
stats,
|
||||||
logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]),
|
logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]),
|
||||||
lastSummary,
|
lastSummary,
|
||||||
runCheck, startProxy, stopProxy,
|
runCheck, cancelCheck, startProxy, stopProxy,
|
||||||
toggleExpand,
|
toggleExpand,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
// Whenever a new App method is added in internal/gui/app.go, mirror it here.
|
// Whenever a new App method is added in internal/gui/app.go, mirror it here.
|
||||||
|
|
||||||
export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) }
|
export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) }
|
||||||
|
export function CancelCheck() { return window['go']['gui']['App']['CancelCheck']() }
|
||||||
export function StartEngine() { return window['go']['gui']['App']['StartEngine']() }
|
export function StartEngine() { return window['go']['gui']['App']['StartEngine']() }
|
||||||
export function StopEngine() { return window['go']['gui']['App']['StopEngine']() }
|
export function StopEngine() { return window['go']['gui']['App']['StopEngine']() }
|
||||||
export function GetStatus() { return window['go']['gui']['App']['GetStatus']() }
|
export function GetStatus() { return window['go']['gui']['App']['GetStatus']() }
|
||||||
|
|||||||
Reference in New Issue
Block a user