From fd4d75682344fb086f53782692d99d750d658133 Mon Sep 17 00:00:00 2001 From: Daniel Wu Date: Fri, 12 Jun 2026 10:44:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=AF=BC=E5=87=BA/=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=20=E2=80=94=20otpauth=20URI=20=E5=88=97=E8=A1=A8=20+?= =?UTF-8?q?=20=E5=8A=A0=E5=AF=86=E5=A4=87=E4=BB=BD=E5=AF=BC=E5=87=BA/?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 三语翻译 --- internal/i18n/locales/de.toml | 50 +++++ internal/i18n/locales/en.toml | 64 +++++++ internal/i18n/locales/zh-CN.toml | 64 +++++++ internal/qr/export.go | 142 ++++++++++++++ internal/ui/app.go | 25 +++ internal/ui/dialog_export.go | 273 +++++++++++++++++++++++++++ internal/ui/dialog_restore_backup.go | 94 +++++++++ internal/ui/settings_menu.go | 10 + 8 files changed, 722 insertions(+) create mode 100644 internal/qr/export.go create mode 100644 internal/ui/dialog_export.go create mode 100644 internal/ui/dialog_restore_backup.go diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml index f423c23..24b2768 100644 --- a/internal/i18n/locales/de.toml +++ b/internal/i18n/locales/de.toml @@ -523,3 +523,53 @@ other = "Passwortschutz aktivieren" [btn_welcome_skip] other = "Vorerst überspringen" + +# --- Export / Backup --- + +[menu_export] +other = "Exportieren..." + +[menu_restore_backup] +other = "Backup wiederherstellen..." + +[dialog_export_title] +other = "Authentifikatoren exportieren" + +[export_tab_otpauth] +other = "otpauth://-URIs" + +[export_tab_backup] +other = "Verschlüsseltes Backup" + +[export_otpauth_intro] +other = "Unten stehen die Standard-otpauth://-URIs für jeden Eintrag. Sie können in andere Authenticator-Apps (Google Authenticator, Authy usw.) importiert werden." + +[export_partial_warning] +other = "Teilweiser Export — herstellerspezifische Felder fehlen" + +[export_backup_intro] +other = "Schreibe eine verschlüsselte Backupdatei, die auf jedem Rechner wiederhergestellt werden kann. Wähle ein starkes Passwort — es ist NICHT mit dem aktuellen WinAuth-Passwort verknüpft." + +[label_backup_path] +other = "Pfad zur Backupdatei" + +[btn_export] +other = "Exportieren" + +[msg_empty_backup_path] +other = "Bitte den Pfad zur Backupdatei eingeben." + +[msg_export_done] +other = "Backup erfolgreich exportiert." + +[msg_export_failed] +other = "Export fehlgeschlagen: %s" + +[dialog_restore_backup_title] +other = "Backup wiederherstellen" + +[restore_backup_intro] +other = "Wähle eine verschlüsselte Backupdatei (.winauth.bak) und gib das beim Erstellen gesetzte Passwort ein. Einträge werden an die aktuelle Liste angehängt." + +[msg_restore_done] +other = "Backup wiederhergestellt." diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml index d902077..c6c9166 100644 --- a/internal/i18n/locales/en.toml +++ b/internal/i18n/locales/en.toml @@ -511,3 +511,67 @@ other = "Change icon..." [dialog_icon_picker_title] other = "Choose icon" + +# --- First-run welcome dialog --- + +[dialog_welcome_title] +other = "Welcome to WinAuth" + +[msg_welcome_intro] +other = "Choose whether to encrypt the list of authenticators stored on disk. Encryption protects the file with a password of your choice. You can change this later in Settings." + +[btn_welcome_enable_password] +other = "Enable password protection" + +[btn_welcome_skip] +other = "Skip for now" + +# --- Export / backup --- + +[menu_export] +other = "Export..." + +[menu_restore_backup] +other = "Restore backup..." + +[dialog_export_title] +other = "Export authenticators" + +[export_tab_otpauth] +other = "otpauth:// URIs" + +[export_tab_backup] +other = "Encrypted backup" + +[export_otpauth_intro] +other = "Below are standard otpauth:// URIs for each entry. You can import them into other authenticator apps (Google Authenticator, Authy, etc.)." + +[export_partial_warning] +other = "partial export — vendor-specific fields omitted" + +[export_backup_intro] +other = "Write an encrypted backup file that can be restored on any machine. Choose a strong password — it is NOT linked to your current WinAuth password." + +[label_backup_path] +other = "Backup file path" + +[btn_export] +other = "Export" + +[msg_empty_backup_path] +other = "Please enter a backup file path." + +[msg_export_done] +other = "Backup exported successfully." + +[msg_export_failed] +other = "Export failed: %s" + +[dialog_restore_backup_title] +other = "Restore from backup" + +[restore_backup_intro] +other = "Select an encrypted backup file (.winauth.bak) and enter the password that was set when it was created. Entries will be appended to your current list." + +[msg_restore_done] +other = "Backup restored." diff --git a/internal/i18n/locales/zh-CN.toml b/internal/i18n/locales/zh-CN.toml index 0714c2a..bbc27cd 100644 --- a/internal/i18n/locales/zh-CN.toml +++ b/internal/i18n/locales/zh-CN.toml @@ -509,3 +509,67 @@ other = "更换图标..." [dialog_icon_picker_title] other = "选择图标" + +# --- 首次启动欢迎 --- + +[dialog_welcome_title] +other = "欢迎使用 WinAuth" + +[msg_welcome_intro] +other = "选择是否对磁盘上存储的验证器列表进行加密。加密将使用你选择的密码保护文件。你可以在设置中随时更改。" + +[btn_welcome_enable_password] +other = "启用密码保护" + +[btn_welcome_skip] +other = "暂时跳过" + +# --- 导出 / 备份 --- + +[menu_export] +other = "导出..." + +[menu_restore_backup] +other = "恢复备份..." + +[dialog_export_title] +other = "导出验证器" + +[export_tab_otpauth] +other = "otpauth:// URI" + +[export_tab_backup] +other = "加密备份" + +[export_otpauth_intro] +other = "以下是每个条目的标准 otpauth:// URI,可导入到其他验证器应用(Google Authenticator、Authy 等)。" + +[export_partial_warning] +other = "部分导出 — 厂商专用字段已省略" + +[export_backup_intro] +other = "将加密备份文件写入磁盘,可在任何机器上恢复。请选择强密码——此密码与当前 WinAuth 密码无关。" + +[label_backup_path] +other = "备份文件路径" + +[btn_export] +other = "导出" + +[msg_empty_backup_path] +other = "请输入备份文件路径。" + +[msg_export_done] +other = "备份导出成功。" + +[msg_export_failed] +other = "导出失败:%s" + +[dialog_restore_backup_title] +other = "从备份恢复" + +[restore_backup_intro] +other = "选择加密备份文件(.winauth.bak)并输入创建时设置的密码。条目将追加到当前列表。" + +[msg_restore_done] +other = "备份已恢复。" diff --git a/internal/qr/export.go b/internal/qr/export.go new file mode 100644 index 0000000..9f6f298 --- /dev/null +++ b/internal/qr/export.go @@ -0,0 +1,142 @@ +package qr + +import ( + "encoding/hex" + "fmt" + "net/url" + "strconv" + "strings" + + "git.wxccs.org/iceking2nd/winauth-go/internal/authenticator" + "git.wxccs.org/iceking2nd/winauth-go/internal/config" +) + +// EntryToOtpAuth converts a config.Entry into an OtpAuth struct suitable +// for URI export. It parses the SecretRaw field directly instead of +// constructing a full authenticator, so the qr package stays free of +// network/Win32 dependencies. For Battle.Net and Steam the partial flag +// is set because those vendors carry extra fields (serial, device ID, +// session data) that cannot be expressed in the standard otpauth:// +// format — only the raw HMAC secret and basic TOTP params are exported. +func EntryToOtpAuth(e config.Entry) (oa *OtpAuth, partial bool, err error) { + if e.SecretRaw == "" { + return nil, false, fmt.Errorf("qr: entry %q has no secret data", e.Name) + } + + head, _, _ := strings.Cut(e.SecretRaw, "|") + parts := strings.Split(head, "\t") + if len(parts) == 0 || parts[0] == "" { + return nil, false, fmt.Errorf("qr: entry %q has empty secret", e.Name) + } + + secretBytes, err := hex.DecodeString(parts[0]) + if err != nil { + return nil, false, fmt.Errorf("qr: entry %q: bad secret hex: %w", e.Name, err) + } + + oa = &OtpAuth{ + SecretBase32: authenticator.Base32Encode(secretBytes), + Digits: authenticator.DefaultCodeDigits, + Algorithm: "SHA1", + Period: authenticator.DefaultPeriod, + } + + if len(parts) > 1 { + if d, e := strconv.Atoi(parts[1]); e == nil && d > 0 { + oa.Digits = d + } + } + if len(parts) > 2 { + oa.Algorithm = strings.ToUpper(parts[2]) + } + if len(parts) > 3 { + if p, e := strconv.Atoi(parts[3]); e == nil && p > 0 { + oa.Period = p + } + } + + switch e.Vendor { + case "hotp": + oa.Type = "hotp" + // Counter is stored after the first "|" in SecretRaw. + if idx := strings.Index(e.SecretRaw, "|"); idx >= 0 { + if c, e := strconv.ParseUint(strings.TrimSpace(e.SecretRaw[idx+1:]), 10, 64); e == nil { + oa.Counter = c + } + } + default: + oa.Type = "totp" + } + + issuer := vendorToIssuer(e.Vendor) + oa.Issuer = issuer + if e.Name != "" { + if issuer != "" { + oa.Label = issuer + ":" + e.Name + } else { + oa.Label = e.Name + } + } + + if e.Vendor == "battlenet" || e.Vendor == "steam" { + partial = true + } + + return oa, partial, nil +} + +// URI renders the OtpAuth as an otpauth:// URI string per the +// Key-Uri-Format spec used by Google Authenticator et al. +func (oa *OtpAuth) URI() string { + var buf strings.Builder + buf.WriteString("otpauth://") + buf.WriteString(oa.Type) + buf.WriteByte('/') + if oa.Label != "" { + buf.WriteString(url.PathEscape(oa.Label)) + } + buf.WriteString("?secret=") + buf.WriteString(oa.SecretBase32) + if oa.Issuer != "" { + buf.WriteString("&issuer=") + buf.WriteString(url.QueryEscape(oa.Issuer)) + } + if oa.Algorithm != "" && oa.Algorithm != "SHA1" { + buf.WriteString("&algorithm=") + buf.WriteString(oa.Algorithm) + } + if oa.Digits > 0 && oa.Digits != 6 { + buf.WriteString("&digits=") + buf.WriteString(strconv.Itoa(oa.Digits)) + } + if oa.Period > 0 && oa.Period != 30 { + buf.WriteString("&period=") + buf.WriteString(strconv.Itoa(oa.Period)) + } + if oa.Type == "hotp" && oa.Counter > 0 { + buf.WriteString("&counter=") + buf.WriteString(strconv.FormatUint(oa.Counter, 10)) + } + return buf.String() +} + +// vendorToIssuer maps internal vendor strings to standard issuer names +// for otpauth:// URIs. +func vendorToIssuer(vendor string) string { + switch vendor { + case "google": + return "Google" + case "microsoft": + return "Microsoft" + case "okta": + return "Okta" + case "hotp": + return "" + case "battlenet": + return "Battle.Net" + case "steam": + return "Steam" + default: + return "" + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index c52f933..cf46d82 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -100,6 +100,8 @@ type appState struct { changePwDlg *changePasswordDialog welcomeDlg *welcomeDialog importDialog *importLegacyDialog + exportDlg *exportDialog + restoreDlg *restoreBackupDialog hotkeyDialog *hotkeyDialog hotkeyTarget *entry tradesDialog *steamTradesDialog @@ -556,6 +558,10 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind st.changePwDlg = newChangePasswordDialog() case settingsActionImportLegacy: st.importDialog = newImportLegacyDialog() + case settingsActionExport: + st.exportDlg = newExportDialog(st.snapshotEntries()) + case settingsActionRestoreBackup: + st.restoreDlg = newRestoreBackupDialog() case settingsActionPreferences: lang, theme, autoLock, minToTray := st.store.Preferences() st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray) @@ -606,6 +612,25 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind }) } + if st.exportDlg != nil { + return st.exportDlg.Layout(gtx, th, func(r exportResult) { + st.exportDlg = nil + w.Invalidate() + }) + } + + if st.restoreDlg != nil { + return st.restoreDlg.Layout(gtx, th, func(r restoreBackupResult) { + if !r.cancel && r.cfg != nil { + st.mergeImportedConfig(r.cfg) + st.store.Push() + st.toast.Show(i18n.T("msg_restore_done"), w) + } + st.restoreDlg = nil + w.Invalidate() + }) + } + if st.vendorMenu != nil { if v, closed := st.vendorMenu.Pick(gtx); closed { st.vendorMenu = nil diff --git a/internal/ui/dialog_export.go b/internal/ui/dialog_export.go new file mode 100644 index 0000000..3adf1ea --- /dev/null +++ b/internal/ui/dialog_export.go @@ -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 = "" +} diff --git a/internal/ui/dialog_restore_backup.go b/internal/ui/dialog_restore_backup.go new file mode 100644 index 0000000..2e58f5a --- /dev/null +++ b/internal/ui/dialog_restore_backup.go @@ -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("") +} diff --git a/internal/ui/settings_menu.go b/internal/ui/settings_menu.go index a92afd6..b7e607b 100644 --- a/internal/ui/settings_menu.go +++ b/internal/ui/settings_menu.go @@ -17,6 +17,8 @@ const ( settingsActionSetPassword settingsActionChangePassword settingsActionImportLegacy + settingsActionExport + settingsActionRestoreBackup settingsActionPreferences settingsActionAbout ) @@ -27,6 +29,8 @@ type settingsMenu struct { setPwBtn widget.Clickable changePwBtn widget.Clickable importBtn widget.Clickable + exportBtn widget.Clickable + restoreBtn widget.Clickable prefsBtn widget.Clickable aboutBtn widget.Clickable cancelBtn widget.Clickable @@ -43,6 +47,10 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) { return settingsActionChangePassword, true case m.importBtn.Clicked(gtx): return settingsActionImportLegacy, true + case m.exportBtn.Clicked(gtx): + return settingsActionExport, true + case m.restoreBtn.Clicked(gtx): + return settingsActionRestoreBackup, true case m.prefsBtn.Clicked(gtx): return settingsActionPreferences, true case m.aboutBtn.Clicked(gtx): @@ -81,6 +89,8 @@ func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dim row(&m.setPwBtn, i18n.T("menu_set_password")), row(&m.changePwBtn, i18n.T("menu_change_password")), row(&m.importBtn, i18n.T("menu_import_legacy")), + row(&m.exportBtn, i18n.T("menu_export")), + row(&m.restoreBtn, i18n.T("menu_restore_backup")), row(&m.prefsBtn, i18n.T("menu_preferences")), row(&m.aboutBtn, i18n.T("menu_about")), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),