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:
@@ -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 = ""
|
||||
}
|
||||
Reference in New Issue
Block a user