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"` AutoLockMinutes int `yaml:"auto_lock_minutes,omitempty" json:"auto_lock_minutes,omitempty"`
// MinimizeToTray sends the window to the system tray instead of // MinimizeToTray sends the window to the system tray instead of
// exiting when the close button is pressed. // exiting when the close button is pressed.
MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"` MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"`
Encrypted bool `yaml:"encrypted" json:"encrypted"` // AutoStart registers the application to launch at Windows logon
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"` // via the HKCU Run key.
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"` 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] [btn_welcome_skip]
other = "Vorerst überspringen" other = "Vorerst überspringen"
# --- Export / Backup ---
[menu_export]
other = "Exportieren..."
[menu_restore_backup]
other = "Backup wiederherstellen..."
[dialog_export_title]
other = "Authentifikatoren exportieren"
[export_tab_otpauth]
other = "otpauth://-URIs"
[export_tab_backup]
other = "Verschlüsseltes Backup"
[export_otpauth_intro]
other = "Unten stehen die Standard-otpauth://-URIs für jeden Eintrag. Sie können in andere Authenticator-Apps (Google Authenticator, Authy usw.) importiert werden."
[export_partial_warning]
other = "Teilweiser Export — herstellerspezifische Felder fehlen"
[export_backup_intro]
other = "Schreibe eine verschlüsselte Backupdatei, die auf jedem Rechner wiederhergestellt werden kann. Wähle ein starkes Passwort — es ist NICHT mit dem aktuellen WinAuth-Passwort verknüpft."
[label_backup_path]
other = "Pfad zur Backupdatei"
[btn_export]
other = "Exportieren"
[msg_empty_backup_path]
other = "Bitte den Pfad zur Backupdatei eingeben."
[msg_export_done]
other = "Backup erfolgreich exportiert."
[msg_export_failed]
other = "Export fehlgeschlagen: %s"
[dialog_restore_backup_title]
other = "Backup wiederherstellen"
[restore_backup_intro]
other = "Wähle eine verschlüsselte Backupdatei (.winauth.bak) und gib das beim Erstellen gesetzte Passwort ein. Einträge werden an die aktuelle Liste angehängt."
[msg_restore_done]
other = "Backup wiederhergestellt."
[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] [dialog_icon_picker_title]
other = "Choose icon" other = "Choose icon"
# --- First-run welcome dialog ---
[dialog_welcome_title]
other = "Welcome to WinAuth"
[msg_welcome_intro]
other = "Choose whether to encrypt the list of authenticators stored on disk. Encryption protects the file with a password of your choice. You can change this later in Settings."
[btn_welcome_enable_password]
other = "Enable password protection"
[btn_welcome_skip]
other = "Skip for now"
# --- Export / backup ---
[menu_export]
other = "Export..."
[menu_restore_backup]
other = "Restore backup..."
[dialog_export_title]
other = "Export authenticators"
[export_tab_otpauth]
other = "otpauth:// URIs"
[export_tab_backup]
other = "Encrypted backup"
[export_otpauth_intro]
other = "Below are standard otpauth:// URIs for each entry. You can import them into other authenticator apps (Google Authenticator, Authy, etc.)."
[export_partial_warning]
other = "partial export — vendor-specific fields omitted"
[export_backup_intro]
other = "Write an encrypted backup file that can be restored on any machine. Choose a strong password — it is NOT linked to your current WinAuth password."
[label_backup_path]
other = "Backup file path"
[btn_export]
other = "Export"
[msg_empty_backup_path]
other = "Please enter a backup file path."
[msg_export_done]
other = "Backup exported successfully."
[msg_export_failed]
other = "Export failed: %s"
[dialog_restore_backup_title]
other = "Restore from backup"
[restore_backup_intro]
other = "Select an encrypted backup file (.winauth.bak) and enter the password that was set when it was created. Entries will be appended to your current list."
[msg_restore_done]
other = "Backup restored."
[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] [dialog_icon_picker_title]
other = "选择图标" other = "选择图标"
# --- 首次启动欢迎 ---
[dialog_welcome_title]
other = "欢迎使用 WinAuth"
[msg_welcome_intro]
other = "选择是否对磁盘上存储的验证器列表进行加密。加密将使用你选择的密码保护文件。你可以在设置中随时更改。"
[btn_welcome_enable_password]
other = "启用密码保护"
[btn_welcome_skip]
other = "暂时跳过"
# --- 导出 / 备份 ---
[menu_export]
other = "导出..."
[menu_restore_backup]
other = "恢复备份..."
[dialog_export_title]
other = "导出验证器"
[export_tab_otpauth]
other = "otpauth:// URI"
[export_tab_backup]
other = "加密备份"
[export_otpauth_intro]
other = "以下是每个条目的标准 otpauth:// URI,可导入到其他验证器应用(Google Authenticator、Authy 等)。"
[export_partial_warning]
other = "部分导出 — 厂商专用字段已省略"
[export_backup_intro]
other = "将加密备份文件写入磁盘,可在任何机器上恢复。请选择强密码——此密码与当前 WinAuth 密码无关。"
[label_backup_path]
other = "备份文件路径"
[btn_export]
other = "导出"
[msg_empty_backup_path]
other = "请输入备份文件路径。"
[msg_export_done]
other = "备份导出成功。"
[msg_export_failed]
other = "导出失败:%s"
[dialog_restore_backup_title]
other = "从备份恢复"
[restore_backup_intro]
other = "选择加密备份文件(.winauth.bak)并输入创建时设置的密码。条目将追加到当前列表。"
[msg_restore_done]
other = "备份已恢复。"
[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() { go func() {
w := new(app.Window) 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( w.Option(
app.Title(i18n.T("app_title")), 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 { if err := loop(w, configPath); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("ui loop failed") global.Log.WithField("func", fn).WithError(err).Error("ui loop failed")
@@ -100,6 +111,8 @@ type appState struct {
changePwDlg *changePasswordDialog changePwDlg *changePasswordDialog
welcomeDlg *welcomeDialog welcomeDlg *welcomeDialog
importDialog *importLegacyDialog importDialog *importLegacyDialog
exportDlg *exportDialog
restoreDlg *restoreBackupDialog
hotkeyDialog *hotkeyDialog hotkeyDialog *hotkeyDialog
hotkeyTarget *entry hotkeyTarget *entry
tradesDialog *steamTradesDialog tradesDialog *steamTradesDialog
@@ -114,6 +127,7 @@ type appState struct {
renameDlg *renameDialog renameDlg *renameDialog
confirmDelDlg *confirmDeleteDialog confirmDelDlg *confirmDeleteDialog
iconPickerDlg *iconPickerDialog iconPickerDlg *iconPickerDialog
bnetInfoDlg *bnetInfoDialog
rowTargetIdx int rowTargetIdx int
store *store store *store
@@ -193,7 +207,7 @@ func loop(w *app.Window, configPath string) error {
// Seed the theme cache from the stored preference. Empty / unknown // Seed the theme cache from the stored preference. Empty / unknown
// values resolve to "system" which buildTheme then maps to light or // values resolve to "system" which buildTheme then maps to light or
// dark via the OS-specific systemPrefersDark probe. // dark via the OS-specific systemPrefersDark probe.
_, themePref, _, _ := state.store.Preferences() _, themePref, _, _, _, _, _ := state.store.Preferences()
state.themeMode = normalizeThemeMode(themePref) state.themeMode = normalizeThemeMode(themePref)
// Spin up the global hotkey manager and register whatever the user // 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 // Install the system tray icon + close-hook (Windows only). HWND
// discovery races the very first FrameEvent, so kick it from a // discovery races the very first FrameEvent, so kick it from a
// goroutine that polls FindWindow for a few hundred ms. // goroutine that polls FindWindow for a few hundred ms.
_, _, _, minToTray := state.store.Preferences() _, _, _, minToTray, _, _, _ := state.store.Preferences()
go func() { go func() {
tr := installTrayRuntime(i18n.T("app_title"), minToTray, w.Invalidate) tr := installTrayRuntime(i18n.T("app_title"), minToTray, w.Invalidate)
state.mu.Lock() state.mu.Lock()
@@ -227,6 +241,12 @@ func loop(w *app.Window, configPath string) error {
for { for {
switch e := w.Event().(type) { switch e := w.Event().(type) {
case app.DestroyEvent: 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") global.Log.WithField("func", fn).Info("window closed")
state.tray.Stop() state.tray.Stop()
return e.Err return e.Err
@@ -265,7 +285,7 @@ func (st *appState) maybeAutoLock() bool {
if !st.store.Encrypted() { if !st.store.Encrypted() {
return false return false
} }
_, _, autoLockMinutes, _ := st.store.Preferences() _, _, autoLockMinutes, _, _, _, _ := st.store.Preferences()
if autoLockMinutes <= 0 { if autoLockMinutes <= 0 {
return false return false
} }
@@ -478,7 +498,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
total := len(st.entries) total := len(st.entries)
st.mu.Unlock() st.mu.Unlock()
st.rowTargetIdx = moreTargetIdx 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 // 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() st.changePwDlg = newChangePasswordDialog()
case settingsActionImportLegacy: case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog() st.importDialog = newImportLegacyDialog()
case settingsActionExport:
st.exportDlg = newExportDialog(st.snapshotEntries())
case settingsActionRestoreBackup:
st.restoreDlg = newRestoreBackupDialog()
case settingsActionPreferences: case settingsActionPreferences:
lang, theme, autoLock, minToTray := st.store.Preferences() lang, theme, autoLock, minToTray, autoStart, _, _ := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray) st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray, autoStart)
case settingsActionAbout: case settingsActionAbout:
st.aboutDialog = newAboutDialog() st.aboutDialog = newAboutDialog()
} }
@@ -571,8 +601,10 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if st.prefsDialog != nil { if st.prefsDialog != nil {
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) { return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
if !r.cancel { if !r.cancel {
_, _, _, prevMinToTray := st.store.Preferences() _, _, _, prevMinToTray, _, _, _ := st.store.Preferences()
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray) _, _, _, _, _, 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) i18n.SetLanguage(r.language)
st.themeMode = r.theme st.themeMode = r.theme
st.theme = nil // force rebuild on next frame 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 st.vendorMenu != nil {
if v, closed := st.vendorMenu.Pick(gtx); closed { if v, closed := st.vendorMenu.Pick(gtx); closed {
st.vendorMenu = nil st.vendorMenu = nil
@@ -699,6 +750,14 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
} }
st.mu.Unlock() st.mu.Unlock()
st.iconPickerDlg = newIconPickerDialog(current) 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() w.Invalidate()
} else { } 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, // 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 { type preferencesDialog struct {
lang string lang string
theme themeMode theme themeMode
@@ -38,6 +38,10 @@ type preferencesDialog struct {
// system tray instead of exiting. Tray icon stays visible either way. // system tray instead of exiting. Tray icon stays visible either way.
minToTray widget.Bool 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 okBt widget.Clickable
cancelBt widget.Clickable cancelBt widget.Clickable
} }
@@ -50,6 +54,7 @@ type preferencesResult struct {
theme themeMode theme themeMode
autoLockMinutes int autoLockMinutes int
minimizeToTray bool minimizeToTray bool
autoStart bool
} }
// supportedLanguages is the closed set the prefs dialog exposes. Keep // supportedLanguages is the closed set the prefs dialog exposes. Keep
@@ -63,7 +68,7 @@ var supportedLanguages = []struct {
{"de", "Deutsch"}, {"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 == "" { if currentLang == "" {
currentLang = "en" currentLang = "en"
} }
@@ -74,6 +79,7 @@ func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAut
d.autoLockEd.SingleLine = true d.autoLockEd.SingleLine = true
d.autoLockEd.SetText(strconv.Itoa(currentAutoLock)) d.autoLockEd.SetText(strconv.Itoa(currentAutoLock))
d.minToTray.Value = currentMinToTray d.minToTray.Value = currentMinToTray
d.autoStart.Value = currentAutoStart
return d return d
} }
@@ -98,6 +104,7 @@ func (d *preferencesDialog) Layout(
theme: d.theme, theme: d.theme,
autoLockMinutes: mins, autoLockMinutes: mins,
minimizeToTray: d.minToTray.Value, minimizeToTray: d.minToTray.Value,
autoStart: d.autoStart.Value,
}) })
return layout.Dimensions{Size: gtx.Constraints.Max} 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(material.CheckBox(th, &d.minToTray, i18n.T("label_minimize_to_tray")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_minimize_to_tray")).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 rowActionMoveUp
rowActionMoveDown rowActionMoveDown
rowActionChangeIcon rowActionChangeIcon
rowActionBnetInfo
) )
// rowActionMenu is the small popup opened by the ⋯ button on each entry // rowActionMenu is the small popup opened by the ⋯ button on each entry
@@ -29,20 +30,23 @@ type rowActionMenu struct {
idx int idx int
canMoveUp bool canMoveUp bool
canMoveDown bool canMoveDown bool
isBnet bool
renameBtn widget.Clickable renameBtn widget.Clickable
deleteBtn widget.Clickable deleteBtn widget.Clickable
moveUpBtn widget.Clickable moveUpBtn widget.Clickable
moveDownBtn widget.Clickable moveDownBtn widget.Clickable
changeIconBtn widget.Clickable changeIconBtn widget.Clickable
bnetInfoBtn widget.Clickable
cancelBtn widget.Clickable cancelBtn widget.Clickable
} }
func newRowActionMenu(idx, total int) *rowActionMenu { func newRowActionMenu(idx, total int, vendor string) *rowActionMenu {
return &rowActionMenu{ return &rowActionMenu{
idx: idx, idx: idx,
canMoveUp: idx > 0, canMoveUp: idx > 0,
canMoveDown: idx < total-1, 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.renameBtn, i18n.T("action_rename"), true),
row(&m.deleteBtn, i18n.T("action_delete"), true), row(&m.deleteBtn, i18n.T("action_delete"), true),
row(&m.changeIconBtn, i18n.T("action_change_icon"), 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.moveUpBtn, i18n.T("action_move_up"), m.canMoveUp),
row(&m.moveDownBtn, i18n.T("action_move_down"), m.canMoveDown), row(&m.moveDownBtn, i18n.T("action_move_down"), m.canMoveDown),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
+10
View File
@@ -17,6 +17,8 @@ const (
settingsActionSetPassword settingsActionSetPassword
settingsActionChangePassword settingsActionChangePassword
settingsActionImportLegacy settingsActionImportLegacy
settingsActionExport
settingsActionRestoreBackup
settingsActionPreferences settingsActionPreferences
settingsActionAbout settingsActionAbout
) )
@@ -27,6 +29,8 @@ type settingsMenu struct {
setPwBtn widget.Clickable setPwBtn widget.Clickable
changePwBtn widget.Clickable changePwBtn widget.Clickable
importBtn widget.Clickable importBtn widget.Clickable
exportBtn widget.Clickable
restoreBtn widget.Clickable
prefsBtn widget.Clickable prefsBtn widget.Clickable
aboutBtn widget.Clickable aboutBtn widget.Clickable
cancelBtn widget.Clickable cancelBtn widget.Clickable
@@ -43,6 +47,10 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
return settingsActionChangePassword, true return settingsActionChangePassword, true
case m.importBtn.Clicked(gtx): case m.importBtn.Clicked(gtx):
return settingsActionImportLegacy, true return settingsActionImportLegacy, true
case m.exportBtn.Clicked(gtx):
return settingsActionExport, true
case m.restoreBtn.Clicked(gtx):
return settingsActionRestoreBackup, true
case m.prefsBtn.Clicked(gtx): case m.prefsBtn.Clicked(gtx):
return settingsActionPreferences, true return settingsActionPreferences, true
case m.aboutBtn.Clicked(gtx): case m.aboutBtn.Clicked(gtx):
@@ -81,6 +89,8 @@ func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dim
row(&m.setPwBtn, i18n.T("menu_set_password")), row(&m.setPwBtn, i18n.T("menu_set_password")),
row(&m.changePwBtn, i18n.T("menu_change_password")), row(&m.changePwBtn, i18n.T("menu_change_password")),
row(&m.importBtn, i18n.T("menu_import_legacy")), row(&m.importBtn, i18n.T("menu_import_legacy")),
row(&m.exportBtn, i18n.T("menu_export")),
row(&m.restoreBtn, i18n.T("menu_restore_backup")),
row(&m.prefsBtn, i18n.T("menu_preferences")), row(&m.prefsBtn, i18n.T("menu_preferences")),
row(&m.aboutBtn, i18n.T("menu_about")), row(&m.aboutBtn, i18n.T("menu_about")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
+27 -3
View File
@@ -33,6 +33,9 @@ type store struct {
theme string theme string
autoLockMinutes int autoLockMinutes int
minimizeToTray bool minimizeToTray bool
autoStart bool
windowWidth int
windowHeight int
// dirty signals a save is pending. The worker reads & resets it. // dirty signals a save is pending. The worker reads & resets it.
dirty bool dirty bool
@@ -47,21 +50,24 @@ type store struct {
// Preferences returns the persisted top-level prefs. UI code reads these // Preferences returns the persisted top-level prefs. UI code reads these
// after Load to seed dialog defaults. // 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() s.mu.Lock()
defer s.mu.Unlock() 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 // SetPreferences updates the persisted top-level prefs and schedules a
// save. Callers pass the current value for each field — there is no // save. Callers pass the current value for each field — there is no
// per-field "leave unchanged" sentinel. // 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.mu.Lock()
s.language = language s.language = language
s.theme = theme s.theme = theme
s.autoLockMinutes = autoLockMinutes s.autoLockMinutes = autoLockMinutes
s.minimizeToTray = minimizeToTray s.minimizeToTray = minimizeToTray
s.autoStart = autoStart
s.windowWidth = windowWidth
s.windowHeight = windowHeight
s.mu.Unlock() s.mu.Unlock()
s.Push() s.Push()
} }
@@ -119,6 +125,9 @@ func (s *store) Load(passphrase []byte) (*config.Config, error) {
s.theme = cfg.Theme s.theme = cfg.Theme
s.autoLockMinutes = cfg.AutoLockMinutes s.autoLockMinutes = cfg.AutoLockMinutes
s.minimizeToTray = cfg.MinimizeToTray s.minimizeToTray = cfg.MinimizeToTray
s.autoStart = cfg.AutoStart
s.windowWidth = cfg.WindowWidth
s.windowHeight = cfg.WindowHeight
s.mu.Unlock() s.mu.Unlock()
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store") logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
return cfg, nil return cfg, nil
@@ -160,6 +169,15 @@ func (s *store) Encrypted() bool {
return s.encrypted 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 // Push schedules a save. Calls within ~300ms of each other coalesce into
// a single write. // a single write.
func (s *store) Push() { func (s *store) Push() {
@@ -196,6 +214,9 @@ func (s *store) run() {
theme := s.theme theme := s.theme
autoLock := s.autoLockMinutes autoLock := s.autoLockMinutes
minToTray := s.minimizeToTray minToTray := s.minimizeToTray
autoStart := s.autoStart
winW := s.windowWidth
winH := s.windowHeight
s.mu.Unlock() s.mu.Unlock()
entries := s.snapshotFn() entries := s.snapshotFn()
@@ -205,6 +226,9 @@ func (s *store) run() {
Theme: theme, Theme: theme,
AutoLockMinutes: autoLock, AutoLockMinutes: autoLock,
MinimizeToTray: minToTray, MinimizeToTray: minToTray,
AutoStart: autoStart,
WindowWidth: winW,
WindowHeight: winH,
Encrypted: enc, Encrypted: enc,
Entries: entries, 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
}