Compare commits

..

12 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
iceking2nd 133091a32e feat(ui): 首次启动加密引导 + 真窗口图标 (.syso)
首次启动:
- 配置文件不存在时弹欢迎对话框:选「启用密码保护」或「稍后再说」
- 选启用直接进 setPasswordDialog,OK 时 store.SetPassword 触发首次保存
- io/fs.ErrNotExist 检测新分支
- modalCard 抽出 modalShell 内核以便 welcomeDialog 自定义 footer

真窗口图标:
- tools/gen_ico 从 PNG 生成多分辨率 .ico(7 尺寸:16~256)
- 提交 rsrc_windows.syso(rsrc 通过 goproxy.cn 生成),go build 自动链入
- tray_windows.go 改用嵌入资源 ID=1,失败 fallback IDI_APPLICATION
- README 更新完整的图标生成流程
2026-06-12 10:07:13 +08:00
iceking2nd 727be3557a feat(cli): list/get 子命令 — 脚本集成 TOTP 获取
- winauth list: tabwriter 列出所有条目(名称/vendor/icon/hotkey)
- winauth get NAME: 输出当前 OTP,-n 不换行,--advance 允许 HOTP
- 加密配置自动 tty 隐藏密码提示(golang.org/x/term)
- --config 改为 PersistentFlag,子命令可继承
- HOTP get 默认拒绝,需 --advance 显式确认并保存配置
- CLI 不运行 GUI、不持有单实例锁
2026-06-12 09:50:07 +08:00
iceking2nd 969600b6ee feat(ui): 接入 vendor 真实图标库 + 图标选择器
- assets/icons/ 共 80+ 张真实品牌图标嵌入 UI 包
- entry 新增 IconKey 字段,持久化到 config.Entry.IconName
- iconPickerDialog 网格布局图标选择器
- Add 完成后自动弹出图标选择器,Skip 则用 vendor 占位图
- ⋯ 菜单新增 "Change icon..." 项
- vendorIconFor 解析顺序:catalog → vendor 占位 → qr 兜底
2026-06-12 09:41:30 +08:00
iceking2nd 352b1d24b8 feat(ui): 条目管理增强 — 重命名/删除/重排序
每行右侧增加 ⋯ 按钮,弹出菜单支持重命名、删除、上移、下移。
- renameEntry / deleteEntry / moveEntry 三个 appState 方法
- rowActionMenu 弹出菜单 + renameDialog + confirmDeleteDialog
- 删除自动注销快捷键,所有操作触发配置保存
- 保留顶栏 Add/Remove 全局按钮不变
2026-06-12 09:19:07 +08:00
iceking2nd c0dd3b9028 feat(ui): 系统托盘 + 关闭最小化到托盘
- 原生 win32 实现的 TrayManager:Shell_NotifyIcon + 私有 message-only
  窗口承载 popup 菜单,专用 OS 线程跑 GetMessage 泵,避免引入 cgo 依赖。
- WindowSubclass 通过 SetWindowLongPtrW 子类化 Gio 主窗口,拦截
  WM_CLOSE:minimize_to_tray=true 时吞掉消息只隐藏窗口;exit 路径下
  放行让 Gio 正常关闭事件循环。
- Preferences 新增"最小化到托盘"复选框,切换后立即重装/卸载钩子,
  不需要重启程序。
- 托盘菜单提供 Show / Hide / Exit;左键单击图标等同 Show;右键弹菜单。
- 非 Windows 平台所有相关 API 退化为 ErrUnsupported / no-op stub。
2026-06-12 04:13:35 +08:00
iceking2nd 0918fd446a feat(ui): 安全增强 — 修改密码 / 自动锁定 / HOTP 提示 / 配置备份
- 修改密码对话框引入旧密码校验:通过 store.VerifyPassword 走 constant-time
  比较,对话框自身不持有 live passphrase 副本。
- 配置加密且 auto_lock_minutes>0 时启用自动锁定;明文配置不受影响。
  锁定时刻意保留 store.passphrase,避免解锁瞬间出现保存竞态——进程内存
  无论如何都是威胁边界。
- HOTP 点击后 toast 仅显示推进后的计数器值,绝不携带验证码字符串。
- SaveYAML 每次写入前将旧文件轮转为 .bak(0o600),防止加密/序列化失败
  导致用户无可恢复副本。
