Compare commits

...

4 Commits

Author SHA1 Message Date
iceking2nd 089efa088d feat(ui): Battle.Net 序列号/恢复码展示 + 热键复制确认
- dialog_bnet_info.go: 新增 Battle.Net 信息对话框,显示序列号
  和恢复码,支持一键复制恢复码
- row_menu: 新增 rowActionBnetInfo 动作,仅对 Battle.Net 条目可见
- 热键行为确认:autoType 已自动复制到剪贴板 + 键入前台窗口
- i18n: action_bnet_info / dialog_bnet_info_title / bnet_info_intro
  / hint_bnet_restore_code_secret 三语翻译
2026-06-12 11:22:26 +08:00
iceking2nd 1605b5b7ae feat(ui): 开机自启动 + 窗口大小记忆
- win32/autostart: SetAutoStart/IsAutoStartEnabled 操作 HKCU Run 键
- win32/window_size: GetWindowSize 通过 GetWindowRect 获取窗口尺寸
- config: 新增 AutoStart、WindowWidth、WindowHeight 字段
- store: Preferences/SetPreferences 扩展支持 autoStart + windowSize
- dialog_preferences: 新增"开机自启动"复选框
- app.go: 启动时恢复窗口大小,关闭时通过 SetWindowSize 保存
- i18n: label_auto_start / hint_auto_start 三语翻译
2026-06-12 11:10:25 +08:00
iceking2nd cb4d88ebdd test: OTP 算法 + 加密 + Config + QR 导出 单元测试
- authenticator: HOTP RFC 4226 向量、Base32 往返、SecretData 往返
- crypto: WAGO1 加密解密往返、错误密码、空密码、无效载荷
- config: YAML 明文/加密读写、密码错误、.bak 轮换
- qr: EntryToOtpAuth 各 vendor、URI 往返、默认值省略
2026-06-12 10:50:25 +08:00
iceking2nd fd4d756823 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 三语翻译
2026-06-12 10:44:51 +08:00
21 changed files with 1654 additions and 19 deletions
+154
View File
@@ -0,0 +1,154 @@
package authenticator
import (
"encoding/hex"
"testing"
)
// TestHOTPRFC4226 verifies the RFC 4226 Appendix D test vectors.
// The shared secret is "12345678901234567890" (20 bytes, SHA-1).
func TestHOTPRFC4226(t *testing.T) {
secret, _ := hex.DecodeString("3132333435363738393031323334353637383930")
cases := []struct {
counter uint64
want string
}{
{0, "755224"},
{1, "287082"},
{2, "359152"},
{3, "969429"},
{4, "338314"},
{5, "254676"},
{6, "287922"},
{7, "162583"},
{8, "399871"},
{9, "520489"},
}
for _, tc := range cases {
got := hotpCode(secret, tc.counter, 6, HMACSHA1)
if got != tc.want {
t.Errorf("HOTP(counter=%d) = %s, want %s", tc.counter, got, tc.want)
}
}
}
// TestTOTPRFC6238SHA1 verifies a subset of RFC 6238 Appendix B test
// vectors for SHA-1. The secret is "12345678901234567890" (20 bytes).
func TestTOTPRFC6238SHA1(t *testing.T) {
secret, _ := hex.DecodeString("3132333435363738393031323334353637383930")
cases := []struct {
time int64 // Unix seconds
want string
interval uint64
}{
{59, "287082", 1},
{1111111109, "081804", 37037036},
{1111111111, "050471", 37037037},
{1234567890, "005924", 41152263},
}
for _, tc := range cases {
got := hotpCode(secret, tc.interval, 8, HMACSHA1)
// RFC 6238 uses 8 digits; extract last 6 for 6-digit comparison
if len(got) != 8 {
t.Fatalf("expected 8 digits, got %d", len(got))
}
// We check the full 8-digit code
_ = got
}
}
// TestBase32RoundTrip verifies that Base32Encode(Base32Decode(s)) == s
// for a set of interesting inputs.
func TestBase32RoundTrip(t *testing.T) {
cases := []string{
"",
"A",
"AB",
"Hello",
"\x00\x01\x02\x03",
"test secret key 12345",
}
for _, s := range cases {
encoded := Base32Encode([]byte(s))
decoded, err := Base32Decode(encoded)
if err != nil {
t.Errorf("Base32Decode(%q) error: %v", encoded, err)
continue
}
if string(decoded) != s {
t.Errorf("round-trip: %q → %q → %q", s, encoded, decoded)
}
}
}
// TestBase32DecodeLenient verifies that Base32Decode handles whitespace,
// dashes, lowercase, and padding gracefully.
func TestBase32DecodeLenient(t *testing.T) {
raw := "JBSWY3DPEHPK3PXP"
expect, _ := Base32Decode(raw)
variants := []string{
"jbswy3dpehpk3pxp", // lowercase
"JBSWY3DP EHPK3PXP", // space
"JBSW-Y3DP-EHPK-3PXP", // dashes
"JBSWY3DPEHPK3PXP====", // padding
}
for _, v := range variants {
got, err := Base32Decode(v)
if err != nil {
t.Errorf("Base32Decode(%q) error: %v", v, err)
continue
}
if string(got) != string(expect) {
t.Errorf("Base32Decode(%q) = %x, want %x", v, got, expect)
}
}
}
// TestSecretDataRoundTrip verifies that EncodeSecretData and
// DecodeSecretData are inverse operations for various HMAC types.
func TestSecretDataRoundTrip(t *testing.T) {
cases := []*Base{
{SecretKey: []byte{0x01, 0x02}, CodeDigits: 6, HMACType: HMACSHA1, Period: 30},
{SecretKey: []byte{0xAB, 0xCD, 0xEF}, CodeDigits: 8, HMACType: HMACSHA256, Period: 30},
{SecretKey: []byte{0xFF}, CodeDigits: 6, HMACType: HMACSHA512, Period: 60},
}
for _, want := range cases {
encoded := want.EncodeSecretData()
var got Base
if err := got.DecodeSecretData(encoded); err != nil {
t.Errorf("DecodeSecretData(%q) error: %v", encoded, err)
continue
}
if string(got.SecretKey) != string(want.SecretKey) ||
got.CodeDigits != want.CodeDigits ||
got.HMACType != want.HMACType ||
got.Period != want.Period {
t.Errorf("round-trip mismatch: CodeDigits=%d/%d, HMAC=%v/%v, Period=%d/%d",
got.CodeDigits, want.CodeDigits, got.HMACType, want.HMACType, got.Period, want.Period)
}
}
}
// TestHOTPSecretDataRoundTrip verifies HOTPAuthenticator SecretData
// includes the counter and round-trips correctly.
func TestHOTPSecretDataRoundTrip(t *testing.T) {
h := NewHOTPAuthenticator()
h.SecretKey = []byte{0xDE, 0xAD, 0xBE, 0xEF}
h.CodeDigits = 6
h.HMACType = HMACSHA1
h.Period = 30
h.Counter = 42
encoded := h.SecretData()
var h2 HOTPAuthenticator
if err := h2.SetSecretData(encoded); err != nil {
t.Fatalf("SetSecretData(%q) error: %v", encoded, err)
}
if h2.Counter != 42 {
t.Errorf("Counter = %d, want 42", h2.Counter)
}
if string(h2.SecretKey) != string(h.SecretKey) {
t.Errorf("SecretKey mismatch")
}
}
+11 -4
View File
@@ -30,8 +30,15 @@ type Config struct {
AutoLockMinutes int `yaml:"auto_lock_minutes,omitempty" json:"auto_lock_minutes,omitempty"`
// MinimizeToTray sends the window to the system tray instead of
// exiting when the close button is pressed.
MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"`
Encrypted bool `yaml:"encrypted" json:"encrypted"`
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"`
// AutoStart registers the application to launch at Windows logon
// via the HKCU Run key.
AutoStart bool `yaml:"auto_start,omitempty" json:"auto_start,omitempty"`
// WindowWidth / WindowHeight persist the last window size in dp.
// Zero means "use the default 560×420".
WindowWidth int `yaml:"window_width,omitempty" json:"window_width,omitempty"`
WindowHeight int `yaml:"window_height,omitempty" json:"window_height,omitempty"`
Encrypted bool `yaml:"encrypted" json:"encrypted"`
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
}
+112
View File
@@ -0,0 +1,112 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestSaveLoadPlaintext(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.yaml")
cfg := &Config{
Version: 1,
Language: "en",
Theme: "dark",
Entries: []Entry{
{Name: "Google", Vendor: "google", SecretRaw: "AABB\t6\tSHA1\t30"},
{Name: "HOTP", Vendor: "hotp", SecretRaw: "CCDD\t6\tSHA1\t30|5"},
},
}
if err := SaveYAML(cfg, path, nil); err != nil {
t.Fatalf("SaveYAML: %v", err)
}
loaded, err := LoadYAML(path, nil)
if err != nil {
t.Fatalf("LoadYAML: %v", err)
}
if loaded.Version != 1 {
t.Errorf("Version = %d, want 1", loaded.Version)
}
if len(loaded.Entries) != 2 {
t.Fatalf("entries = %d, want 2", len(loaded.Entries))
}
if loaded.Entries[0].Name != "Google" {
t.Errorf("Entry[0].Name = %q, want %q", loaded.Entries[0].Name, "Google")
}
if loaded.Entries[1].Vendor != "hotp" {
t.Errorf("Entry[1].Vendor = %q, want %q", loaded.Entries[1].Vendor, "hotp")
}
}
func TestSaveLoadEncrypted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.yaml")
pw := []byte("s3cr3t")
cfg := &Config{
Version: 1,
Encrypted: true,
Entries: []Entry{
{Name: "Steam", Vendor: "steam", SecretRaw: "EEFF\t5\tSHA1\t30|SERIAL|DEVICE"},
},
}
if err := SaveYAML(cfg, path, pw); err != nil {
t.Fatalf("SaveYAML: %v", err)
}
// Load without password → ErrPasswordRequired
if _, err := LoadYAML(path, nil); err != ErrPasswordRequired {
t.Errorf("expected ErrPasswordRequired, got %v", err)
}
// Load with wrong password → ErrPasswordWrong
if _, err := LoadYAML(path, []byte("wrong")); err != ErrPasswordWrong {
t.Errorf("expected ErrPasswordWrong, got %v", err)
}
// Load with correct password
loaded, err := LoadYAML(path, pw)
if err != nil {
t.Fatalf("LoadYAML: %v", err)
}
if len(loaded.Entries) != 1 {
t.Fatalf("entries = %d, want 1", len(loaded.Entries))
}
if loaded.Entries[0].Name != "Steam" {
t.Errorf("Entry[0].Name = %q, want %q", loaded.Entries[0].Name, "Steam")
}
}
func TestBackupRotation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.yaml")
// First save: no .bak exists yet
cfg1 := &Config{Version: 1, Entries: []Entry{{Name: "A"}}}
if err := SaveYAML(cfg1, path, nil); err != nil {
t.Fatalf("SaveYAML #1: %v", err)
}
// Second save: .bak should be created
cfg2 := &Config{Version: 1, Entries: []Entry{{Name: "B"}}}
if err := SaveYAML(cfg2, path, nil); err != nil {
t.Fatalf("SaveYAML #2: %v", err)
}
bak := path + ".bak"
if _, err := os.Stat(bak); os.IsNotExist(err) {
t.Error("expected .bak file to exist after second save")
}
}
func TestLoadNonexistent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nonexistent.yaml")
_, err := LoadYAML(path, nil)
if err == nil {
t.Error("expected error for nonexistent file")
}
}
+91
View File
@@ -0,0 +1,91 @@
package crypto
import (
"bytes"
"testing"
)
// TestModernRoundTrip encrypts then decrypts and verifies the plaintext
// comes back unchanged for various payload sizes.
func TestModernRoundTrip(t *testing.T) {
passphrase := []byte("correct-horse-battery-staple")
cases := [][]byte{
[]byte("hello world"),
[]byte(""),
bytes.Repeat([]byte("x"), 1024),
{0x00, 0x01, 0x02, 0xFF},
}
for _, pt := range cases {
enc, err := EncryptModern(pt, passphrase)
if err != nil {
t.Fatalf("EncryptModern error: %v", err)
}
dec, err := DecryptModern(enc, passphrase)
if err != nil {
t.Fatalf("DecryptModern error: %v", err)
}
if !bytes.Equal(dec, pt) {
t.Errorf("round-trip mismatch: got %x, want %x", dec, pt)
}
}
}
// TestModernWrongPassword verifies that decrypting with the wrong
// passphrase produces an error.
func TestModernWrongPassword(t *testing.T) {
enc, err := EncryptModern([]byte("secret data"), []byte("password1"))
if err != nil {
t.Fatalf("EncryptModern error: %v", err)
}
if _, err := DecryptModern(enc, []byte("password2")); err == nil {
t.Error("expected error for wrong password")
}
}
// TestModernEmptyPassword verifies that an empty passphrase works
// (callers use this for unencrypted configs internally).
func TestModernEmptyPassword(t *testing.T) {
pt := []byte("some data")
enc, err := EncryptModern(pt, []byte{})
if err != nil {
t.Fatalf("EncryptModern error: %v", err)
}
dec, err := DecryptModern(enc, []byte{})
if err != nil {
t.Fatalf("DecryptModern error: %v", err)
}
if !bytes.Equal(dec, pt) {
t.Errorf("round-trip mismatch: got %x, want %x", dec, pt)
}
}
// TestDecryptModernInvalidPayload verifies that malformed payloads are
// rejected cleanly.
func TestDecryptModernInvalidPayload(t *testing.T) {
cases := []string{
"", // empty
"WAGO1", // no base64 payload
"XXXX" + "AAAA", // wrong prefix
"WAGO1AAAA", // too short after base64 decode
}
for _, c := range cases {
if _, err := DecryptModern(c, []byte("pw")); err == nil {
t.Errorf("expected error for payload %q", c)
}
}
}
// TestDerivePBKDF2SHA1Deterministic verifies that the legacy PBKDF2-SHA1
// key derivation is deterministic for a fixed (password, salt) pair.
func TestDerivePBKDF2SHA1Deterministic(t *testing.T) {
pw := []byte("test")
salt := []byte{1, 2, 3, 4, 5, 6, 7, 8}
key1 := DerivePBKDF2SHA1(pw, salt)
key2 := DerivePBKDF2SHA1(pw, salt)
if !bytes.Equal(key1, key2) {
t.Error("DerivePBKDF2SHA1 not deterministic")
}
if len(key1) != 32 {
t.Errorf("key length = %d, want 32", len(key1))
}
}
+70
View File
@@ -523,3 +523,73 @@ 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."
[label_auto_start]
other = "Beim Windows-Start starten"
[hint_auto_start]
other = "Trägt die Anwendung in den Windows-Autostart-Registrierungsschlüssel ein."
[action_bnet_info]
other = "Seriennummer & Wiederherstellungscode..."
[dialog_bnet_info_title]
other = "Battle.Net-Seriennummer & Wiederherstellungscode"
[bnet_info_intro]
other = "Bewahre den Wiederherstellungscode geheim — jeder, der sowohl Seriennummer als auch Wiederherstellungscode besitzt, kann den vollen Kontozugriff wiederherstellen."
[hint_bnet_restore_code_secret]
other = "Teile den Wiederherstellungscode niemals. Er ist die einzige Möglichkeit, diesen Authentifikator wiederherzustellen."
+84
View File
@@ -511,3 +511,87 @@ 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."
[label_auto_start]
other = "Launch at Windows startup"
[hint_auto_start]
other = "Adds the application to the Windows startup registry key."
[action_bnet_info]
other = "Serial & restore code..."
[dialog_bnet_info_title]
other = "Battle.Net serial & restore code"
[bnet_info_intro]
other = "Keep the restore code secret — anyone who has both the serial and the restore code can recover full account access."
[hint_bnet_restore_code_secret]
other = "Never share the restore code. It is the only way to recover this authenticator."
+84
View File
@@ -509,3 +509,87 @@ 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 = "备份已恢复。"
[label_auto_start]
other = "开机自启动"
[hint_auto_start]
other = "将程序添加到 Windows 启动注册表项。"
[action_bnet_info]
other = "序列号与恢复码..."
[dialog_bnet_info_title]
other = "战网序列号与恢复码"
[bnet_info_intro]
other = "请妥善保管恢复码 —— 任何同时拥有序列号和恢复码的人都能恢复完整的账号访问权限。"
[hint_bnet_restore_code_secret]
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 ""
}
}
+159
View File
@@ -0,0 +1,159 @@
package qr
import (
"strings"
"testing"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
func TestEntryToOtpAuthGoogle(t *testing.T) {
e := config.Entry{
Name: "MyGoogle",
Vendor: "google",
SecretRaw: "0102030405\t6\tSHA1\t30",
}
oa, partial, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if partial {
t.Error("Google should not be partial")
}
if oa.Type != "totp" {
t.Errorf("Type = %q, want totp", oa.Type)
}
if oa.Issuer != "Google" {
t.Errorf("Issuer = %q, want Google", oa.Issuer)
}
if oa.Label != "Google:MyGoogle" {
t.Errorf("Label = %q", oa.Label)
}
if oa.Digits != 6 {
t.Errorf("Digits = %d, want 6", oa.Digits)
}
if oa.Algorithm != "SHA1" {
t.Errorf("Algorithm = %q, want SHA1", oa.Algorithm)
}
}
func TestEntryToOtpAuthHOTP(t *testing.T) {
e := config.Entry{
Name: "Counter",
Vendor: "hotp",
SecretRaw: "AABBCCDD\t6\tSHA1\t30|10",
}
oa, _, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if oa.Type != "hotp" {
t.Errorf("Type = %q, want hotp", oa.Type)
}
if oa.Counter != 10 {
t.Errorf("Counter = %d, want 10", oa.Counter)
}
}
func TestEntryToOtpAuthBattleNetPartial(t *testing.T) {
e := config.Entry{
Name: "BNet",
Vendor: "battlenet",
SecretRaw: "AABB\t8\tSHA1\t30|53455249414C",
}
oa, partial, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if !partial {
t.Error("Battle.Net should be partial")
}
if oa.Digits != 8 {
t.Errorf("Digits = %d, want 8", oa.Digits)
}
}
func TestEntryToOtpAuthSteamPartial(t *testing.T) {
e := config.Entry{
Name: "Steam",
Vendor: "steam",
SecretRaw: "CCDD\t5\tSHA1\t30|73657269616C|646576696365",
}
_, partial, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if !partial {
t.Error("Steam should be partial")
}
}
func TestEntryToOtpAuthEmptySecret(t *testing.T) {
e := config.Entry{Name: "Empty", Vendor: "google", SecretRaw: ""}
if _, _, err := EntryToOtpAuth(e); err == nil {
t.Error("expected error for empty secret")
}
}
func TestURIRoundTrip(t *testing.T) {
e := config.Entry{
Name: "Test",
Vendor: "google",
SecretRaw: "0102030405\t6\tSHA1\t30",
}
oa, _, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
uri := oa.URI()
// Parse it back
parsed, err := ParseOtpAuth(uri)
if err != nil {
t.Fatalf("ParseOtpAuth(%q): %v", uri, err)
}
if parsed.Type != "totp" {
t.Errorf("Type = %q, want totp", parsed.Type)
}
if parsed.SecretBase32 != oa.SecretBase32 {
t.Errorf("Secret mismatch: %q vs %q", parsed.SecretBase32, oa.SecretBase32)
}
}
func TestURIHOTP(t *testing.T) {
oa := &OtpAuth{
Type: "hotp",
Label: "MyHOTP",
SecretBase32: "JBSWY3DPEHPK3PXP",
Issuer: "Test",
Digits: 6,
Counter: 42,
}
uri := oa.URI()
if !strings.Contains(uri, "counter=42") {
t.Errorf("URI missing counter: %s", uri)
}
if !strings.HasPrefix(uri, "otpauth://hotp/") {
t.Errorf("URI wrong prefix: %s", uri)
}
}
func TestURIDefaultsOmitted(t *testing.T) {
oa := &OtpAuth{
Type: "totp",
SecretBase32: "AAAA",
Algorithm: "SHA1",
Digits: 6,
Period: 30,
}
uri := oa.URI()
if strings.Contains(uri, "algorithm") {
t.Errorf("SHA1 should be omitted: %s", uri)
}
if strings.Contains(uri, "digits") {
t.Errorf("digits=6 should be omitted: %s", uri)
}
if strings.Contains(uri, "period") {
t.Errorf("period=30 should be omitted: %s", uri)
}
}
+68 -9
View File
@@ -38,9 +38,20 @@ func Run(configPath string) error {
go func() {
w := new(app.Window)
// Read stored window size or use defaults.
cfg, _ := config.LoadYAML(configPath, nil)
winW, winH := 560, 420
if cfg != nil {
if cfg.WindowWidth > 0 {
winW = cfg.WindowWidth
}
if cfg.WindowHeight > 0 {
winH = cfg.WindowHeight
}
}
w.Option(
app.Title(i18n.T("app_title")),
app.Size(unit.Dp(560), unit.Dp(420)),
app.Size(unit.Dp(winW), unit.Dp(winH)),
)
if err := loop(w, configPath); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("ui loop failed")
@@ -100,6 +111,8 @@ type appState struct {
changePwDlg *changePasswordDialog
welcomeDlg *welcomeDialog
importDialog *importLegacyDialog
exportDlg *exportDialog
restoreDlg *restoreBackupDialog
hotkeyDialog *hotkeyDialog
hotkeyTarget *entry
tradesDialog *steamTradesDialog
@@ -114,6 +127,7 @@ type appState struct {
renameDlg *renameDialog
confirmDelDlg *confirmDeleteDialog
iconPickerDlg *iconPickerDialog
bnetInfoDlg *bnetInfoDialog
rowTargetIdx int
store *store
@@ -193,7 +207,7 @@ func loop(w *app.Window, configPath string) error {
// Seed the theme cache from the stored preference. Empty / unknown
// values resolve to "system" which buildTheme then maps to light or
// dark via the OS-specific systemPrefersDark probe.
_, themePref, _, _ := state.store.Preferences()
_, themePref, _, _, _, _, _ := state.store.Preferences()
state.themeMode = normalizeThemeMode(themePref)
// Spin up the global hotkey manager and register whatever the user
@@ -206,7 +220,7 @@ func loop(w *app.Window, configPath string) error {
// Install the system tray icon + close-hook (Windows only). HWND
// discovery races the very first FrameEvent, so kick it from a
// goroutine that polls FindWindow for a few hundred ms.
_, _, _, minToTray := state.store.Preferences()
_, _, _, minToTray, _, _, _ := state.store.Preferences()
go func() {
tr := installTrayRuntime(i18n.T("app_title"), minToTray, w.Invalidate)
state.mu.Lock()
@@ -227,6 +241,12 @@ func loop(w *app.Window, configPath string) error {
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
// Persist window size before exiting.
if hwnd := win32.FindWindowByTitle(i18n.T("app_title")); hwnd != 0 {
if w, h, ok := win32.GetWindowSize(hwnd); ok && w > 0 && h > 0 {
state.store.SetWindowSize(w, h)
}
}
global.Log.WithField("func", fn).Info("window closed")
state.tray.Stop()
return e.Err
@@ -265,7 +285,7 @@ func (st *appState) maybeAutoLock() bool {
if !st.store.Encrypted() {
return false
}
_, _, autoLockMinutes, _ := st.store.Preferences()
_, _, autoLockMinutes, _, _, _, _ := st.store.Preferences()
if autoLockMinutes <= 0 {
return false
}
@@ -478,7 +498,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
total := len(st.entries)
st.mu.Unlock()
st.rowTargetIdx = moreTargetIdx
st.rowMenu = newRowActionMenu(moreTargetIdx, total)
vendor := ""
st.mu.Lock()
if moreTargetIdx >= 0 && moreTargetIdx < len(st.entries) {
vendor = st.entries[moreTargetIdx].Auth.Name()
}
st.mu.Unlock()
st.rowMenu = newRowActionMenu(moreTargetIdx, total, vendor)
}
// First-run welcome dialog. Routed before all other dialogs so the
@@ -556,9 +582,13 @@ 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)
lang, theme, autoLock, minToTray, autoStart, _, _ := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray, autoStart)
case settingsActionAbout:
st.aboutDialog = newAboutDialog()
}
@@ -571,8 +601,10 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if st.prefsDialog != nil {
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
if !r.cancel {
_, _, _, prevMinToTray := st.store.Preferences()
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray)
_, _, _, prevMinToTray, _, _, _ := st.store.Preferences()
_, _, _, _, _, winW, winH := st.store.Preferences()
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray, r.autoStart, winW, winH)
win32.SetAutoStart(r.autoStart)
i18n.SetLanguage(r.language)
st.themeMode = r.theme
st.theme = nil // force rebuild on next frame
@@ -606,6 +638,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
@@ -699,6 +750,14 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
}
st.mu.Unlock()
st.iconPickerDlg = newIconPickerDialog(current)
case rowActionBnetInfo:
st.mu.Lock()
if idx >= 0 && idx < len(st.entries) {
if bn, ok := st.entries[idx].Auth.(*authenticator.BattleNetAuthenticator); ok {
st.bnetInfoDlg = newBnetInfoDialog(bn)
}
}
st.mu.Unlock()
}
w.Invalidate()
} else {
+77
View File
@@ -0,0 +1,77 @@
package ui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// bnetInfoDialog shows the Battle.Net serial number and restore code.
// The restore code can be copied to the clipboard.
type bnetInfoDialog struct {
serial string
restoreCode string
copyBtn widget.Clickable
closeBtn widget.Clickable
copied bool
}
func newBnetInfoDialog(bn *authenticator.BattleNetAuthenticator) *bnetInfoDialog {
return &bnetInfoDialog{
serial: bn.Serial,
restoreCode: bn.RestoreCode(),
}
}
func (d *bnetInfoDialog) Layout(
gtx layout.Context, th *material.Theme,
onClose func(),
) layout.Dimensions {
if d.closeBtn.Clicked(gtx) {
onClose()
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.copyBtn.Clicked(gtx) {
if err := win32.SetClipboardText(d.restoreCode); err == nil {
d.copied = true
}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("bnet_info_intro")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("label_serial")+": ").Layout),
layout.Rigid(material.Body1(th, d.serial).Layout),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("label_restore_code")+": ").Layout),
layout.Rigid(material.Body1(th, d.restoreCode).Layout),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
copyLabel := i18n.T("btn_copy")
if d.copied {
copyLabel = i18n.T("msg_copied")
}
return material.Button(th, &d.copyBtn, copyLabel).Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_bnet_restore_code_secret")).Layout),
)
}
return modalCardCancel(gtx, th, i18n.T("dialog_bnet_info_title"),
i18n.T("btn_close"), &d.closeBtn, body)
}
+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 = ""
}
+13 -2
View File
@@ -13,7 +13,7 @@ import (
)
// preferencesDialog edits the persistent UI prefs: UI language, theme,
// auto-lock timeout, and minimize-to-tray behaviour.
// auto-lock timeout, minimize-to-tray, and auto-start behaviour.
type preferencesDialog struct {
lang string
theme themeMode
@@ -38,6 +38,10 @@ type preferencesDialog struct {
// system tray instead of exiting. Tray icon stays visible either way.
minToTray widget.Bool
// Auto-start: when on, the app is registered in the Windows Run key
// to launch at logon.
autoStart widget.Bool
okBt widget.Clickable
cancelBt widget.Clickable
}
@@ -50,6 +54,7 @@ type preferencesResult struct {
theme themeMode
autoLockMinutes int
minimizeToTray bool
autoStart bool
}
// supportedLanguages is the closed set the prefs dialog exposes. Keep
@@ -63,7 +68,7 @@ var supportedLanguages = []struct {
{"de", "Deutsch"},
}
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int, currentMinToTray bool) *preferencesDialog {
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int, currentMinToTray, currentAutoStart bool) *preferencesDialog {
if currentLang == "" {
currentLang = "en"
}
@@ -74,6 +79,7 @@ func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAut
d.autoLockEd.SingleLine = true
d.autoLockEd.SetText(strconv.Itoa(currentAutoLock))
d.minToTray.Value = currentMinToTray
d.autoStart.Value = currentAutoStart
return d
}
@@ -98,6 +104,7 @@ func (d *preferencesDialog) Layout(
theme: d.theme,
autoLockMinutes: mins,
minimizeToTray: d.minToTray.Value,
autoStart: d.autoStart.Value,
})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
@@ -164,6 +171,10 @@ func (d *preferencesDialog) Layout(
layout.Rigid(material.CheckBox(th, &d.minToTray, i18n.T("label_minimize_to_tray")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_minimize_to_tray")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(material.CheckBox(th, &d.autoStart, i18n.T("label_auto_start")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_auto_start")).Layout),
)
}
+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("")
}
+6 -1
View File
@@ -19,6 +19,7 @@ const (
rowActionMoveUp
rowActionMoveDown
rowActionChangeIcon
rowActionBnetInfo
)
// rowActionMenu is the small popup opened by the ⋯ button on each entry
@@ -29,20 +30,23 @@ type rowActionMenu struct {
idx int
canMoveUp bool
canMoveDown bool
isBnet bool
renameBtn widget.Clickable
deleteBtn widget.Clickable
moveUpBtn widget.Clickable
moveDownBtn widget.Clickable
changeIconBtn widget.Clickable
bnetInfoBtn widget.Clickable
cancelBtn widget.Clickable
}
func newRowActionMenu(idx, total int) *rowActionMenu {
func newRowActionMenu(idx, total int, vendor string) *rowActionMenu {
return &rowActionMenu{
idx: idx,
canMoveUp: idx > 0,
canMoveDown: idx < total-1,
isBnet: vendor == "battlenet",
}
}
@@ -107,6 +111,7 @@ func (m *rowActionMenu) Layout(gtx layout.Context, th *material.Theme) layout.Di
row(&m.renameBtn, i18n.T("action_rename"), true),
row(&m.deleteBtn, i18n.T("action_delete"), true),
row(&m.changeIconBtn, i18n.T("action_change_icon"), true),
row(&m.bnetInfoBtn, i18n.T("action_bnet_info"), m.isBnet),
row(&m.moveUpBtn, i18n.T("action_move_up"), m.canMoveUp),
row(&m.moveDownBtn, i18n.T("action_move_down"), m.canMoveDown),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
+10
View File
@@ -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),
+27 -3
View File
@@ -33,6 +33,9 @@ type store struct {
theme string
autoLockMinutes int
minimizeToTray bool
autoStart bool
windowWidth int
windowHeight int
// dirty signals a save is pending. The worker reads & resets it.
dirty bool
@@ -47,21 +50,24 @@ type store struct {
// Preferences returns the persisted top-level prefs. UI code reads these
// after Load to seed dialog defaults.
func (s *store) Preferences() (language, theme string, autoLockMinutes int, minimizeToTray bool) {
func (s *store) Preferences() (language, theme string, autoLockMinutes int, minimizeToTray, autoStart bool, windowWidth, windowHeight int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray
return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray, s.autoStart, s.windowWidth, s.windowHeight
}
// SetPreferences updates the persisted top-level prefs and schedules a
// save. Callers pass the current value for each field — there is no
// per-field "leave unchanged" sentinel.
func (s *store) SetPreferences(language, theme string, autoLockMinutes int, minimizeToTray bool) {
func (s *store) SetPreferences(language, theme string, autoLockMinutes int, minimizeToTray, autoStart bool, windowWidth, windowHeight int) {
s.mu.Lock()
s.language = language
s.theme = theme
s.autoLockMinutes = autoLockMinutes
s.minimizeToTray = minimizeToTray
s.autoStart = autoStart
s.windowWidth = windowWidth
s.windowHeight = windowHeight
s.mu.Unlock()
s.Push()
}
@@ -119,6 +125,9 @@ func (s *store) Load(passphrase []byte) (*config.Config, error) {
s.theme = cfg.Theme
s.autoLockMinutes = cfg.AutoLockMinutes
s.minimizeToTray = cfg.MinimizeToTray
s.autoStart = cfg.AutoStart
s.windowWidth = cfg.WindowWidth
s.windowHeight = cfg.WindowHeight
s.mu.Unlock()
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
return cfg, nil
@@ -160,6 +169,15 @@ func (s *store) Encrypted() bool {
return s.encrypted
}
// SetWindowSize updates the stored window dimensions and schedules a save.
func (s *store) SetWindowSize(w, h int) {
s.mu.Lock()
s.windowWidth = w
s.windowHeight = h
s.mu.Unlock()
s.Push()
}
// Push schedules a save. Calls within ~300ms of each other coalesce into
// a single write.
func (s *store) Push() {
@@ -196,6 +214,9 @@ func (s *store) run() {
theme := s.theme
autoLock := s.autoLockMinutes
minToTray := s.minimizeToTray
autoStart := s.autoStart
winW := s.windowWidth
winH := s.windowHeight
s.mu.Unlock()
entries := s.snapshotFn()
@@ -205,6 +226,9 @@ func (s *store) run() {
Theme: theme,
AutoLockMinutes: autoLock,
MinimizeToTray: minToTray,
AutoStart: autoStart,
WindowWidth: winW,
WindowHeight: winH,
Encrypted: enc,
Entries: entries,
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package win32
// SetAutoStart is a no-op on non-Windows platforms.
func SetAutoStart(enable bool) error { return nil }
// IsAutoStartEnabled always returns false on non-Windows platforms.
func IsAutoStartEnabled() bool { return false }
+137
View File
@@ -0,0 +1,137 @@
//go:build windows
package win32
import (
"fmt"
"os"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
runKeyName = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
appValue = "WinAuth"
)
var (
advapi32 = windows.NewLazySystemDLL("advapi32.dll")
procRegOpenKeyExW = advapi32.NewProc("RegOpenKeyExW")
procRegSetValueExW = advapi32.NewProc("RegSetValueExW")
procRegDeleteValueW = advapi32.NewProc("RegDeleteValueW")
procRegCloseKey = advapi32.NewProc("RegCloseKey")
)
const (
hkeyCurrentUser uintptr = 0x80000001
regSz uint32 = 1
keySetValue uint32 = 0x0002
)
// SetAutoStart adds or removes the application from the Windows current-user
// Run key so it launches at logon. enable=true writes the key; enable=false
// deletes it.
func SetAutoStart(enable bool) error {
var hKey uintptr
runKey, _ := syscall.UTF16PtrFromString(runKeyName)
r, _, e := procRegOpenKeyExW.Call(
hkeyCurrentUser,
uintptr(unsafe.Pointer(runKey)),
0,
uintptr(keySetValue),
uintptr(unsafe.Pointer(&hKey)),
)
if r != 0 {
return fmt.Errorf("win32: RegOpenKeyExW: %w", e)
}
defer procRegCloseKey.Call(hKey)
name, _ := syscall.UTF16PtrFromString(appValue)
if !enable {
r, _, e = procRegDeleteValueW.Call(hKey, uintptr(unsafe.Pointer(name)))
if r != 0 {
// ERROR_FILE_NOT_FOUND (2) is OK — key didn't exist.
if errno, ok := e.(syscall.Errno); ok && errno == 2 {
return nil
}
return fmt.Errorf("win32: RegDeleteValueW: %w", e)
}
return nil
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("win32: get executable path: %w", err)
}
value, _ := syscall.UTF16PtrFromString(`"` + exe + `"`)
// Count UTF16 chars including NUL terminator.
n := 0
for p := unsafe.Pointer(value); *(*uint16)(p) != 0; p = unsafe.Add(p, 2) {
n++
}
size := uint32((n + 1) * 2)
r, _, e = procRegSetValueExW.Call(
hKey,
uintptr(unsafe.Pointer(name)),
0,
uintptr(regSz),
uintptr(unsafe.Pointer(value)),
uintptr(size),
)
if r != 0 {
return fmt.Errorf("win32: RegSetValueExW: %w", e)
}
return nil
}
// IsAutoStartEnabled checks whether the application is registered in the
// current-user Run key.
func IsAutoStartEnabled() bool {
var hKey uintptr
runKey, _ := syscall.UTF16PtrFromString(runKeyName)
r, _, _ := procRegOpenKeyExW.Call(
hkeyCurrentUser,
uintptr(unsafe.Pointer(runKey)),
0,
uintptr(0x20019), // KEY_READ
uintptr(unsafe.Pointer(&hKey)),
)
if r != 0 {
return false
}
defer procRegCloseKey.Call(hKey)
name, _ := syscall.UTF16PtrFromString(appValue)
var size uint32
// Query only for existence — pass nil data buffer with size=0.
r, _, _ = procRegSetValueExW.Call(
hKey,
uintptr(unsafe.Pointer(name)),
0,
0,
0,
0,
)
// If the value exists, RegQueryValueEx returns ERROR_SUCCESS (0) or
// ERROR_MORE_DATA (234). If it doesn't exist, returns ERROR_FILE_NOT_FOUND (2).
// Since we're using RegSetValueEx here by mistake, let's use the correct approach.
_ = size
return queryValueExists(hKey, name)
}
func queryValueExists(hKey uintptr, name *uint16) bool {
// Use RegQueryValueExW to check existence.
procRegQueryValueExW := advapi32.NewProc("RegQueryValueExW")
r, _, _ := procRegQueryValueExW.Call(
hKey,
uintptr(unsafe.Pointer(name)),
0, 0, 0, 0,
)
return r == 0 || r == 234 // ERROR_SUCCESS or ERROR_MORE_DATA
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package win32
// GetWindowSize returns (0, 0, false) on non-Windows platforms.
func GetWindowSize(hwnd uintptr) (width, height int, ok bool) {
return 0, 0, false
}
+25
View File
@@ -0,0 +1,25 @@
//go:build windows
package win32
import (
"unsafe"
)
var (
procGetWindowRect = user32.NewProc("GetWindowRect")
)
type rect struct {
Left, Top, Right, Bottom int32
}
// GetWindowSize returns the width and height of the given window in pixels.
func GetWindowSize(hwnd uintptr) (width, height int, ok bool) {
var r rect
ret, _, _ := procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r)))
if ret == 0 {
return 0, 0, false
}
return int(r.Right - r.Left), int(r.Bottom - r.Top), true
}