package ui import ( "bytes" "context" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "net/http" "strings" "sync" "time" "gioui.org/layout" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "git.wxccs.org/iceking2nd/winauth-go/internal/authenticator" "git.wxccs.org/iceking2nd/winauth-go/internal/global" "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" ) // steamWizardStep enumerates the wizard pages. The state machine is // driven by the EnrollState.Requires* flags returned by each Enroll // call; we just map them onto a UI step. type steamWizardStep int const ( steamStepCredentials steamWizardStep = iota steamStepCaptcha steamStepEmail steamStepActivation steamStepDone ) // addSteamDialog is the Steam mobile authenticator enrollment wizard. // It owns a single long-lived EnrollState plus a SteamAuthenticator // instance; each "OK"-style button kicks off a goroutine that runs one // Enroll round and updates the UI when it finishes. // // SECURITY: state.Password is wiped by Enroll itself once the credentials // have been RSA-encrypted and sent. Captcha / email / SMS codes are not // secrets per se but we still avoid logging them anywhere. type addSteamDialog struct { nameEd widget.Editor usernameEd widget.Editor passwordEd widget.Editor captchaEd widget.Editor emailEd widget.Editor activateEd widget.Editor okBtn widget.Clickable cancelBtn widget.Clickable step steamWizardStep errorMsg string mu sync.Mutex pending bool authImpl *authenticator.SteamAuthenticator state *authenticator.EnrollState lastDone bool lastErr error finalName string // Cached decoded captcha image, plus a marker so we re-fetch only // when the URL actually changes between Enroll rounds. captchaURLLoaded string captchaImg image.Image captchaImgErr error captchaLoading bool invalidate func() } func newAddSteamDialog(invalidate func()) *addSteamDialog { d := &addSteamDialog{ step: steamStepCredentials, authImpl: authenticator.NewSteamAuthenticator(), state: &authenticator.EnrollState{}, invalidate: invalidate, } d.nameEd.SingleLine = true d.usernameEd.SingleLine = true d.passwordEd.SingleLine = true d.passwordEd.Mask = '*' d.captchaEd.SingleLine = true d.emailEd.SingleLine = true d.activateEd.SingleLine = true return d } func (d *addSteamDialog) Layout( gtx layout.Context, th *material.Theme, onDone func(authenticator.Authenticator, string), ) layout.Dimensions { const fn = "internal.ui.addSteamDialog.Layout" d.mu.Lock() pending := d.pending finished := !pending && d.lastDone resErr := d.lastErr stateErr := "" if d.state != nil { stateErr = d.state.Error } d.mu.Unlock() // Network-error path (e.g. transport, parse): show and let user retry. if finished && resErr != nil { d.errorMsg = fmt.Sprintf(i18n.T("msg_enroll_failed"), resErr.Error()) d.mu.Lock() d.lastDone = false d.lastErr = nil d.mu.Unlock() } // Successful Enroll round: advance the wizard based on which flags // the state machine just set, or surface state.Error for soft fails. if finished && resErr == nil { d.mu.Lock() d.lastDone = false st := d.state d.mu.Unlock() if st.Success { d.step = steamStepDone d.errorMsg = "" } else if stateErr != "" { d.errorMsg = stateErr // Stay on current step so the user can retry the same input. } else { d.errorMsg = "" switch { case st.RequiresCaptcha: d.step = steamStepCaptcha case st.RequiresEmailAuth: d.step = steamStepEmail case st.RequiresActivation: d.step = steamStepActivation } } } // Captcha image fetch when we land on the captcha step with a new URL. if d.step == steamStepCaptcha && d.state != nil && d.state.CaptchaURL != "" && d.state.CaptchaURL != d.captchaURLLoaded && !d.captchaLoading { d.captchaLoading = true d.captchaURLLoaded = d.state.CaptchaURL urlCopy := d.state.CaptchaURL go func(u string) { img, err := fetchCaptchaImage(u) d.mu.Lock() d.captchaImg = img d.captchaImgErr = err d.captchaLoading = false d.mu.Unlock() if d.invalidate != nil { d.invalidate() } }(urlCopy) } // Cancel: always allowed; abort whatever step we're on. if d.cancelBtn.Clicked(gtx) { // Scrub the password just in case the user cancels before Enroll // had a chance to wipe it. d.mu.Lock() if d.state != nil { d.state.Password = "" } d.mu.Unlock() onDone(nil, "") return layout.Dimensions{Size: gtx.Constraints.Max} } // OK button: action depends on current step. if d.okBtn.Clicked(gtx) && !pending { switch d.step { case steamStepDone: // Final hand-off: build SessionData JSON now (we couldn't // earlier because we kept the jar live until success). d.mu.Lock() sess := authenticator.SessionFromEnrollState(d.state) d.authImpl.SessionData = sess.ToJSON() d.mu.Unlock() name := d.finalName if name == "" { name = i18n.T("vendor_steam") } onDone(d.authImpl, name) return layout.Dimensions{Size: gtx.Constraints.Max} case steamStepCredentials: username := strings.TrimSpace(d.usernameEd.Text()) password := d.passwordEd.Text() if username == "" { d.errorMsg = i18n.T("msg_empty_username") break } if password == "" { d.errorMsg = i18n.T("msg_empty_password") break } name := strings.TrimSpace(d.nameEd.Text()) if name == "" { name = i18n.T("vendor_steam") } d.finalName = name d.errorMsg = "" d.mu.Lock() d.state.Username = username d.state.Password = password // Drop the editor's plaintext copy as soon as we've handed // it to the state machine. d.passwordEd.SetText("") d.mu.Unlock() d.runEnroll(fn) case steamStepCaptcha: txt := strings.TrimSpace(d.captchaEd.Text()) if txt == "" { d.errorMsg = i18n.T("msg_empty_captcha") break } d.errorMsg = "" d.mu.Lock() d.state.CaptchaText = txt d.mu.Unlock() d.captchaEd.SetText("") d.runEnroll(fn) case steamStepEmail: txt := strings.TrimSpace(d.emailEd.Text()) if txt == "" { d.errorMsg = i18n.T("msg_empty_email_code") break } d.errorMsg = "" d.mu.Lock() d.state.EmailAuthText = txt d.mu.Unlock() d.emailEd.SetText("") d.runEnroll(fn) case steamStepActivation: txt := strings.TrimSpace(d.activateEd.Text()) if txt == "" { d.errorMsg = i18n.T("msg_empty_activation_code") break } d.errorMsg = "" d.mu.Lock() d.state.ActivationCode = txt d.mu.Unlock() d.activateEd.SetText("") d.runEnroll(fn) } } body := d.layoutBody(th) return modalCard(gtx, th, i18n.T("dialog_add_steam_title"), d.okLabel(), i18n.T("btn_cancel"), &d.okBtn, &d.cancelBtn, body) } // runEnroll fires off a background goroutine that performs exactly one // Enroll round. The Layout function picks up the result on the next frame. func (d *addSteamDialog) runEnroll(fn string) { d.mu.Lock() if d.pending { d.mu.Unlock() return } d.pending = true d.errorMsg = i18n.T("msg_busy") d.mu.Unlock() go func() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() _, err := d.authImpl.Enroll(ctx, d.state) d.mu.Lock() d.pending = false d.lastDone = true d.lastErr = err d.mu.Unlock() if err != nil { global.Log.WithField("func", fn).WithError(err).Warn("steam enroll round failed") } if d.invalidate != nil { d.invalidate() } }() } // okLabel chooses the primary-button text for the current step. func (d *addSteamDialog) okLabel() string { switch d.step { case steamStepCredentials: return i18n.T("btn_login") case steamStepActivation: return i18n.T("btn_activate") case steamStepDone: return i18n.T("btn_finish") default: return i18n.T("btn_continue") } } // layoutBody renders the inputs specific to the current wizard step. func (d *addSteamDialog) layoutBody(th *material.Theme) layout.Widget { return func(gtx layout.Context) layout.Dimensions { switch d.step { case steamStepCredentials: return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(th, i18n.T("steam_step_credentials")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_steam"))), layout.Rigid(labeledEditor(th, i18n.T("label_username"), &d.usernameEd, "")), layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.passwordEd, "")), layout.Rigid(errorLabel(th, d.errorMsg)), ) case steamStepCaptcha: d.mu.Lock() img := d.captchaImg loading := d.captchaLoading imgErr := d.captchaImgErr d.mu.Unlock() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(th, i18n.T("steam_step_captcha")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(captchaImageWidget(th, img, loading, imgErr)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(th, i18n.T("label_captcha_text"), &d.captchaEd, "")), layout.Rigid(errorLabel(th, d.errorMsg)), ) case steamStepEmail: domain := "" d.mu.Lock() if d.state != nil { domain = d.state.EmailDomain } d.mu.Unlock() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(th, fmt.Sprintf(i18n.T("steam_step_email"), domain)).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(th, i18n.T("label_email_code"), &d.emailEd, "")), layout.Rigid(errorLabel(th, d.errorMsg)), ) case steamStepActivation: return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(th, i18n.T("steam_step_activation")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(th, i18n.T("label_activation_code"), &d.activateEd, "")), layout.Rigid(errorLabel(th, d.errorMsg)), ) case steamStepDone: revocation := "" d.mu.Lock() if d.state != nil { revocation = d.state.RevocationCode } d.mu.Unlock() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(th, i18n.T("steam_step_done")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(th, i18n.T("label_revocation_code")).Layout), layout.Rigid(material.H6(th, revocation).Layout), ) }), layout.Rigid(errorLabel(th, d.errorMsg)), ) } return layout.Dimensions{} } } // captchaImageWidget paints the captcha PNG/JPEG, or a placeholder line // while it loads / on failure. We never log the captcha URL because it // contains the captcha gid which is a Steam session token. func captchaImageWidget(th *material.Theme, img image.Image, loading bool, imgErr error) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if loading { return material.Body2(th, i18n.T("hint_captcha_loading")).Layout(gtx) } if imgErr != nil { return material.Body2(th, fmt.Sprintf(i18n.T("hint_captcha_failed"), imgErr.Error())).Layout(gtx) } if img == nil { return layout.Dimensions{} } return widget.Image{ Src: paint.NewImageOp(img), Fit: widget.Unscaled, }.Layout(gtx) } } // fetchCaptchaImage GETs the captcha URL and decodes it as PNG/JPEG/GIF. // 10s timeout — Steam's captchas are tiny so anything longer is a hang. func fetchCaptchaImage(rawURL string) (image.Image, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } // Steam's captcha endpoint accepts any UA but the rest of the flow // uses the mobile UA, so be consistent. req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 4.1.1) Mobile") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("status %d", resp.StatusCode) } // Cap at 256KB — captchas are tiny; anything bigger is suspicious. body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) if err != nil { return nil, err } img, _, err := image.Decode(bytes.NewReader(body)) if err != nil { return nil, err } return img, nil }