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:
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "备份已恢复。"
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user