Compare commits
4 Commits
133091a32e
...
089efa088d
| Author | SHA1 | Date | |
|---|---|---|---|
|
089efa088d
|
|||
|
1605b5b7ae
|
|||
|
cb4d88ebdd
|
|||
|
fd4d756823
|
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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 = "切勿泄露恢复码。它是恢复此验证器的唯一途径。"
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user