package ui import ( "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "os" "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" "git.wxccs.org/iceking2nd/winauth-go/internal/qr" "git.wxccs.org/iceking2nd/winauth-go/internal/win32" ) // scanQRDialog drives the "scan otpauth:// QR code" flow. It offers two // entry points: read a PNG/JPG file from disk, or grab whatever image // is currently on the clipboard (Win+Shift+S screenshot landing zone). // On success the dialog hands the caller a fully built authenticator // and a default display name derived from the QR's issuer + label. type scanQRDialog struct { pathEd widget.Editor fromFileBt widget.Clickable clipBt widget.Clickable cancelBt widget.Clickable errorMsg string } func newScanQRDialog() *scanQRDialog { d := &scanQRDialog{} d.pathEd.SingleLine = true return d } // Layout follows the same Dialog contract as the per-vendor add // dialogs: onDone(nil, "") on cancel, onDone(auth, displayName) on // successful scan + parse. func (d *scanQRDialog) Layout( gtx layout.Context, th *material.Theme, onDone func(authenticator.Authenticator, string), ) layout.Dimensions { if d.cancelBt.Clicked(gtx) { onDone(nil, "") return layout.Dimensions{Size: gtx.Constraints.Max} } if d.fromFileBt.Clicked(gtx) { path := d.pathEd.Text() if path == "" { d.errorMsg = i18n.T("msg_empty_qr_path") } else if auth, name, err := decodeFromFile(path); err != nil { d.errorMsg = fmt.Sprintf(i18n.T("msg_qr_failed"), err.Error()) global.Log.WithField("func", "internal.ui.scanQRDialog.fromFile"). WithError(err).Warn("QR scan from file failed") } else { onDone(auth, name) return layout.Dimensions{Size: gtx.Constraints.Max} } } if d.clipBt.Clicked(gtx) { if auth, name, err := decodeFromClipboard(); err != nil { d.errorMsg = fmt.Sprintf(i18n.T("msg_qr_failed"), err.Error()) global.Log.WithField("func", "internal.ui.scanQRDialog.fromClipboard"). WithError(err).Warn("QR scan from clipboard failed") } else { onDone(auth, name) 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("qr_intro")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(th, i18n.T("label_qr_path"), &d.pathEd, "C:\\...\\code.png")), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(material.Button(th, &d.fromFileBt, i18n.T("btn_qr_from_file")).Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(th, &d.clipBt, i18n.T("btn_qr_from_clipboard")).Layout), ) }), layout.Rigid(errorLabel(th, d.errorMsg)), ) } // Use modalCard with only the cancel button (OK is a no-op here // because the action buttons live inside the body). return modalCardCancel(gtx, th, i18n.T("dialog_scan_qr_title"), i18n.T("btn_cancel"), &d.cancelBt, body) } // decodeFromFile reads a PNG/JPG/GIF off disk, decodes any QR code in // it, parses the otpauth URI, then builds the matching authenticator. func decodeFromFile(path string) (authenticator.Authenticator, string, error) { f, err := os.Open(path) if err != nil { return nil, "", err } defer f.Close() img, _, err := image.Decode(f) if err != nil { return nil, "", fmt.Errorf("decode image: %w", err) } return decodeAndBuild(img) } // decodeFromClipboard pulls the current clipboard image (Snipping Tool // landing zone) and runs the same pipeline. func decodeFromClipboard() (authenticator.Authenticator, string, error) { img, err := win32.GetClipboardImage() if err != nil { return nil, "", err } if img == nil { return nil, "", fmt.Errorf("%s", i18n.T("msg_clipboard_no_image")) } return decodeAndBuild(img) } func decodeAndBuild(img image.Image) (authenticator.Authenticator, string, error) { text, err := qr.DecodeImage(img) if err != nil { return nil, "", err } parsed, err := qr.ParseOtpAuth(text) if err != nil { return nil, "", err } auth, err := authenticatorFromOtpAuth(parsed) if err != nil { return nil, "", err } return auth, displayNameFromOtpAuth(parsed), nil } func authenticatorFromOtpAuth(p *qr.OtpAuth) (authenticator.Authenticator, error) { switch p.Type { case "totp": a := authenticator.NewGoogleAuthenticator() if err := a.Enroll(p.SecretBase32); err != nil { return nil, err } if p.Digits > 0 { a.CodeDigits = p.Digits } if p.Period > 0 { a.Period = p.Period } return a, nil case "hotp": a := authenticator.NewHOTPAuthenticator() if err := a.Enroll(p.SecretBase32, p.Counter); err != nil { return nil, err } if p.Digits > 0 { a.CodeDigits = p.Digits } return a, nil default: return nil, fmt.Errorf("unsupported otpauth type %q", p.Type) } } func displayNameFromOtpAuth(p *qr.OtpAuth) string { if p.Issuer != "" && p.Label != "" { return p.Issuer + ": " + p.Label } if p.Label != "" { return p.Label } if p.Issuer != "" { return p.Issuer } return "QR" }