Files
drover-go/internal/gui/app.go
T
root b6619ef53b
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped
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>
2026-05-01 15:17:19 +03:00

173 lines
5.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"
"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
}
// 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.
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"`
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
Duration int64 `json:"duration_ms,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.
//
// Phase 1+: replace this with internal/checker.Run() and stream its
// real results.
func (a *App) RunCheck(cfg Config) {
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.
runtime.EventsEmit(a.ctx, "check:result", CheckResult{
ID: id,
Status: "passed",
Metric: fakeMetric[id],
Duration: 350,
})
passedCount++
}
runtime.EventsEmit(a.ctx, "check:done", map[string]int{
"total": len(ids),
"passed": passedCount,
"failed": len(ids) - passedCount,
})
}()
}
// 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)
}