1c1ab566d9
- 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>
234 lines
7.2 KiB
Go
234 lines
7.2 KiB
Go
// Package gui hosts the Wails app: the App struct (whose exported methods
|
|
// become the JS API for the frontend) and the Run() helper invoked from
|
|
// cmd/drover/main.go when the user double-clicks the binary.
|
|
package gui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.okcu.io/root/drover-go/internal/checker"
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// App is the Wails-bound struct. Every exported method is callable from JS
|
|
// via the auto-generated wailsjs/go/main/App.* bindings.
|
|
//
|
|
// Right now everything except the proxy form is a deterministic stub —
|
|
// the real WinDivert + SOCKS5 engine arrives in Phase 1. The stubs are
|
|
// sufficient for the UI to feel alive: Check fakes a 7-step diagnostic,
|
|
// Start/Stop toggles a phase, GetStats emits realistic-looking numbers.
|
|
type App struct {
|
|
ctx context.Context
|
|
version string
|
|
|
|
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
|
|
// (so the GUI can display it in the title bar).
|
|
func NewApp(version string) *App { return &App{version: version} }
|
|
|
|
// Version returns the build version (e.g. "0.2.0", "test-local", or
|
|
// "dev"). Frontend reads it on mount to populate the custom title bar.
|
|
func (a *App) Version() string { return a.version }
|
|
|
|
// Startup is called by Wails right after the window is created and the
|
|
// JS runtime is ready. We grab the context for runtime.EventsEmit calls
|
|
// from any subsequent method.
|
|
func (a *App) Startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
go a.statsLoop()
|
|
}
|
|
|
|
// Config is the proxy/auth payload the frontend sends back from the form.
|
|
type Config struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Auth bool `json:"auth"`
|
|
Login string `json:"login"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// CheckResult is one row in the diagnostic table; the frontend listens
|
|
// 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,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 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.
|
|
//
|
|
// 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() {
|
|
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: 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,
|
|
})
|
|
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()
|
|
}()
|
|
}
|
|
|
|
// 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 {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.running = true
|
|
a.startedAt = time.Now()
|
|
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
|
return nil
|
|
}
|
|
|
|
// StopEngine turns the proxy off.
|
|
func (a *App) StopEngine() error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.running = false
|
|
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
|
|
return nil
|
|
}
|
|
|
|
// GetStatus is read by the frontend on first paint to know whether to
|
|
// show "Idle" or "Active".
|
|
func (a *App) GetStatus() map[string]any {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return map[string]any{
|
|
"running": a.running,
|
|
"uptimeS": int(time.Since(a.startedAt).Seconds()),
|
|
}
|
|
}
|
|
|
|
// statsLoop emits a stats event every second when the engine is running.
|
|
// Numbers are random but stable enough to look real.
|
|
func (a *App) statsLoop() {
|
|
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
tick := time.NewTicker(time.Second)
|
|
defer tick.Stop()
|
|
for range tick.C {
|
|
a.mu.Lock()
|
|
if !a.running || a.ctx == nil {
|
|
a.mu.Unlock()
|
|
continue
|
|
}
|
|
uptime := int(time.Since(a.startedAt).Seconds())
|
|
a.mu.Unlock()
|
|
|
|
runtime.EventsEmit(a.ctx, "stats:update", map[string]any{
|
|
"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,
|
|
"uptimeS": uptime,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Greet remains as a smoke check that the bindings pipeline survived
|
|
// the transition. Frontend can call it from a debug button if needed.
|
|
func (a *App) Greet(name string) string {
|
|
return fmt.Sprintf("Hello %s — Drover-Go GUI is alive.", name)
|
|
}
|