package authenticator import ( "context" "math/rand" "time" "git.wxccs.org/iceking2nd/winauth-go/internal/global" ) // confirmationEventDelay is the base sleep between firing successive // OnConfirmation callbacks. Matches the C# CONFIRMATION_EVENT_DELAY: // the per-event sleep is uniformly randomised to 100%-150% of this // value, throttling the UI when several new trades arrive at once. const confirmationEventDelay = 1000 * time.Millisecond // defaultConfirmationPollerRetries is the number of consecutive failed // poll cycles before OnConfirmationError fires. Mirrors the C# default. const defaultConfirmationPollerRetries = 3 // ConfirmationCallback receives one notification per newly observed // pending confirmation. action tells the UI whether the user wanted a // passive notification, an interactive prompt, or silent auto-accept. type ConfirmationCallback func(conf Confirmation, action PollerAction) // ConfirmationErrorCallback is fired once per failure burst (every // ConfirmationPollerRetries consecutive failures), letting the UI // surface "Steam unreachable" once instead of on every cycle. type ConfirmationErrorCallback func(message string, action PollerAction, err error) // pollerHandle tracks a running background poller so it can be stopped // cleanly. Kept private; SteamClient exposes Start/Stop wrappers. type pollerHandle struct { cancel context.CancelFunc done chan struct{} } // StartConfirmationPoller starts (or restarts) the background goroutine // that periodically calls GetConfirmations and fires OnConfirmation / // OnConfirmationError. Passing a nil or zero-Duration poller stops any // running poller and returns. // // It is safe to call StartConfirmationPoller repeatedly — the previous // poller is stopped (and its goroutine joined) before the new one is // started. func (c *SteamClient) StartConfirmationPoller(poller *ConfirmationPoller) { c.StopConfirmationPoller() if poller == nil || poller.Duration <= 0 { return } c.mu.Lock() if c.Session == nil { c.mu.Unlock() return } c.Session.Confirmations = poller if c.ConfirmationPollerRetries <= 0 { c.ConfirmationPollerRetries = defaultConfirmationPollerRetries } retries := c.ConfirmationPollerRetries c.mu.Unlock() ctx, cancel := context.WithCancel(context.Background()) h := &pollerHandle{cancel: cancel, done: make(chan struct{})} c.mu.Lock() c.poller = h c.mu.Unlock() go c.runPollerLoop(ctx, h, retries) } // StopConfirmationPoller cancels the running poller (if any) and waits // for its goroutine to exit before returning. Also clears // Session.Confirmations so a restored session does not auto-restart. func (c *SteamClient) StopConfirmationPoller() { c.mu.Lock() h := c.poller c.poller = nil if c.Session != nil { c.Session.Confirmations = nil } c.mu.Unlock() if h == nil { return } h.cancel() <-h.done } // runPollerLoop is the goroutine body. It owns no locks across network // calls. Snapshots of the poller config / retry budget are taken once // per iteration to avoid races with concurrent Stop / Start callers. func (c *SteamClient) runPollerLoop(ctx context.Context, h *pollerHandle, maxRetries int) { const fn = "internal.authenticator.SteamClient.runPollerLoop" logger := global.Log.WithField("func", fn) defer close(h.done) rng := rand.New(rand.NewSource(time.Now().UnixNano())) retryCount := 0 for ctx.Err() == nil { c.mu.Lock() poller := c.Session.Confirmations onConf := c.OnConfirmation onErr := c.OnConfirmationError c.mu.Unlock() if poller == nil { logger.Debug("poller cleared; exiting loop") return } action := poller.Action confs, err := c.GetConfirmations(ctx) if err != nil { if ctx.Err() != nil { return } retryCount++ logger.WithError(err).WithField("retry", retryCount).Warn("poll failed") if retryCount >= maxRetries { if onErr != nil { onErr("Failed to read confirmations", action, err) } } else { // Best-effort cookie refresh — matches the C# fallback. _, _ = c.Refresh(ctx) } } else { retryCount = 0 if onConf != nil { for i := range confs { if !confs[i].IsNew { continue } if ctx.Err() != nil { return } start := time.Now() onConf(confs[i], action) // Jitter the inter-event delay 100%-150% to keep // the UI from being slammed during a burst. delay := confirmationEventDelay + time.Duration(rng.Int63n(int64(confirmationEventDelay/2))) elapsed := time.Since(start) if delay > elapsed { if !sleepWithCancel(ctx, delay-elapsed) { return } } } } } // Re-read duration in case the user changed it mid-flight. c.mu.Lock() var wait time.Duration if c.Session != nil && c.Session.Confirmations != nil { wait = time.Duration(c.Session.Confirmations.Duration) * time.Minute } c.mu.Unlock() if wait <= 0 { return } if !sleepWithCancel(ctx, wait) { return } } } // sleepWithCancel sleeps for d, or returns early if ctx is cancelled. // Returns true if the full duration elapsed, false on cancellation. func sleepWithCancel(ctx context.Context, d time.Duration) bool { t := time.NewTimer(d) defer t.Stop() select { case <-t.C: return true case <-ctx.Done(): return false } }