feat(ui): 导出/备份 — otpauth URI 列表 + 加密备份导出/恢复

- qr/export.go: EntryToOtpAuth + OtpAuth.URI,将 config.Entry 反向
  转换为标准 otpauth:// URI(Battle.Net/Steam 标记为 partial)
- dialog_export.go: Tab 切换对话框,otpauth URI 列表可复制 +
  加密备份文件导出(独立密码,0o600 权限)
- dialog_restore_backup.go: 从 .winauth.bak 恢复,密码验证后合并
- settings_menu: 新增 Export / Restore Backup 菜单项
- i18n: en/zh-CN/de 三语翻译
This commit is contained in:
2026-06-12 10:44:51 +08:00
parent 133091a32e
commit fd4d756823
8 changed files with 722 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
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 = ""
}