package ui import ( "context" "fmt" "strings" "sync" "time" "gioui.org/layout" "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" ) // restoreBattleNetDialog drives the paper-restore flow: the user types // the serial they wrote down at enrollment time plus the 10-char // restore code, and the backend asks Blizzard for the original secret. // // SECURITY: the restore code is functionally a root key — anybody // holding it can recover the authenticator and authorize Battle.Net // logins. The editor masks it by default and we never log it. type restoreBattleNetDialog struct { nameEd widget.Editor serialEd widget.Editor codeEd widget.Editor showCode widget.Bool okBtn widget.Clickable cancelBtn widget.Clickable errorMsg string mu sync.Mutex pending bool result *authenticator.BattleNetAuthenticator resultEr error invalidate func() } func newRestoreBattleNetDialog(invalidate func()) *restoreBattleNetDialog { d := &restoreBattleNetDialog{invalidate: invalidate} d.nameEd.SingleLine = true d.serialEd.SingleLine = true d.codeEd.SingleLine = true d.codeEd.Mask = '*' return d } func (d *restoreBattleNetDialog) Layout( gtx layout.Context, th *material.Theme, onDone func(authenticator.Authenticator, string), ) layout.Dimensions { const fn = "internal.ui.restoreBattleNetDialog.Layout" if d.showCode.Update(gtx) { if d.showCode.Value { d.codeEd.Mask = 0 } else { d.codeEd.Mask = '*' } } d.mu.Lock() pending := d.pending finished := !pending && (d.result != nil || d.resultEr != nil) res := d.result resErr := d.resultEr d.mu.Unlock() if finished { if resErr != nil { d.errorMsg = fmt.Sprintf(i18n.T("msg_restore_failed"), resErr.Error()) d.mu.Lock() d.result, d.resultEr = nil, nil d.mu.Unlock() } else if res != nil { name := strings.TrimSpace(d.nameEd.Text()) if name == "" { name = i18n.T("vendor_battlenet") } // Wipe the code editor so a leftover value cannot be read // off the screen if the parent reuses the dialog. d.codeEd.SetText("") onDone(res, name) return layout.Dimensions{Size: gtx.Constraints.Max} } } if d.okBtn.Clicked(gtx) && !pending { serial := strings.TrimSpace(d.serialEd.Text()) code := d.codeEd.Text() if serial == "" { d.errorMsg = i18n.T("msg_empty_serial") } else if strings.TrimSpace(code) == "" { d.errorMsg = i18n.T("msg_empty_restore_code") } else { d.mu.Lock() d.pending = true d.errorMsg = i18n.T("msg_restoring") d.mu.Unlock() go func(serial, code string) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() b := authenticator.NewBattleNetAuthenticator() err := b.Restore(ctx, serial, code) d.mu.Lock() d.pending = false if err != nil { global.Log.WithField("func", fn).WithError(err).Warn("battle.net restore failed") d.resultEr = err } else { d.result = b } d.mu.Unlock() if d.invalidate != nil { d.invalidate() } }(serial, code) } } if d.cancelBtn.Clicked(gtx) && !pending { // Clear the code editor on cancel so the secret does not linger. d.codeEd.SetText("") onDone(nil, "") return layout.Dimensions{Size: gtx.Constraints.Max} } body := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(th, i18n.T("battlenet_restore_intro")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_battlenet"))), layout.Rigid(labeledEditor(th, i18n.T("label_serial"), &d.serialEd, "US-1234-5678-9012")), layout.Rigid(labeledEditor(th, i18n.T("label_restore_code"), &d.codeEd, "")), layout.Rigid(material.CheckBox(th, &d.showCode, i18n.T("label_show_restore_code")).Layout), layout.Rigid(errorLabel(th, d.errorMsg)), ) } return modalCard(gtx, th, i18n.T("dialog_restore_battlenet_title"), i18n.T("btn_restore"), i18n.T("btn_cancel"), &d.okBtn, &d.cancelBtn, body) }