package ui import ( "fmt" "os" "strings" "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "git.wxccs.org/iceking2nd/winauth-go/internal/config" "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" ) // exportTab selects which sub-page of the export dialog is visible. type exportTab int const ( exportTabOtpAuth exportTab = iota exportTabBackup ) // exportDialog lets the user export their authenticators in one of two // formats via a tab strip at the top: // // - otpauth:// URIs: a read-only text area listing every entry as a // standard otpauth:// URI. Battle.Net and Steam entries produce a // partial URI (missing vendor-specific fields) and are flagged. // - Encrypted backup: pick a file path and a new password to write a // portable encrypted config file that can be restored on any machine. type exportDialog struct { tabOtpAuthBtn widget.Clickable tabBackupBtn widget.Clickable cancelBtn widget.Clickable copyBtn widget.Clickable exportBtn widget.Clickable tab exportTab // otpauth tab state uriText string copied bool warnings []string // backup tab state pathEd widget.Editor pwEd widget.Editor pw2Ed widget.Editor errorMsg string done bool // entries snapshot — set once at construction time. entries []config.Entry } func newExportDialog(entries []config.Entry) *exportDialog { d := &exportDialog{ tab: exportTabOtpAuth, entries: entries, } d.pathEd.SingleLine = true d.pwEd.SingleLine = true d.pwEd.Mask = '*' d.pw2Ed.SingleLine = true d.pw2Ed.Mask = '*' d.buildURIs() return d } // buildURIs generates the otpauth:// URI text and collects warnings for // partial-export entries (Battle.Net / Steam). func (d *exportDialog) buildURIs() { var lines []string for _, e := range d.entries { oa, partial, err := qr.EntryToOtpAuth(e) if err != nil { d.warnings = append(d.warnings, fmt.Sprintf("%s: %s", e.Name, err.Error())) continue } lines = append(lines, oa.URI()) if partial { d.warnings = append(d.warnings, fmt.Sprintf("%s: %s", e.Name, i18n.T("export_partial_warning"))) } } d.uriText = strings.Join(lines, "\n") } // exportResult carries the outcome of the dialog back to app.go. type exportResult struct { cancel bool } func (d *exportDialog) Layout( gtx layout.Context, th *material.Theme, onDone func(exportResult), ) layout.Dimensions { if d.cancelBtn.Clicked(gtx) { onDone(exportResult{cancel: true}) return layout.Dimensions{Size: gtx.Constraints.Max} } if d.tabOtpAuthBtn.Clicked(gtx) { d.tab = exportTabOtpAuth } if d.tabBackupBtn.Clicked(gtx) { d.tab = exportTabBackup } // Tab-specific actions. switch d.tab { case exportTabOtpAuth: if d.copyBtn.Clicked(gtx) { if err := win32.SetClipboardText(d.uriText); err == nil { d.copied = true } } case exportTabBackup: if d.exportBtn.Clicked(gtx) { d.doBackup(onDone) return layout.Dimensions{Size: gtx.Constraints.Max} } } tabBtn := func(btn *widget.Clickable, label string, active bool) layout.FlexChild { b := material.Button(th, btn, label) if active { b.Background = th.ContrastBg } else { b.Background = activePalette.DialogBg } return layout.Rigid(b.Layout) } body := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, // Tab strip layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, tabBtn(&d.tabOtpAuthBtn, i18n.T("export_tab_otpauth"), d.tab == exportTabOtpAuth), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), tabBtn(&d.tabBackupBtn, i18n.T("export_tab_backup"), d.tab == exportTabBackup), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), // Tab content layout.Rigid(func(gtx layout.Context) layout.Dimensions { switch d.tab { case exportTabOtpAuth: return d.layoutOtpAuthTab(gtx, th) case exportTabBackup: return d.layoutBackupTab(gtx, th) } return layout.Dimensions{} }), ) } footer := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(material.Button(th, &d.cancelBtn, i18n.T("btn_close")).Layout), ) } return modalShell(gtx, th, i18n.T("dialog_export_title"), body, footer) } func (d *exportDialog) layoutOtpAuthTab(gtx layout.Context, th *material.Theme) layout.Dimensions { children := []layout.FlexChild{ layout.Rigid(material.Body2(th, i18n.T("export_otpauth_intro")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), } // Warnings for partial exports. if len(d.warnings) > 0 { warnText := strings.Join(d.warnings, "\n") children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body2(th, warnText) lbl.Color = activePalette.ErrorFg return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), ) } // URI text area (read-only display). children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(th, d.uriText) return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), ) // Copy button + feedback. copyLabel := i18n.T("btn_copy") if d.copied { copyLabel = i18n.T("msg_copied") } children = append(children, layout.Rigid(material.Button(th, &d.copyBtn, copyLabel).Layout), ) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } func (d *exportDialog) layoutBackupTab(gtx layout.Context, th *material.Theme) layout.Dimensions { children := []layout.FlexChild{ layout.Rigid(material.Body2(th, i18n.T("export_backup_intro")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(th, i18n.T("label_backup_path"), &d.pathEd, "backup.winauth.bak")), layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.pwEd, "")), layout.Rigid(labeledEditor(th, i18n.T("label_password_confirm"), &d.pw2Ed, "")), layout.Rigid(errorLabel(th, d.errorMsg)), } if d.done { children = append(children, layout.Rigid(material.Body2(th, i18n.T("msg_export_done")).Layout), ) } else { children = append(children, layout.Rigid(material.Button(th, &d.exportBtn, i18n.T("btn_export")).Layout), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } func (d *exportDialog) doBackup(onDone func(exportResult)) { const fn = "internal.ui.exportDialog.doBackup" path := strings.TrimSpace(d.pathEd.Text()) if path == "" { d.errorMsg = i18n.T("msg_empty_backup_path") return } pw := d.pwEd.Text() pw2 := d.pw2Ed.Text() if pw == "" { d.errorMsg = i18n.T("msg_empty_password") return } if pw != pw2 { d.errorMsg = i18n.T("msg_password_mismatch") return } cfg := &config.Config{ Version: 1, Encrypted: true, Entries: d.entries, } if err := config.SaveYAML(cfg, path, []byte(pw)); err != nil { d.errorMsg = fmt.Sprintf(i18n.T("msg_export_failed"), err.Error()) global.Log.WithField("func", fn).WithError(err).Warn("backup export failed") return } // Restrict backup file permissions. _ = os.Chmod(path, 0o600) global.Log.WithField("func", fn).WithField("path", path).Info("backup exported") d.done = true d.errorMsg = "" }