// 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 / voice-quality / voice-srv / api Status string `json:"status"` // running | passed | warn | 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, checker.StatusWarn: // Warn is a "soft pass" — counted as passed for the // final summary, but the row still surfaces the hint. 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) }