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
+50
View File
@@ -523,3 +523,53 @@ other = "Passwortschutz aktivieren"
[btn_welcome_skip] [btn_welcome_skip]
other = "Vorerst überspringen" 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."
+64
View File
@@ -511,3 +511,67 @@ other = "Change icon..."
[dialog_icon_picker_title] [dialog_icon_picker_title]
other = "Choose icon" 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."
+64
View File
@@ -509,3 +509,67 @@ other = "更换图标..."
[dialog_icon_picker_title] [dialog_icon_picker_title]
other = "选择图标" 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 = "备份已恢复。"
+142
View File
@@ -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 ""
}
}
+25
View File
@@ -100,6 +100,8 @@ type appState struct {
changePwDlg *changePasswordDialog changePwDlg *changePasswordDialog
welcomeDlg *welcomeDialog welcomeDlg *welcomeDialog
importDialog *importLegacyDialog importDialog *importLegacyDialog
exportDlg *exportDialog
restoreDlg *restoreBackupDialog
hotkeyDialog *hotkeyDialog hotkeyDialog *hotkeyDialog
hotkeyTarget *entry hotkeyTarget *entry
tradesDialog *steamTradesDialog tradesDialog *steamTradesDialog
@@ -556,6 +558,10 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
st.changePwDlg = newChangePasswordDialog() st.changePwDlg = newChangePasswordDialog()
case settingsActionImportLegacy: case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog() st.importDialog = newImportLegacyDialog()
case settingsActionExport:
st.exportDlg = newExportDialog(st.snapshotEntries())
case settingsActionRestoreBackup:
st.restoreDlg = newRestoreBackupDialog()
case settingsActionPreferences: case settingsActionPreferences:
lang, theme, autoLock, minToTray := st.store.Preferences() lang, theme, autoLock, minToTray := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray) 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 st.vendorMenu != nil {
if v, closed := st.vendorMenu.Pick(gtx); closed { if v, closed := st.vendorMenu.Pick(gtx); closed {
st.vendorMenu = nil st.vendorMenu = nil
+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 = ""
}
+94
View File
@@ -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("")
}
+10
View File
@@ -17,6 +17,8 @@ const (
settingsActionSetPassword settingsActionSetPassword
settingsActionChangePassword settingsActionChangePassword
settingsActionImportLegacy settingsActionImportLegacy
settingsActionExport
settingsActionRestoreBackup
settingsActionPreferences settingsActionPreferences
settingsActionAbout settingsActionAbout
) )
@@ -27,6 +29,8 @@ type settingsMenu struct {
setPwBtn widget.Clickable setPwBtn widget.Clickable
changePwBtn widget.Clickable changePwBtn widget.Clickable
importBtn widget.Clickable importBtn widget.Clickable
exportBtn widget.Clickable
restoreBtn widget.Clickable
prefsBtn widget.Clickable prefsBtn widget.Clickable
aboutBtn widget.Clickable aboutBtn widget.Clickable
cancelBtn widget.Clickable cancelBtn widget.Clickable
@@ -43,6 +47,10 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
return settingsActionChangePassword, true return settingsActionChangePassword, true
case m.importBtn.Clicked(gtx): case m.importBtn.Clicked(gtx):
return settingsActionImportLegacy, true return settingsActionImportLegacy, true
case m.exportBtn.Clicked(gtx):
return settingsActionExport, true
case m.restoreBtn.Clicked(gtx):
return settingsActionRestoreBackup, true
case m.prefsBtn.Clicked(gtx): case m.prefsBtn.Clicked(gtx):
return settingsActionPreferences, true return settingsActionPreferences, true
case m.aboutBtn.Clicked(gtx): 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.setPwBtn, i18n.T("menu_set_password")),
row(&m.changePwBtn, i18n.T("menu_change_password")), row(&m.changePwBtn, i18n.T("menu_change_password")),
row(&m.importBtn, i18n.T("menu_import_legacy")), 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.prefsBtn, i18n.T("menu_preferences")),
row(&m.aboutBtn, i18n.T("menu_about")), row(&m.aboutBtn, i18n.T("menu_about")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),