diff --git a/internal/gui/app.go b/internal/gui/app.go index ce947d5..5e76be2 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "git.okcu.io/root/drover-go/internal/checker" "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -27,6 +28,15 @@ type App struct { mu sync.Mutex running bool 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 @@ -55,60 +65,111 @@ type Config struct { } // 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 { - ID string `json:"id"` // tcp / greet / auth / connect / udp / stun / api - Status string `json:"status"` // running | passed | failed | skipped - Metric string `json:"metric"` + ID string `json:"id"` // tcp / greet / auth / connect / udp / stun / api + Status string `json:"status"` // running | passed | failed | skipped + Metric string `json:"metric,omitempty"` Error string `json:"error,omitempty"` Hint string `json:"hint,omitempty"` + RawHex string `json:"rawHex,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 -// the JS side as a "check:result" event; when the run finishes we emit -// "check:done" with the overall summary. +// RunCheck runs a real 7-step SOCKS5 diagnostic via internal/checker. Each +// Result from the checker channel is forwarded to the frontend as a +// "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 -// real results. +// If a previous check is still in flight, its context is cancelled and we +// 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) { + // 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() { - ids := []string{"tcp", "greet"} - if cfg.Auth { - ids = append(ids, "auth") - } - ids = append(ids, "connect", "udp", "stun", "api") - - 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. + defer close(done) + var passed, failed int + for r := range checker.Run(ctx, ckCfg) { + // Always emit on a.ctx, never on the per-check ctx — the + // per-check ctx may already be cancelled when the final + // "cancelled" result arrives, which would silently drop it. runtime.EventsEmit(a.ctx, "check:result", CheckResult{ - ID: id, - Status: "passed", - Metric: fakeMetric[id], - Duration: 350, + ID: r.ID, + Status: string(r.Status), + Metric: r.Metric, + 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": len(ids), - "passed": passedCount, - "failed": len(ids) - passedCount, + "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() }() } +// CancelCheck cancels the currently-running diagnostic, if any. Safe to +// call when no check is running (no-op). +func (a *App) CancelCheck() { + 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 // note the start time so GetStats can produce a believable uptime. func (a *App) StartEngine() error { @@ -156,7 +217,7 @@ func (a *App) statsLoop() { a.mu.Unlock() runtime.EventsEmit(a.ctx, "stats:update", map[string]any{ - "up": r.Intn(50_000) + 5_000, // bytes/sec out + "up": r.Intn(50_000) + 5_000, // bytes/sec out "down": r.Intn(500_000) + 50_000, // bytes/sec in "tcp": r.Intn(8) + 1, "udp": r.Intn(5) + 1, diff --git a/internal/gui/frontend/src/components/Classic.jsx b/internal/gui/frontend/src/components/Classic.jsx index 223073e..fa97bff 100644 --- a/internal/gui/frontend/src/components/Classic.jsx +++ b/internal/gui/frontend/src/components/Classic.jsx @@ -121,9 +121,18 @@ export function ClassicWindow({ mode = 'dark', initial, onToggleMode }) { - - {D.phase === 'checking' ? 'Checking…' : 'Check connection'} - + {D.phase === 'checking' ? ( +
+ + Checking… + + +
+ ) : ( + + Check connection + + )} {/* Status */}
@@ -316,8 +325,19 @@ function ClassicStatus({ t, D, palette, fontMono }) { }}>
{r.error}
{r.hint}
+ {r.rawHex && ( +
+ {r.rawHex.length > 64 ? r.rawHex.slice(0, 64) + '…' : r.rawHex} +
+ )}
- @@ -388,6 +408,22 @@ function ClassicStartBtn({ t, D, fontMono }) { ); } +function ClassicCancelBtn({ t, onClick }) { + const [hover, setHover] = React.useState(false); + return ( + + ); +} + function ClassicStopBtn({ t, D }) { const enabled = D.phase === 'active'; return ( diff --git a/internal/gui/frontend/src/components/shared.jsx b/internal/gui/frontend/src/components/shared.jsx index 3b23288..4925d32 100644 --- a/internal/gui/frontend/src/components/shared.jsx +++ b/internal/gui/frontend/src/components/shared.jsx @@ -13,7 +13,7 @@ // 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 { RunCheck, CancelCheck, StartEngine, StopEngine, GetStatus } from '../../wailsjs/go/gui/App' import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime' // ─── Test catalog ────────────────────────────────────────────────────────── @@ -117,10 +117,12 @@ export function useDrover(initial = {}) { setResults(prev => ({ ...prev, [r.id]: { - result: r.status, - metric: r.metric, - error: r.error, - hint: r.hint, + result: r.status, + metric: r.metric, + error: r.error, + hint: r.hint, + rawHex: r.rawHex, + attempt: r.attempt, expanded: r.status === 'failed', }, })); @@ -162,6 +164,11 @@ export function useDrover(initial = {}) { // The rest is event-driven (check:result, check:done) — see useEffect above. } + function cancelCheck() { + CancelCheck(); + pushLog('WARN', 'check cancelled by user'); + } + async function startProxy() { if (phase !== 'checked') return; if (lastSummary?.failed === tests.length) return; @@ -196,7 +203,7 @@ export function useDrover(initial = {}) { stats, logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]), lastSummary, - runCheck, startProxy, stopProxy, + runCheck, cancelCheck, startProxy, stopProxy, toggleExpand, }; } diff --git a/internal/gui/frontend/wailsjs/go/gui/App.js b/internal/gui/frontend/wailsjs/go/gui/App.js index 45dd069..b764106 100644 --- a/internal/gui/frontend/wailsjs/go/gui/App.js +++ b/internal/gui/frontend/wailsjs/go/gui/App.js @@ -11,6 +11,7 @@ // 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 CancelCheck() { return window['go']['gui']['App']['CancelCheck']() } export function StartEngine() { return window['go']['gui']['App']['StartEngine']() } export function StopEngine() { return window['go']['gui']['App']['StopEngine']() } export function GetStatus() { return window['go']['gui']['App']['GetStatus']() }