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,94 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gioui.org/layout"
|
||||
"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"
|
||||
)
|
||||
|
||||
// restoreBackupDialog loads an encrypted .winauth.bak file produced by the
|
||||
// export dialog. The user supplies the path and the backup's password; on
|
||||
// success the loaded entries are handed back to the caller for merging.
|
||||
type restoreBackupDialog struct {
|
||||
pathEd widget.Editor
|
||||
pwEd widget.Editor
|
||||
restoreB widget.Clickable
|
||||
cancelB widget.Clickable
|
||||
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func newRestoreBackupDialog() *restoreBackupDialog {
|
||||
d := &restoreBackupDialog{}
|
||||
d.pathEd.SingleLine = true
|
||||
d.pwEd.SingleLine = true
|
||||
d.pwEd.Mask = '*'
|
||||
return d
|
||||
}
|
||||
|
||||
// restoreBackupResult carries the outcome of the dialog.
|
||||
type restoreBackupResult struct {
|
||||
cfg *config.Config
|
||||
cancel bool
|
||||
}
|
||||
|
||||
func (d *restoreBackupDialog) Layout(
|
||||
gtx layout.Context, th *material.Theme,
|
||||
onDone func(restoreBackupResult),
|
||||
) layout.Dimensions {
|
||||
if d.cancelB.Clicked(gtx) {
|
||||
d.wipe()
|
||||
onDone(restoreBackupResult{cancel: true})
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
if d.restoreB.Clicked(gtx) {
|
||||
path := d.pathEd.Text()
|
||||
if path == "" {
|
||||
d.errorMsg = i18n.T("msg_empty_backup_path")
|
||||
} else {
|
||||
pw := []byte(d.pwEd.Text())
|
||||
cfg, err := config.LoadYAML(path, pw)
|
||||
for i := range pw {
|
||||
pw[i] = 0
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, config.ErrPasswordRequired):
|
||||
d.errorMsg = i18n.T("msg_password_required")
|
||||
case errors.Is(err, config.ErrPasswordWrong):
|
||||
d.errorMsg = i18n.T("msg_password_wrong")
|
||||
case err != nil:
|
||||
d.errorMsg = fmt.Sprintf(i18n.T("msg_restore_failed"), err.Error())
|
||||
global.Log.WithField("func", "internal.ui.restoreBackupDialog.Layout").
|
||||
WithError(err).Warn("restore backup failed")
|
||||
default:
|
||||
d.wipe()
|
||||
onDone(restoreBackupResult{cfg: cfg})
|
||||
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("restore_backup_intro")).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: 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(errorLabel(th, d.errorMsg)),
|
||||
)
|
||||
}
|
||||
return modalCard(gtx, th, i18n.T("dialog_restore_backup_title"),
|
||||
i18n.T("btn_restore"), i18n.T("btn_cancel"),
|
||||
&d.restoreB, &d.cancelB, body)
|
||||
}
|
||||
|
||||
func (d *restoreBackupDialog) wipe() {
|
||||
d.pwEd.SetText("")
|
||||
}
|
||||
Reference in New Issue
Block a user