- Preferences 对话框新增"自动锁定(分钟)"输入项。
2026-06-12 03:57:01 +08:00
iceking2nd fedc198452 feat(ui): vendor 图标系统 + 应用图标占位
- 新增 tools/gen_icons 生成 7 张 vendor 占位 PNG + 256x256 应用图标
- internal/ui/vendor_icons.go 用 //go:embed 加载并按 Authenticator.Name() 查表
- entry 行圆环中心叠 16dp vendor 图标,HOTP 直接用图标占位
- 图标为通用色块 + 首字母,无第三方商标;用户可按文件名替换为真实 logo
- assets/README 说明替换方式与 Windows .syso 嵌入步骤
2026-06-12 03:47:00 +08:00
iceking2nd b483360778 feat(ui): 主题切换 + 语言持久化 + About 对话框
- 新增 light/dark 主题调色板与 Windows 注册表系统主题探测
- 偏好设置对话框:主题三选一 + 语言下拉(en/zh-CN/de),即时生效
- 持久化 Language/Theme/AutoLockMinutes/MinimizeToTray 至 YAML 顶层
- 启动时按 config 中保存的语言初始化 i18n
- About 对话框展示 version/runtime/项目地址/许可证
- 全部硬编码颜色迁移到 themePalette,为深色模式做准备
2026-06-12 03:38:13 +08:00
151 changed files with 5134 additions and 81 deletions
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
func newGetCmd() *cobra.Command {
var noNewline, advance bool
cmd := &cobra.Command{
Use: "get NAME",
Short: "Print the current OTP for an entry",
Long: `Print the current one-time password for the entry named NAME.
The name must match exactly (case-sensitive).
For HOTP (counter-based) entries, this command refuses by default
because generating a code advances the counter. Use --advance to
confirm that you want to consume a counter value; the config file
will be updated automatically.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runGet(cmd, args[0], noNewline, advance)
},
}
cmd.Flags().BoolVarP(&noNewline, "no-newline", "n", false, "Do not append a newline after the code")
cmd.Flags().BoolVar(&advance, "advance", false, "Advance HOTP counter (required for counter-based entries)")
return cmd
}
func runGet(cmd *cobra.Command, name string, noNewline, advance bool) error {
path := resolveConfigPath(cmd)
lang := preferredLanguage(path)
_ = i18n.Init(lang)
cfg, passphrase, err := cliLoadConfig(path)
if err != nil {
if errors.Is(err, config.ErrPasswordWrong) {
return fmt.Errorf("wrong password")
}
return fmt.Errorf("load config: %w", err)
}
// Find entry by exact name.
var target *config.Entry
var matches []string
for i := range cfg.Entries {
if cfg.Entries[i].Name == name {
target = &cfg.Entries[i]
break
}
matches = append(matches, cfg.Entries[i].Name)
}
if target == nil {
return fmt.Errorf("entry %q not found", name)
}
a, err := buildAuth(*target)
if err != nil {
return fmt.Errorf("build authenticator: %w", err)
}
// HOTP safety gate.
if a.Name() == "hotp" && !advance {
return fmt.Errorf("entry %q is counter-based (HOTP); re-run with --advance to consume a counter value", name)
}
code, err := a.CurrentCode()
if err != nil {
return fmt.Errorf("compute OTP: %w", err)
}
if noNewline {
fmt.Print(code)
} else {
fmt.Println(code)
}
// Persist the updated counter for HOTP entries.
if a.Name() == "hotp" {
target.SecretRaw = a.SecretData()
if err := config.SaveYAML(cfg, path, passphrase); err != nil {
return fmt.Errorf("save config after HOTP advance: %w", err)
}
}
return nil
}
// buildAuth mirrors the switch in internal/ui/convert.go. Duplicated here
// so the CLI binary does not depend on the UI package.
func buildAuth(e config.Entry) (authenticator.Authenticator, error) {
var a authenticator.Authenticator
switch e.Vendor {
case "google", "":
a = authenticator.NewGoogleAuthenticator()
case "microsoft":
a = authenticator.NewMicrosoftAuthenticator()
case "okta":
a = authenticator.NewOktaVerifyAuthenticator()
case "hotp":
a = authenticator.NewHOTPAuthenticator()
case "battlenet":
a = authenticator.NewBattleNetAuthenticator()
case "steam":
a = authenticator.NewSteamAuthenticator()
default:
return nil, fmt.Errorf("unknown vendor %q", e.Vendor)
}
if err := a.SetSecretData(e.SecretRaw); err != nil {
return nil, fmt.Errorf("decode entry %q: %w", e.Name, err)
}
return a, nil
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
func newListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all authenticator entries",
Long: "Prints a table of all entries in the config file. For encrypted configs, a password prompt will appear.",
Args: cobra.NoArgs,
RunE: runList,
}
}
func runList(cmd *cobra.Command, args []string) error {
const fn = "cmd.winauth.runList"
path := resolveConfigPath(cmd)
lang := preferredLanguage(path)
_ = i18n.Init(lang) // best-effort; list output is English anyway
cfg, _, err := cliLoadConfig(path)
if err != nil {
if errors.Is(err, config.ErrPasswordWrong) {
return fmt.Errorf("wrong password")
}
return fmt.Errorf("load config: %w", err)
}
if len(cfg.Entries) == 0 {
fmt.Fprintln(os.Stderr, "No entries found.")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tVENDOR\tICON\tHOTKEY")
for _, e := range cfg.Entries {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Name, e.Vendor, e.IconName, e.Hotkey)
}
return tw.Flush()
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
// resolveConfigPath returns the config file path from the --config
// flag or the default location.
func resolveConfigPath(cmd *cobra.Command) string {
if p, _ := cmd.Root().PersistentFlags().GetString("config"); p != "" {
return p
}
return config.DefaultPath()
}
// cliLoadConfig loads the config at path, prompting for a password if
// the file is encrypted. Returns the config and the passphrase (empty
// for plaintext configs). Up to 3 password attempts are allowed.
func cliLoadConfig(path string) (*config.Config, []byte, error) {
cfg, err := config.LoadYAML(path, nil)
if err == nil {
return cfg, nil, nil
}
if !errors.Is(err, config.ErrPasswordRequired) {
return nil, nil, err
}
for attempt := 0; attempt < 3; attempt++ {
pw, err := readPassword("Password: ")
if err != nil {
return nil, nil, fmt.Errorf("read password: %w", err)
}
cfg, err = config.LoadYAML(path, pw)
if err == nil {
return cfg, pw, nil
}
if errors.Is(err, config.ErrPasswordWrong) {
fmt.Fprintln(os.Stderr, "Wrong password.")
continue
}
return nil, nil, err
}
return nil, nil, fmt.Errorf("too many failed password attempts")
}
+30 -3
View File
@@ -6,10 +6,12 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global" "git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n" "git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/logging" "git.wxccs.org/iceking2nd/winauth-go/internal/logging"
"git.wxccs.org/iceking2nd/winauth-go/internal/ui" "git.wxccs.org/iceking2nd/winauth-go/internal/ui"
"git.wxccs.org/iceking2nd/winauth-go/internal/version"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32" "git.wxccs.org/iceking2nd/winauth-go/internal/win32"
) )
@@ -53,7 +55,8 @@ func main() {
} }
const fn = "cmd.winauth.main" const fn = "cmd.winauth.main"
global.Log.WithField("func", fn).Info("winauth-go starting") global.Log.WithField("func", fn).
WithField("version", version.String()).Info("winauth-go starting")
release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex) release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex)
if err != nil { if err != nil {
@@ -70,7 +73,12 @@ func main() {
defer release() defer release()
} }
if err := i18n.Init(""); err != nil { // Peek the config (without password) to recover the user's
// last language choice before initialising i18n. Encrypted
// configs still expose Language at the YAML top level so we
// can localise the password prompt itself.
lang := preferredLanguage(configPath)
if err := i18n.Init(lang); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys") global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys")
} }
@@ -83,11 +91,30 @@ func main() {
"Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)") "Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)")
root.Flags().StringVar(&logFile, "log-file", "", root.Flags().StringVar(&logFile, "log-file", "",
"If set, also write logs to this file in addition to the console") "If set, also write logs to this file in addition to the console")
root.Flags().StringVar(&configPath, "config", "", root.PersistentFlags().StringVar(&configPath, "config", "",
"Path to the YAML config file (default: %APPDATA%\\winauth-go\\config.yaml or $XDG_CONFIG_HOME/winauth-go/config.yaml)") "Path to the YAML config file (default: %APPDATA%\\winauth-go\\config.yaml or $XDG_CONFIG_HOME/winauth-go/config.yaml)")
root.AddCommand(newListCmd())
root.AddCommand(newGetCmd())
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) os.Exit(1)
} }
} }
// preferredLanguage reads the user's saved language tag out of the YAML
// config without requiring a passphrase. We resolve the default config
// path when configPath is blank and silently return "" on any failure;
// callers fall back to the i18n default in that case. Encrypted configs
// still expose the top-level Language field so this works for them too.
func preferredLanguage(configPath string) string {
if configPath == "" {
configPath = config.DefaultPath()
}
cfg, _ := config.LoadYAML(configPath, nil)
if cfg == nil {
return ""
}
return cfg.Language
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"bufio"
"fmt"
"os"
"golang.org/x/term"
)
// readPassword prompts the user for a password on the given file
// descriptor. If the fd is a terminal the input is hidden; otherwise
// (piped stdin) it falls back to a plain read.
func readPassword(prompt string) ([]byte, error) {
fd := int(os.Stdin.Fd())
if term.IsTerminal(fd) {
fmt.Fprint(os.Stderr, prompt)
pw, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
return pw, err
}
// Non-tty (piped): read a line without echo hiding.
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return nil, err
}
return nil, fmt.Errorf("no input on stdin")
}
return []byte(scanner.Text()), nil
}
+5 -4
View File
@@ -1,14 +1,18 @@
module git.wxccs.org/iceking2nd/winauth-go module git.wxccs.org/iceking2nd/winauth-go
go 1.23 go 1.25.0
require ( require (
gioui.org v0.7.1 gioui.org v0.7.1
github.com/BurntSushi/toml v1.4.0 github.com/BurntSushi/toml v1.4.0
github.com/makiuchi-d/gozxing v0.1.1
github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/nicksnyder/go-i18n/v2 v2.4.0
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
golang.org/x/image v0.18.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.19.0 golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -18,11 +22,8 @@ require (
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
github.com/go-text/typesetting v0.1.1 // indirect github.com/go-text/typesetting v0.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/makiuchi-d/gozxing v0.1.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
) )
+4 -2
View File
@@ -44,8 +44,10 @@ golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRY
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+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")
}
}
+17 -2
View File
@@ -21,8 +21,23 @@ type Entry struct {
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext // default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
// produced by internal/crypto.EncryptModern and Entries is empty on disk. // produced by internal/crypto.EncryptModern and Entries is empty on disk.
type Config struct { type Config struct {
Version int `yaml:"version" json:"version"` Version int `yaml:"version" json:"version"`
Language string `yaml:"language,omitempty" json:"language,omitempty"` Language string `yaml:"language,omitempty" json:"language,omitempty"`
// Theme is one of "", "light", "dark", "system" (empty = system).
Theme string `yaml:"theme,omitempty" json:"theme,omitempty"`
// AutoLockMinutes locks the UI (hides codes, requires password) after
// this many minutes of inactivity. 0 disables auto-lock.
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"`
// 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"` Encrypted bool `yaml:"encrypted" json:"encrypted"`
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"` EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"` Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
+38
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -49,6 +50,14 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
if err := EnsureDir(path); err != nil { if err := EnsureDir(path); err != nil {
return err return err
} }
// Best-effort .bak rotation: if a previous file exists, copy it to
// path+".bak" before we overwrite, so a botched encrypt / serialize
// does not leave the user with no recoverable copy. Failure to back
// up is logged but does not block the save — the data is more
// important than the safety net.
if err := backupIfExists(path); err != nil {
logger.WithError(err).Warn("backup before save failed")
}
tmp := path + ".tmp" tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil { if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err return err
@@ -61,6 +70,35 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
return nil return nil
} }
// backupIfExists copies path to path+".bak" with 0o600 permissions when
// path exists, atomically overwriting any previous .bak. Returns nil if
// path does not exist (first save).
func backupIfExists(path string) error {
src, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
defer src.Close()
bakTmp := path + ".bak.tmp"
dst, err := os.OpenFile(bakTmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
_ = os.Remove(bakTmp)
return err
}
if err := dst.Close(); err != nil {
_ = os.Remove(bakTmp)
return err
}
return os.Rename(bakTmp, path+".bak")
}
// LoadYAML reads the YAML config at path. If the file is encrypted, // LoadYAML reads the YAML config at path. If the file is encrypted,
// passphrase is required and the EncryptedBlob is decrypted into Entries. // passphrase is required and the EncryptedBlob is decrypted into Entries.
func LoadYAML(path string, passphrase []byte) (*Config, error) { func LoadYAML(path string, passphrase []byte) (*Config, error) {
+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))
}
}
+226
View File
@@ -367,3 +367,229 @@ other = "QR-Scan fehlgeschlagen: %s"
[msg_clipboard_no_image] [msg_clipboard_no_image]
other = "Zwischenablage enthält kein Bild" other = "Zwischenablage enthält kein Bild"
# --- Einstellungen ---
[menu_preferences]
other = "Einstellungen..."
[dialog_preferences_title]
other = "Einstellungen"
[label_theme]
other = "Design"
[theme_system]
other = "System"
[theme_light]
other = "Hell"
[theme_dark]
other = "Dunkel"
[label_language]
other = "Sprache"
# --- Über-Dialog ---
[dialog_about_title]
other = "Über WinAuth"
[about_app_name]
other = "WinAuth (Go-Portierung) — TOTP/HOTP-Authentifikator"
[about_version_label]
other = "Version"
[about_runtime_label]
other = "Laufzeit"
[about_project_label]
other = "Projekt"
[about_project_url]
other = "https://git.wxccs.org/iceking2nd/winauth-go"
[about_license_label]
other = "Lizenz"
[about_license_value]
other = "MIT"
[about_credits]
other = "Original-WinAuth von Colin Mackie. Go-Portierung wird von den Projektautoren gepflegt."
# --- Sicherheit: Passwort ändern / automatische Sperre / HOTP-Hinweis ---
[menu_change_password]
other = "Passwort ändern..."
[dialog_change_password_title]
other = "Passwort ändern"
[label_password_current]
other = "Aktuelles Passwort"
[label_password_new]
other = "Neues Passwort"
[msg_password_changed]
other = "Passwort geändert."
[msg_hotp_advanced]
other = "Zähler auf %d erhöht"
[msg_locked]
other = "Wegen Inaktivität gesperrt. Passwort zum Entsperren eingeben."
[label_auto_lock_minutes]
other = "Automatisch sperren nach (Minuten)"
[hint_auto_lock_disabled]
other = "0 deaktiviert. Nur wirksam, wenn die Konfiguration verschlüsselt ist."
# --- System-Tray ---
[tray_show]
other = "WinAuth anzeigen"
[tray_hide]
other = "WinAuth ausblenden"
[tray_exit]
other = "Beenden"
[label_minimize_to_tray]
other = "Beim Schließen in den Tray minimieren"
[hint_minimize_to_tray]
other = "Schließen des Fensters versteckt es im System-Tray statt das Programm zu beenden."
# --- Eintragsaktionen (umbenennen / löschen / verschieben) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "Löschen"
[dialog_row_actions_title]
other = "Eintragsaktionen"
[action_rename]
other = "Umbenennen..."
[action_delete]
other = "Löschen..."
[action_move_up]
other = "Nach oben"
[action_move_down]
other = "Nach unten"
[dialog_rename_title]
other = "Eintrag umbenennen"
[label_entry_name]
other = "Neuer Name"
[msg_empty_name]
other = "Name darf nicht leer sein."
[dialog_confirm_delete_title]
other = "Löschen bestätigen"
[msg_confirm_delete]
other = "Eintrag \"%s\" wirklich löschen? Dies kann nicht rückgängig gemacht werden."
[action_change_icon]
other = "Symbol ändern..."
[dialog_icon_picker_title]
other = "Symbol auswählen"
# --- Erster Start: Verschlüsselungsauswahl ---
[dialog_welcome_title]
other = "Willkommen bei WinAuth"
[msg_welcome_intro]
other = "Wähle, ob die Liste der Authenticatoren verschlüsselt auf der Festplatte gespeichert werden soll. Die Verschlüsselung schützt die Datei mit einem Passwort deiner Wahl. Du kannst dies später jederzeit in den Einstellungen ändern."
[btn_welcome_enable_password]
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."
+226
View File
@@ -369,3 +369,229 @@ other = "QR scan failed: %s"
[msg_clipboard_no_image] [msg_clipboard_no_image]
other = "Clipboard does not contain an image" other = "Clipboard does not contain an image"
# --- Preferences ---
[menu_preferences]
other = "Preferences..."
[dialog_preferences_title]
other = "Preferences"
[label_theme]
other = "Theme"
[theme_system]
other = "System"
[theme_light]
other = "Light"
[theme_dark]
other = "Dark"
[label_language]
other = "Language"
# --- About dialog ---
[dialog_about_title]
other = "About WinAuth"
[about_app_name]
other = "WinAuth (Go port) — TOTP/HOTP authenticator"
[about_version_label]
other = "Version"
[about_runtime_label]
other = "Runtime"
[about_project_label]
other = "Project"
[about_project_url]
other = "https://git.wxccs.org/iceking2nd/winauth-go"
[about_license_label]
other = "License"
[about_license_value]
other = "MIT"
[about_credits]
other = "Original WinAuth by Colin Mackie. Go port maintained by the project authors."
# --- Security: change password / auto-lock / HOTP toast ---
[menu_change_password]
other = "Change password..."
[dialog_change_password_title]
other = "Change password"
[label_password_current]
other = "Current password"
[label_password_new]
other = "New password"
[msg_password_changed]
other = "Password changed."
[msg_hotp_advanced]
other = "Counter advanced to %d"
[msg_locked]
other = "Locked due to inactivity. Enter the password to unlock."
[label_auto_lock_minutes]
other = "Auto-lock after (minutes)"
[hint_auto_lock_disabled]
other = "Set to 0 to disable. Only takes effect when the configuration is encrypted."
# --- System tray ---
[tray_show]
other = "Show WinAuth"
[tray_hide]
other = "Hide WinAuth"
[tray_exit]
other = "Exit"
[label_minimize_to_tray]
other = "Minimize to tray when closing the window"
[hint_minimize_to_tray]
other = "Closing the window hides it to the system tray instead of exiting."
# --- Entry actions (rename / delete / reorder) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "Delete"
[dialog_row_actions_title]
other = "Entry actions"
[action_rename]
other = "Rename..."
[action_delete]
other = "Delete..."
[action_move_up]
other = "Move up"
[action_move_down]
other = "Move down"
[dialog_rename_title]
other = "Rename entry"
[label_entry_name]
other = "New name"
[msg_empty_name]
other = "Name cannot be empty."
[dialog_confirm_delete_title]
other = "Confirm delete"
[msg_confirm_delete]
other = "Delete the entry \"%s\"? This cannot be undone."
[action_change_icon]
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."
+226
View File
@@ -367,3 +367,229 @@ other = "二维码扫描失败:%s"
[msg_clipboard_no_image] [msg_clipboard_no_image]
other = "剪贴板中没有图片" other = "剪贴板中没有图片"
# --- 偏好设置 ---
[menu_preferences]
other = "偏好设置..."
[dialog_preferences_title]
other = "偏好设置"
[label_theme]
other = "主题"
[theme_system]
other = "跟随系统"
[theme_light]
other = "浅色"
[theme_dark]
other = "深色"
[label_language]
other = "语言"
# --- 关于对话框 ---
[dialog_about_title]
other = "关于 WinAuth"
[about_app_name]
other = "WinAuth (Go 版) — TOTP/HOTP 验证器"
[about_version_label]
other = "版本"
[about_runtime_label]
other = "运行时"
[about_project_label]
other = "项目主页"
[about_project_url]
other = "https://git.wxccs.org/iceking2nd/winauth-go"
[about_license_label]
other = "许可证"
[about_license_value]
other = "MIT"
[about_credits]
other = "原版 WinAuth 由 Colin Mackie 创作。Go 移植由本项目作者维护。"
# --- 安全:修改密码 / 自动锁定 / HOTP 提示 ---
[menu_change_password]
other = "修改密码..."
[dialog_change_password_title]
other = "修改密码"
[label_password_current]
other = "当前密码"
[label_password_new]
other = "新密码"
[msg_password_changed]
other = "密码已更新。"
[msg_hotp_advanced]
other = "计数器推进到 %d"
[msg_locked]
other = "因长时间无操作已锁定,请输入密码解锁。"
[label_auto_lock_minutes]
other = "自动锁定(分钟)"
[hint_auto_lock_disabled]
other = "填 0 关闭。仅当配置已加密时生效。"
# --- 系统托盘 ---
[tray_show]
other = "显示 WinAuth"
[tray_hide]
other = "隐藏 WinAuth"
[tray_exit]
other = "退出"
[label_minimize_to_tray]
other = "关闭窗口时最小化到托盘"
[hint_minimize_to_tray]
other = "勾选后点击窗口关闭按钮将隐藏到系统托盘而非退出程序。"
# --- 条目操作(重命名 / 删除 / 重排序) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "删除"
[dialog_row_actions_title]
other = "条目操作"
[action_rename]
other = "重命名..."
[action_delete]
other = "删除..."
[action_move_up]
other = "上移"
[action_move_down]
other = "下移"
[dialog_rename_title]
other = "重命名条目"
[label_entry_name]
other = "新名称"
[msg_empty_name]
other = "名称不能为空。"
[dialog_confirm_delete_title]
other = "确认删除"
[msg_confirm_delete]
other = "确定删除条目 \"%s\"?此操作不可撤销。"
[action_change_icon]
other = "更换图标..."
[dialog_icon_picker_title]
other = "选择图标"
# --- 首次启动欢迎 ---
[dialog_welcome_title]
other = "欢迎使用 WinAuth"
[msg_welcome_intro]
other = "选择是否对磁盘上存储的验证器列表进行加密。加密将使用你选择的密码保护文件。你可以在设置中随时更改。"
[btn_welcome_enable_password]
other = "启用密码保护"
[btn_welcome_skip]
other = "暂时跳过"
# --- 导出 / 备份 ---
[menu_export]
other = "导出..."
[menu_restore_backup]
other = "恢复备份..."
[dialog_export_title]
other = "导出验证器"
[export_tab_otpauth]
other = "otpauth:// URI"
[export_tab_backup]
other = "加密备份"
[export_otpauth_intro]
other = "以下是每个条目的标准 otpauth:// URI,可导入到其他验证器应用(Google Authenticator、Authy 等)。"
[export_partial_warning]
other = "部分导出 — 厂商专用字段已省略"
[export_backup_intro]
other = "将加密备份文件写入磁盘,可在任何机器上恢复。请选择强密码——此密码与当前 WinAuth 密码无关。"
[label_backup_path]
other = "备份文件路径"
[btn_export]
other = "导出"
[msg_empty_backup_path]
other = "请输入备份文件路径。"
[msg_export_done]
other = "备份导出成功。"
[msg_export_failed]
other = "导出失败:%s"
[dialog_restore_backup_title]
other = "从备份恢复"
[restore_backup_intro]
other = "选择加密备份文件(.winauth.bak)并输入创建时设置的密码。条目将追加到当前列表。"
[msg_restore_done]
other = "备份已恢复。"
[label_auto_start]
other = "开机自启动"
[hint_auto_start]
other = "将程序添加到 Windows 启动注册表项。"
[action_bnet_info]
other = "序列号与恢复码..."
[dialog_bnet_info_title]
other = "战网序列号与恢复码"
[bnet_info_intro]
other = "请妥善保管恢复码 —— 任何同时拥有序列号和恢复码的人都能恢复完整的账号访问权限。"
[hint_bnet_restore_code_secret]
other = "切勿泄露恢复码。它是恢复此验证器的唯一途径。"
+142
View File
@@ -0,0 +1,142 @@
package qr
import (
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
// EntryToOtpAuth converts a config.Entry into an OtpAuth struct suitable
// for URI export. It parses the SecretRaw field directly instead of
// constructing a full authenticator, so the qr package stays free of
// network/Win32 dependencies. For Battle.Net and Steam the partial flag
// is set because those vendors carry extra fields (serial, device ID,
// session data) that cannot be expressed in the standard otpauth://
// format — only the raw HMAC secret and basic TOTP params are exported.
func EntryToOtpAuth(e config.Entry) (oa *OtpAuth, partial bool, err error) {
if e.SecretRaw == "" {
return nil, false, fmt.Errorf("qr: entry %q has no secret data", e.Name)
}
head, _, _ := strings.Cut(e.SecretRaw, "|")
parts := strings.Split(head, "\t")
if len(parts) == 0 || parts[0] == "" {
return nil, false, fmt.Errorf("qr: entry %q has empty secret", e.Name)
}
secretBytes, err := hex.DecodeString(parts[0])
if err != nil {
return nil, false, fmt.Errorf("qr: entry %q: bad secret hex: %w", e.Name, err)
}
oa = &OtpAuth{
SecretBase32: authenticator.Base32Encode(secretBytes),
Digits: authenticator.DefaultCodeDigits,
Algorithm: "SHA1",
Period: authenticator.DefaultPeriod,
}
if len(parts) > 1 {
if d, e := strconv.Atoi(parts[1]); e == nil && d > 0 {
oa.Digits = d
}
}
if len(parts) > 2 {
oa.Algorithm = strings.ToUpper(parts[2])
}
if len(parts) > 3 {
if p, e := strconv.Atoi(parts[3]); e == nil && p > 0 {
oa.Period = p
}
}
switch e.Vendor {
case "hotp":
oa.Type = "hotp"
// Counter is stored after the first "|" in SecretRaw.
if idx := strings.Index(e.SecretRaw, "|"); idx >= 0 {
if c, e := strconv.ParseUint(strings.TrimSpace(e.SecretRaw[idx+1:]), 10, 64); e == nil {
oa.Counter = c
}
}
default:
oa.Type = "totp"
}
issuer := vendorToIssuer(e.Vendor)
oa.Issuer = issuer
if e.Name != "" {
if issuer != "" {
oa.Label = issuer + ":" + e.Name
} else {
oa.Label = e.Name
}
}
if e.Vendor == "battlenet" || e.Vendor == "steam" {
partial = true
}
return oa, partial, nil
}
// URI renders the OtpAuth as an otpauth:// URI string per the
// Key-Uri-Format spec used by Google Authenticator et al.
func (oa *OtpAuth) URI() string {
var buf strings.Builder
buf.WriteString("otpauth://")
buf.WriteString(oa.Type)
buf.WriteByte('/')
if oa.Label != "" {
buf.WriteString(url.PathEscape(oa.Label))
}
buf.WriteString("?secret=")
buf.WriteString(oa.SecretBase32)
if oa.Issuer != "" {
buf.WriteString("&issuer=")
buf.WriteString(url.QueryEscape(oa.Issuer))
}
if oa.Algorithm != "" && oa.Algorithm != "SHA1" {
buf.WriteString("&algorithm=")
buf.WriteString(oa.Algorithm)
}
if oa.Digits > 0 && oa.Digits != 6 {
buf.WriteString("&digits=")
buf.WriteString(strconv.Itoa(oa.Digits))
}
if oa.Period > 0 && oa.Period != 30 {
buf.WriteString("&period=")
buf.WriteString(strconv.Itoa(oa.Period))
}
if oa.Type == "hotp" && oa.Counter > 0 {
buf.WriteString("&counter=")
buf.WriteString(strconv.FormatUint(oa.Counter, 10))
}
return buf.String()
}
// vendorToIssuer maps internal vendor strings to standard issuer names
// for otpauth:// URIs.
func vendorToIssuer(vendor string) string {
switch vendor {
case "google":
return "Google"
case "microsoft":
return "Microsoft"
case "okta":
return "Okta"
case "hotp":
return ""
case "battlenet":
return "Battle.Net"
case "steam":
return "Steam"
default:
return ""
}
}
+159
View File
@@ -0,0 +1,159 @@
package qr
import (
"strings"
"testing"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
func TestEntryToOtpAuthGoogle(t *testing.T) {
e := config.Entry{
Name: "MyGoogle",
Vendor: "google",
SecretRaw: "0102030405\t6\tSHA1\t30",
}
oa, partial, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if partial {
t.Error("Google should not be partial")
}
if oa.Type != "totp" {
t.Errorf("Type = %q, want totp", oa.Type)
}
if oa.Issuer != "Google" {
t.Errorf("Issuer = %q, want Google", oa.Issuer)
}
if oa.Label != "Google:MyGoogle" {
t.Errorf("Label = %q", oa.Label)
}
if oa.Digits != 6 {
t.Errorf("Digits = %d, want 6", oa.Digits)
}
if oa.Algorithm != "SHA1" {
t.Errorf("Algorithm = %q, want SHA1", oa.Algorithm)
}
}
func TestEntryToOtpAuthHOTP(t *testing.T) {
e := config.Entry{
Name: "Counter",
Vendor: "hotp",
SecretRaw: "AABBCCDD\t6\tSHA1\t30|10",
}
oa, _, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if oa.Type != "hotp" {
t.Errorf("Type = %q, want hotp", oa.Type)
}
if oa.Counter != 10 {
t.Errorf("Counter = %d, want 10", oa.Counter)
}
}
func TestEntryToOtpAuthBattleNetPartial(t *testing.T) {
e := config.Entry{
Name: "BNet",
Vendor: "battlenet",
SecretRaw: "AABB\t8\tSHA1\t30|53455249414C",
}
oa, partial, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if !partial {
t.Error("Battle.Net should be partial")
}
if oa.Digits != 8 {
t.Errorf("Digits = %d, want 8", oa.Digits)
}
}
func TestEntryToOtpAuthSteamPartial(t *testing.T) {
e := config.Entry{
Name: "Steam",
Vendor: "steam",
SecretRaw: "CCDD\t5\tSHA1\t30|73657269616C|646576696365",
}
_, partial, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
if !partial {
t.Error("Steam should be partial")
}
}
func TestEntryToOtpAuthEmptySecret(t *testing.T) {
e := config.Entry{Name: "Empty", Vendor: "google", SecretRaw: ""}
if _, _, err := EntryToOtpAuth(e); err == nil {
t.Error("expected error for empty secret")
}
}
func TestURIRoundTrip(t *testing.T) {
e := config.Entry{
Name: "Test",
Vendor: "google",
SecretRaw: "0102030405\t6\tSHA1\t30",
}
oa, _, err := EntryToOtpAuth(e)
if err != nil {
t.Fatalf("EntryToOtpAuth: %v", err)
}
uri := oa.URI()
// Parse it back
parsed, err := ParseOtpAuth(uri)
if err != nil {
t.Fatalf("ParseOtpAuth(%q): %v", uri, err)
}
if parsed.Type != "totp" {
t.Errorf("Type = %q, want totp", parsed.Type)
}
if parsed.SecretBase32 != oa.SecretBase32 {
t.Errorf("Secret mismatch: %q vs %q", parsed.SecretBase32, oa.SecretBase32)
}
}
func TestURIHOTP(t *testing.T) {
oa := &OtpAuth{
Type: "hotp",
Label: "MyHOTP",
SecretBase32: "JBSWY3DPEHPK3PXP",
Issuer: "Test",
Digits: 6,
Counter: 42,
}
uri := oa.URI()
if !strings.Contains(uri, "counter=42") {
t.Errorf("URI missing counter: %s", uri)
}
if !strings.HasPrefix(uri, "otpauth://hotp/") {
t.Errorf("URI wrong prefix: %s", uri)
}
}
func TestURIDefaultsOmitted(t *testing.T) {
oa := &OtpAuth{
Type: "totp",
SecretBase32: "AAAA",
Algorithm: "SHA1",
Digits: 6,
Period: 30,
}
uri := oa.URI()
if strings.Contains(uri, "algorithm") {
t.Errorf("SHA1 should be omitted: %s", uri)
}
if strings.Contains(uri, "digits") {
t.Errorf("digits=6 should be omitted: %s", uri)
}
if strings.Contains(uri, "period") {
t.Errorf("period=30 should be omitted: %s", uri)
}
}
+421 -16
View File
@@ -5,17 +5,16 @@ import (
"errors" "errors"
"fmt" "fmt"
"image/color" "image/color"
"io/fs"
"os" "os"
"sync" "sync"
"time" "time"
"gioui.org/app" "gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
@@ -39,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")
@@ -58,6 +68,11 @@ type entry struct {
Auth authenticator.Authenticator Auth authenticator.Authenticator
Code string Code string
// IconKey selects a specific brand icon from the embedded catalog
// (e.g. "GitHubIcon", "DropboxIcon"). Empty means fall back to the
// vendor-name placeholder ("google", "steam", …).
IconKey string
// Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G") // Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G")
// or "" if none is set. // or "" if none is set.
Hotkey string Hotkey string
@@ -75,6 +90,8 @@ type entry struct {
hotkeyBtn widget.Clickable hotkeyBtn widget.Clickable
// copyBtn copies the current code to the clipboard. // copyBtn copies the current code to the clipboard.
copyBtn widget.Clickable copyBtn widget.Clickable
// moreBtn opens the per-row ⋯ popup (rename / delete / move).
moreBtn widget.Clickable
} }
type appState struct { type appState struct {
@@ -91,10 +108,27 @@ type appState struct {
dialog Dialog dialog Dialog
pwDialog *passwordDialog pwDialog *passwordDialog
setPwDialog *setPasswordDialog setPwDialog *setPasswordDialog
changePwDlg *changePasswordDialog
welcomeDlg *welcomeDialog
importDialog *importLegacyDialog importDialog *importLegacyDialog
exportDlg *exportDialog
restoreDlg *restoreBackupDialog
hotkeyDialog *hotkeyDialog hotkeyDialog *hotkeyDialog
hotkeyTarget *entry hotkeyTarget *entry
tradesDialog *steamTradesDialog tradesDialog *steamTradesDialog
prefsDialog *preferencesDialog
aboutDialog *aboutDialog
// Per-row action popup state. rowMenu owns the visible popup;
// renameDlg / confirmDelDlg are the follow-up modals it spawns.
// rowTargetIdx is the entry index the popup applies to; it is
// re-resolved on each frame in case the entries slice mutated.
rowMenu *rowActionMenu
renameDlg *renameDialog
confirmDelDlg *confirmDeleteDialog
iconPickerDlg *iconPickerDialog
bnetInfoDlg *bnetInfoDialog
rowTargetIdx int
store *store store *store
saveErr string // surfaced in the top bar saveErr string // surfaced in the top bar
@@ -102,6 +136,27 @@ type appState struct {
hkMgr *win32.HotkeyManager hkMgr *win32.HotkeyManager
toast toast toast toast
// themeMode is the user's current theme preference. drawFrame
// rebuilds the material.Theme when this changes.
themeMode themeMode
// theme is the cached material.Theme matching themeMode. nil forces a
// rebuild on the next frame.
theme *material.Theme
// Auto-lock state. Active only when the config is encrypted and the
// user set auto_lock_minutes > 0. lastActivity is bumped on any UI
// interaction; once now-lastActivity exceeds the threshold the lock
// dialog is raised and the entry list hidden until the user
// re-enters the live passphrase.
lastActivity time.Time
locked bool
lockDialog *passwordDialog
// tray owns the system-tray icon and (when minimize_to_tray is on)
// the WM_CLOSE subclass. nil on platforms or installs where it
// could not be set up.
tray *trayRuntime
} }
// snapshotEntries returns a freshly serialized slice of config entries. // snapshotEntries returns a freshly serialized slice of config entries.
@@ -111,7 +166,7 @@ func (st *appState) snapshotEntries() []config.Entry {
defer st.mu.Unlock() defer st.mu.Unlock()
out := make([]config.Entry, 0, len(st.entries)) out := make([]config.Entry, 0, len(st.entries))
for _, en := range st.entries { for _, en := range st.entries {
out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey)) out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey, en.IconKey))
} }
return out return out
} }
@@ -119,11 +174,9 @@ func (st *appState) snapshotEntries() []config.Entry {
func loop(w *app.Window, configPath string) error { func loop(w *app.Window, configPath string) error {
const fn = "internal.ui.loop" const fn = "internal.ui.loop"
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
state := &appState{} state := &appState{}
state.list.Axis = layout.Vertical state.list.Axis = layout.Vertical
state.lastActivity = time.Now()
state.store = newStore(configPath, state.snapshotEntries, func(err error) { state.store = newStore(configPath, state.snapshotEntries, func(err error) {
state.mu.Lock() state.mu.Lock()
@@ -132,10 +185,15 @@ func loop(w *app.Window, configPath string) error {
w.Invalidate() w.Invalidate()
}) })
// First-load attempt: empty passphrase. If the file is encrypted we'll // First-load attempt: empty passphrase. The branches are:
// surface a password dialog on the first frame. // - file missing → first-run welcome dialog (encrypt / skip)
// - file encrypted → password dialog
// - other load error → log + show error banner
if cfg, err := state.store.Load(nil); err != nil { if cfg, err := state.store.Load(nil); err != nil {
switch { switch {
case errors.Is(err, fs.ErrNotExist):
global.Log.WithField("func", fn).Info("no config file; showing first-run welcome")
state.welcomeDlg = newWelcomeDialog()
case errors.Is(err, ErrPasswordRequired): case errors.Is(err, ErrPasswordRequired):
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required")) state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
default: default:
@@ -146,6 +204,12 @@ func loop(w *app.Window, configPath string) error {
state.absorbConfig(cfg) state.absorbConfig(cfg)
} }
// 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()
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
// already had configured. Failures are non-fatal (logged + the row // already had configured. Failures are non-fatal (logged + the row
// just won't fire). // just won't fire).
@@ -153,6 +217,17 @@ func loop(w *app.Window, configPath string) error {
state.registerAllHotkeys() state.registerAllHotkeys()
go state.runHotkeyLoop(w) go state.runHotkeyLoop(w)
// 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()
go func() {
tr := installTrayRuntime(i18n.T("app_title"), minToTray, w.Invalidate)
state.mu.Lock()
state.tray = tr
state.mu.Unlock()
}()
// Tick once per second to refresh TOTP codes. // Tick once per second to refresh TOTP codes.
go func() { go func() {
t := time.NewTicker(time.Second) t := time.NewTicker(time.Second)
@@ -166,16 +241,67 @@ 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()
return e.Err return e.Err
case app.FrameEvent: case app.FrameEvent:
gtx := app.NewContext(&ops, e) gtx := app.NewContext(&ops, e)
drawFrame(gtx, th, state, w) if state.theme == nil {
th, pal := buildTheme(state.themeMode)
state.theme = th
activePalette = pal
}
// Paint the window background using the active palette so
// dark mode does not show through as the Gio default grey.
fillBackground(gtx, activePalette.Background)
drawFrame(gtx, state.theme, state, w)
e.Frame(gtx.Ops) e.Frame(gtx.Ops)
} }
} }
} }
// maybeAutoLock checks whether the inactivity threshold has elapsed and,
// if so, raises the lock dialog. Returns true when the UI should render
// the lock prompt this frame and skip the rest of the layout. The check
// is a no-op unless the configuration is encrypted AND the user set
// auto_lock_minutes > 0 — locking a plaintext config buys nothing
// security-wise (the file is already readable).
//
// Note: we do NOT clear store.passphrase on lock. Re-decrypt on every
// unlock would mean a transient window where we cannot save (e.g. a
// hotkey press just before unlock would race), and it would not improve
// the security model — process memory holding the key is the threat
// either way.
func (st *appState) maybeAutoLock() bool {
if st.locked {
return true
}
if !st.store.Encrypted() {
return false
}
_, _, autoLockMinutes, _, _, _, _ := st.store.Preferences()
if autoLockMinutes <= 0 {
return false
}
if st.lastActivity.IsZero() {
st.lastActivity = time.Now()
return false
}
threshold := time.Duration(autoLockMinutes) * time.Minute
if time.Since(st.lastActivity) < threshold {
return false
}
st.locked = true
st.lockDialog = newPasswordDialog(i18n.T("msg_locked"))
return true
}
// absorbConfig replaces the in-memory entries with the contents of cfg, // absorbConfig replaces the in-memory entries with the contents of cfg,
// best-effort: bad entries are logged and skipped. // best-effort: bad entries are logged and skipped.
func (st *appState) absorbConfig(cfg *config.Config) { func (st *appState) absorbConfig(cfg *config.Config) {
@@ -192,7 +318,7 @@ func (st *appState) absorbConfig(cfg *config.Config) {
global.Log.WithField("func", fn).WithError(err).Warn("skip entry") global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
continue continue
} }
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey}) st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey, IconKey: e.IconName})
} }
} }
@@ -212,13 +338,88 @@ func (st *appState) mergeImportedConfig(cfg *config.Config) {
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry") global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
continue continue
} }
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey}) st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey, IconKey: e.IconName})
} }
} }
// renameEntry sets a new display name for the entry at idx. Empty names
// are rejected by the caller; here we trust the input and only guard
// the index. Pushes a save on success.
func (st *appState) renameEntry(idx int, name string) {
st.mu.Lock()
if idx < 0 || idx >= len(st.entries) {
st.mu.Unlock()
return
}
st.entries[idx].Name = name
st.mu.Unlock()
st.store.Push()
}
// deleteEntry removes the entry at idx, unregistering its hotkey if
// any. Bounds-checked; out-of-range indexes are a silent no-op so a
// stale popup that survived a concurrent mutation cannot panic.
func (st *appState) deleteEntry(idx int) {
const fn = "internal.ui.appState.deleteEntry"
st.mu.Lock()
if idx < 0 || idx >= len(st.entries) {
st.mu.Unlock()
return
}
en := st.entries[idx]
if en.hotkeyID != 0 && st.hkMgr != nil {
if err := st.hkMgr.Unregister(en.hotkeyID); err != nil {
global.Log.WithField("func", fn).WithError(err).
Warn("unregister hotkey during delete failed")
}
en.hotkeyID = 0
}
st.entries = append(st.entries[:idx], st.entries[idx+1:]...)
st.mu.Unlock()
st.store.Push()
}
// moveEntry swaps the entry at idx with its neighbour delta steps away
// (typically ±1). Bounds-checked. Pushes a save on a successful swap.
func (st *appState) moveEntry(idx, delta int) {
st.mu.Lock()
j := idx + delta
if idx < 0 || idx >= len(st.entries) || j < 0 || j >= len(st.entries) {
st.mu.Unlock()
return
}
st.entries[idx], st.entries[j] = st.entries[j], st.entries[idx]
st.mu.Unlock()
st.store.Push()
}
func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions { func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions {
// Auto-lock check runs before any input is dispatched so a user who
// returns mid-frame still has to type the passphrase before they can
// interact. Active only when the config is encrypted and the user
// configured a positive timeout.
if st.maybeAutoLock() && st.lockDialog != nil {
return st.lockDialog.Layout(gtx, th, func(pw []byte, ok bool) {
if !ok {
// Cancel is meaningless while locked — just re-prompt.
st.lockDialog.SetError(i18n.T("msg_locked"))
w.Invalidate()
return
}
if st.store.VerifyPassword(pw) {
st.locked = false
st.lockDialog = nil
st.lastActivity = time.Now()
} else {
st.lockDialog.SetError(i18n.T("msg_password_wrong"))
}
w.Invalidate()
})
}
if st.addBtn.Clicked(gtx) { if st.addBtn.Clicked(gtx) {
st.vendorMenu = newVendorMenu() st.vendorMenu = newVendorMenu()
st.lastActivity = time.Now()
} }
if st.removeBtn.Clicked(gtx) { if st.removeBtn.Clicked(gtx) {
st.mu.Lock() st.mu.Lock()
@@ -227,9 +428,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
} }
st.mu.Unlock() st.mu.Unlock()
st.store.Push() st.store.Push()
st.lastActivity = time.Now()
} }
if st.settingsBtn.Clicked(gtx) { if st.settingsBtn.Clicked(gtx) {
st.settingsMenu = newSettingsMenu() st.settingsMenu = newSettingsMenu()
st.lastActivity = time.Now()
} }
// Refresh TOTP codes on every frame; HOTP entries advance on user click only. // Refresh TOTP codes on every frame; HOTP entries advance on user click only.
@@ -237,22 +440,36 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
var tradesTarget *entry var tradesTarget *entry
var hotkeyTarget *entry var hotkeyTarget *entry
var copyTarget *entry var copyTarget *entry
for _, en := range st.entries { moreTargetIdx := -1
for i, en := range st.entries {
if en.Auth.Name() == "steam" { if en.Auth.Name() == "steam" {
if en.tradesBtn.Clicked(gtx) { if en.tradesBtn.Clicked(gtx) {
tradesTarget = en tradesTarget = en
st.lastActivity = time.Now()
} }
} }
if en.hotkeyBtn.Clicked(gtx) { if en.hotkeyBtn.Clicked(gtx) {
hotkeyTarget = en hotkeyTarget = en
st.lastActivity = time.Now()
} }
if en.copyBtn.Clicked(gtx) { if en.copyBtn.Clicked(gtx) {
copyTarget = en copyTarget = en
st.lastActivity = time.Now()
}
if en.moreBtn.Clicked(gtx) {
moreTargetIdx = i
st.lastActivity = time.Now()
} }
if en.Auth.Name() == "hotp" { if en.Auth.Name() == "hotp" {
if en.click.Clicked(gtx) { if en.click.Clicked(gtx) {
if code, err := en.Auth.CurrentCode(); err == nil { if code, err := en.Auth.CurrentCode(); err == nil {
en.Code = code en.Code = code
// Surface the new counter value (not the code) so the
// user has feedback that the click registered.
if h, ok := en.Auth.(*authenticator.HOTPAuthenticator); ok {
st.toast.Show(fmt.Sprintf(i18n.T("msg_hotp_advanced"), h.Counter), w)
}
st.lastActivity = time.Now()
// Counter advanced — persist so a restart does not // Counter advanced — persist so a restart does not
// reuse the same counter value. // reuse the same counter value.
go st.store.Push() go st.store.Push()
@@ -276,6 +493,36 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if copyTarget != nil { if copyTarget != nil {
st.copyCodeToClipboard(copyTarget, w) st.copyCodeToClipboard(copyTarget, w)
} }
if moreTargetIdx >= 0 {
st.mu.Lock()
total := len(st.entries)
st.mu.Unlock()
st.rowTargetIdx = moreTargetIdx
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
// encryption choice appears immediately on a fresh install.
if st.welcomeDlg != nil {
return st.welcomeDlg.Layout(gtx, th, func(r welcomeResult) {
st.welcomeDlg = nil
if r.encrypt {
// Hand off to the existing setPasswordDialog. OK on
// that dialog will call store.SetPassword, which
// creates the config file.
st.setPwDialog = newSetPasswordDialog()
}
// skip: leave entries empty and unencrypted; the user
// can encrypt later via Settings → Set password.
w.Invalidate()
})
}
// Password retry / first-decrypt loop. // Password retry / first-decrypt loop.
if st.pwDialog != nil { if st.pwDialog != nil {
@@ -312,16 +559,38 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
}) })
} }
if st.changePwDlg != nil {
return st.changePwDlg.Layout(gtx, th,
func(oldPw []byte) bool { return st.store.VerifyPassword(oldPw) },
func(r changePasswordResult) {
if !r.cancel {
st.store.SetPassword(r.newPw)
st.toast.Show(i18n.T("msg_password_changed"), w)
}
st.changePwDlg = nil
w.Invalidate()
})
}
if st.settingsMenu != nil { if st.settingsMenu != nil {
if act, closed := st.settingsMenu.Pick(gtx); closed { if act, closed := st.settingsMenu.Pick(gtx); closed {
st.settingsMenu = nil st.settingsMenu = nil
switch act { switch act {
case settingsActionSetPassword: case settingsActionSetPassword:
st.setPwDialog = newSetPasswordDialog() st.setPwDialog = newSetPasswordDialog()
case settingsActionChangePassword:
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:
lang, theme, autoLock, minToTray, autoStart, _, _ := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray, autoStart)
case settingsActionAbout: case settingsActionAbout:
// TODO: about dialog (next phase). st.aboutDialog = newAboutDialog()
} }
w.Invalidate() w.Invalidate()
} else { } else {
@@ -329,6 +598,35 @@ 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()
_, _, _, _, _, 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
// Re-arm or release the WM_CLOSE hook when the user
// flips the minimize-to-tray preference. Hot-reloading
// avoids forcing a restart for a setting change.
if prevMinToTray != r.minimizeToTray && st.tray != nil {
st.tray.setMinimizeToTray(r.minimizeToTray)
}
}
st.prefsDialog = nil
w.Invalidate()
})
}
if st.aboutDialog != nil {
return st.aboutDialog.Layout(gtx, th, func() {
st.aboutDialog = nil
w.Invalidate()
})
}
if st.importDialog != nil { if st.importDialog != nil {
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) { return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
if !r.cancel && r.cfg != nil { if !r.cancel && r.cfg != nil {
@@ -340,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
@@ -372,8 +689,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if added != nil { if added != nil {
st.mu.Lock() st.mu.Lock()
st.entries = append(st.entries, &entry{Name: name, Auth: added}) st.entries = append(st.entries, &entry{Name: name, Auth: added})
idx := len(st.entries) - 1
st.mu.Unlock() st.mu.Unlock()
st.store.Push() st.store.Push()
// Auto-open icon picker so the user can choose a brand
// icon for the entry right after adding it.
st.rowTargetIdx = idx
st.iconPickerDlg = newIconPickerDialog("")
} }
st.dialog = nil st.dialog = nil
w.Invalidate() w.Invalidate()
@@ -396,6 +718,85 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
}) })
} }
// Per-row action popup. Must be resolved before the entry list
// renders so the popup overlays the main UI without interference.
if st.rowMenu != nil {
action, closed := st.rowMenu.Pick(gtx)
if closed {
idx := st.rowTargetIdx
st.rowMenu = nil
switch action {
case rowActionRename:
st.mu.Lock()
if idx >= 0 && idx < len(st.entries) {
st.renameDlg = newRenameDialog(st.entries[idx].Name)
}
st.mu.Unlock()
case rowActionDelete:
st.mu.Lock()
if idx >= 0 && idx < len(st.entries) {
st.confirmDelDlg = newConfirmDeleteDialog(st.entries[idx].Name)
}
st.mu.Unlock()
case rowActionMoveUp:
st.moveEntry(idx, -1)
case rowActionMoveDown:
st.moveEntry(idx, +1)
case rowActionChangeIcon:
st.mu.Lock()
current := ""
if idx >= 0 && idx < len(st.entries) {
current = st.entries[idx].IconKey
}
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 {
return st.rowMenu.Layout(gtx, th)
}
}
if st.renameDlg != nil {
return st.renameDlg.Layout(gtx, th, func(name string, cancel bool) {
if !cancel {
st.renameEntry(st.rowTargetIdx, name)
}
st.renameDlg = nil
w.Invalidate()
})
}
if st.confirmDelDlg != nil {
return st.confirmDelDlg.Layout(gtx, th, func(confirmed bool) {
if confirmed {
st.deleteEntry(st.rowTargetIdx)
}
st.confirmDelDlg = nil
w.Invalidate()
})
}
if st.iconPickerDlg != nil {
return st.iconPickerDlg.Layout(gtx, th, func(iconKey string, cancel bool) {
if !cancel {
st.mu.Lock()
if st.rowTargetIdx >= 0 && st.rowTargetIdx < len(st.entries) {
st.entries[st.rowTargetIdx].IconKey = iconKey
}
st.mu.Unlock()
st.store.Push()
}
st.iconPickerDlg = nil
w.Invalidate()
})
}
if st.tradesDialog != nil { if st.tradesDialog != nil {
return st.tradesDialog.Layout(gtx, th) return st.tradesDialog.Layout(gtx, th)
} }
@@ -427,7 +828,7 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
return layout.Dimensions{} return layout.Dimensions{}
} }
lbl := material.Body2(th, msg) lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff} lbl.Color = activePalette.ErrorFg
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout) return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -485,9 +886,13 @@ func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensio
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout) material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.moreBtn, i18n.T("btn_more")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H6(th, en.Code) lbl := material.H6(th, en.Code)
lbl.Color = color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff} lbl.Color = activePalette.RingFg
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
) )
+53
View File
@@ -0,0 +1,53 @@
# UI assets
## vendor_icons/
Generated placeholder icons (64×64 PNG) used to identify each
authenticator vendor in the entry list. They are intentionally generic —
a coloured rounded square with a single white initial — so they carry no
third-party trademark.
To use real vendor logos, replace any individual file in place. The
filename is the lookup key (matches `authenticator.Authenticator.Name()`):
- `google.png`
- `microsoft.png`
- `okta.png`
- `hotp.png`
- `battlenet.png`
- `steam.png`
- `qr.png` — fallback for entries with an unknown vendor
The recommended size is 64×64 PNG with transparent corners. Files are
embedded at build time via `//go:embed` in `internal/ui/vendor_icons.go`.
## app_icon.png
A 256×256 placeholder for the application window/tray icon. Gio v0.7 has
no runtime API to set the window icon — for Windows you need to embed a
`.ico` resource via `rsrc` or `goversioninfo` at build time. The
workflow is:
```
# 1. Regenerate app_icon.png (optional — only if you change the source)
go run ./tools/gen_icons
# 2. Bake a multi-resolution app_icon.ico from the PNG
go run ./tools/gen_ico
# 3. Compile the .ico into a COFF .syso that Go auto-links
GOPROXY=https://goproxy.cn,direct go run github.com/akavel/rsrc@latest \
-ico internal/ui/assets/app_icon.ico \
-o internal/ui/assets/rsrc_windows.syso
```
`rsrc_windows.syso` lives next to other assets so `go build` picks it up
without any extra build-tag wiring. The tray runtime loads the icon by
resource id 1 (the first (and only) icon rsrc emits); if loading fails
it falls back to `IDI_APPLICATION`.
To regenerate the placeholders, run:
```
go run ./tools/gen_icons
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More