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 }) {
-